diff --git a/src/api/routes/auth/generate-registration-tokens.ts b/src/api/routes/auth/generate-registration-tokens.ts index 303c6ab5..ca5020fb 100644 --- a/src/api/routes/auth/generate-registration-tokens.ts +++ b/src/api/routes/auth/generate-registration-tokens.ts @@ -16,7 +16,7 @@ along with this program. If not, see . */ -import { random, route } from "@spacebar/api"; +import { randomString, route } from "@spacebar/api"; import { Config, ValidRegistrationToken } from "@spacebar/util"; import { Request, Response, Router } from "express"; @@ -51,7 +51,7 @@ router.get( for (let i = 0; i < count; i++) { const token = ValidRegistrationToken.create({ - token: random(length), + token: randomString(length), expires_at: new Date( Date.now() + Config.get().security diff --git a/src/api/routes/channels/#channel_id/attachments.ts b/src/api/routes/channels/#channel_id/attachments.ts new file mode 100644 index 00000000..5da1a778 --- /dev/null +++ b/src/api/routes/channels/#channel_id/attachments.ts @@ -0,0 +1,100 @@ +/* + 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 . +*/ + +import { generateCode, randomString, route } from "@spacebar/api"; +import { + Attachment, + Channel, + Config, + emitEvent, + GreetRequestSchema, + Message, + MessageCreateEvent, + MessageType, + Permissions, + Sticker, + UploadAttachmentRequestSchema, + UploadAttachmentResponseSchema, + User, +} from "@spacebar/util"; +import { Request, Response, Router } from "express"; +import { In } from "typeorm"; +import { CloudAttachment } from "../../../../util/entities/CloudAttachment"; + +const router: Router = Router(); + +router.post( + "/", + route({ + requestBody: "UploadAttachmentRequestSchema", + responses: { + 200: { + body: "UploadAttachmentResponseSchema", + }, + 404: {}, + 400: { + body: "APIErrorResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const payload = req.body as UploadAttachmentRequestSchema; + const { channel_id } = req.params; + + const user = await User.findOneOrFail({ where: { id: req.user_id } }); + const channel = await Channel.findOneOrFail({ where: { id: channel_id } }); + + if (!(await channel.getUserPermissions({ user_id: req.user_id })).has(Permissions.FLAGS.ATTACH_FILES)) { + return res.status(403).json({ + code: 403, + message: "Missing Permissions: ATTACH_FILES", + }); + } + + const cdnUrl = Config.get().cdn.endpointPublic; + const batchId = `CLOUD_${user.id}_${randomString(128)}`; + const attachments = await Promise.all( + payload.files.map(async (attachment) => { + attachment.filename = attachment.filename.replaceAll(" ", "_").replace(/[^a-zA-Z0-9._]+/g, ""); + const uploadFilename = `${batchId}/${attachment.id}/${attachment.filename}`; + const newAttachment = CloudAttachment.create({ + user: user, + channel: channel, + uploadFilename: uploadFilename, + userAttachmentId: attachment.id, + userFilename: attachment.filename, + userFileSize: attachment.file_size, + userIsClip: attachment.is_clip, + userOriginalContentType: attachment.original_content_type, + }); + await newAttachment.save(); + return newAttachment; + }), + ); + + res.send({attachments: attachments.map(a => { + return { + id: a.userAttachmentId, + upload_filename: a.uploadFilename, + upload_url: `${cdnUrl}/attachments/${a.uploadFilename}`, + } + })} as UploadAttachmentResponseSchema); + }, +); + +export default router; diff --git a/src/api/routes/channels/#channel_id/invites.ts b/src/api/routes/channels/#channel_id/invites.ts index ae32e80d..e8602729 100644 --- a/src/api/routes/channels/#channel_id/invites.ts +++ b/src/api/routes/channels/#channel_id/invites.ts @@ -16,7 +16,7 @@ along with this program. If not, see . */ -import { random, route } from "@spacebar/api"; +import { randomString, route } from "@spacebar/api"; import { Channel, Guild, @@ -70,7 +70,7 @@ router.post( : new Date(body.max_age * 1000 + Date.now()); const invite = await Invite.create({ - code: random(), + code: randomString(), temporary: body.temporary || true, uses: 0, max_uses: body.max_uses ? Math.max(0, body.max_uses) : 0, diff --git a/src/api/routes/guilds/#guild_id/widget.json.ts b/src/api/routes/guilds/#guild_id/widget.json.ts index 5d9f28c2..f0518184 100644 --- a/src/api/routes/guilds/#guild_id/widget.json.ts +++ b/src/api/routes/guilds/#guild_id/widget.json.ts @@ -16,7 +16,7 @@ along with this program. If not, see . */ -import { random, route } from "@spacebar/api"; +import { randomString, route } from "@spacebar/api"; import { Channel, DiscordApiErrors, @@ -77,7 +77,7 @@ router.get( const expires_at = new Date(max_age * 1000 + Date.now()); invite = await Invite.create({ - code: random(), + code: randomString(), temporary: false, uses: 0, max_uses: 0, diff --git a/src/api/util/utility/RandomInviteID.ts b/src/api/util/utility/RandomInviteID.ts index 926750d3..bd50ed92 100644 --- a/src/api/util/utility/RandomInviteID.ts +++ b/src/api/util/utility/RandomInviteID.ts @@ -22,7 +22,7 @@ import crypto from "crypto"; // TODO: 'random'? seriously? who named this? // And why is this even here? Just use cryto.randomBytes? -export function random(length = 6) { +export function randomString(length = 6) { // Declare all characters const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; diff --git a/src/cdn/routes/attachments.ts b/src/cdn/routes/attachments.ts index 4dca803b..79fbed57 100644 --- a/src/cdn/routes/attachments.ts +++ b/src/cdn/routes/attachments.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 @@ -16,133 +16,135 @@ along with this program. If not, see . */ -import { - Config, - hasValidSignature, - NewUrlUserSignatureData, - Snowflake, - UrlSignResult, -} from "@spacebar/util"; +import { Config, hasValidSignature, NewUrlUserSignatureData, Snowflake, UrlSignResult } from "@spacebar/util"; import { Request, Response, Router } from "express"; import FileType from "file-type"; 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"; const router = Router(); -const SANITIZED_CONTENT_TYPE = [ - "text/html", - "text/mhtml", - "multipart/related", - "application/xhtml+xml", -]; +const SANITIZED_CONTENT_TYPE = ["text/html", "text/mhtml", "multipart/related", "application/xhtml+xml"]; -router.post( - "/:channel_id", - multer.single("file"), - async (req: Request, res: Response) => { - if (req.headers.signature !== Config.get().security.requestSignature) - throw new HTTPError("Invalid request signature"); +router.post("/:channel_id", multer.single("file"), async (req: Request, res: Response) => { + if (req.headers.signature !== Config.get().security.requestSignature) throw new HTTPError("Invalid request signature"); - if (!req.file) throw new HTTPError("file missing"); + if (!req.file) throw new HTTPError("file missing"); - const { buffer, mimetype, size, originalname } = req.file; - const { channel_id } = req.params; - const filename = originalname - .replaceAll(" ", "_") - .replace(/[^a-zA-Z0-9._]+/g, ""); - const id = Snowflake.generate(); - const path = `attachments/${channel_id}/${id}/${filename}`; + const { buffer, mimetype, size, originalname } = req.file; + const { channel_id } = req.params; + const filename = originalname.replaceAll(" ", "_").replace(/[^a-zA-Z0-9._]+/g, ""); + const id = Snowflake.generate(); + const path = `attachments/${channel_id}/${id}/${filename}`; - const endpoint = - Config.get()?.cdn.endpointPublic || "http://localhost:3001"; + const endpoint = Config.get()?.cdn.endpointPublic || "http://localhost:3001"; - await storage.set(path, buffer); - let width; - let height; - if (mimetype.includes("image")) { - const dimensions = imageSize(buffer); - if (dimensions) { - width = dimensions.width; - height = dimensions.height; - } + await storage.set(path, buffer); + let width; + let height; + if (mimetype.includes("image")) { + const dimensions = imageSize(buffer); + if (dimensions) { + width = dimensions.width; + height = dimensions.height; } + } - const finalUrl = `${endpoint}/${path}`; + const finalUrl = `${endpoint}/${path}`; - const file = { - id, - content_type: mimetype, - filename: filename, - size, - url: finalUrl, - path, - width, - height, - }; + const file = { + id, + content_type: mimetype, + filename: filename, + size, + url: finalUrl, + path, + width, + height, + }; - return res.json(file); - }, -); + return res.json(file); +}); -router.get( - "/:channel_id/:id/:filename", - async (req: Request, res: Response) => { - const { channel_id, id, filename } = req.params; - // const { format } = req.query; +router.get("/:channel_id/:id/:filename", async (req: Request, res: Response) => { + const { channel_id, id, filename } = req.params; + // const { format } = req.query; - const path = `attachments/${channel_id}/${id}/${filename}`; + 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; + 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."); + 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 FileType.fromBuffer(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); + res.set("Cache-Control", "public, max-age=31536000"); + + return res.send(file); +}); + +router.delete("/:channel_id/:id/:filename", async (req: Request, res: Response) => { + if (req.headers.signature !== Config.get().security.requestSignature) throw new HTTPError("Invalid request signature"); + + const { channel_id, id, filename } = req.params; + const path = `attachments/${channel_id}/${id}/${filename}`; + + await storage.delete(path); + + 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}`, + }, + }); + + if (!req.file) throw new HTTPError("file missing"); + + const { buffer, mimetype, size } = req.file; + const path = `${channel_id}/${batch_id}/${attachment_id}/${filename}`; + + await storage.set(path, buffer); + if (mimetype.includes("image")) { + const dimensions = imageSize(buffer); + if (dimensions) { + att.width = dimensions.width; + att.height = dimensions.height; } + } - const file = await storage.get(path); - if (!file) throw new HTTPError("File not found"); - const type = await FileType.fromBuffer(file); - let content_type = type?.mime || "application/octet-stream"; + att.size = size; + att.contentType = att.userOriginalContentType ?? mimetype; - if (SANITIZED_CONTENT_TYPE.includes(content_type)) { - content_type = "application/octet-stream"; - } + await att.save(); - res.set("Content-Type", content_type); - res.set("Cache-Control", "public, max-age=31536000"); - - return res.send(file); - }, -); - -router.delete( - "/:channel_id/:id/:filename", - async (req: Request, res: Response) => { - if (req.headers.signature !== Config.get().security.requestSignature) - throw new HTTPError("Invalid request signature"); - - const { channel_id, id, filename } = req.params; - const path = `attachments/${channel_id}/${id}/${filename}`; - - await storage.delete(path); - - return res.send({ success: true }); - }, -); + return res.status(200); +}); export default router; diff --git a/src/util/entities/CloudAttachment.ts b/src/util/entities/CloudAttachment.ts new file mode 100644 index 00000000..02c52743 --- /dev/null +++ b/src/util/entities/CloudAttachment.ts @@ -0,0 +1,85 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 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 . +*/ + +import { BeforeRemove, Column, Entity, JoinColumn, ManyToOne, OneToOne, RelationId } from "typeorm"; +import { URL } from "url"; +import { deleteFile } from "../util/cdn"; +import { BaseClass } from "./BaseClass"; +import { dbEngine } from "../util/Database"; +import { User } from "./User"; +import { Channel } from "./Channel"; + +@Entity({ + name: "cloud_attachments", + engine: dbEngine, +}) +export class CloudAttachment extends BaseClass { + // Internal tracking metadata + @Column({ name: "user_id", nullable: true }) + @RelationId((att: CloudAttachment) => att.user) + userId: string; + + @JoinColumn({ name: "userId" }) + @ManyToOne(() => User, { nullable: true, onDelete: "SET NULL" }) + user?: User; + + @Column({ name: "channel_id", nullable: true }) + @RelationId((att: CloudAttachment) => att.channel) + channelId?: string; // channel the file is uploaded to + + @JoinColumn({ name: "channelId" }) + @ManyToOne(() => Channel, { nullable: true, onDelete: "SET NULL" }) + channel?: Channel; // channel the file is uploaded to + + @Column({ name: "upload_filename" }) + uploadFilename: string; + + // User-provided info + @Column({ name: "user_attachment_id", nullable: true }) + userAttachmentId?: string; + + @Column({ name: "user_filename" }) + userFilename: string; // name of file attached + + @Column({ name: "user_file_size", nullable: true }) + userFileSize?: number; // size of file in bytes + + @Column({ name: "user_original_content_type", nullable: true }) + userOriginalContentType?: string; + + @Column({ name: "user_is_clip", nullable: true }) + userIsClip?: boolean; // whether the file is a clip + + // Actual file info, initialised after upload + @Column({ nullable: true }) + size?: number; // size of file in bytes + + @Column({ nullable: true }) + height?: number; // height of file (if image) + + @Column({ nullable: true }) + width?: number; // width of file (if image) + + @Column({ name: "content_type", nullable: true }) + contentType?: string; + + // @BeforeRemove() + // onDelete() { + // return deleteFile(new URL(this.url).pathname); + // } +} diff --git a/src/util/migration/postgres/1758654246197-CloudAttachments.ts b/src/util/migration/postgres/1758654246197-CloudAttachments.ts new file mode 100644 index 00000000..03f745b2 --- /dev/null +++ b/src/util/migration/postgres/1758654246197-CloudAttachments.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CloudAttachments1758654246197 implements MigrationInterface { + name = 'CloudAttachments1758654246197' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "cloud_attachments" ("id" character varying NOT NULL, "user_id" character varying, "channel_id" character varying, "upload_filename" character varying NOT NULL, "user_attachment_id" character varying, "user_filename" character varying NOT NULL, "user_file_size" integer, "user_original_content_type" character varying, "user_is_clip" boolean, "size" integer, "height" integer, "width" integer, "content_type" character varying, "userId" character varying, "channelId" character varying, CONSTRAINT "PK_5794827a3ee7c9318612dcb70c8" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "cloud_attachments" ADD CONSTRAINT "FK_e6b32df2004e8ad0f488b4a2019" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "cloud_attachments" ADD CONSTRAINT "FK_cab965a18f8ca30293bff3d50a8" FOREIGN KEY ("channelId") REFERENCES "channels"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "cloud_attachments" DROP CONSTRAINT "FK_cab965a18f8ca30293bff3d50a8"`); + await queryRunner.query(`ALTER TABLE "cloud_attachments" DROP CONSTRAINT "FK_e6b32df2004e8ad0f488b4a2019"`); + await queryRunner.query(`DROP TABLE "cloud_attachments"`); + } + +} diff --git a/src/util/schemas/UploadAttachmentRequestSchema.ts b/src/util/schemas/UploadAttachmentRequestSchema.ts new file mode 100644 index 00000000..06cb65f1 --- /dev/null +++ b/src/util/schemas/UploadAttachmentRequestSchema.ts @@ -0,0 +1,30 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2024 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 . +*/ + + +export interface UploadAttachmentRequestSchema { + files: UploadAttachmentRequest[]; +} + +export interface UploadAttachmentRequest { + id: string; + filename: string; + file_size: number; + is_clip?: boolean; + original_content_type?: string; +} diff --git a/src/util/schemas/index.ts b/src/util/schemas/index.ts index 9bba9cb4..39710425 100644 --- a/src/util/schemas/index.ts +++ b/src/util/schemas/index.ts @@ -81,6 +81,7 @@ export * from "./TemplateModifySchema"; export * from "./TotpDisableSchema"; export * from "./TotpEnableSchema"; export * from "./TotpSchema"; +export * from "./UploadAttachmentRequestSchema"; export * from "./UserDeleteSchema"; export * from "./UserGuildSettingsSchema"; export * from "./UserModifySchema"; diff --git a/src/util/schemas/responses/UploadAttachmentResponseSchema.ts b/src/util/schemas/responses/UploadAttachmentResponseSchema.ts new file mode 100644 index 00000000..322137da --- /dev/null +++ b/src/util/schemas/responses/UploadAttachmentResponseSchema.ts @@ -0,0 +1,28 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2024 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 . +*/ + + +export interface UploadAttachmentResponseSchema { + attachments: UploadAttachmentResponse[]; +} + +export interface UploadAttachmentResponse { + id?: string; + upload_url: string; + upload_filename: string; +} diff --git a/src/util/schemas/responses/index.ts b/src/util/schemas/responses/index.ts index 8ca6fc61..561af184 100644 --- a/src/util/schemas/responses/index.ts +++ b/src/util/schemas/responses/index.ts @@ -58,6 +58,7 @@ export * from "./Tenor"; export * from "./TokenResponse"; export * from "./TypedResponses"; export * from "./UpdatesResponse"; +export * from "./UploadAttachmentResponseSchema"; export * from "./UserNoteResponse"; export * from "./UserProfileResponse"; export * from "./UserRelationshipsResponse"; diff --git a/src/util/util/Permissions.ts b/src/util/util/Permissions.ts index 53a4c4f0..440be9df 100644 --- a/src/util/util/Permissions.ts +++ b/src/util/util/Permissions.ts @@ -20,7 +20,7 @@ export class Permissions extends BitField { constructor(bits: BitFieldResolvable = 0) { super(bits); if (this.bitfield & Permissions.FLAGS.ADMINISTRATOR) { - this.bitfield = ALL_PERMISSIONS; + this.bitfield = Permissions.ALL_PERMISSIONS; } } @@ -73,6 +73,11 @@ export class Permissions extends BitField { // CUSTOM_PERMISSION: BigInt(1) << BigInt(0) + CUSTOM_PERMISSION_OFFSET }; + static ALL_PERMISSIONS = Object.values(Permissions.FLAGS).reduce( + (total, val) => total | val, + BigInt(0), + ); + any(permission: PermissionResolvable, checkAdmin = true) { return ( (checkAdmin && super.any(Permissions.FLAGS.ADMINISTRATOR)) || @@ -207,11 +212,6 @@ export class Permissions extends BitField { )); } -const ALL_PERMISSIONS = Object.values(Permissions.FLAGS).reduce( - (total, val) => total | val, - BigInt(0), -); - export type PermissionCache = { channel?: Channel | undefined; member?: Member | undefined;