From 29d7b096772adce0a2d6d8c00b83dc317ff3f208 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Sun, 12 May 2024 16:52:19 -1000 Subject: [PATCH] fix(api): :bug: Deleting emojis now removes them from object storage --- cli/commands/emoji/delete.ts | 24 +++++++---- cli/commands/emoji/list.ts | 20 ++++++++- packages/media-manager/index.ts | 37 ++++++++++++++++ .../tests/media-backends.test.ts | 42 ++++++++++++++++++- server/api/api/v1/emojis/:id/index.ts | 7 ++++ 5 files changed, 119 insertions(+), 11 deletions(-) diff --git a/cli/commands/emoji/delete.ts b/cli/commands/emoji/delete.ts index b0d444cf..b6550a5c 100644 --- a/cli/commands/emoji/delete.ts +++ b/cli/commands/emoji/delete.ts @@ -7,6 +7,8 @@ import { EmojiFinderCommand } from "~cli/classes"; import { formatArray } from "~cli/utils/format"; import { db } from "~drizzle/db"; import { Emojis } from "~drizzle/schema"; +import { config } from "~packages/config-manager"; +import { MediaBackend } from "~packages/media-manager"; export default class EmojiDelete extends EmojiFinderCommand< typeof EmojiDelete @@ -77,16 +79,22 @@ export default class EmojiDelete extends EmojiFinderCommand< const spinner = ora("Deleting emoji(s)").start(); - await db.delete(Emojis).where( - inArray( - Emojis.id, - emojis.map((e) => e.id), - ), - ); + for (const emoji of emojis) { + spinner.text = `Deleting emoji ${chalk.gray(emoji.shortcode)} (${ + emojis.findIndex((e) => e.id === emoji.id) + 1 + }/${emojis.length})`; - spinner.succeed(); + const mediaBackend = await MediaBackend.fromBackendType( + config.media.backend, + config, + ); - this.log(chalk.bold(`${chalk.green("✓")} Emoji(s) deleted`)); + await mediaBackend.deleteFileByUrl(emoji.url); + + await db.delete(Emojis).where(eq(Emojis.id, emoji.id)); + } + + spinner.succeed("Emoji(s) deleted"); this.exit(0); } diff --git a/cli/commands/emoji/list.ts b/cli/commands/emoji/list.ts index 7a738690..fab54b70 100644 --- a/cli/commands/emoji/list.ts +++ b/cli/commands/emoji/list.ts @@ -3,7 +3,7 @@ import { and, eq, getTableColumns, isNotNull, isNull } from "drizzle-orm"; import { BaseCommand } from "~cli/base"; import { formatArray } from "~cli/utils/format"; import { db } from "~drizzle/db"; -import { Emojis, Instances } from "~drizzle/schema"; +import { Emojis, Instances, Users } from "~drizzle/schema"; export default class EmojiList extends BaseCommand { static override args = {}; @@ -36,6 +36,10 @@ export default class EmojiList extends BaseCommand { description: "Limit the number of emojis", default: 200, }), + username: Flags.string({ + char: "u", + description: "Filter by username", + }), }; public async run(): Promise { @@ -45,17 +49,29 @@ export default class EmojiList extends BaseCommand { .select({ ...getTableColumns(Emojis), instanceUrl: Instances.baseUrl, + owner: Users.username, }) .from(Emojis) .leftJoin(Instances, eq(Emojis.instanceId, Instances.id)) + .leftJoin(Users, eq(Emojis.ownerId, Users.id)) .where( and( flags.local ? isNull(Emojis.instanceId) : undefined, flags.remote ? isNotNull(Emojis.instanceId) : undefined, + flags.username + ? eq(Users.username, flags.username) + : undefined, ), ); - const keys = ["id", "shortcode", "alt", "contentType", "instanceUrl"]; + const keys = [ + "id", + "shortcode", + "alt", + "contentType", + "instanceUrl", + "owner", + ]; this.log( formatArray( diff --git a/packages/media-manager/index.ts b/packages/media-manager/index.ts index 7eb789c8..e84200f7 100644 --- a/packages/media-manager/index.ts +++ b/packages/media-manager/index.ts @@ -1,3 +1,4 @@ +import { rm } from "node:fs/promises"; import { S3Client } from "@jsr/bradenmacdonald__s3-lite-client"; import type { Config } from "config-manager"; import { MediaConverter } from "./media-converter"; @@ -71,6 +72,12 @@ export class MediaBackend { ); } + public deleteFileByUrl(url: string): Promise { + return Promise.reject( + new Error("Do not call MediaBackend directly: use a subclass"), + ); + } + /** * Fetches file from backend from filename * @param filename File name @@ -133,6 +140,24 @@ export class LocalMediaBackend extends MediaBackend { }; } + public async deleteFileByUrl(url: string) { + // url is of format https://base-url/media/SHA256HASH/FILENAME + const urlO = new URL(url); + + const hash = urlO.pathname.split("/")[1]; + + const dirPath = `${this.config.media.local_uploads_folder}/${hash}`; + + try { + await rm(dirPath, { recursive: true }); + } catch (e) { + console.error(`Failed to delete directory at ${dirPath}`); + console.error(e); + } + + return; + } + public async getFileByHash( hash: string, databaseHashFetcher: (sha256: string) => Promise, @@ -173,6 +198,18 @@ export class S3MediaBackend extends MediaBackend { super(config, MediaBackendType.S3); } + public async deleteFileByUrl(url: string) { + // url is of format https://s3-base-url/SHA256HASH/FILENAME + const urlO = new URL(url); + + const hash = urlO.pathname.split("/")[1]; + const filename = urlO.pathname.split("/")[2]; + + await this.s3Client.deleteObject(`${hash}/${filename}`); + + return; + } + public async addFile(file: File) { let convertedFile = file; if (this.shouldConvertImages(this.config)) { diff --git a/packages/media-manager/tests/media-backends.test.ts b/packages/media-manager/tests/media-backends.test.ts index 25d60271..bb586382 100644 --- a/packages/media-manager/tests/media-backends.test.ts +++ b/packages/media-manager/tests/media-backends.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, jest, spyOn } from "bun:test"; +import { beforeEach, describe, expect, it, jest, mock, spyOn } from "bun:test"; import type { S3Client } from "@jsr/bradenmacdonald__s3-lite-client"; import type { Config } from "config-manager"; import { @@ -127,6 +127,7 @@ describe("S3MediaBackend", () => { blob: jest.fn().mockResolvedValue(new Blob()), headers: new Headers({ "Content-Type": "image/jpeg" }), }), + deleteObject: jest.fn().mockResolvedValue({}), } as Partial; s3MediaBackend = new S3MediaBackend( mockConfig as Config, @@ -187,6 +188,21 @@ describe("S3MediaBackend", () => { expect(file?.name).toEqual(mockFilename); expect(file?.type).toEqual("image/jpeg"); }); + + it("should delete file", async () => { + // deleteFileByUrl + // Upload file first + const mockHash = "test-hash"; + spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash); + const result = await s3MediaBackend.addFile(mockFile); + const url = result.path; + + await s3MediaBackend.deleteFileByUrl(`http://localhost:4566/${url}`); + + expect(mockS3Client.deleteObject).toHaveBeenCalledWith( + expect.stringContaining(url), + ); + }); }); describe("LocalMediaBackend", () => { @@ -274,4 +290,28 @@ describe("LocalMediaBackend", () => { expect(file?.name).toEqual(mockFilename); expect(file?.type).toEqual("image/jpeg"); }); + + it("should delete file", async () => { + // deleteByUrl + const mockHash = "test-hash"; + spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash); + const result = await localMediaBackend.addFile(mockFile); + const rmMock = jest.fn().mockResolvedValue(Promise.resolve()); + + // Spy on fs/promises rm + mock.module("fs/promises", () => { + return { + rm: rmMock, + }; + }); + + await localMediaBackend.deleteFileByUrl( + "http://localhost:4566/test-hash", + ); + + expect(rmMock).toHaveBeenCalledWith( + `${mockConfig.media.local_uploads_folder}/${mockHash}`, + { recursive: true }, + ); + }); }); diff --git a/server/api/api/v1/emojis/:id/index.ts b/server/api/api/v1/emojis/:id/index.ts index 7314820b..1039b6a6 100644 --- a/server/api/api/v1/emojis/:id/index.ts +++ b/server/api/api/v1/emojis/:id/index.ts @@ -104,6 +104,13 @@ export default (app: Hono) => switch (context.req.method) { case "DELETE": { + const mediaBackend = await MediaBackend.fromBackendType( + config.media.backend, + config, + ); + + await mediaBackend.deleteFileByUrl(emoji.url); + await db.delete(Emojis).where(eq(Emojis.id, id)); return response(null, 204);