diff --git a/bun.lockb b/bun.lockb index 1393f36f..4174b527 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/classes/media.ts b/classes/media.ts index 98a602e1..9eb6bfd6 100644 --- a/classes/media.ts +++ b/classes/media.ts @@ -5,7 +5,7 @@ import { S3Client, } from "@aws-sdk/client-s3"; import { ConfigType } from "@config"; - +import sharp from "sharp"; class MediaBackend { backend: string; @@ -16,26 +16,98 @@ class MediaBackend { /** * Adds media to the media backend * @param media - * @returns The hash of the file in SHA-256 (hex format) + * @returns The hash of the file in SHA-256 (hex format) with the file extension added to it */ async addMedia(media: File) { const hash = new Bun.SHA256() .update(await media.arrayBuffer()) .digest("hex"); - return hash; + return `${hash}.${media.name.split(".").pop()}`; } + + async convertMedia(media: File, config: ConfigType) { + const sharpCommand = sharp(await media.arrayBuffer()); + + // Rename ".jpg" files to ".jpeg" to avoid sharp errors + let name = media.name; + if (media.name.endsWith(".jpg")) { + name = media.name.replace(".jpg", ".jpeg"); + } + + const fileFormatToConvertTo = config.media.conversion.convert_to; + + switch (fileFormatToConvertTo) { + case "png": + return new File( + [(await sharpCommand.png().toBuffer()).buffer], + // Replace the file extension with PNG + name.replace(/\.[^/.]+$/, ".png"), + { + type: "image/png", + } + ); + case "webp": + return new File( + [(await sharpCommand.webp().toBuffer()).buffer], + // Replace the file extension with WebP + name.replace(/\.[^/.]+$/, ".webp"), + { + type: "image/webp", + } + ); + case "jpeg": + return new File( + [(await sharpCommand.jpeg().toBuffer()).buffer], + // Replace the file extension with JPEG + name.replace(/\.[^/.]+$/, ".jpeg"), + { + type: "image/jpeg", + } + ); + case "avif": + return new File( + [(await sharpCommand.avif().toBuffer()).buffer], + // Replace the file extension with AVIF + name.replace(/\.[^/.]+$/, ".avif"), + { + type: "image/avif", + } + ); + // Needs special build of libvips + case "jxl": + return new File( + [(await sharpCommand.jxl().toBuffer()).buffer], + // Replace the file extension with JXL + name.replace(/\.[^/.]+$/, ".jxl"), + { + type: "image/jxl", + } + ); + case "heif": + return new File( + [(await sharpCommand.heif().toBuffer()).buffer], + // Replace the file extension with HEIF + name.replace(/\.[^/.]+$/, ".heif"), + { + type: "image/heif", + } + ); + default: + return media; + } + } + /** * Retrieves element from media backend by hash * @param hash The hash of the element in SHA-256 hex format + * @param extension The extension of the file * @returns The file as a File object */ // eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars async getMediaByHash( // eslint-disable-next-line @typescript-eslint/no-unused-vars - hash: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - extension: string + hash: string ): Promise { return new File([], "test"); } @@ -45,35 +117,29 @@ class MediaBackend { * S3 Backend, stores files in S3 */ export class S3Backend extends MediaBackend { - endpoint: string; - bucket: string; - region: string; - accessKey: string; - secretKey: string; - publicUrl: string; client: S3Client; + config: ConfigType; constructor(config: ConfigType) { super("s3"); - this.endpoint = config.s3.endpoint; - this.bucket = config.s3.bucket_name; - this.region = config.s3.region; - this.accessKey = config.s3.access_key; - this.secretKey = config.s3.secret_access_key; - this.publicUrl = config.s3.public_url; + this.config = config; this.client = new S3Client({ - endpoint: this.endpoint, - region: this.region || "auto", + endpoint: this.config.s3.endpoint, + region: this.config.s3.region || "auto", credentials: { - accessKeyId: this.accessKey, - secretAccessKey: this.secretKey, + accessKeyId: this.config.s3.access_key, + secretAccessKey: this.config.s3.secret_access_key, }, }); } async addMedia(media: File): Promise { + if (this.config.media.conversion.convert_images) { + media = await this.convertMedia(media, this.config); + } + const hash = await super.addMedia(media); if (!hash) { @@ -81,10 +147,7 @@ export class S3Backend extends MediaBackend { } // Check if file is already present - const existingFile = await this.getMediaByHash( - hash, - media.name.split(".").pop() || "" - ); + const existingFile = await this.getMediaByHash(hash); if (existingFile) { // File already exists, so return the hash without uploading it @@ -92,7 +155,7 @@ export class S3Backend extends MediaBackend { } const command = new PutObjectCommand({ - Bucket: this.bucket, + Bucket: this.config.s3.bucket_name, Key: hash, Body: Buffer.from(await media.arrayBuffer()), ContentType: media.type, @@ -111,12 +174,9 @@ export class S3Backend extends MediaBackend { return hash; } - async getMediaByHash( - hash: string, - extension: string - ): Promise { + async getMediaByHash(hash: string): Promise { const command = new GetObjectCommand({ - Bucket: this.bucket, + Bucket: this.config.s3.bucket_name, Key: hash, }); @@ -138,7 +198,7 @@ export class S3Backend extends MediaBackend { throw new Error("Failed to get file"); } - return new File([body], `${hash}.${extension}`, { + return new File([body], hash, { type: response.ContentType, }); } @@ -148,11 +208,19 @@ export class S3Backend extends MediaBackend { * Local backend, stores files on filesystem */ export class LocalBackend extends MediaBackend { - constructor() { + config: ConfigType; + + constructor(config: ConfigType) { super("local"); + + this.config = config; } async addMedia(media: File): Promise { + if (this.config.media.conversion.convert_images) { + media = await this.convertMedia(media, this.config); + } + const hash = await super.addMedia(media); await Bun.write(Bun.file(`${process.cwd()}/uploads/${hash}`), media); @@ -160,18 +228,41 @@ export class LocalBackend extends MediaBackend { return hash; } - async getMediaByHash( - hash: string, - extension: string - ): Promise { + async getMediaByHash(hash: string): Promise { const file = Bun.file(`${process.cwd()}/uploads/${hash}`); if (!(await file.exists())) { return null; } - return new File([await file.arrayBuffer()], `${hash}.${extension}`, { + return new File([await file.arrayBuffer()], `${hash}`, { type: file.type, }); } } + +export const uploadFile = (file: File, config: ConfigType) => { + const backend = config.media.backend; + + if (backend === "local") { + return new LocalBackend(config).addMedia(file); + } else if (backend === "s3") { + return new S3Backend(config).addMedia(file); + } +}; + +export const getFile = ( + hash: string, + extension: string, + config: ConfigType +) => { + const backend = config.media.backend; + + if (backend === "local") { + return new LocalBackend(config).getMediaByHash(hash); + } else if (backend === "s3") { + return new S3Backend(config).getMediaByHash(hash); + } + + return null; +}; diff --git a/config/config.example.toml b/config/config.example.toml index d4e4e935..081ca89f 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -23,14 +23,17 @@ tls = true [media] # Can be "s3" or "local", where "local" uploads the file to the local filesystem -backend = "s3" # NOT IMPLEMENTED +# If you need to change this value after setting up your instance, you must move all the files +# from one backend to the other manually +backend = "s3" # Whether to check the hash of media when uploading to avoid duplication -deduplicate_media = true # NOT IMPLEMENTED +deduplicate_media = true [media.conversion] -convert_images = false # NOT IMPLEMENTED -# Can be: "jxl", "webp", "avif", "png", "jpg", "gif" -convert_to = "webp" # NOT IMPLEMENTED +convert_images = false +# Can be: "jxl", "webp", "avif", "png", "jpg", "heif" +# JXL support will likely not work +convert_to = "webp" [s3] # Can be left blank if you don't use the S3 media backend diff --git a/database/entities/RawActor.ts b/database/entities/RawActor.ts index 2b174aad..47095d1a 100644 --- a/database/entities/RawActor.ts +++ b/database/entities/RawActor.ts @@ -120,10 +120,10 @@ export class RawActor extends BaseEntity { isLocalUser ? "" : `@${this.getInstanceDomain()}` }`, avatar: - ((icon as APImage).url as string | undefined) ?? + ((icon as APImage).url as string | undefined) || config.defaults.avatar, header: - ((image as APImage).url as string | undefined) ?? + ((image as APImage).url as string | undefined) || config.defaults.header, locked: false, created_at: new Date(published ?? 0).toISOString(), diff --git a/database/entities/User.ts b/database/entities/User.ts index dd3e2f05..97392082 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -1,4 +1,4 @@ -import { getConfig } from "@config"; +import { ConfigType, getConfig } from "@config"; import { BaseEntity, Column, @@ -14,7 +14,11 @@ import { } from "typeorm"; import { APIAccount } from "~types/entities/account"; import { RawActor } from "./RawActor"; -import { APActor, APOrderedCollectionPage } from "activitypub-types"; +import { + APActor, + APCollectionPage, + APOrderedCollectionPage, +} from "activitypub-types"; import { RawObject } from "./RawObject"; import { Token } from "./Token"; import { Status } from "./Status"; @@ -90,13 +94,13 @@ export class User extends BaseEntity { source!: APISource; /** - * The avatar for the user. + * The avatar for the user (filename, as UUID) */ @Column("varchar") avatar!: string; /** - * The header for the user. + * The header for the user (filename, as UUID) */ @Column("varchar") header!: string; @@ -156,6 +160,32 @@ export class User extends BaseEntity { @JoinTable() pinned_notes!: RawObject[]; + /** + * Get the user's avatar in raw URL format + * @param config The config to use + * @returns The raw URL for the user's avatar + */ + getAvatarUrl(config: ConfigType) { + if (config.media.backend === "local") { + return `${config.http.base_url}/media/${this.avatar}`; + } else if (config.media.backend === "s3") { + return `${config.s3.public_url}/${this.avatar}`; + } + } + + /** + * Get the user's header in raw URL format + * @param config The config to use + * @returns The raw URL for the user's header + */ + getHeaderUrl(config: ConfigType) { + if (config.media.backend === "local") { + return `${config.http.base_url}/media/${this.header}`; + } else if (config.media.backend === "s3") { + return `${config.s3.public_url}/${this.header}`; + } + } + static async getFromRequest(req: Request) { // Check auth token const token = req.headers.get("Authorization")?.split(" ")[1] || ""; @@ -196,7 +226,7 @@ export class User extends BaseEntity { while (followers.type === "OrderedCollectionPage" && followers.next) { followers = await fetch((followers.next as string).toString(), { headers: { Accept: "application/activity+json" }, - }).then(res => res.json()); + }).then(res => res.json() as APCollectionPage); followersList = { ...followersList, @@ -408,11 +438,11 @@ export class User extends BaseEntity { summary: this.note, icon: { type: "Image", - url: this.avatar, + url: this.getAvatarUrl(config), }, image: { type: "Image", - url: this.header, + url: this.getHeaderUrl(config), }, publicKey: { id: `${config.http.base_url}/users/${this.username}/actor#main-key`, diff --git a/package.json b/package.json index 8ea9c694..780c7cf9 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,9 @@ "dev": "bun run index.ts", "start": "bun run index.ts" }, + "trustedDependencies": [ + "sharp" + ], "devDependencies": { "@julr/unocss-preset-forms": "^0.0.5", "@types/jsonld": "^1.5.9", diff --git a/server/api/api/v1/accounts/update_credentials/index.ts b/server/api/api/v1/accounts/update_credentials/index.ts index 14339ab5..76fb4e90 100644 --- a/server/api/api/v1/accounts/update_credentials/index.ts +++ b/server/api/api/v1/accounts/update_credentials/index.ts @@ -5,6 +5,7 @@ import { User } from "~database/entities/User"; import { applyConfig } from "@api"; import { sanitize } from "isomorphic-dompurify"; import { sanitizeHtml } from "@sanitization"; +import { uploadFile } from "~classes/media"; export const meta = applyConfig({ allowedMethods: ["PATCH"], @@ -145,7 +146,9 @@ export default async (req: Request): Promise => { ); } - // TODO: Store the file somewhere and then change the user's actual avatar + const hash = await uploadFile(avatar, config); + + user.avatar = hash || ""; } if (header) { @@ -156,7 +159,10 @@ export default async (req: Request): Promise => { 422 ); } - // TODO: Store the file somewhere and then change the user's actual header + + const hash = await uploadFile(header, config); + + user.header = hash || ""; } if (locked) { diff --git a/server/api/api/v1/instance/index.ts b/server/api/api/v1/instance/index.ts index c981d881..a1c20de8 100644 --- a/server/api/api/v1/instance/index.ts +++ b/server/api/api/v1/instance/index.ts @@ -48,6 +48,12 @@ export default async (): Promise => { characters_reserved_per_url: 0, max_characters: config.validation.max_note_size, max_media_attachments: config.validation.max_media_attachments, + supported_mime_types: [ + "text/plain", + "text/markdown", + "text/html", + "text/x.misskeymarkdown", + ], }, }, description: "A test instance", @@ -67,7 +73,43 @@ export default async (): Promise => { urls: { streaming_api: "", }, - version: "0.0.1", + version: "4.2.0+glitch (compatible; Lysand 0.0.1)", max_toot_chars: config.validation.max_note_size, + pleroma: { + metadata: { + // account_activation_required: false, + features: [ + "pleroma_api", + "akkoma_api", + "mastodon_api", + // "mastodon_api_streaming", + // "polls", + // "v2_suggestions", + // "pleroma_explicit_addressing", + // "shareable_emoji_packs", + // "multifetch", + // "pleroma:api/v1/notifications:include_types_filter", + "quote_posting", + "editing", + // "bubble_timeline", + // "relay", + // "pleroma_emoji_reactions", + // "exposable_reactions", + // "profile_directory", + // "custom_emoji_reactions", + // "pleroma:get:main/ostatus", + ], + post_formats: [ + "text/plain", + "text/html", + "text/markdown", + "text/x.misskeymarkdown", + ], + privileged_staff: false, + }, + stats: { + mau: 2, + }, + }, }); }; diff --git a/server/api/api/v1/statuses/[id]/index.ts b/server/api/api/v1/statuses/[id]/index.ts index e70369e3..0afa139e 100644 --- a/server/api/api/v1/statuses/[id]/index.ts +++ b/server/api/api/v1/statuses/[id]/index.ts @@ -30,8 +30,6 @@ export default async ( const { user } = await User.getFromRequest(req); - // TODO: Add checks for user's permissions to view this status - let foundStatus: RawObject | null; try { foundStatus = await RawObject.findOneBy({ @@ -43,6 +41,14 @@ export default async ( if (!foundStatus) return errorResponse("Record not found", 404); + // Check if user is authorized to view this status (if it's private) + if ( + (await foundStatus.toAPI()).visibility === "private" && + (await foundStatus.toAPI()).account.id !== user?.id + ) { + return errorResponse("Record not found", 404); + } + if (req.method === "GET") { return jsonResponse(await foundStatus.toAPI()); } else if (req.method === "DELETE") { diff --git a/server/api/api/v1/statuses/index.ts b/server/api/api/v1/statuses/index.ts index ba2e7a53..68c23a3e 100644 --- a/server/api/api/v1/statuses/index.ts +++ b/server/api/api/v1/statuses/index.ts @@ -8,6 +8,7 @@ import { errorResponse, jsonResponse } from "@response"; import { sanitizeHtml } from "@sanitization"; import { APActor } from "activitypub-types"; import { sanitize } from "isomorphic-dompurify"; +import { parse } from "marked"; import { Application } from "~database/entities/Application"; import { RawObject } from "~database/entities/RawObject"; import { Status } from "~database/entities/Status"; @@ -50,6 +51,7 @@ export default async (req: Request): Promise => { sensitive, spoiler_text, visibility, + content_type, } = await parseRequest<{ status: string; media_ids?: string[]; @@ -67,14 +69,22 @@ export default async (req: Request): Promise => { content_type?: string; }>(req); - // TODO: Parse Markdown statuses - // Validate status if (!status) { return errorResponse("Status is required", 422); } - const sanitizedStatus = await sanitizeHtml(status); + let sanitizedStatus: string; + + if (content_type === "text/markdown") { + sanitizedStatus = await sanitizeHtml(parse(status)); + } else if (content_type === "text/x.misskeymarkdown") { + // Parse as MFM + // TODO: Parse as MFM + sanitizedStatus = await sanitizeHtml(parse(status)); + } else { + sanitizedStatus = await sanitizeHtml(status); + } if (sanitizedStatus.length > config.validation.max_note_size) { return errorResponse( diff --git a/tests/actor.test.ts b/tests/actor.test.ts index 0d8391ea..a3c15f02 100644 --- a/tests/actor.test.ts +++ b/tests/actor.test.ts @@ -23,27 +23,34 @@ beforeAll(async () => { describe("POST /@test/actor", () => { test("should return a valid ActivityPub Actor when querying an existing user", async () => { - const response = await fetch(`${config.http.base_url}/users/test/actor`, { - method: "GET", - headers: { - Accept: "application/activity+json", - }, - }); + const response = await fetch( + `${config.http.base_url}/users/test/actor`, + { + method: "GET", + headers: { + Accept: "application/activity+json", + }, + } + ); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toBe( "application/activity+json" ); - const actor: APActor = await response.json(); + const actor = (await response.json()) as APActor; expect(actor.type).toBe("Person"); expect(actor.id).toBe(`${config.http.base_url}/users/test`); expect(actor.preferredUsername).toBe("test"); expect(actor.inbox).toBe(`${config.http.base_url}/users/test/inbox`); expect(actor.outbox).toBe(`${config.http.base_url}/users/test/outbox`); - expect(actor.followers).toBe(`${config.http.base_url}/users/test/followers`); - expect(actor.following).toBe(`${config.http.base_url}/users/test/following`); + expect(actor.followers).toBe( + `${config.http.base_url}/users/test/followers` + ); + expect(actor.following).toBe( + `${config.http.base_url}/users/test/following` + ); expect((actor as any).publicKey).toBeDefined(); expect((actor as any).publicKey.id).toBeDefined(); expect((actor as any).publicKey.owner).toBe( @@ -82,4 +89,6 @@ afterAll(async () => { if (user) { await user.remove(); } + + await AppDataSource.destroy(); }); diff --git a/tests/api.test.ts b/tests/api.test.ts index 3539941b..314b8ca4 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -66,6 +66,28 @@ describe("API Tests", () => { token = await token.save(); }); + afterAll(async () => { + const activities = await RawActivity.createQueryBuilder("activity") + .where("activity.data->>'actor' = :actor", { + actor: `${config.http.base_url}/users/test`, + }) + .leftJoinAndSelect("activity.objects", "objects") + .getMany(); + + // Delete all created objects and activities as part of testing + for (const activity of activities) { + for (const object of activity.objects) { + await object.remove(); + } + await activity.remove(); + } + + await user.remove(); + await user2.remove(); + + await AppDataSource.destroy(); + }); + describe("POST /api/v1/accounts/:id", () => { test("should return a 404 error when trying to fetch a non-existent user", async () => { const response = await fetch( @@ -150,7 +172,7 @@ describe("API Tests", () => { "application/json" ); - const statuses: APIStatus[] = await response.json(); + const statuses = (await response.json()) as APIStatus[]; expect(statuses.some(s => s.id === status?.id)).toBe(true); }); @@ -177,7 +199,7 @@ describe("API Tests", () => { "application/json" ); - const user: APIAccount = await response.json(); + const user = (await response.json()) as APIAccount; expect(user.display_name).toBe("New Display Name"); }); @@ -201,7 +223,7 @@ describe("API Tests", () => { "application/json" ); - const account: APIAccount = await response.json(); + const account = (await response.json()) as APIAccount; expect(account.username).toBe(user.username); expect(account.bot).toBe(false); @@ -246,7 +268,7 @@ describe("API Tests", () => { "application/json" ); - const statuses: APIStatus[] = await response.json(); + const statuses = (await response.json()) as APIStatus[]; expect(statuses.length).toBe(1); @@ -278,7 +300,7 @@ describe("API Tests", () => { "application/json" ); - const account: APIRelationship = await response.json(); + const account = (await response.json()) as APIRelationship; expect(account.id).toBe(user2.id); expect(account.following).toBe(true); @@ -304,7 +326,7 @@ describe("API Tests", () => { "application/json" ); - const account: APIRelationship = await response.json(); + const account = (await response.json()) as APIRelationship; expect(account.id).toBe(user2.id); expect(account.following).toBe(false); @@ -330,7 +352,7 @@ describe("API Tests", () => { "application/json" ); - const account: APIRelationship = await response.json(); + const account = (await response.json()) as APIRelationship; expect(account.id).toBe(user2.id); expect(account.followed_by).toBe(false); @@ -356,7 +378,7 @@ describe("API Tests", () => { "application/json" ); - const account: APIRelationship = await response.json(); + const account = (await response.json()) as APIRelationship; expect(account.id).toBe(user2.id); expect(account.blocking).toBe(true); @@ -382,7 +404,7 @@ describe("API Tests", () => { "application/json" ); - const account: APIRelationship = await response.json(); + const account = (await response.json()) as APIRelationship; expect(account.id).toBe(user2.id); expect(account.blocking).toBe(false); @@ -408,7 +430,7 @@ describe("API Tests", () => { "application/json" ); - const account: APIRelationship = await response.json(); + const account = (await response.json()) as APIRelationship; expect(account.id).toBe(user2.id); expect(account.muting).toBe(true); @@ -433,7 +455,7 @@ describe("API Tests", () => { "application/json" ); - const account: APIRelationship = await response.json(); + const account = (await response.json()) as APIRelationship; expect(account.id).toBe(user2.id); expect(account.muting).toBe(true); @@ -460,7 +482,7 @@ describe("API Tests", () => { "application/json" ); - const account: APIRelationship = await response.json(); + const account = (await response.json()) as APIRelationship; expect(account.id).toBe(user2.id); expect(account.muting).toBe(false); @@ -486,7 +508,7 @@ describe("API Tests", () => { "application/json" ); - const account: APIRelationship = await response.json(); + const account = (await response.json()) as APIRelationship; expect(account.id).toBe(user2.id); expect(account.endorsed).toBe(true); @@ -512,7 +534,7 @@ describe("API Tests", () => { "application/json" ); - const account: APIRelationship = await response.json(); + const account = (await response.json()) as APIRelationship; expect(account.id).toBe(user2.id); expect(account.endorsed).toBe(false); @@ -538,7 +560,7 @@ describe("API Tests", () => { "application/json" ); - const account: APIAccount = await response.json(); + const account = (await response.json()) as APIAccount; expect(account.id).toBe(user2.id); expect(account.note).toBe("This is a new note"); @@ -562,7 +584,7 @@ describe("API Tests", () => { "application/json" ); - const relationships: APIRelationship[] = await response.json(); + const relationships = (await response.json()) as APIRelationship[]; expect(Array.isArray(relationships)).toBe(true); expect(relationships.length).toBeGreaterThan(0); @@ -595,8 +617,10 @@ describe("API Tests", () => { "application/json" ); - const familiarFollowers: { id: string; accounts: APIAccount[] }[] = - await response.json(); + const familiarFollowers = (await response.json()) as { + id: string; + accounts: APIAccount[]; + }[]; expect(Array.isArray(familiarFollowers)).toBe(true); expect(familiarFollowers.length).toBeGreaterThan(0); @@ -657,7 +681,7 @@ describe("API Tests", () => { "application/json" ); - const statusJson = await response.json(); + const statusJson = (await response.json()) as APIStatus; expect(statusJson.id).toBe(status?.id); expect(statusJson.content).toBeDefined(); @@ -718,7 +742,7 @@ describe("API Tests", () => { "application/json" ); - const instance: APIInstance = await response.json(); + const instance = (await response.json()) as APIInstance; expect(instance.uri).toBe(new URL(config.http.base_url).hostname); expect(instance.title).toBeDefined(); @@ -764,7 +788,7 @@ describe("API Tests", () => { "application/json" ); - const emojis: APIEmoji[] = await response.json(); + const emojis = (await response.json()) as APIEmoji[]; expect(emojis.length).toBeGreaterThan(0); expect(emojis[0].shortcode).toBe("test"); @@ -774,26 +798,4 @@ describe("API Tests", () => { await Emoji.delete({ shortcode: "test" }); }); }); - - afterAll(async () => { - const activities = await RawActivity.createQueryBuilder("activity") - .where("activity.data->>'actor' = :actor", { - actor: `${config.http.base_url}/users/test`, - }) - .leftJoinAndSelect("activity.objects", "objects") - .getMany(); - - // Delete all created objects and activities as part of testing - await Promise.all( - activities.map(async activity => { - await Promise.all( - activity.objects.map(async object => await object.remove()) - ); - await activity.remove(); - }) - ); - - await user.remove(); - await user2.remove(); - }); }); diff --git a/tests/entities/Instance.test.ts b/tests/entities/Instance.test.ts index 724c36d6..73ce4e1a 100644 --- a/tests/entities/Instance.test.ts +++ b/tests/entities/Instance.test.ts @@ -37,4 +37,6 @@ describe("Instance", () => { afterAll(async () => { await instance.remove(); + + await AppDataSource.destroy(); }); diff --git a/tests/entities/Media.test.ts b/tests/entities/Media.test.ts index 551ded43..befd57d6 100644 --- a/tests/entities/Media.test.ts +++ b/tests/entities/Media.test.ts @@ -1,17 +1,27 @@ -import { getConfig } from "@config"; +import { ConfigType, getConfig } from "@config"; import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { LocalBackend, S3Backend } from "~classes/media"; import { unlink } from "fs/promises"; import { DeleteObjectCommand } from "@aws-sdk/client-s3"; -const config = getConfig(); +const originalConfig = getConfig(); +const modifiedConfig: ConfigType = { + ...originalConfig, + media: { + ...originalConfig.media, + conversion: { + ...originalConfig.media.conversion, + convert_images: false, + }, + }, +}; describe("LocalBackend", () => { let localBackend: LocalBackend; let fileName: string; beforeAll(() => { - localBackend = new LocalBackend(); + localBackend = new LocalBackend(modifiedConfig); }); afterAll(async () => { @@ -25,7 +35,7 @@ describe("LocalBackend", () => { }); const hash = await localBackend.addMedia(media); - fileName = `${hash}`; + fileName = hash; expect(hash).toBeDefined(); }); @@ -33,16 +43,14 @@ describe("LocalBackend", () => { describe("getMediaByHash", () => { it("should retrieve the file from the local filesystem and return it as a File object", async () => { - const media = await localBackend.getMediaByHash(fileName, "txt"); + const media = await localBackend.getMediaByHash(fileName); expect(media).toBeInstanceOf(File); }); it("should return null if the file does not exist", async () => { - const media = await localBackend.getMediaByHash( - "does-not-exist", - "txt" - ); + const media = + await localBackend.getMediaByHash("does-not-exist.txt"); expect(media).toBeNull(); }); @@ -50,12 +58,12 @@ describe("LocalBackend", () => { }); describe("S3Backend", () => { - const s3Backend = new S3Backend(config); + const s3Backend = new S3Backend(modifiedConfig); let fileName: string; afterAll(async () => { const command = new DeleteObjectCommand({ - Bucket: config.s3.bucket_name, + Bucket: modifiedConfig.s3.bucket_name, Key: fileName, }); @@ -69,7 +77,7 @@ describe("S3Backend", () => { }); const hash = await s3Backend.addMedia(media); - fileName = `${hash}`; + fileName = hash; expect(hash).toBeDefined(); }); @@ -77,16 +85,13 @@ describe("S3Backend", () => { describe("getMediaByHash", () => { it("should retrieve the file from the S3 bucket and return it as a File object", async () => { - const media = await s3Backend.getMediaByHash(fileName, "txt"); + const media = await s3Backend.getMediaByHash(fileName); expect(media).toBeInstanceOf(File); }); it("should return null if the file does not exist", async () => { - const media = await s3Backend.getMediaByHash( - "does-not-exist", - "txt" - ); + const media = await s3Backend.getMediaByHash("does-not-exist.txt"); expect(media).toBeNull(); }); diff --git a/tests/inbox.test.ts b/tests/inbox.test.ts index ed99474a..7de0f7f8 100644 --- a/tests/inbox.test.ts +++ b/tests/inbox.test.ts @@ -210,6 +210,7 @@ describe("POST /@test/inbox", () => { manuallyApprovesFollowers: false, followers: `${config.http.base_url}/users/test/followers`, following: `${config.http.base_url}/users/test/following`, + published: expect.any(String), name: "", "@context": [ "https://www.w3.org/ns/activitystreams", @@ -217,11 +218,11 @@ describe("POST /@test/inbox", () => { ], icon: { type: "Image", - url: "", + url: expect.any(String), }, image: { type: "Image", - url: "", + url: expect.any(String), }, inbox: `${config.http.base_url}/users/test/inbox`, type: "Person", @@ -311,4 +312,6 @@ afterAll(async () => { await Promise.all(tokens.map(async token => await token.remove())); if (user) await user.remove(); + + await AppDataSource.destroy(); }); diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index f204953a..6d310310 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -33,6 +33,7 @@ describe("POST /api/v1/apps/", () => { formData.append("redirect_uris", "https://example.com"); formData.append("scopes", "read write"); + // @ts-expect-error FormData works const response = await fetch(`${config.http.base_url}/api/v1/apps/`, { method: "POST", body: formData, @@ -42,7 +43,7 @@ describe("POST /api/v1/apps/", () => { expect(response.headers.get("content-type")).toBe("application/json"); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const json = await response.json(); + const json = (await response.json()) as any; expect(json).toEqual({ id: expect.any(String), @@ -67,6 +68,8 @@ describe("POST /auth/login/", () => { formData.append("email", "test@test.com"); formData.append("password", "test"); + + // @ts-expect-error FormData works const response = await fetch( `${config.http.base_url}/auth/login/?client_id=${client_id}&redirect_uri=https://example.com&response_type=code&scope=read+write`, { @@ -96,13 +99,17 @@ describe("POST /oauth/token/", () => { formData.append("client_secret", client_secret); formData.append("scope", "read+write"); + // @ts-expect-error FormData works const response = await fetch(`${config.http.base_url}/oauth/token/`, { method: "POST", + headers: { + "Content-Type": "multipart/form-data", + }, body: formData, }); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const json = await response.json(); + const json = (await response.json()) as any; expect(response.status).toBe(200); expect(response.headers.get("content-type")).toBe("application/json"); @@ -134,7 +141,7 @@ describe("GET /api/v1/apps/verify_credentials", () => { expect(response.status).toBe(200); expect(response.headers.get("content-type")).toBe("application/json"); - const credentials: Partial = await response.json(); + const credentials = (await response.json()) as Partial; expect(credentials.name).toBe("Test Application"); expect(credentials.website).toBe("https://example.com"); @@ -167,4 +174,6 @@ afterAll(async () => { ); if (user) await user.remove(); + + await AppDataSource.destroy(); }); diff --git a/utils/request.ts b/utils/request.ts index 64efbc93..a7e379a8 100644 --- a/utils/request.ts +++ b/utils/request.ts @@ -38,11 +38,11 @@ export async function parseRequest(request: Request): Promise> { // If a file, set as a file if (value instanceof File) { data[key] = value; + } else { + // Otherwise, set as a string + // eslint-disable-next-line @typescript-eslint/no-base-to-string + data[key] = value.toString(); } - - // Otherwise, set as a string - // eslint-disable-next-line @typescript-eslint/no-base-to-string - data[key] = value.toString(); } }