unoffical wafrn mirror wafrn.net
atproto social-network activitypub
at testPDSNotExplode 272 lines 9.4 kB view raw
1/* eslint-disable @typescript-eslint/no-explicit-any */ 2// exports a user into an ActivityPub compatible backup file. 3 4import fs from 'fs' 5import { Post, User } from '../../models/index.js' 6import { userToJSONLD } from '../activitypub/userToJSONLD.js' 7import archiver from 'archiver' 8import { v4 as uuidv4 } from 'uuid' 9import { postToJSONLD } from '../activitypub/postToJSONLD.js' 10import axios from 'axios' 11import { completeEnvironment } from '../backendOptions.js' 12 13const archived: Record<string, boolean> = {} 14 15async function extractImages(archive: archiver.Archiver, data: any, remoteFetch: boolean) { 16 const promises: Promise<any>[] = [] 17 if (Array.isArray(data)) { 18 for (const value of data) { 19 promises.push(extractImages(archive, value, remoteFetch)) 20 } 21 } else if (typeof data === 'object') { 22 for (const key in data) { 23 const value = data[key] 24 if (key == 'url' && typeof value === 'string') { 25 // ugly hack to skip the main "url" objects in the post and blog events 26 if (value.indexOf('fediverse/post') !== -1) { 27 } else if (value.indexOf('fediverse/blog') !== -1) { 28 } else if (value.startsWith(completeEnvironment.mediaUrl)) { 29 const fileName = value.slice(completeEnvironment.mediaUrl.length + 1) 30 const newFileName = `media_attachments/files/${fileName}` 31 if (!archived[fileName]) { 32 console.log(`Media file ${value} - ${fileName}`) 33 archived[fileName] = true 34 archive.file(`uploads/${fileName}`, { name: newFileName }) 35 } 36 data[key] = `/${newFileName}` 37 } else if (remoteFetch) { 38 const fileName = value.replaceAll(/[^.a-zA-Z0-9_-]/g, '_') 39 const downloadedFile = await axios 40 .get(completeEnvironment.externalCacheurl + value, { responseType: 'stream' }) 41 .catch(() => null) 42 if (downloadedFile?.data) { 43 const newFileName = `media_attachments/files/${fileName}` 44 if (!archived[fileName]) { 45 console.log(`Remote media file ${value} - ${fileName}`) 46 archived[fileName] = true 47 archive.append(downloadedFile.data, { name: newFileName }) 48 } 49 data[key] = `/${newFileName}` 50 } else { 51 console.log(`Could not download remote media file ${value} - ${fileName}`) 52 } 53 } 54 } else { 55 promises.push(extractImages(archive, value, remoteFetch)) 56 } 57 } 58 } 59 return Promise.all(promises) 60} 61 62async function exportBackup(userUrl: string, exportType: string): Promise<string> { 63 return new Promise(async (resolve, reject) => { 64 const user = await User.findOne({ where: { url: userUrl } }) 65 if (!user) return 66 67 const fileName = `backup-${userUrl}-${Date.now()}-${uuidv4()}.zip` 68 const output = fs.createWriteStream('uploads/' + fileName) 69 70 const archive = archiver('zip', { zlib: { level: 9 } }) 71 72 output.on('close', () => { 73 resolve(fileName) 74 }) 75 76 archive.on('error', (err: Error) => { 77 reject(err) 78 }) 79 80 archive.pipe(output) 81 82 // Export Blog 83 const userData = await userToJSONLD(user) 84 await extractImages(archive, userData, exportType == '3') 85 archive.append(JSON.stringify(userData), { name: 'actor.json' }) 86 87 // Export Posts 88 const outbox: any = {} 89 outbox['@context'] = [ 90 'https://www.w3.org/ns/activitystreams', 91 'https://w3id.org/security/v1', 92 { 93 manuallyApprovesFollowers: 'as:manuallyApprovesFollowers', 94 sensitive: 'as:sensitive', 95 Hashtag: 'as:Hashtag', 96 movedTo: { 97 '@id': 'as:movedTo', 98 '@type': '@id' 99 }, 100 alsoKnownAs: { 101 '@id': 'as:alsoKnownAs', 102 '@type': '@id' 103 }, 104 toot: 'http://joinmastodon.org/ns#', 105 Emoji: 'toot:Emoji', 106 featured: { 107 '@id': 'toot:featured', 108 '@type': '@id' 109 }, 110 featuredTags: { 111 '@id': 'toot:featuredTags', 112 '@type': '@id' 113 }, 114 schema: 'http://schema.org#', 115 PropertyValue: 'schema:PropertyValue', 116 value: 'schema:value', 117 ostatus: 'http://ostatus.org#', 118 atomUri: 'ostatus:atomUri', 119 inReplyToAtomUri: 'ostatus:inReplyToAtomUri', 120 conversation: 'ostatus:conversation', 121 focalPoint: { 122 '@container': '@list', 123 '@id': 'toot:focalPoint' 124 }, 125 blurhash: 'toot:blurhash', 126 discoverable: 'toot:discoverable', 127 indexable: 'toot:indexable', 128 memorial: 'toot:memorial', 129 votersCount: 'toot:votersCount', 130 suspended: 'toot:suspended', 131 attributionDomains: { 132 '@id': 'toot:attributionDomains', 133 '@type': '@id' 134 }, 135 gts: 'https://gotosocial.org/ns#', 136 interactionPolicy: { 137 '@id': 'gts:interactionPolicy', 138 '@type': '@id' 139 }, 140 canQuote: { 141 '@id': 'gts:canQuote', 142 '@type': '@id' 143 }, 144 automaticApproval: { 145 '@id': 'gts:automaticApproval', 146 '@type': '@id' 147 }, 148 manualApproval: { 149 '@id': 'gts:manualApproval', 150 '@type': '@id' 151 } 152 } 153 ] 154 outbox.id = 'outbox.json' 155 outbox.type = 'OrderedCollection' 156 outbox.orderedItems = [] 157 for (const postItem of await user.getPosts({ order: [['createdAt', 'ASC']] })) { 158 const postsToExport: Post[] = [postItem] 159 if (exportType == '2' || exportType == '3') { 160 while (postsToExport[0].parentId && (await postsToExport[0].getParent())) { 161 const parentPost = await postsToExport[0].getParent() 162 postsToExport.unshift(parentPost) 163 } 164 } 165 166 for (const post of postsToExport) { 167 if (archived[post.id]) continue 168 169 archived[post.id] = true 170 console.log(`Processing ${post.id}`) 171 try { 172 const postData = await postToJSONLD(post.id) 173 if (postData) { 174 await extractImages(archive, postData, exportType == '3') 175 if (postData.type == 'Create') { 176 if (post.remotePostId) { 177 postData.object.id = post.remotePostId 178 } 179 const postUser = await post.getUser() 180 181 if (postUser.remoteId) { 182 postData.object.attributedTo = postUser.remoteId 183 } 184 if (postUser.isRemoteUser) { 185 // local posts also have bskyUri so this is to determine if this is a remote bluesky post&user 186 if (post.bskyUri) { 187 postData.object.id = post.bskyUri 188 } 189 if (postUser.bskyDid) { 190 postData.object.attributedTo = `at://${postUser.bskyDid}` 191 } 192 } 193 194 const postParent = post.parentId && (await post.getParent({ include: 'user' })) 195 if (postParent) { 196 if (postParent.isRemoteBlueskyPost) { 197 postData.object.inReplyTo = postParent.bskyUri 198 } 199 } 200 } else if (postData.type == 'Announce') { 201 const postParent = post.parentId && (await post.getParent({ include: 'user' })) 202 if (postParent) { 203 if (postParent.bskyUri) { 204 postData.object = (await post.getParent()).bskyUri 205 } 206 } 207 } 208 outbox.orderedItems.push(postData) 209 delete outbox.orderedItems[outbox.orderedItems.length - 1]['@context'] 210 } 211 } catch (error) { 212 console.log('Error during JSONLD processing') 213 console.log(error) 214 } 215 } 216 } 217 outbox.totalItems = outbox.orderedItems.length 218 archive.append(JSON.stringify(outbox), { name: 'outbox.json' }) 219 220 // Export Likes 221 222 const likes: any = { 223 '@context': 'https://www.w3.org/ns/activitystreams', 224 id: 'likes.json', 225 type: 'OrderedCollection', 226 orderedItems: [] 227 } 228 229 for (const like of await user.getUserLikesPostRelations({ include: Post })) { 230 if (like.post) { 231 const postRemoteId = await like.post.fullUrlIncludingBsky() 232 likes.orderedItems.push(postRemoteId) 233 } 234 } 235 236 archive.append(JSON.stringify(likes), { name: 'likes.json' }) 237 238 // Export Bookmarks 239 240 const bookmarks: any = { 241 '@context': 'https://www.w3.org/ns/activitystreams', 242 id: 'bookmarks.json', 243 type: 'OrderedCollection', 244 orderedItems: [] 245 } 246 247 for (const bookmark of await user.getUserBookmarkedPosts({ include: Post })) { 248 if (bookmark.post) { 249 const postRemoteId = await bookmark.post.fullUrlIncludingBsky() 250 bookmarks.orderedItems.push(postRemoteId) 251 } 252 } 253 254 archive.append(JSON.stringify(bookmarks), { name: 'bookmarks.json' }) 255 256 archive.finalize() 257 }) 258} 259 260if (!process.argv[2]) { 261 console.log('Usage: tsx exportActivityPubBackup.ts <localUserName> <exportType>') 262 console.log('exportType:') 263 console.log(" 1: export blog's posts and it's media only (default)") 264 console.log(" 2: export blog's posts, the entire thread, and all local media") 265 console.log(" 3: export blog's posts, the entire thread, and both local and remote media files") 266 process.exit(0) 267} 268 269const fileName = await exportBackup(process.argv[2], process.argv[3]) 270 271console.log(`Exported to: ${completeEnvironment.mediaUrl}/${fileName}`) 272process.exit(0)