summary refs log tree commit diff
path: root/src/cdn
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2025-12-25 05:31:32 +0100
committerRory& <root@rory.gay>2025-12-25 07:11:26 +0100
commit19edf63e37e84f81e7168e7411285084a30002ba (patch)
treec924a7da07b3cdcfb5ac0a70932cd241c6a18e96 /src/cdn
parentWIP nix stuff (diff)
downloadserver-ts-dev/emma-fuck-this.tar.xz
various crap i was working on github/dev/emma-fuck-this dev/emma-fuck-this
Diffstat (limited to 'src/cdn')
-rw-r--r--src/cdn/Server.ts74
-rw-r--r--src/cdn/index.ts4
-rw-r--r--src/cdn/routes/_spacebar/cdn/cloud-attachments.ts171
-rw-r--r--src/cdn/routes/_spacebar/cdn/upload.ts51
-rw-r--r--src/cdn/routes/attachments.ts115
-rw-r--r--src/cdn/routes/avatars.ts15
-rw-r--r--src/cdn/routes/badge-icons.ts5
-rw-r--r--src/cdn/routes/embed.ts8
-rw-r--r--src/cdn/routes/guild-profiles.ts10
-rw-r--r--src/cdn/routes/role-icons.ts10
-rw-r--r--src/cdn/util/basicCrdFileRouter.ts8
-rw-r--r--src/cdn/util/cache.ts7
-rw-r--r--src/cdn/util/fileStorage.ts (renamed from src/cdn/util/FileStorage.ts)6
-rw-r--r--src/cdn/util/index.ts7
-rw-r--r--src/cdn/util/mimetypes.ts2
-rw-r--r--src/cdn/util/s3Storage.ts (renamed from src/cdn/util/S3Storage.ts)6
-rw-r--r--src/cdn/util/storage.ts (renamed from src/cdn/util/Storage.ts)5
17 files changed, 306 insertions, 198 deletions
diff --git a/src/cdn/Server.ts b/src/cdn/Server.ts

