unoffical wafrn mirror wafrn.net
atproto social-network activitypub
at angular21 346 lines 11 kB view raw
1import { Application, Request, Response } from "express"; 2import crypto from "crypto"; 3import fs from "fs"; 4import axios, { AxiosResponse } from "axios"; 5import { logger } from "../utils/logger.js"; 6import optimizeMedia from "../utils/optimizeMedia.js"; 7import { Resolver } from "did-resolver"; 8import { getResolver } from "plc-did-resolver"; 9import { redisCache } from "../utils/redis.js"; 10import { getLinkPreview } from "link-preview-js"; 11import { linkPreviewRateLimiter } from "../utils/rateLimiters.js"; 12import { getMimeType } from "stream-mime-type"; 13import { completeEnvironment } from "../utils/backendOptions.js"; 14import { Media } from "../models/media.js"; 15import { Op } from "sequelize"; 16import { spawn } from "child_process"; 17import sequelize from "sequelize/lib/sequelize"; 18import { return404 } from "../utils/return404.js"; 19import { User } from "../models/user.js"; 20import { Emoji } from "../models/emoji.js"; 21 22function sendWithCache(res: Response, localFileName: string) { 23 // Does the .mime file exist? 24 if (fs.existsSync(localFileName + ".mime")) { 25 let mime = fs.readFileSync(localFileName + ".mime").toString(); 26 res.contentType(mime); 27 } 28 // 1 hour of cache 29 res.set("Cache-control", "public, max-age=3600"); 30 res.set( 31 "Content-Disposition", 32 `inline; filename="${localFileName.split("/").pop()}"` 33 ); 34 res.sendFile(localFileName, { root: "." }); 35} 36 37// converting the stream parsing to a promise to be able to use async/await and catch the errors with the try/catch blocks 38function writeStream( 39 stream: NodeJS.ReadableStream, 40 localFileName: string, 41 mime: string, 42 altText: string 43) { 44 const writeStream = fs.createWriteStream(localFileName); 45 fs.writeFileSync(localFileName + ".mime", mime); 46 return new Promise((resolve, reject) => { 47 writeStream.on("finish", async () => { 48 writeStream.close(); 49 if (altText != "") { 50 try { 51 const updateAltText = spawn("exiv2", [ 52 "-M", 53 `set Exif.Photo.UserComment charset=Ascii ${altText 54 .replaceAll('"', "") 55 .replaceAll("'", "") 56 .replaceAll("\\", "") 57 .replaceAll("$", "") 58 .replaceAll("@", "")}`, 59 localFileName, 60 ]); 61 updateAltText.on("close", () => { 62 return resolve(localFileName); 63 }); 64 } catch (error) { 65 return resolve(localFileName); 66 } 67 } else { 68 return resolve(localFileName); 69 } 70 }); 71 writeStream.on("error", (error) => { 72 return reject(error); 73 }); 74 stream.pipe(writeStream); 75 }); 76} 77function cacheRoutes(app: Application) { 78 app.get("/api/cache", async (req: Request, res: Response) => { 79 let mediaUrl = String(req.query?.media); 80 if (!mediaUrl) { 81 res.sendStatus(404); 82 return; 83 } 84 logger.trace({ 85 message: `Old cache use: ${mediaUrl}`, 86 }); 87 await getMediaFromUrl(mediaUrl, res); 88 }); 89 90 app.get("/api/v2/cache/media/:id", async (req: Request, res: Response) => { 91 let mediaId = req.params.id; 92 const media = mediaId ? await Media.findByPk(mediaId) : undefined; 93 if (media) { 94 await getMediaFromUrl( 95 media.external ? media.url : completeEnvironment.mediaUrl + media.url, 96 res 97 ); 98 } else { 99 res.sendStatus(404); 100 } 101 }); 102 103 app.get("/api/v2/cache/avatar/:id", async (req: Request, res: Response) => { 104 try { 105 let userId = req.params.id; 106 const user = userId 107 ? await User.scope("full").findOne({ 108 attributes: ["email", "avatar"], 109 where: { 110 banned: false, 111 [Op.or]: [ 112 { 113 id: userId, 114 }, 115 { 116 url: userId, 117 }, 118 ], 119 }, 120 }) 121 : undefined; 122 if (user) { 123 const url = user.email 124 ? `${completeEnvironment.mediaUrl}${user.avatar}` 125 : user.avatar; 126 await getMediaFromUrl(url, res); 127 } else { 128 res.sendStatus(404); 129 } 130 } catch (error) { 131 logger.debug({ 132 message: `Error caching user avatar`, 133 error: error, 134 }); 135 res.sendStatus(500); 136 } 137 }); 138 139 app.get("/api/v2/cache/emoji/:id", async (req: Request, res: Response) => { 140 let emojiUUID = req.params.id; 141 const emoji = emojiUUID 142 ? await Emoji.findOne({ 143 where: { 144 uuid: emojiUUID, 145 }, 146 }) 147 : undefined; 148 if (emoji) { 149 await getMediaFromUrl( 150 emoji.external ? emoji.url : completeEnvironment.mediaUrl + emoji.url, 151 res 152 ); 153 } else { 154 res.sendStatus(404); 155 } 156 }); 157 158 app.get("/api/v2/cache/youtube/:id", async (req: Request, res: Response) => { 159 const youtubeId = decodeURIComponent(req.params.id); 160 const ytRegex = 161 /((?:https?:\/\/)?(www.|m.)?(youtube(\-nocookie)?\.com|youtu\.be)\/(v\/|watch\?v=|embed\/)?([\S]{11}))([^\S]|\?[\S]*|\&[\S]*|\b)/g; 162 const match = youtubeId.matchAll(ytRegex).toArray(); 163 if (match && match.length >= 7) { 164 await getMediaFromUrl( 165 `https://img.youtube.com/vi/${match[6]}/hqdefault.jpg`, 166 res 167 ); 168 } else { 169 res.sendStatus(404); 170 } 171 }); 172 173 app.get("/api/v2/cache/favicon/:id", async (req: Request, res: Response) => { 174 try { 175 const link = new URL(decodeURIComponent(req.params.id)); 176 await getMediaFromUrl("https://" + link.hostname + "/favicon.ico", res); 177 } catch (error) { 178 res.sendStatus(500); 179 } 180 }); 181 182 app.get("/api/v2/cache/imageurl/:id", async (req: Request, res: Response) => { 183 try { 184 const link = decodeURIComponent(req.params.id); 185 186 const shasum = crypto.createHash("sha1"); 187 shasum.update(link.toLowerCase()); 188 const urlHash = shasum.digest("hex"); 189 // Here is the thing: for this to be asked, the link component has to load first so we can assume cache has been set 190 const cacheResult = JSON.parse( 191 (await redisCache.get("linkPreviewCache:" + urlHash)) || "{}" 192 ); 193 if (cacheResult && cacheResult.images && cacheResult.images[0]) { 194 await getMediaFromUrl(cacheResult.images[0], res); 195 } else { 196 res.sendStatus(404); 197 } 198 } catch (error) { 199 res.sendStatus(500); 200 } 201 }); 202 203 app.get( 204 "/api/linkPreview", 205 linkPreviewRateLimiter, 206 async (req: Request, res: Response) => { 207 const url = String(req.query?.url); 208 const shasum = crypto.createHash("sha1"); 209 shasum.update(url.toLowerCase()); 210 const urlHash = shasum.digest("hex"); 211 const cacheResult = await redisCache.get("linkPreviewCache:" + urlHash); 212 if (cacheResult) { 213 res.send(cacheResult); 214 } else { 215 let result = {}; 216 try { 217 result = await getLinkPreview(url, { 218 followRedirects: "follow", 219 headers: { "User-Agent": completeEnvironment.instanceUrl }, 220 }); 221 } catch (error) {} 222 // we cache the url 24 hours if success, 5 minutes if not 223 await redisCache.set( 224 "linkPreviewCache:" + urlHash, 225 JSON.stringify(result), 226 "EX", 227 result ? 3600 * 24 : 300 228 ); 229 res.send(result); 230 } 231 } 232 ); 233} 234 235async function getMediaFromUrl(mediaUrl: string, res?: Response) { 236 try { 237 const mediaLinkHash = crypto 238 .createHash("sha256") 239 .update(mediaUrl) 240 .digest("hex"); 241 let localFileName = `cache/${mediaLinkHash}`; 242 // if file exists 243 if (fs.existsSync(localFileName) && res) { 244 return await sendWithCache(res, localFileName); 245 } else { 246 try { 247 if (mediaUrl.startsWith("?cid=")) { 248 try { 249 const did = decodeURIComponent(mediaUrl.split("&did=")[1]); 250 const cid = decodeURIComponent( 251 mediaUrl.split("&did=")[0].split("?cid=")[1] 252 ); 253 if ((!did || !cid) && res) { 254 return res.sendStatus(400); 255 } 256 const plcResolver = getResolver(); 257 const didResolver = new Resolver(plcResolver); 258 const didData = await didResolver.resolve(did); 259 if (didData?.didDocument?.service) { 260 const url = 261 didData.didDocument.service[0].serviceEndpoint + 262 "/xrpc/com.atproto.sync.getBlob?did=" + 263 encodeURIComponent(did) + 264 "&cid=" + 265 encodeURIComponent(cid); 266 mediaUrl = url; 267 } else if (did.startsWith("did:web")) { 268 // get did doc first 269 const docRes = await fetch( 270 `https://${did.split("did:web:")[1]}/.well-known/did.json` 271 ); 272 const didDoc = await docRes.json(); 273 const atProtoServer = didDoc.service.find( 274 (x: any) => 275 x.id === "#atproto_pds" || 276 x.type === "AtprotoPersonalDataServer" 277 ); 278 if (!atProtoServer && res) { 279 return res.sendStatus(500); 280 } 281 const url = 282 atProtoServer.serviceEndpoint + 283 "/xrpc/com.atproto.sync.getBlob?did=" + 284 encodeURIComponent(did) + 285 "&cid=" + 286 encodeURIComponent(cid); 287 mediaUrl = url; 288 } 289 } catch (error) { 290 if (res) { 291 return res.sendStatus(500); 292 } 293 } 294 } 295 const response = await axios.get(mediaUrl, { 296 responseType: "stream", 297 headers: { "User-Agent": "wafrnCacher" }, 298 }); 299 let altText = ""; 300 /* 301 let dbMediaUrl = String(req.query?.media).startsWith( 302 completeEnvironment.mediaUrl 303 ) 304 ? String(req.query?.media).split(completeEnvironment.mediaUrl)[1] 305 : String(req.query?.media); 306 // we are disabling this feature temporarily 307 let media = true 308 ? undefined 309 : await Media.findOne({ 310 where: sequelize.where( 311 sequelize.fn("md5", sequelize.col("url")), 312 crypto.createHash("md5").update(dbMediaUrl).digest("hex") 313 ), 314 }); 315 if (media) { 316 altText = media.description; 317 } 318 */ 319 320 const { stream, mime } = await getMimeType(response.data); 321 if (res) { 322 res.contentType(mime); 323 } 324 await writeStream(stream, localFileName, mime, altText); 325 if (res) { 326 return await sendWithCache(res, localFileName); 327 } 328 } catch (error) { 329 if (res) { 330 return res.sendStatus(500); 331 } 332 } 333 } 334 } catch (error) { 335 logger.debug({ 336 message: "Error with cacher", 337 url: mediaUrl, 338 error: error, 339 }); 340 if (res) { 341 res.sendStatus(500); 342 } 343 } 344} 345 346export { cacheRoutes, getMediaFromUrl };