diff --git a/worker/src/cleanup.spec.ts b/worker/src/cleanup.spec.ts new file mode 100644 index 0000000..843a00c --- /dev/null +++ b/worker/src/cleanup.spec.ts @@ -0,0 +1,29 @@ +import { drizzle } from 'drizzle-orm/d1'; +import * as models from './models'; +import cleanup from './cleanup'; +import { createShortUrl } from './controller'; +import { appEnv } from './index.spec'; + +test('cleanup', async () => { + const { DB } = appEnv; + const d = drizzle(DB, { schema: models }); + + const validDate = new Date(); + validDate.setMonth(validDate.getMonth() + 1); + + const expiredShortLink = await createShortUrl(d, 'https://example.com', new Date(2000, 1, 1).getTime()); + const validShortLink = await createShortUrl(d, 'https://example.com', validDate.getTime()); + const noExpiryShortLink = await createShortUrl(d, 'https://example.com', null); + + const deleted = await cleanup(d, { + accessKeyId: appEnv.S3_ACCESS_KEY_ID, + secretAccessKey: appEnv.S3_SECRET_ACCESS_KEY, + bucket: appEnv.S3_BUCKET, + endpointUrl: appEnv.S3_ENDPOINT_URL, + region: appEnv.S3_REGION, + }); + + expect(deleted).toContain(expiredShortLink.id); + expect(deleted).not.toContain(validShortLink.id); + expect(deleted).not.toContain(noExpiryShortLink.id); +}); diff --git a/worker/src/cleanup.ts b/worker/src/cleanup.ts new file mode 100644 index 0000000..766128b --- /dev/null +++ b/worker/src/cleanup.ts @@ -0,0 +1,38 @@ +import { type DrizzleD1Database } from 'drizzle-orm/d1'; +import { eq, lt } from 'drizzle-orm'; +import * as models from './models'; +import { deleteObject, type S3Configuration } from './s3'; + +export default async function cleanup( + db: DrizzleD1Database, + s3Config: S3Configuration, +): Promise { + const deleted: string[] = []; + const toCleanUp = await db.query.shortLinks.findMany({ + where: lt(models.shortLinks.expiresAt, new Date()), + }); + + await Promise.all(toCleanUp.map(async (shortLink) => { + switch (shortLink.type) { + case 'url': + await db.delete(models.shortLinkUrls).where(eq(models.shortLinkUrls.id, shortLink.id)); + await db.delete(models.shortLinks).where(eq(models.shortLinks.id, shortLink.id)); + break; + case 'paste': + await db.delete(models.shortLinkPastes).where(eq(models.shortLinkPastes.id, shortLink.id)); + await db.delete(models.shortLinks).where(eq(models.shortLinks.id, shortLink.id)); + break; + case 'upload': + await db.delete(models.shortLinkUploads) + .where(eq(models.shortLinkUploads.id, shortLink.id)); + await db.delete(models.shortLinks).where(eq(models.shortLinks.id, shortLink.id)); + await deleteObject(s3Config, shortLink.id); + break; + default: + throw new Error(`Unexpected short link type ${shortLink.type}`); + } + deleted.push(shortLink.id); + })); + + return deleted; +} diff --git a/worker/src/index.spec.ts b/worker/src/index.spec.ts index abc024a..730158a 100644 --- a/worker/src/index.spec.ts +++ b/worker/src/index.spec.ts @@ -6,7 +6,8 @@ import { S3Configuration, putObject } from './s3'; import { sha256 } from './controller'; const { bindings } = await getBindingsProxy(); -const appEnv: Bindings = { +// eslint-disable-next-line import/prefer-default-export +export const appEnv: Bindings = { DB: bindings.DB as D1Database, VH7_ENV: 'testing', S3_ACCESS_KEY_ID: process.env.S3_ACCESS_KEY_ID || 'minioadmin', @@ -14,6 +15,7 @@ const appEnv: Bindings = { S3_REGION: process.env.S3_REGION || 'eu-west-1', S3_ENDPOINT_URL: process.env.S3_ENDPOINT_URL || 'http://localhost:9000', S3_BUCKET: process.env.S3_BUCKET || 'vh7-uploads', + VH7_ADMIN_TOKEN: 'keyboardcat', }; beforeAll(async () => { @@ -234,4 +236,17 @@ describe('API', () => { }, appEnv); expect(res.status).toBe(404); }); + + test('cleanup requires auth', async () => { + const noAuthRes = await app.request('http://vh7.uk/api/cleanup', {}, appEnv); + expect([401, 403]).toContain(noAuthRes.status); + + const authRes = await app.request('http://vh7.uk/api/cleanup', { + headers: { + Authorization: `Bearer ${appEnv.VH7_ADMIN_TOKEN}`, + }, + }, appEnv); + expect(authRes.status).toBe(200); + expectTypeOf(await authRes.json()).toBeArray(); + }); }); diff --git a/worker/src/index.ts b/worker/src/index.ts index 7b40010..c03c21c 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -8,6 +8,7 @@ import { import { checkDirectUserAgent, getFrontendUrl, isValidId } from './helpers'; import * as models from './models'; import { S3Configuration, getObject } from './s3'; +import cleanup from './cleanup'; export type Bindings = { DB: D1Database, @@ -16,7 +17,8 @@ export type Bindings = { S3_SECRET_ACCESS_KEY: string, S3_REGION: string, S3_ENDPOINT_URL: string, - S3_BUCKET: string + S3_BUCKET: string, + VH7_ADMIN_TOKEN: string }; type Env = { @@ -130,6 +132,31 @@ app.get('/api/info/:id', withDb, async (c) => { }, 404); }); +app.get('/api/cleanup', withDb, async (c) => { + const token = c.req.header('Authorization')?.replace('Bearer ', '') || ''; + + if (token !== c.env.VH7_ADMIN_TOKEN) { + return c.text('Invalid or non-existant admin token', 403); + } + + if (c.var.db === undefined) { + return c.status(500); + } + + const s3Config: S3Configuration = { + accessKeyId: c.env.S3_ACCESS_KEY_ID, + secretAccessKey: c.env.S3_SECRET_ACCESS_KEY, + bucket: c.env.S3_BUCKET, + endpointUrl: c.env.S3_ENDPOINT_URL, + region: c.env.S3_REGION, + }; + + const deleted = await cleanup(c.var.db, s3Config); + return c.json({ + deleted, + }); +}); + app.get('/:id', withDb, async (c) => { let id: string | undefined = c.req.param('id'); const frontendUrl = getFrontendUrl(c.env.VH7_ENV); diff --git a/worker/src/s3.ts b/worker/src/s3.ts index c91f151..98ff5c8 100644 --- a/worker/src/s3.ts +++ b/worker/src/s3.ts @@ -54,3 +54,8 @@ export async function putObject(config: S3Configuration, filename: string, file: return req; } + +export async function deleteObject(config: S3Configuration, filename: string) { + const req = makeRequest(config, `/${filename}`, { method: 'DELETE' }); + return req; +} diff --git a/worker/vitest.config.ts b/worker/vitest.config.ts index 393f295..8755358 100644 --- a/worker/vitest.config.ts +++ b/worker/vitest.config.ts @@ -1,8 +1,9 @@ /// -import { defineConfig } from 'vitest/config' +import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - globals: true - } -}) + globals: true, + fileParallelism: false, + }, +}); diff --git a/worker/wrangler.toml b/worker/wrangler.toml index e3549bd..b9d6f21 100644 --- a/worker/wrangler.toml +++ b/worker/wrangler.toml @@ -14,6 +14,7 @@ S3_REGION = "eu-west-1" S3_ENDPOINT_URL = "http://localhost:9000" S3_BUCKET = "vh7-uploads" VH7_ENV = "development" +VH7_ADMIN_TOKEN = "keyboardcat" [[d1_databases]] binding = "DB" # i.e. available in your Worker on env.DB