index 091621f0..e9115fc7 100644 --- a/src/cdn/Server.ts +++ b/src/cdn/Server.ts
@@ -25,6 +25,8 @@ import guildProfilesRoute from "./routes/guild-profiles"; import iconsRoute from "./routes/role-icons"; import morgan from "morgan"; import { Like } from "typeorm"; +import { Router } from "express"; +import { BasicCrdFileRouterOptions, createBasicCrdFileRouter } from "./util/basicCrdFileRouter"; export type CDNServerOptions = ServerOptions; @@ -62,48 +64,40 @@ export class CDNServer extends Server { this.app.use(BodyParser({ inflate: true, limit: "10mb" })); await registerRoutes(this, path.join(__dirname, "routes/")); + const register = (path: string, ...handlers: Router[]) => { + this.app.use(path, ...handlers); + console.log(`[Server] Route ${path} registered`); + }; - this.app.use("/icons/", avatarsRoute); - console.log("[Server] Route /icons registered"); + register("/role-icons/", iconsRoute); + register("/guilds/:guild_id/users/:user_id/avatars", guildProfilesRoute); + register("/guilds/:guild_id/users/:user_id/banners", guildProfilesRoute); - this.app.use("/role-icons/", iconsRoute); - console.log("[Server] Route /role-icons registered"); - - this.app.use("/emojis/", avatarsRoute); - console.log("[Server] Route /emojis registered"); - - this.app.use("/stickers/", avatarsRoute); - console.log("[Server] Route /stickers registered"); - - this.app.use("/banners/", avatarsRoute); - console.log("[Server] Route /banners registered"); - - this.app.use("/splashes/", avatarsRoute); - console.log("[Server] Route /splashes registered"); - - this.app.use("/discovery-splashes/", avatarsRoute); - console.log("[Server] Route /discovery-splashes registered"); - - this.app.use("/app-icons/", avatarsRoute); - console.log("[Server] Route /app-icons registered"); - - this.app.use("/app-assets/", avatarsRoute); - console.log("[Server] Route /app-assets registered"); - - this.app.use("/discover-splashes/", avatarsRoute); - console.log("[Server] Route /discover-splashes registered"); - - this.app.use("/team-icons/", avatarsRoute); - console.log("[Server] Route /team-icons registered"); - - this.app.use("/channel-icons/", avatarsRoute); - console.log("[Server] Route /channel-icons registered"); - - this.app.use("/guilds/:guild_id/users/:user_id/avatars", guildProfilesRoute); - console.log("[Server] Route /guilds/avatars registered"); - - this.app.use("/guilds/:guild_id/users/:user_id/banners", guildProfilesRoute); - console.log("[Server] Route /guilds/banners registered"); + if (!process.env.CDN_CRD_ROUTER) { + register("/icons/", avatarsRoute); + register("/emojis/", avatarsRoute); + register("/stickers/", avatarsRoute); + register("/banners/", avatarsRoute); + register("/splashes/", avatarsRoute); + register("/discovery-splashes/", avatarsRoute); + register("/app-icons/", avatarsRoute); + register("/app-assets/", avatarsRoute); + register("/discover-splashes/", avatarsRoute); + register("/team-icons/", avatarsRoute); + register("/channel-icons/", avatarsRoute); + } else { + register("/icons/", createBasicCrdFileRouter(new BasicCrdFileRouterOptions({ pathPrefix: "icons/", fallbackToAvatarPath: true }))); + register("/emojis/", createBasicCrdFileRouter(new BasicCrdFileRouterOptions({ pathPrefix: "emojis/", fallbackToAvatarPath: true }))); + register("/stickers/", createBasicCrdFileRouter(new BasicCrdFileRouterOptions({ pathPrefix: "stickers/", fallbackToAvatarPath: true }))); + register("/banners/", createBasicCrdFileRouter(new BasicCrdFileRouterOptions({ pathPrefix: "banners/", fallbackToAvatarPath: true }))); + register("/splashes/", createBasicCrdFileRouter(new BasicCrdFileRouterOptions({ pathPrefix: "splashes/", fallbackToAvatarPath: true }))); + register("/discovery-splashes/", createBasicCrdFileRouter(new BasicCrdFileRouterOptions({ pathPrefix: "discovery-splashes/", fallbackToAvatarPath: true }))); + register("/app-icons/", createBasicCrdFileRouter(new BasicCrdFileRouterOptions({ pathPrefix: "app-icons/", fallbackToAvatarPath: true }))); + register("/app-assets/", createBasicCrdFileRouter(new BasicCrdFileRouterOptions({ pathPrefix: "app-assets/", fallbackToAvatarPath: true }))); + register("/discover-splashes/", createBasicCrdFileRouter(new BasicCrdFileRouterOptions({ pathPrefix: "discover-splashes/", fallbackToAvatarPath: true }))); + register("/team-icons/", createBasicCrdFileRouter(new BasicCrdFileRouterOptions({ pathPrefix: "team-icons/", fallbackToAvatarPath: true }))); + register("/channel-icons/", createBasicCrdFileRouter(new BasicCrdFileRouterOptions({ pathPrefix: "channel-icons/", fallbackToAvatarPath: true }))); + } return super.start(); } diff --git a/src/cdn/index.ts b/src/cdn/index.ts
index dea9942b..dc9fd0f7 100644 --- a/src/cdn/index.ts +++ b/src/cdn/index.ts
@@ -17,6 +17,4 @@ */ export * from "./Server"; -export * from "./util/FileStorage"; -export * from "./util/Storage"; -export * from "./util/multer"; +export * from "./util"; diff --git a/src/cdn/routes/_spacebar/cdn/cloud-attachments.ts b/src/cdn/routes/_spacebar/cdn/cloud-attachments.ts new file mode 100644
index 00000000..5da92880 --- /dev/null +++ b/src/cdn/routes/_spacebar/cdn/cloud-attachments.ts
@@ -0,0 +1,171 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2025 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +import { CloudAttachment, Config, hasValidSignature, NewUrlUserSignatureData, Snowflake, UrlSignResult } from "@spacebar/util"; +import { Request, Response, Router } from "express"; +import imageSize from "image-size"; +import { HTTPError } from "lambert-server"; +import { fileTypeFromBuffer } from "file-type"; +import { cache, multer, storage } from "../../../util"; + +const router = Router({ mergeParams: true }); + +const SANITIZED_CONTENT_TYPE = ["text/html", "text/mhtml", "multipart/related", "application/xhtml+xml"]; + +router.get("/:channel_id/:id/:filename", cache, async (req: Request, res: Response) => { + const { channel_id, id, filename } = req.params; + // const { format } = req.query; + + const path = `attachments/${channel_id}/${id}/${filename}`; + + const fullUrl = (req.headers["x-forwarded-proto"] ?? req.protocol) + "://" + (req.headers["x-forwarded-host"] ?? req.hostname) + req.originalUrl; + + if ( + Config.get().security.cdnSignUrls && + !hasValidSignature( + new NewUrlUserSignatureData({ + ip: req.ip, + userAgent: req.headers["user-agent"] as string, + }), + UrlSignResult.fromUrl(fullUrl), + ) + ) { + return res.status(404).send("This content is no longer available."); + } + + const file = await storage.get(path); + if (!file) throw new HTTPError("File not found"); + const type = await fileTypeFromBuffer(file); + let content_type = type?.mime || "application/octet-stream"; + + if (SANITIZED_CONTENT_TYPE.includes(content_type)) { + content_type = "application/octet-stream"; + } + + res.set("Content-Type", content_type); + + return res.send(file); +}); + +// "cloud attachments" +router.put("/:channel_id/:batch_id/:attachment_id/:filename", multer.single("file"), async (req: Request, res: Response) => { + const { channel_id, batch_id, attachment_id, filename } = req.params; + const att = await CloudAttachment.findOneOrFail({ + where: { + uploadFilename: `${channel_id}/${batch_id}/${attachment_id}/${filename}`, + channelId: channel_id, + userAttachmentId: attachment_id, + userFilename: filename, + }, + }); + + const maxLength = Config.get().cdn.maxAttachmentSize; + + console.log("[Cloud Upload] Uploading attachment", att.id, att.userFilename, `Max size: ${maxLength} bytes`); + + const chunks: Buffer[] = []; + let length = 0; + + req.on("data", (chunk) => { + console.log(`[Cloud Upload] Received chunk of size ${chunk.length} bytes`); + chunks.push(chunk); + length += chunk.length; + if (length > maxLength) { + res.status(413).send("File too large"); + req.destroy(); + } + }); + req.on("end", async () => { + console.log(`[Cloud Upload] Finished receiving file, total size ${length} bytes`); + const buffer = Buffer.concat(chunks); + const path = `attachments/${channel_id}/${batch_id}/${attachment_id}/${filename}`; + + await storage.set(path, buffer); + + let mimeType = att.userOriginalContentType; + if (att.userOriginalContentType === null) { + const ft = await fileTypeFromBuffer(buffer); + mimeType = att.contentType = ft?.mime || "application/octet-stream"; + } + + if (mimeType?.includes("image")) { + const dimensions = imageSize(buffer); + if (dimensions) { + att.width = dimensions.width; + att.height = dimensions.height; + } + } + + att.size = buffer.length; + await att.save(); + + console.log("[Cloud Upload] Saved attachment", att.id, att.userFilename); + res.status(200).end(); + }); +}); + +router.delete("/:channel_id/:batch_id/:attachment_id/:filename", async (req: Request, res: Response) => { + if (req.headers.signature !== Config.get().security.requestSignature) throw new HTTPError("Invalid request signature"); + console.log("[Cloud Delete] Deleting attachment", req.params); + + const { channel_id, batch_id, attachment_id, filename } = req.params; + const path = `attachments/${channel_id}/${batch_id}/${attachment_id}/${filename}`; + + const att = await CloudAttachment.findOne({ + where: { + uploadFilename: `${channel_id}/${batch_id}/${attachment_id}/${filename}`, + channelId: channel_id, + userAttachmentId: attachment_id, + userFilename: filename, + }, + }); + + if (att) { + await att.remove(); + await storage.delete(path); + return res.send({ success: true }); + } + return res.status(404).send("Attachment not found"); +}); + +router.post("/:channel_id/:batch_id/:attachment_id/:filename/clone_to_message/:message_id", async (req: Request, res: Response) => { + if (req.headers.signature !== Config.get().security.requestSignature) throw new HTTPError("Invalid request signature"); + console.log("[Cloud Clone] Cloning attachment to message", req.params); + + const { channel_id, batch_id, attachment_id, filename, message_id } = req.params; + const path = `attachments/${channel_id}/${batch_id}/${attachment_id}/${filename}`; + const newPath = `attachments/${channel_id}/${message_id}/${filename}`; + + const att = await CloudAttachment.findOne({ + where: { + uploadFilename: `${channel_id}/${batch_id}/${attachment_id}/${filename}`, + channelId: channel_id, + userAttachmentId: attachment_id, + userFilename: filename, + }, + }); + + if (att) { + await storage.clone(path, newPath); + return res.send({ success: true, new_path: newPath }); + } + + return res.status(404).send("Attachment not found"); +}); + +export default router; diff --git a/src/cdn/routes/_spacebar/cdn/upload.ts b/src/cdn/routes/_spacebar/cdn/upload.ts new file mode 100644
index 00000000..f706d15c --- /dev/null +++ b/src/cdn/routes/_spacebar/cdn/upload.ts
@@ -0,0 +1,51 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2025 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +import { CloudAttachment, Config, hasValidSignature, NewUrlUserSignatureData, Snowflake, UrlSignResult } from "@spacebar/util"; +import { Request, Response, Router } from "express"; +import imageSize from "image-size"; +import { HTTPError } from "lambert-server"; +import { fileTypeFromBuffer } from "file-type"; +import { cache, multer, storage } from "../../../util"; +import { CdnImageLimitsConfiguration } from "../../../../util/config/types"; + +const router = Router({ mergeParams: true }); + +const SANITIZED_CONTENT_TYPE = ["text/html", "text/mhtml", "multipart/related", "application/xhtml+xml"]; + +const limits = Config.get().cdn.limits; +function createImageUploadRoute(name: string, path: string, limits: CdnImageLimitsConfiguration) { + router.post(`/${name}/:user_id`, multer.single("file"), async (req: Request, res: Response) => {}); + console.log(`Registered image upload /_spacebar/cdn/upload/${name} (-> storage/${path}/) with limits:`, JSON.stringify(limits)); +} + +createImageUploadRoute("icon", "icons", limits.icon); +createImageUploadRoute("role-icon", "role-icons", limits.roleIcon); +createImageUploadRoute("emoji", "emojis", limits.emoji); +createImageUploadRoute("sticker", "stickers", limits.sticker); +createImageUploadRoute("banner", "banners", limits.banner); +createImageUploadRoute("splash", "splashs", limits.splash); +createImageUploadRoute("avatar", "avatars", limits.avatar); +createImageUploadRoute("discovery-splash", "discovery-splashes", limits.discoverySplash); +createImageUploadRoute("app-icon", "app-icons", limits.appIcon); +createImageUploadRoute("discover-splash", "discover-splashes", limits.discoverSplash); +createImageUploadRoute("team-icon", "team-icons", limits.teamIcon); +createImageUploadRoute("channel-icon", "channel-icons", limits.channelIcon); +createImageUploadRoute("guild-avatar", "guild-avatars", limits.guildAvatar); + +export default router; diff --git a/src/cdn/routes/attachments.ts b/src/cdn/routes/attachments.ts
index db2b9503..b10b3b0a 100644 --- a/src/cdn/routes/attachments.ts +++ b/src/cdn/routes/attachments.ts
@@ -16,14 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import { Config, hasValidSignature, NewUrlUserSignatureData, Snowflake, UrlSignResult } from "@spacebar/util"; +import { CloudAttachment, Config, hasValidSignature, NewUrlUserSignatureData, Snowflake, UrlSignResult } from "@spacebar/util"; import { Request, Response, Router } from "express"; import imageSize from "image-size"; import { HTTPError } from "lambert-server"; -import { multer } from "../util/multer"; -import { storage } from "../util/Storage"; -import { CloudAttachment } from "../../util/entities/CloudAttachment"; import { fileTypeFromBuffer } from "file-type"; +import { cache, multer, storage } from "../util"; const router = Router({ mergeParams: true }); @@ -69,7 +67,7 @@ router.post("/:channel_id", multer.single("file"), async (req: Request, res: Res return res.json(file); }); -router.get("/:channel_id/:id/:filename", async (req: Request, res: Response) => { +router.get("/:channel_id/:id/:filename", cache, async (req: Request, res: Response) => { const { channel_id, id, filename } = req.params; // const { format } = req.query; @@ -100,7 +98,6 @@ router.get("/:channel_id/:id/:filename", async (req: Request, res: Response) => } res.set("Content-Type", content_type); - res.set("Cache-Control", "public, max-age=31536000"); return res.send(file); }); @@ -116,110 +113,4 @@ router.delete("/:channel_id/:id/:filename", async (req: Request, res: Response) return res.send({ success: true }); }); -// "cloud attachments" -router.put("/:channel_id/:batch_id/:attachment_id/:filename", multer.single("file"), async (req: Request, res: Response) => { - const { channel_id, batch_id, attachment_id, filename } = req.params; - const att = await CloudAttachment.findOneOrFail({ - where: { - uploadFilename: `${channel_id}/${batch_id}/${attachment_id}/${filename}`, - channelId: channel_id, - userAttachmentId: attachment_id, - userFilename: filename, - }, - }); - - const maxLength = Config.get().cdn.maxAttachmentSize; - - console.log("[Cloud Upload] Uploading attachment", att.id, att.userFilename, `Max size: ${maxLength} bytes`); - - const chunks: Buffer[] = []; - let length = 0; - - req.on("data", (chunk) => { - console.log(`[Cloud Upload] Received chunk of size ${chunk.length} bytes`); - chunks.push(chunk); - length += chunk.length; - if (length > maxLength) { - res.status(413).send("File too large"); - req.destroy(); - } - }); - req.on("end", async () => { - console.log(`[Cloud Upload] Finished receiving file, total size ${length} bytes`); - const buffer = Buffer.concat(chunks); - const path = `attachments/${channel_id}/${batch_id}/${attachment_id}/${filename}`; - - await storage.set(path, buffer); - - let mimeType = att.userOriginalContentType; - if (att.userOriginalContentType === null) { - const ft = await fileTypeFromBuffer(buffer); - mimeType = att.contentType = ft?.mime || "application/octet-stream"; - } - - if (mimeType?.includes("image")) { - const dimensions = imageSize(buffer); - if (dimensions) { - att.width = dimensions.width; - att.height = dimensions.height; - } - } - - att.size = buffer.length; - await att.save(); - - console.log("[Cloud Upload] Saved attachment", att.id, att.userFilename); - res.status(200).end(); - }); -}); - -router.delete("/:channel_id/:batch_id/:attachment_id/:filename", async (req: Request, res: Response) => { - if (req.headers.signature !== Config.get().security.requestSignature) throw new HTTPError("Invalid request signature"); - console.log("[Cloud Delete] Deleting attachment", req.params); - - const { channel_id, batch_id, attachment_id, filename } = req.params; - const path = `attachments/${channel_id}/${batch_id}/${attachment_id}/${filename}`; - - const att = await CloudAttachment.findOne({ - where: { - uploadFilename: `${channel_id}/${batch_id}/${attachment_id}/${filename}`, - channelId: channel_id, - userAttachmentId: attachment_id, - userFilename: filename, - }, - }); - - if (att) { - await att.remove(); - await storage.delete(path); - return res.send({ success: true }); - } - return res.status(404).send("Attachment not found"); -}); - -router.post("/:channel_id/:batch_id/:attachment_id/:filename/clone_to_message/:message_id", async (req: Request, res: Response) => { - if (req.headers.signature !== Config.get().security.requestSignature) throw new HTTPError("Invalid request signature"); - console.log("[Cloud Clone] Cloning attachment to message", req.params); - - const { channel_id, batch_id, attachment_id, filename, message_id } = req.params; - const path = `attachments/${channel_id}/${batch_id}/${attachment_id}/${filename}`; - const newPath = `attachments/${channel_id}/${message_id}/${filename}`; - - const att = await CloudAttachment.findOne({ - where: { - uploadFilename: `${channel_id}/${batch_id}/${attachment_id}/${filename}`, - channelId: channel_id, - userAttachmentId: attachment_id, - userFilename: filename, - }, - }); - - if (att) { - await storage.clone(path, newPath); - return res.send({ success: true, new_path: newPath }); - } - - return res.status(404).send("Attachment not found"); -}); - export default router; diff --git a/src/cdn/routes/avatars.ts b/src/cdn/routes/avatars.ts
index c770007a..4a433b23 100644 --- a/src/cdn/routes/avatars.ts +++ b/src/cdn/routes/avatars.ts
@@ -18,19 +18,16 @@ import { Router, Response, Request } from "express"; import { Config, Snowflake } from "@spacebar/util"; -import { storage } from "../util/Storage"; import { fileTypeFromBuffer } from "file-type"; import { HTTPError } from "lambert-server"; import crypto from "crypto"; -import { multer } from "../util/multer"; +import { multer, storage, cache, ANIMATED_MIME_TYPES, STATIC_MIME_TYPES } from "../util"; // TODO: check premium and animated pfp are allowed in the config // TODO: generate different sizes of icon // TODO: generate different image types of icon // TODO: delete old icons -const ANIMATED_MIME_TYPES = ["image/apng", "image/gif", "image/gifv"]; -const STATIC_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/svg+xml", "image/svg"]; const ALLOWED_MIME_TYPES = [...ANIMATED_MIME_TYPES, ...STATIC_MIME_TYPES]; const router = Router({ mergeParams: true }); @@ -60,7 +57,7 @@ router.post("/:user_id", multer.single("file"), async (req: Request, res: Respon }); }); -router.get("/:user_id", async (req: Request, res: Response) => { +router.get("/:user_id", cache, async (req: Request, res: Response) => { let { user_id } = req.params; user_id = user_id.split(".")[0]; // remove .file extension const path = `avatars/${user_id}`; @@ -70,12 +67,11 @@ router.get("/:user_id", async (req: Request, res: Response) => { const type = await fileTypeFromBuffer(file); res.set("Content-Type", type?.mime); - res.set("Cache-Control", "public, max-age=31536000"); return res.send(file); }); -export const getAvatar = async (req: Request, res: Response) => { +router.get("/:user_id/:hash", cache, async (req: Request, res: Response) => { const { user_id } = req.params; let { hash } = req.params; hash = hash.split(".")[0]; // remove .file extension @@ -86,12 +82,9 @@ export const getAvatar = async (req: Request, res: Response) => { const type = await fileTypeFromBuffer(file); res.set("Content-Type", type?.mime); - res.set("Cache-Control", "public, max-age=31536000"); return res.send(file); -}; - -router.get("/:user_id/:hash", getAvatar); +}); router.delete("/:user_id/:id", async (req: Request, res: Response) => { if (req.headers.signature !== Config.get().security.requestSignature) throw new HTTPError("Invalid request signature"); diff --git a/src/cdn/routes/badge-icons.ts b/src/cdn/routes/badge-icons.ts
index ea7314a9..ac7c9385 100644 --- a/src/cdn/routes/badge-icons.ts +++ b/src/cdn/routes/badge-icons.ts
@@ -17,13 +17,13 @@ */ import { Router, Response, Request } from "express"; -import { storage } from "../util/Storage"; import { HTTPError } from "lambert-server"; import { fileTypeFromBuffer } from "file-type"; +import { cache, storage } from "../util"; const router = Router({ mergeParams: true }); -router.get("/:badge_id", async (req: Request, res: Response) => { +router.get("/:badge_id", cache, async (req: Request, res: Response) => { const { badge_id } = req.params; const path = `badge-icons/${badge_id}`; @@ -32,7 +32,6 @@ router.get("/:badge_id", async (req: Request, res: Response) => { const type = await fileTypeFromBuffer(file); res.set("Content-Type", type?.mime); - res.set("Cache-Control", "public, max-age=31536000, must-revalidate"); return res.send(file); }); diff --git a/src/cdn/routes/embed.ts b/src/cdn/routes/embed.ts
index 2a08da5a..f8e11ea4 100644 --- a/src/cdn/routes/embed.ts +++ b/src/cdn/routes/embed.ts
@@ -21,6 +21,7 @@ import fs from "fs/promises"; import { HTTPError } from "lambert-server"; import { join } from "path"; import { fileTypeFromBuffer } from "file-type"; +import { cache } from "../util"; const defaultAvatarHashMap = new Map([ ["0", "4a8562cf00887030c416d3ec2d46385a"], @@ -46,6 +47,7 @@ const router = Router({ mergeParams: true }); async function getFile(path: string) { try { + console.log("[CDN/Embed.ts] Trying to read file:", path); return await fs.readFile(path); } catch (error) { try { @@ -58,7 +60,7 @@ async function getFile(path: string) { } } -router.get("/avatars/:id", async (req: Request, res: Response) => { +router.get("/avatars/:id", cache, async (req: Request, res: Response) => { let { id } = req.params; id = id.split(".")[0]; // remove .file extension const hash = defaultAvatarHashMap.get(id); @@ -70,12 +72,11 @@ router.get("/avatars/:id", async (req: Request, res: Response) => { const type = await fileTypeFromBuffer(file); res.set("Content-Type", type?.mime); - res.set("Cache-Control", "public, max-age=31536000"); return res.send(file); }); -router.get("/group-avatars/:id", async (req: Request, res: Response) => { +router.get("/group-avatars/:id", cache, async (req: Request, res: Response) => { let { id } = req.params; id = id.split(".")[0]; // remove .file extension const hash = defaultGroupDMAvatarHashMap.get(id); @@ -87,7 +88,6 @@ router.get("/group-avatars/:id", async (req: Request, res: Response) => { const type = await fileTypeFromBuffer(file); res.set("Content-Type", type?.mime); - res.set("Cache-Control", "public, max-age=31536000"); return res.send(file); }); diff --git a/src/cdn/routes/guild-profiles.ts b/src/cdn/routes/guild-profiles.ts
index 6660b720..3c9eb5fb 100644 --- a/src/cdn/routes/guild-profiles.ts +++ b/src/cdn/routes/guild-profiles.ts
@@ -21,16 +21,14 @@ import crypto from "crypto"; import { Request, Response, Router } from "express"; import { HTTPError } from "lambert-server"; import { multer } from "../util/multer"; -import { storage } from "../util/Storage"; import { fileTypeFromBuffer } from "file-type"; +import { ANIMATED_MIME_TYPES, cache, STATIC_MIME_TYPES, storage } from "../util"; // TODO: check premium and animated pfp are allowed in the config // TODO: generate different sizes of icon // TODO: generate different image types of icon // TODO: delete old icons -const ANIMATED_MIME_TYPES = ["image/apng", "image/gif", "image/gifv"]; -const STATIC_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/svg+xml", "image/svg"]; const ALLOWED_MIME_TYPES = [...ANIMATED_MIME_TYPES, ...STATIC_MIME_TYPES]; const router = Router({ mergeParams: true }); @@ -60,7 +58,7 @@ router.post("/", multer.single("file"), async (req: Request, res: Response) => { }); }); -router.get("/", async (req: Request, res: Response) => { +router.get("/", cache, async (req: Request, res: Response) => { const { guild_id } = req.params; let { user_id } = req.params; user_id = user_id.split(".")[0]; // remove .file extension @@ -71,12 +69,11 @@ router.get("/", async (req: Request, res: Response) => { const type = await fileTypeFromBuffer(file); res.set("Content-Type", type?.mime); - res.set("Cache-Control", "public, max-age=31536000"); return res.send(file); }); -router.get("/:hash", async (req: Request, res: Response) => { +router.get("/:hash", cache, async (req: Request, res: Response) => { const { guild_id, user_id } = req.params; let { hash } = req.params; hash = hash.split(".")[0]; // remove .file extension @@ -87,7 +84,6 @@ router.get("/:hash", async (req: Request, res: Response) => { const type = await fileTypeFromBuffer(file); res.set("Content-Type", type?.mime); - res.set("Cache-Control", "public, max-age=31536000"); return res.send(file); }); diff --git a/src/cdn/routes/role-icons.ts b/src/cdn/routes/role-icons.ts
index 1e00f987..f987479d 100644 --- a/src/cdn/routes/role-icons.ts +++ b/src/cdn/routes/role-icons.ts
@@ -18,11 +18,10 @@ import { Router, Response, Request } from "express"; import { Config, Snowflake } from "@spacebar/util"; -import { storage } from "../util/Storage"; import { fileTypeFromBuffer } from "file-type"; import { HTTPError } from "lambert-server"; import crypto from "crypto"; -import { multer } from "../util/multer"; +import { cache, multer, STATIC_MIME_TYPES, storage } from "../util"; //Role icons ---> avatars.ts modified @@ -30,7 +29,6 @@ import { multer } from "../util/multer"; // TODO: generate different sizes of icon // TODO: generate different image types of icon -const STATIC_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/svg+xml", "image/svg"]; const ALLOWED_MIME_TYPES = [...STATIC_MIME_TYPES]; const router = Router({ mergeParams: true }); @@ -59,7 +57,7 @@ router.post("/:role_id", multer.single("file"), async (req: Request, res: Respon }); }); -router.get("/:role_id", async (req: Request, res: Response) => { +router.get("/:role_id", cache, async (req: Request, res: Response) => { const { role_id } = req.params; //role_id = role_id.split(".")[0]; // remove .file extension const path = `role-icons/${role_id}`; @@ -69,12 +67,11 @@ router.get("/:role_id", async (req: Request, res: Response) => { const type = await fileTypeFromBuffer(file); res.set("Content-Type", type?.mime); - res.set("Cache-Control", "public, max-age=31536000, must-revalidate"); return res.send(file); }); -router.get("/:role_id/:hash", async (req: Request, res: Response) => { +router.get("/:role_id/:hash", cache, async (req: Request, res: Response) => { const { role_id, hash } = req.params; //hash = hash.split(".")[0]; // remove .file extension const requested_extension = hash.split(".")[1]; @@ -92,7 +89,6 @@ router.get("/:role_id/:hash", async (req: Request, res: Response) => { const type = await fileTypeFromBuffer(file); res.set("Content-Type", type?.mime); - res.set("Cache-Control", "public, max-age=31536000, must-revalidate"); return res.send(file); }); diff --git a/src/cdn/util/basicCrdFileRouter.ts b/src/cdn/util/basicCrdFileRouter.ts
index 46fcf882..b05f3dbe 100644 --- a/src/cdn/util/basicCrdFileRouter.ts +++ b/src/cdn/util/basicCrdFileRouter.ts
@@ -1,6 +1,6 @@ /* Spacebar: A FOSS re-implementation and extension of the Discord.com backend. - Copyright (C) 2023 Spacebar and Spacebar Contributors + Copyright (C) 2025 Spacebar and Spacebar Contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published @@ -18,19 +18,16 @@ import { Router, Response, Request } from "express"; import { Config, Snowflake } from "@spacebar/util"; -import { storage } from "../util/Storage"; import { fileTypeFromBuffer } from "file-type"; import { HTTPError } from "lambert-server"; import crypto from "crypto"; -import { multer } from "../util/multer"; +import { storage, multer, ANIMATED_MIME_TYPES, STATIC_MIME_TYPES } from "../util"; // TODO: check premium and animated pfp are allowed in the config // TODO: generate different sizes of icon // TODO: generate different image types of icon // TODO: delete old icons -const ANIMATED_MIME_TYPES = ["image/apng", "image/gif", "image/gifv"]; -const STATIC_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/svg+xml", "image/svg"]; const ALLOWED_MIME_TYPES = [...ANIMATED_MIME_TYPES, ...STATIC_MIME_TYPES]; export class BasicCrdFileRouterOptions { @@ -44,6 +41,7 @@ export class BasicCrdFileRouterOptions { export function createBasicCrdFileRouter(opts: BasicCrdFileRouterOptions) { const router = Router({ mergeParams: true }); + console.log("Creating Basic CRD File Router with opts:", JSON.stringify(opts)); router.post("/:user_id", multer.single("file"), async (req: Request, res: Response) => { if (req.headers.signature !== Config.get().security.requestSignature) throw new HTTPError("Invalid request signature"); diff --git a/src/cdn/util/cache.ts b/src/cdn/util/cache.ts new file mode 100644
index 00000000..c48087dd --- /dev/null +++ b/src/cdn/util/cache.ts
@@ -0,0 +1,7 @@ +import { NextFunction, Response, Request } from "express"; + +export function cache(req: Request, res: Response, next: NextFunction) { + const durationInSeconds = 21600; // 6 hours + res.set("Cache-Control", `public, max-age=${durationInSeconds}, s-maxage=${durationInSeconds}, immutable`); + next(); +} diff --git a/src/cdn/util/FileStorage.ts b/src/cdn/util/fileStorage.ts
index 6f01b3c5..e2118228 100644 --- a/src/cdn/util/FileStorage.ts +++ b/src/cdn/util/fileStorage.ts
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import { Storage } from "./Storage"; +import { Storage } from "."; import fs from "fs"; import fsp from "fs/promises"; import { join, dirname } from "path"; @@ -36,6 +36,7 @@ function getPath(path: string) { export class FileStorage implements Storage { async get(path: string): Promise<Buffer | null> { + if (process.env.CDN_LOG_IO) console.log(`[CDN][IO] read file: ${path}`); path = getPath(path); try { return await fsp.readFile(path); @@ -51,6 +52,7 @@ export class FileStorage implements Storage { } async clone(path: string, newPath: string) { + if (process.env.CDN_LOG_IO) console.log(`[CDN][IO] clone file: ${path} -> ${newPath}`); path = getPath(path); newPath = getPath(newPath); @@ -61,6 +63,7 @@ export class FileStorage implements Storage { } async set(path: string, value: Buffer) { + if (process.env.CDN_LOG_IO) console.log(`[CDN][IO] write file: ${path}`); path = getPath(path); if (!fs.existsSync(dirname(path))) fs.mkdirSync(dirname(path), { recursive: true }); @@ -71,6 +74,7 @@ export class FileStorage implements Storage { } async delete(path: string) { + if (process.env.CDN_LOG_IO) console.log(`[CDN][IO] delete file: ${path}`); //TODO we should delete the parent directory if empty fs.unlinkSync(getPath(path)); } diff --git a/src/cdn/util/index.ts b/src/cdn/util/index.ts
index ba249845..9d58117b 100644 --- a/src/cdn/util/index.ts +++ b/src/cdn/util/index.ts
@@ -16,6 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -export * from "./FileStorage"; +export * from "./storage"; +export * from "./fileStorage"; +export * from "./s3Storage"; export * from "./multer"; -export * from "./Storage"; +export * from "./cache"; +export * from "./mimetypes"; diff --git a/src/cdn/util/mimetypes.ts b/src/cdn/util/mimetypes.ts new file mode 100644
index 00000000..c56bd734 --- /dev/null +++ b/src/cdn/util/mimetypes.ts
@@ -0,0 +1,2 @@ +export const ANIMATED_MIME_TYPES = ["image/apng", "image/gif", "image/gifv"]; +export const STATIC_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/svg+xml", "image/svg"]; diff --git a/src/cdn/util/S3Storage.ts b/src/cdn/util/s3Storage.ts
index f657fd29..2827e974 100644 --- a/src/cdn/util/S3Storage.ts +++ b/src/cdn/util/s3Storage.ts
@@ -17,7 +17,7 @@ */ import { Readable } from "stream"; -import { Storage } from "./Storage"; +import { Storage } from "."; const readableToBuffer = (readable: Readable): Promise<Buffer> => new Promise((resolve, reject) => { @@ -46,6 +46,7 @@ export class S3Storage implements Storage { } async set(path: string, data: Buffer): Promise<void> { + if (process.env.CDN_LOG_IO) console.log(`[CDN][IO] write file: ${path}`); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error await this.client.putObject({ @@ -56,6 +57,7 @@ export class S3Storage implements Storage { } async clone(path: string, newPath: string): Promise<void> { + if (process.env.CDN_LOG_IO) console.log(`[CDN][IO] clone file: ${path} -> ${newPath}`); // TODO: does this even work? // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error @@ -67,6 +69,7 @@ export class S3Storage implements Storage { } async get(path: string): Promise<Buffer | null> { + if (process.env.CDN_LOG_IO) console.log(`[CDN][IO] read file: ${path}`); try { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error @@ -88,6 +91,7 @@ export class S3Storage implements Storage { } async delete(path: string): Promise<void> { + if (process.env.CDN_LOG_IO) console.log(`[CDN][IO] delete file: ${path}`); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error await this.client.deleteObject({ diff --git a/src/cdn/util/Storage.ts b/src/cdn/util/storage.ts
index 1184bde8..f9d9d6f9 100644 --- a/src/cdn/util/Storage.ts +++ b/src/cdn/util/storage.ts
@@ -16,7 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import { FileStorage } from "./FileStorage"; +import { FileStorage } from "./fileStorage"; +import { S3Storage } from "./s3Storage"; import path from "path"; import fs from "fs"; import { red } from "picocolors"; @@ -39,7 +40,7 @@ if (process.env.STORAGE_PROVIDER === "file" || !process.env.STORAGE_PROVIDER) { location = path.join(process.cwd(), "files"); } // TODO: move this to some start func, so it doesn't run when server is imported - //console.log(`[CDN] storage location: ${bgCyan(`${black(location)}`)}`); + // console.log(`[CDN] storage location: ${bgCyan(`${black(location)}`)}`); if (!fs.existsSync(location)) fs.mkdirSync(location); process.env.STORAGE_LOCATION = location;