From cb8a6a800d0e5ed2a5449086f7d77ed4767d95da Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 27 Oct 2026 14:55:27 +0100 Subject: [PATCH 1/9] Drop lastViewedAt from uploads because inactivity cleanup is out of scope. Add a migration and remove the column from the Upload entity. --- .../1761582000000-drop-upload-last-viewed-at.ts | 13 +++++++++++++ backend/src/modules/upload/entities/Upload.ts | 3 --- 2 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 backend/src/database/migrations/1761582000000-drop-upload-last-viewed-at.ts diff --git a/backend/src/database/migrations/1761582000000-drop-upload-last-viewed-at.ts b/backend/src/database/migrations/1761582000000-drop-upload-last-viewed-at.ts new file mode 100644 index 0000000..c938614 --- /dev/null +++ b/backend/src/database/migrations/1761582000000-drop-upload-last-viewed-at.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DropUploadLastViewedAt1761582000000 implements MigrationInterface { + name = 'DropUploadLastViewedAt1761582000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "uploads" DROP COLUMN "lastViewedAt"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "uploads" ADD "lastViewedAt" TIMESTAMP WITH TIME ZONE`); + } +} diff --git a/backend/src/modules/upload/entities/Upload.ts b/backend/src/modules/upload/entities/Upload.ts index 45807e1..0d59e24 100644 --- a/backend/src/modules/upload/entities/Upload.ts +++ b/backend/src/modules/upload/entities/Upload.ts @@ -32,9 +32,6 @@ export class Upload { @OneToOne(() => UploadHotlink, hotlink => hotlink.upload, { cascade: true }) hotlink: UploadHotlink | null; - @Column({ type: 'timestamptz', nullable: true }) - lastViewedAt: Date | null; - @Column({ type: 'timestamptz', nullable: true }) expiresAt: Date | null; -- 2.54.0 From 073e6ac1f20959a8ab7b60c31f9d3c2c870ec5ee Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 27 Oct 2026 14:59:50 +0100 Subject: [PATCH 2/9] Increment view_count after successful ZK and hotlink GETs. Bump count only when ciphertext or decrypted image is ready; 404 paths do not increment. Cover both flows in UploadService tests. --- .../modules/upload/services/UploadService.spec.ts | 14 ++++++++++++-- .../src/modules/upload/services/UploadService.ts | 4 ++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/backend/src/modules/upload/services/UploadService.spec.ts b/backend/src/modules/upload/services/UploadService.spec.ts index e1e8fbc..04bc0c9 100644 --- a/backend/src/modules/upload/services/UploadService.spec.ts +++ b/backend/src/modules/upload/services/UploadService.spec.ts @@ -51,14 +51,15 @@ describe('UploadService', () => { let service: UploadService; let dataSource: jest.Mocked>; let storageService: jest.Mocked>; - let uploadRepository: { create: jest.Mock; save: jest.Mock; findOne: jest.Mock }; + let uploadRepository: { create: jest.Mock; save: jest.Mock; findOne: jest.Mock; increment: jest.Mock }; let transaction: jest.Mock; beforeEach(() => { uploadRepository = { create: jest.fn((entityLike: unknown) => entityLike), save: jest.fn(async (entity: unknown) => entity), - findOne: jest.fn() + findOne: jest.fn(), + increment: jest.fn().mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }) }; transaction = jest.fn(async (runInTransaction: (manager: EntityManager) => Promise) => { @@ -224,6 +225,7 @@ describe('UploadService', () => { relations: ['zk'] }); expect(storageService.read).toHaveBeenCalledWith(fixedId); + expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1); }); it('includes wrap fields when password protected', async () => { @@ -253,6 +255,7 @@ describe('UploadService', () => { wrapIv: wrapIv.toString('base64'), salt: salt.toString('base64') }); + expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1); }); it('throws NotFoundException when upload is missing or not zk', async () => { @@ -260,6 +263,7 @@ describe('UploadService', () => { await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException); expect(storageService.read).not.toHaveBeenCalled(); + expect(uploadRepository.increment).not.toHaveBeenCalled(); }); it('throws NotFoundException when ciphertext file is missing', async () => { @@ -271,6 +275,7 @@ describe('UploadService', () => { storageService.read.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException); + expect(uploadRepository.increment).not.toHaveBeenCalled(); }); it('throws NotFoundException when password protected but wrap data is incomplete', async () => { @@ -287,6 +292,7 @@ describe('UploadService', () => { storageService.read.mockResolvedValue(Buffer.alloc(8)); await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException); + expect(uploadRepository.increment).not.toHaveBeenCalled(); }); }); @@ -314,6 +320,7 @@ describe('UploadService', () => { where: { id: fixedId, mode: UploadMode.Hotlink }, relations: ['hotlink'] }); + expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1); }); it('throws NotFoundException when upload is missing or not hotlink', async () => { @@ -321,6 +328,7 @@ describe('UploadService', () => { await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException); expect(storageService.read).not.toHaveBeenCalled(); + expect(uploadRepository.increment).not.toHaveBeenCalled(); }); it('throws NotFoundException when secret is wrong but well-formed', async () => { @@ -334,6 +342,7 @@ describe('UploadService', () => { const wrongSecret = Buffer.alloc(32, 0x99).toString('base64url'); await expect(service.getHotlinkImage(fixedId, wrongSecret)).rejects.toThrow(NotFoundException); + expect(uploadRepository.increment).not.toHaveBeenCalled(); }); it('throws NotFoundException when ciphertext file is missing', async () => { @@ -345,6 +354,7 @@ describe('UploadService', () => { storageService.read.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException); + expect(uploadRepository.increment).not.toHaveBeenCalled(); }); }); diff --git a/backend/src/modules/upload/services/UploadService.ts b/backend/src/modules/upload/services/UploadService.ts index e50efaf..489220e 100644 --- a/backend/src/modules/upload/services/UploadService.ts +++ b/backend/src/modules/upload/services/UploadService.ts @@ -155,6 +155,8 @@ export class UploadService { result.salt = toBase64(upload.zk.salt); } + await this.uploadRepository.increment({ id }, 'viewCount', 1); + return result; } @@ -190,6 +192,8 @@ export class UploadService { throw new NotFoundException(); } + await this.uploadRepository.increment({ id }, 'viewCount', 1); + return { body, mimeType: upload.hotlink.mimeType -- 2.54.0 From 481eb0bcd661f5a58fc19691dd56ed791f641a81 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 27 Oct 2026 15:19:25 +0100 Subject: [PATCH 3/9] Add DELETE /api/uploads/:id with delete_token and unlink ciphertext. Return 204 when the token hash matches; ignore missing files on unlink so the row is still removed. --- .../upload/controllers/UploadController.ts | 9 ++- .../upload/dto/DeleteUploadQueryDto.ts | 7 ++ .../upload/services/UploadService.spec.ts | 78 ++++++++++++++++++- .../modules/upload/services/UploadService.ts | 24 ++++++ 4 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 backend/src/modules/upload/dto/DeleteUploadQueryDto.ts diff --git a/backend/src/modules/upload/controllers/UploadController.ts b/backend/src/modules/upload/controllers/UploadController.ts index b6fae3c..b4124bf 100644 --- a/backend/src/modules/upload/controllers/UploadController.ts +++ b/backend/src/modules/upload/controllers/UploadController.ts @@ -1,8 +1,9 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; import { Throttle } from '@nestjs/throttler'; import { throttleProfiles } from '../../../config/throttleProfiles'; import { CreateHotlinkUploadDto } from '../dto/CreateHotlinkUploadDto'; import { CreateZkUploadDto } from '../dto/CreateZkUploadDto'; +import { DeleteUploadQueryDto } from '../dto/DeleteUploadQueryDto'; import { UploadService } from '../services/UploadService'; @Controller('uploads') @@ -25,4 +26,10 @@ export class UploadController { getZkCiphertext(@Param('id', ParseUUIDPipe) id: string) { return this.uploadService.getZkUploadCiphertext(id); } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + deleteUpload(@Param('id', ParseUUIDPipe) id: string, @Query() { delete_token }: DeleteUploadQueryDto) { + return this.uploadService.deleteUpload(id, delete_token); + } } diff --git a/backend/src/modules/upload/dto/DeleteUploadQueryDto.ts b/backend/src/modules/upload/dto/DeleteUploadQueryDto.ts new file mode 100644 index 0000000..003d89c --- /dev/null +++ b/backend/src/modules/upload/dto/DeleteUploadQueryDto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class DeleteUploadQueryDto { + @IsNotEmpty() + @IsString() + delete_token: string; +} diff --git a/backend/src/modules/upload/services/UploadService.spec.ts b/backend/src/modules/upload/services/UploadService.spec.ts index 04bc0c9..3ba9041 100644 --- a/backend/src/modules/upload/services/UploadService.spec.ts +++ b/backend/src/modules/upload/services/UploadService.spec.ts @@ -50,8 +50,14 @@ const encryptHotlinkCiphertext = (plaintext: Buffer, key: Buffer, iv: Buffer): B describe('UploadService', () => { let service: UploadService; let dataSource: jest.Mocked>; - let storageService: jest.Mocked>; - let uploadRepository: { create: jest.Mock; save: jest.Mock; findOne: jest.Mock; increment: jest.Mock }; + let storageService: jest.Mocked>; + let uploadRepository: { + create: jest.Mock; + save: jest.Mock; + findOne: jest.Mock; + increment: jest.Mock; + remove: jest.Mock; + }; let transaction: jest.Mock; beforeEach(() => { @@ -59,7 +65,8 @@ describe('UploadService', () => { create: jest.fn((entityLike: unknown) => entityLike), save: jest.fn(async (entity: unknown) => entity), findOne: jest.fn(), - increment: jest.fn().mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }) + increment: jest.fn().mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }), + remove: jest.fn().mockResolvedValue(undefined) }; transaction = jest.fn(async (runInTransaction: (manager: EntityManager) => Promise) => { @@ -73,7 +80,8 @@ describe('UploadService', () => { dataSource = { transaction }; storageService = { write: jest.fn().mockResolvedValue(undefined), - read: jest.fn() + read: jest.fn(), + unlink: jest.fn().mockResolvedValue(undefined) }; mockFileTypeFromBuffer.mockResolvedValue(undefined); @@ -358,6 +366,68 @@ describe('UploadService', () => { }); }); + describe('deleteUpload', () => { + const deleteToken = fixedDeleteTokenBytes.toString('base64url'); + + it('unlinks ciphertext and removes upload when delete token matches', async () => { + const upload = { + storagePath: fixedId, + deleteTokenHash: hashDeleteToken(deleteToken) + }; + + uploadRepository.findOne.mockResolvedValue(upload); + + await expect(service.deleteUpload(fixedId, deleteToken)).resolves.toBeUndefined(); + + expect(uploadRepository.findOne).toHaveBeenCalledWith({ + where: { id: fixedId, deleteTokenHash: hashDeleteToken(deleteToken) } + }); + expect(storageService.unlink).toHaveBeenCalledWith(fixedId); + expect(uploadRepository.remove).toHaveBeenCalledWith(upload); + }); + + it('throws NotFoundException when upload is missing', async () => { + uploadRepository.findOne.mockResolvedValue(null); + + await expect(service.deleteUpload(fixedId, deleteToken)).rejects.toThrow(NotFoundException); + expect(storageService.unlink).not.toHaveBeenCalled(); + expect(uploadRepository.remove).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when delete token is wrong', async () => { + uploadRepository.findOne.mockResolvedValue(null); + + await expect(service.deleteUpload(fixedId, 'wrong-token')).rejects.toThrow(NotFoundException); + expect(storageService.unlink).not.toHaveBeenCalled(); + expect(uploadRepository.remove).not.toHaveBeenCalled(); + }); + + it('still removes upload when ciphertext file is already missing', async () => { + const upload = { + storagePath: fixedId, + deleteTokenHash: hashDeleteToken(deleteToken) + }; + + uploadRepository.findOne.mockResolvedValue(upload); + storageService.unlink.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + await expect(service.deleteUpload(fixedId, deleteToken)).resolves.toBeUndefined(); + + expect(uploadRepository.remove).toHaveBeenCalledWith(upload); + }); + + it('propagates non-ENOENT storage unlink errors', async () => { + uploadRepository.findOne.mockResolvedValue({ + storagePath: fixedId, + deleteTokenHash: hashDeleteToken(deleteToken) + }); + storageService.unlink.mockRejectedValue(new Error('permission denied')); + + await expect(service.deleteUpload(fixedId, deleteToken)).rejects.toThrow('permission denied'); + expect(uploadRepository.remove).not.toHaveBeenCalled(); + }); + }); + describe('transaction rollback on storage failure', () => { it('propagates storage write errors from the transaction', async () => { storageService.write.mockRejectedValue(new Error('disk full')); diff --git a/backend/src/modules/upload/services/UploadService.ts b/backend/src/modules/upload/services/UploadService.ts index 489220e..27c5b8a 100644 --- a/backend/src/modules/upload/services/UploadService.ts +++ b/backend/src/modules/upload/services/UploadService.ts @@ -200,6 +200,30 @@ export class UploadService { }; } + async deleteUpload(id: string, deleteToken: string): Promise { + await this.dataSource.transaction(async manager => { + const uploadRepository = manager.getRepository(Upload); + + const upload = await uploadRepository.findOne({ + where: { id, deleteTokenHash: this.hashDeleteToken(deleteToken) } + }); + + if (!upload) { + throw new NotFoundException(); + } + + try { + await this.storageService.unlink(upload.storagePath); + } catch (error: unknown) { + if (!isErrorEnoent(error)) { + throw error; + } + } + + await uploadRepository.remove(upload); + }); + } + private hashDeleteToken(token: string): string { return createHash('sha256').update(token, 'utf8').digest('hex'); } -- 2.54.0 From 9a2bc67b1d0079dafd7062dcc0f06702563b8370 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 27 Oct 2026 15:30:03 +0100 Subject: [PATCH 4/9] Reject expired uploads on ZK and hotlink GET with 404. Use dayjs to compare expiresAt before reading storage or incrementing view_count. --- .../upload/services/UploadService.spec.ts | 31 +++++++++++++++++++ .../modules/upload/services/UploadService.ts | 9 ++++++ 2 files changed, 40 insertions(+) diff --git a/backend/src/modules/upload/services/UploadService.spec.ts b/backend/src/modules/upload/services/UploadService.spec.ts index 3ba9041..7060632 100644 --- a/backend/src/modules/upload/services/UploadService.spec.ts +++ b/backend/src/modules/upload/services/UploadService.spec.ts @@ -302,6 +302,24 @@ describe('UploadService', () => { await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException); expect(uploadRepository.increment).not.toHaveBeenCalled(); }); + + it('throws NotFoundException when expiresAt is in the past', async () => { + uploadRepository.findOne.mockResolvedValue({ + storagePath: fixedId, + iv: Buffer.alloc(12, 0x11), + expiresAt: new Date(Date.now() - 60_000), + zk: { + passwordProtected: false, + wrappedDek: null, + wrapIv: null, + salt: null + } + }); + + await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException); + expect(storageService.read).not.toHaveBeenCalled(); + expect(uploadRepository.increment).not.toHaveBeenCalled(); + }); }); describe('getHotlinkImage', () => { @@ -364,6 +382,19 @@ describe('UploadService', () => { await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException); expect(uploadRepository.increment).not.toHaveBeenCalled(); }); + + it('throws NotFoundException when expiresAt is in the past', async () => { + uploadRepository.findOne.mockResolvedValue({ + storagePath: fixedId, + iv, + expiresAt: new Date(Date.now() - 60_000), + hotlink: { mimeType: 'image/png' } + }); + + await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException); + expect(storageService.read).not.toHaveBeenCalled(); + expect(uploadRepository.increment).not.toHaveBeenCalled(); + }); }); describe('deleteUpload', () => { diff --git a/backend/src/modules/upload/services/UploadService.ts b/backend/src/modules/upload/services/UploadService.ts index 27c5b8a..30c89a7 100644 --- a/backend/src/modules/upload/services/UploadService.ts +++ b/backend/src/modules/upload/services/UploadService.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import dayjs from 'dayjs'; import { fileTypeFromBuffer } from 'file-type'; import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { DataSource, Repository } from 'typeorm'; @@ -127,6 +128,10 @@ export class UploadService { throw new NotFoundException(); } + if (upload.expiresAt && dayjs(upload.expiresAt).isBefore(dayjs())) { + throw new NotFoundException(); + } + let ciphertextBuffer: Buffer; try { @@ -170,6 +175,10 @@ export class UploadService { throw new NotFoundException(); } + if (upload.expiresAt && dayjs(upload.expiresAt).isBefore(dayjs())) { + throw new NotFoundException(); + } + let ciphertextBuffer: Buffer; try { -- 2.54.0 From c147a010b5bac6a0dff32f485bdc3309d663016d Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 27 Oct 2026 18:30:35 +0100 Subject: [PATCH 5/9] Enforce max views on access and purge after the last allowed view. --- .../upload/services/UploadService.spec.ts | 107 ++++++++++++++++++ .../modules/upload/services/UploadService.ts | 42 +++++-- 2 files changed, 140 insertions(+), 9 deletions(-) diff --git a/backend/src/modules/upload/services/UploadService.spec.ts b/backend/src/modules/upload/services/UploadService.spec.ts index 7060632..f796dea 100644 --- a/backend/src/modules/upload/services/UploadService.spec.ts +++ b/backend/src/modules/upload/services/UploadService.spec.ts @@ -320,6 +320,56 @@ describe('UploadService', () => { expect(storageService.read).not.toHaveBeenCalled(); expect(uploadRepository.increment).not.toHaveBeenCalled(); }); + + it('throws NotFoundException when maxViews is exhausted', async () => { + uploadRepository.findOne.mockResolvedValue({ + storagePath: fixedId, + iv: Buffer.alloc(12, 0x11), + maxViews: 1, + viewCount: 1, + zk: { + passwordProtected: false, + wrappedDek: null, + wrapIv: null, + salt: null + } + }); + + await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException); + expect(storageService.read).not.toHaveBeenCalled(); + expect(uploadRepository.increment).not.toHaveBeenCalled(); + }); + + it('purges upload after the last allowed view', async () => { + const iv = Buffer.alloc(12, 0x11); + const ciphertext = Buffer.alloc(64, 0xcd); + const upload = { + id: fixedId, + storagePath: fixedId, + iv, + maxViews: 1, + viewCount: 0, + zk: { + passwordProtected: false, + wrappedDek: null, + wrapIv: null, + salt: null + } + }; + + uploadRepository.findOne.mockResolvedValue(upload); + storageService.read.mockResolvedValue(ciphertext); + + await expect(service.getZkUploadCiphertext(fixedId)).resolves.toEqual({ + iv: iv.toString('base64'), + ciphertext: ciphertext.toString('base64'), + passwordProtected: false + }); + + expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1); + expect(storageService.unlink).toHaveBeenCalledWith(fixedId); + expect(uploadRepository.remove).toHaveBeenCalledWith(upload); + }); }); describe('getHotlinkImage', () => { @@ -395,6 +445,63 @@ describe('UploadService', () => { expect(storageService.read).not.toHaveBeenCalled(); expect(uploadRepository.increment).not.toHaveBeenCalled(); }); + + it('throws NotFoundException when maxViews is exhausted', async () => { + uploadRepository.findOne.mockResolvedValue({ + storagePath: fixedId, + iv, + maxViews: 2, + viewCount: 2, + hotlink: { mimeType: 'image/png' } + }); + + await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException); + expect(storageService.read).not.toHaveBeenCalled(); + expect(uploadRepository.increment).not.toHaveBeenCalled(); + }); + + it('purges upload after the last allowed view', async () => { + const upload = { + id: fixedId, + storagePath: fixedId, + iv, + maxViews: 1, + viewCount: 0, + hotlink: { mimeType: 'image/png' } + }; + + uploadRepository.findOne.mockResolvedValue(upload); + storageService.read.mockResolvedValue(ciphertext); + + await expect(service.getHotlinkImage(fixedId, secret)).resolves.toEqual({ + body: plaintext, + mimeType: 'image/png' + }); + + expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1); + expect(storageService.unlink).toHaveBeenCalledWith(fixedId); + expect(uploadRepository.remove).toHaveBeenCalledWith(upload); + }); + + it('does not purge when views remain below maxViews', async () => { + uploadRepository.findOne.mockResolvedValue({ + storagePath: fixedId, + iv, + maxViews: 3, + viewCount: 0, + hotlink: { mimeType: 'image/png' } + }); + storageService.read.mockResolvedValue(ciphertext); + + await expect(service.getHotlinkImage(fixedId, secret)).resolves.toEqual({ + body: plaintext, + mimeType: 'image/png' + }); + + expect(uploadRepository.increment).toHaveBeenCalled(); + expect(storageService.unlink).not.toHaveBeenCalled(); + expect(uploadRepository.remove).not.toHaveBeenCalled(); + }); }); describe('deleteUpload', () => { diff --git a/backend/src/modules/upload/services/UploadService.ts b/backend/src/modules/upload/services/UploadService.ts index 30c89a7..2eae57d 100644 --- a/backend/src/modules/upload/services/UploadService.ts +++ b/backend/src/modules/upload/services/UploadService.ts @@ -132,6 +132,10 @@ export class UploadService { throw new NotFoundException(); } + if (typeof upload.maxViews === 'number' && upload.viewCount >= upload.maxViews) { + throw new NotFoundException(); + } + let ciphertextBuffer: Buffer; try { @@ -162,6 +166,12 @@ export class UploadService { await this.uploadRepository.increment({ id }, 'viewCount', 1); + const nextViewCount = upload.viewCount + 1; + + if (typeof upload.maxViews === 'number' && nextViewCount >= upload.maxViews) { + await this.purgeUpload(upload, this.uploadRepository); + } + return result; } @@ -179,6 +189,10 @@ export class UploadService { throw new NotFoundException(); } + if (typeof upload.maxViews === 'number' && upload.viewCount >= upload.maxViews) { + throw new NotFoundException(); + } + let ciphertextBuffer: Buffer; try { @@ -203,6 +217,12 @@ export class UploadService { await this.uploadRepository.increment({ id }, 'viewCount', 1); + const nextViewCount = upload.viewCount + 1; + + if (typeof upload.maxViews === 'number' && nextViewCount >= upload.maxViews) { + await this.purgeUpload(upload, this.uploadRepository); + } + return { body, mimeType: upload.hotlink.mimeType @@ -221,18 +241,22 @@ export class UploadService { throw new NotFoundException(); } - try { - await this.storageService.unlink(upload.storagePath); - } catch (error: unknown) { - if (!isErrorEnoent(error)) { - throw error; - } - } - - await uploadRepository.remove(upload); + await this.purgeUpload(upload, uploadRepository); }); } + private async purgeUpload(upload: Upload, uploadRepository: Repository): Promise { + try { + await this.storageService.unlink(upload.storagePath); + } catch (error: unknown) { + if (!isErrorEnoent(error)) { + throw error; + } + } + + await uploadRepository.remove(upload); + } + private hashDeleteToken(token: string): string { return createHash('sha256').update(token, 'utf8').digest('hex'); } -- 2.54.0 From 00ef845277819e7defa606641c6cf2e03d94fd43 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 27 Oct 2026 19:13:40 +0100 Subject: [PATCH 6/9] Add DeleteView one-click delete page at /d/:id?token=... --- frontend/src/stores/upload.ts | 9 ++- frontend/src/types/delete/DeleteViewState.ts | 1 + frontend/src/views/DeleteView.vue | 59 +++++++++++++++++++- 3 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 frontend/src/types/delete/DeleteViewState.ts diff --git a/frontend/src/stores/upload.ts b/frontend/src/stores/upload.ts index 459dd94..ab5c819 100644 --- a/frontend/src/stores/upload.ts +++ b/frontend/src/stores/upload.ts @@ -96,6 +96,12 @@ export const useUploadStore = defineStore('upload', () => { return data; }; + const deleteUpload = async (uploadId: string, deleteToken: string): Promise => { + await api.delete(`/uploads/${uploadId}`, { + params: { delete_token: deleteToken } + }); + }; + const getUploadRequestHeaders = (altcha?: string) => (altcha ? { [ALTCHA_REQUEST_HEADER]: altcha } : undefined); const uploadBatch = async ( @@ -257,6 +263,7 @@ export const useUploadStore = defineStore('upload', () => { clearAllUploads, uploadBatch, uploadZk, - uploadHotlink + uploadHotlink, + deleteUpload }; }); diff --git a/frontend/src/types/delete/DeleteViewState.ts b/frontend/src/types/delete/DeleteViewState.ts new file mode 100644 index 0000000..3cb0844 --- /dev/null +++ b/frontend/src/types/delete/DeleteViewState.ts @@ -0,0 +1 @@ +export type DeleteViewState = 'invalid' | 'deleting' | 'deleted' | 'notFound' | 'error'; diff --git a/frontend/src/views/DeleteView.vue b/frontend/src/views/DeleteView.vue index 04e61e3..d2945c7 100644 --- a/frontend/src/views/DeleteView.vue +++ b/frontend/src/views/DeleteView.vue @@ -1,6 +1,59 @@ + + -- 2.54.0 From 29407814ccb0c4ee9d9b008aec397dfd98e86469 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 27 Oct 2026 21:07:01 +0100 Subject: [PATCH 7/9] Add UploadCleanupService for cron purge and delete uploads. Move token delete and storage purge off UploadService; enforce expiry and max views on GET without purging until the scheduled cleanup job runs. --- backend/src/modules/upload/UploadModule.ts | 3 +- .../upload/controllers/UploadController.ts | 8 +- .../services/UploadCleanupService.spec.ts | 198 ++++++++++++++++++ .../upload/services/UploadCleanupService.ts | 64 ++++++ .../upload/services/UploadService.spec.ts | 79 +------ .../modules/upload/services/UploadService.ts | 51 +---- backend/src/utils/hashDeleteToken.ts | 4 + 7 files changed, 286 insertions(+), 121 deletions(-) create mode 100644 backend/src/modules/upload/services/UploadCleanupService.spec.ts create mode 100644 backend/src/modules/upload/services/UploadCleanupService.ts create mode 100644 backend/src/utils/hashDeleteToken.ts diff --git a/backend/src/modules/upload/UploadModule.ts b/backend/src/modules/upload/UploadModule.ts index 0ba3a09..fea22ef 100644 --- a/backend/src/modules/upload/UploadModule.ts +++ b/backend/src/modules/upload/UploadModule.ts @@ -6,11 +6,12 @@ import { UploadController } from './controllers/UploadController'; import { Upload } from './entities/Upload'; import { UploadHotlink } from './entities/UploadHotlink'; import { UploadZk } from './entities/UploadZk'; +import { UploadCleanupService } from './services/UploadCleanupService'; import { UploadService } from './services/UploadService'; @Module({ imports: [TypeOrmModule.forFeature([Upload, UploadZk, UploadHotlink]), StorageModule], controllers: [UploadController, HotlinkImageController], - providers: [UploadService] + providers: [UploadService, UploadCleanupService] }) export class UploadModule {} diff --git a/backend/src/modules/upload/controllers/UploadController.ts b/backend/src/modules/upload/controllers/UploadController.ts index b4124bf..3c85f62 100644 --- a/backend/src/modules/upload/controllers/UploadController.ts +++ b/backend/src/modules/upload/controllers/UploadController.ts @@ -4,11 +4,15 @@ import { throttleProfiles } from '../../../config/throttleProfiles'; import { CreateHotlinkUploadDto } from '../dto/CreateHotlinkUploadDto'; import { CreateZkUploadDto } from '../dto/CreateZkUploadDto'; import { DeleteUploadQueryDto } from '../dto/DeleteUploadQueryDto'; +import { UploadCleanupService } from '../services/UploadCleanupService'; import { UploadService } from '../services/UploadService'; @Controller('uploads') export class UploadController { - constructor(private readonly uploadService: UploadService) {} + constructor( + private readonly uploadService: UploadService, + private readonly uploadCleanupService: UploadCleanupService + ) {} @Post('zk') @Throttle(throttleProfiles.upload) @@ -30,6 +34,6 @@ export class UploadController { @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) deleteUpload(@Param('id', ParseUUIDPipe) id: string, @Query() { delete_token }: DeleteUploadQueryDto) { - return this.uploadService.deleteUpload(id, delete_token); + return this.uploadCleanupService.deleteUpload(id, delete_token); } } diff --git a/backend/src/modules/upload/services/UploadCleanupService.spec.ts b/backend/src/modules/upload/services/UploadCleanupService.spec.ts new file mode 100644 index 0000000..8f79ff3 --- /dev/null +++ b/backend/src/modules/upload/services/UploadCleanupService.spec.ts @@ -0,0 +1,198 @@ +import { NotFoundException } from '@nestjs/common'; +import type { DataSource, EntityManager, Repository } from 'typeorm'; +import { hashDeleteToken } from '../../../utils/hashDeleteToken'; +import type { Upload } from '../entities/Upload'; +import { StorageService } from '../../storage/services/StorageService'; +import { UploadCleanupService } from './UploadCleanupService'; + +jest.mock('@nestjs/typeorm', () => ({ + InjectDataSource: () => (): void => undefined, + InjectRepository: () => (): void => undefined +})); + +const fixedId = '11111111-1111-4111-8111-111111111111'; +const fixedDeleteToken = 'fixed-delete-token'; + +describe('UploadCleanupService', () => { + let service: UploadCleanupService; + let storageService: jest.Mocked>; + let dataSource: jest.Mocked>; + let uploadRepository: { + createQueryBuilder: jest.Mock; + findOne: jest.Mock; + remove: jest.Mock; + }; + let getMany: jest.Mock; + let take: jest.Mock; + let transaction: jest.Mock; + + beforeEach(() => { + getMany = jest.fn().mockResolvedValue([]); + take = jest.fn().mockReturnValue({ getMany }); + const orWhere = jest.fn().mockReturnValue({ take }); + const where = jest.fn().mockReturnValue({ orWhere }); + + uploadRepository = { + createQueryBuilder: jest.fn().mockReturnValue({ where }), + findOne: jest.fn(), + remove: jest.fn().mockResolvedValue(undefined) + }; + + transaction = jest.fn(async (runInTransaction: (manager: EntityManager) => Promise) => { + const manager = { + getRepository: jest.fn(() => uploadRepository) + } as unknown as EntityManager; + + return runInTransaction(manager); + }); + + dataSource = { transaction }; + + storageService = { + unlink: jest.fn().mockResolvedValue(undefined) + }; + + service = new UploadCleanupService( + dataSource as unknown as DataSource, + storageService as unknown as StorageService, + uploadRepository as unknown as Repository + ); + }); + + describe('purgeUploads', () => { + it('purges each eligible upload up to the batch size', async () => { + const expired = { id: fixedId, storagePath: fixedId }; + const viewCapped = { + id: '22222222-2222-4222-8222-222222222222', + storagePath: '22222222-2222-4222-8222-222222222222' + }; + + getMany.mockResolvedValue([expired, viewCapped]); + + await expect(service.purgeUploads()).resolves.toBeUndefined(); + + expect(uploadRepository.createQueryBuilder).toHaveBeenCalledWith('upload'); + expect(take).toHaveBeenCalledWith(100); + expect(storageService.unlink).toHaveBeenCalledWith(fixedId); + expect(storageService.unlink).toHaveBeenCalledWith(viewCapped.storagePath); + expect(uploadRepository.remove).toHaveBeenCalledWith(expired); + expect(uploadRepository.remove).toHaveBeenCalledWith(viewCapped); + }); + + it('does nothing when no uploads are eligible', async () => { + getMany.mockResolvedValue([]); + + await expect(service.purgeUploads()).resolves.toBeUndefined(); + + expect(storageService.unlink).not.toHaveBeenCalled(); + expect(uploadRepository.remove).not.toHaveBeenCalled(); + }); + + it('still removes row when ciphertext file is already missing', async () => { + const upload = { id: fixedId, storagePath: fixedId }; + + getMany.mockResolvedValue([upload]); + storageService.unlink.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + await expect(service.purgeUploads()).resolves.toBeUndefined(); + + expect(uploadRepository.remove).toHaveBeenCalledWith(upload); + }); + }); + + describe('deleteUpload', () => { + it('unlinks ciphertext and removes upload when delete token matches', async () => { + const upload = { + storagePath: fixedId, + deleteTokenHash: hashDeleteToken(fixedDeleteToken) + }; + + uploadRepository.findOne.mockResolvedValue(upload); + + await expect(service.deleteUpload(fixedId, fixedDeleteToken)).resolves.toBeUndefined(); + + expect(uploadRepository.findOne).toHaveBeenCalledWith({ + where: { id: fixedId, deleteTokenHash: hashDeleteToken(fixedDeleteToken) } + }); + expect(storageService.unlink).toHaveBeenCalledWith(fixedId); + expect(uploadRepository.remove).toHaveBeenCalledWith(upload); + }); + + it('throws NotFoundException when upload is missing', async () => { + uploadRepository.findOne.mockResolvedValue(null); + + await expect(service.deleteUpload(fixedId, fixedDeleteToken)).rejects.toThrow(NotFoundException); + expect(storageService.unlink).not.toHaveBeenCalled(); + expect(uploadRepository.remove).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when delete token is wrong', async () => { + uploadRepository.findOne.mockResolvedValue(null); + + await expect(service.deleteUpload(fixedId, 'wrong-token')).rejects.toThrow(NotFoundException); + expect(storageService.unlink).not.toHaveBeenCalled(); + expect(uploadRepository.remove).not.toHaveBeenCalled(); + }); + + it('still removes upload when ciphertext file is already missing', async () => { + const upload = { + storagePath: fixedId, + deleteTokenHash: hashDeleteToken(fixedDeleteToken) + }; + + uploadRepository.findOne.mockResolvedValue(upload); + storageService.unlink.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + await expect(service.deleteUpload(fixedId, fixedDeleteToken)).resolves.toBeUndefined(); + + expect(uploadRepository.remove).toHaveBeenCalledWith(upload); + }); + + it('propagates non-ENOENT storage unlink errors', async () => { + uploadRepository.findOne.mockResolvedValue({ + storagePath: fixedId, + deleteTokenHash: hashDeleteToken(fixedDeleteToken) + }); + storageService.unlink.mockRejectedValue(new Error('permission denied')); + + await expect(service.deleteUpload(fixedId, fixedDeleteToken)).rejects.toThrow('permission denied'); + expect(uploadRepository.remove).not.toHaveBeenCalled(); + }); + }); + + describe('purgeUpload', () => { + it('unlinks ciphertext and removes upload', async () => { + const upload = { id: fixedId, storagePath: fixedId }; + + await expect( + service.purgeUpload(upload as never, uploadRepository as never) + ).resolves.toBeUndefined(); + + expect(storageService.unlink).toHaveBeenCalledWith(fixedId); + expect(uploadRepository.remove).toHaveBeenCalledWith(upload); + }); + + it('still removes row when ciphertext file is already missing', async () => { + const upload = { id: fixedId, storagePath: fixedId }; + + storageService.unlink.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + await expect( + service.purgeUpload(upload as never, uploadRepository as never) + ).resolves.toBeUndefined(); + + expect(uploadRepository.remove).toHaveBeenCalledWith(upload); + }); + + it('propagates non-ENOENT storage unlink errors', async () => { + const upload = { id: fixedId, storagePath: fixedId }; + + storageService.unlink.mockRejectedValue(new Error('permission denied')); + + await expect(service.purgeUpload(upload as never, uploadRepository as never)).rejects.toThrow( + 'permission denied' + ); + expect(uploadRepository.remove).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/modules/upload/services/UploadCleanupService.ts b/backend/src/modules/upload/services/UploadCleanupService.ts new file mode 100644 index 0000000..167185e --- /dev/null +++ b/backend/src/modules/upload/services/UploadCleanupService.ts @@ -0,0 +1,64 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { DataSource, Repository } from 'typeorm'; +import { hashDeleteToken } from '../../../utils/hashDeleteToken'; +import { isErrorEnoent } from '../../../utils/isErrorEnoent'; +import { StorageService } from '../../storage/services/StorageService'; +import { Upload } from '../entities/Upload'; + +@Injectable() +export class UploadCleanupService { + constructor( + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly storageService: StorageService, + @InjectRepository(Upload) + private readonly uploadRepository: Repository + ) {} + + async deleteUpload(id: string, deleteToken: string): Promise { + await this.dataSource.transaction(async manager => { + const uploadRepository = manager.getRepository(Upload); + + const upload = await uploadRepository.findOne({ + where: { id, deleteTokenHash: hashDeleteToken(deleteToken) } + }); + + if (!upload) { + throw new NotFoundException(); + } + + await this.purgeUpload(upload, uploadRepository); + }); + } + + @Cron(CronExpression.EVERY_MINUTE) + async purgeUploads(): Promise { + const purgeBatchSize = 100; + const now = new Date(); + + const uploads = await this.uploadRepository + .createQueryBuilder('upload') + .where('upload.expiresAt IS NOT NULL AND upload.expiresAt < :now', { now }) + .orWhere('upload.maxViews IS NOT NULL AND upload.viewCount >= upload.maxViews') + .take(purgeBatchSize) + .getMany(); + + for (const upload of uploads) { + await this.purgeUpload(upload, this.uploadRepository); + } + } + + async purgeUpload(upload: Upload, uploadRepository: Repository): Promise { + try { + await this.storageService.unlink(upload.storagePath); + } catch (error: unknown) { + if (!isErrorEnoent(error)) { + throw error; + } + } + + await uploadRepository.remove(upload); + } +} diff --git a/backend/src/modules/upload/services/UploadService.spec.ts b/backend/src/modules/upload/services/UploadService.spec.ts index f796dea..e638663 100644 --- a/backend/src/modules/upload/services/UploadService.spec.ts +++ b/backend/src/modules/upload/services/UploadService.spec.ts @@ -1,5 +1,6 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { createCipheriv, createHash, randomBytes } from 'node:crypto'; +import { createCipheriv, randomBytes } from 'node:crypto'; +import { hashDeleteToken } from '../../../utils/hashDeleteToken'; import { AES_GCM_IV_BYTE_LENGTH } from '../../../consts/crypto/aesGcmIvByteLength'; import type { DataSource, EntityManager, Repository } from 'typeorm'; import type { Upload } from '../entities/Upload'; @@ -39,8 +40,6 @@ const ciphertextBase64 = (fill = 0xcd): string => Buffer.alloc(64, fill).toStrin const wrapFieldBase64 = (length: number): string => Buffer.alloc(length, 0x22).toString('base64'); -const hashDeleteToken = (token: string): string => createHash('sha256').update(token, 'utf8').digest('hex'); - const encryptHotlinkCiphertext = (plaintext: Buffer, key: Buffer, iv: Buffer): Buffer => { const cipher = createCipheriv('aes-256-gcm', key, iv); @@ -340,7 +339,7 @@ describe('UploadService', () => { expect(uploadRepository.increment).not.toHaveBeenCalled(); }); - it('purges upload after the last allowed view', async () => { + it('does not purge on GET after the last allowed view', async () => { const iv = Buffer.alloc(12, 0x11); const ciphertext = Buffer.alloc(64, 0xcd); const upload = { @@ -367,8 +366,8 @@ describe('UploadService', () => { }); expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1); - expect(storageService.unlink).toHaveBeenCalledWith(fixedId); - expect(uploadRepository.remove).toHaveBeenCalledWith(upload); + expect(storageService.unlink).not.toHaveBeenCalled(); + expect(uploadRepository.remove).not.toHaveBeenCalled(); }); }); @@ -460,7 +459,7 @@ describe('UploadService', () => { expect(uploadRepository.increment).not.toHaveBeenCalled(); }); - it('purges upload after the last allowed view', async () => { + it('does not purge on GET after the last allowed view', async () => { const upload = { id: fixedId, storagePath: fixedId, @@ -479,8 +478,8 @@ describe('UploadService', () => { }); expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1); - expect(storageService.unlink).toHaveBeenCalledWith(fixedId); - expect(uploadRepository.remove).toHaveBeenCalledWith(upload); + expect(storageService.unlink).not.toHaveBeenCalled(); + expect(uploadRepository.remove).not.toHaveBeenCalled(); }); it('does not purge when views remain below maxViews', async () => { @@ -504,68 +503,6 @@ describe('UploadService', () => { }); }); - describe('deleteUpload', () => { - const deleteToken = fixedDeleteTokenBytes.toString('base64url'); - - it('unlinks ciphertext and removes upload when delete token matches', async () => { - const upload = { - storagePath: fixedId, - deleteTokenHash: hashDeleteToken(deleteToken) - }; - - uploadRepository.findOne.mockResolvedValue(upload); - - await expect(service.deleteUpload(fixedId, deleteToken)).resolves.toBeUndefined(); - - expect(uploadRepository.findOne).toHaveBeenCalledWith({ - where: { id: fixedId, deleteTokenHash: hashDeleteToken(deleteToken) } - }); - expect(storageService.unlink).toHaveBeenCalledWith(fixedId); - expect(uploadRepository.remove).toHaveBeenCalledWith(upload); - }); - - it('throws NotFoundException when upload is missing', async () => { - uploadRepository.findOne.mockResolvedValue(null); - - await expect(service.deleteUpload(fixedId, deleteToken)).rejects.toThrow(NotFoundException); - expect(storageService.unlink).not.toHaveBeenCalled(); - expect(uploadRepository.remove).not.toHaveBeenCalled(); - }); - - it('throws NotFoundException when delete token is wrong', async () => { - uploadRepository.findOne.mockResolvedValue(null); - - await expect(service.deleteUpload(fixedId, 'wrong-token')).rejects.toThrow(NotFoundException); - expect(storageService.unlink).not.toHaveBeenCalled(); - expect(uploadRepository.remove).not.toHaveBeenCalled(); - }); - - it('still removes upload when ciphertext file is already missing', async () => { - const upload = { - storagePath: fixedId, - deleteTokenHash: hashDeleteToken(deleteToken) - }; - - uploadRepository.findOne.mockResolvedValue(upload); - storageService.unlink.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); - - await expect(service.deleteUpload(fixedId, deleteToken)).resolves.toBeUndefined(); - - expect(uploadRepository.remove).toHaveBeenCalledWith(upload); - }); - - it('propagates non-ENOENT storage unlink errors', async () => { - uploadRepository.findOne.mockResolvedValue({ - storagePath: fixedId, - deleteTokenHash: hashDeleteToken(deleteToken) - }); - storageService.unlink.mockRejectedValue(new Error('permission denied')); - - await expect(service.deleteUpload(fixedId, deleteToken)).rejects.toThrow('permission denied'); - expect(uploadRepository.remove).not.toHaveBeenCalled(); - }); - }); - describe('transaction rollback on storage failure', () => { it('propagates storage write errors from the transaction', async () => { storageService.write.mockRejectedValue(new Error('disk full')); diff --git a/backend/src/modules/upload/services/UploadService.ts b/backend/src/modules/upload/services/UploadService.ts index 2eae57d..e9f44ca 100644 --- a/backend/src/modules/upload/services/UploadService.ts +++ b/backend/src/modules/upload/services/UploadService.ts @@ -2,11 +2,12 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import dayjs from 'dayjs'; import { fileTypeFromBuffer } from 'file-type'; -import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import { randomBytes, randomUUID } from 'node:crypto'; import { DataSource, Repository } from 'typeorm'; import { decryptAesGcm } from '../../../utils/decryptAesGcm'; import { fromBase64 } from '../../../utils/fromBase64'; import { fromBase64Url } from '../../../utils/fromBase64Url'; +import { hashDeleteToken } from '../../../utils/hashDeleteToken'; import { isErrorEnoent } from '../../../utils/isErrorEnoent'; import { toBase64 } from '../../../utils/toBase64'; import { StorageService } from '../../storage/services/StorageService'; @@ -55,7 +56,7 @@ export class UploadService { storagePath, iv: fromBase64(iv), maxViews, - deleteTokenHash: this.hashDeleteToken(deleteToken), + deleteTokenHash: hashDeleteToken(deleteToken), expiresAt, zk: { passwordProtected, @@ -101,7 +102,7 @@ export class UploadService { storagePath, iv: fromBase64(iv), maxViews, - deleteTokenHash: this.hashDeleteToken(deleteToken), + deleteTokenHash: hashDeleteToken(deleteToken), expiresAt, hotlink: { mimeType } }); @@ -166,12 +167,6 @@ export class UploadService { await this.uploadRepository.increment({ id }, 'viewCount', 1); - const nextViewCount = upload.viewCount + 1; - - if (typeof upload.maxViews === 'number' && nextViewCount >= upload.maxViews) { - await this.purgeUpload(upload, this.uploadRepository); - } - return result; } @@ -217,50 +212,12 @@ export class UploadService { await this.uploadRepository.increment({ id }, 'viewCount', 1); - const nextViewCount = upload.viewCount + 1; - - if (typeof upload.maxViews === 'number' && nextViewCount >= upload.maxViews) { - await this.purgeUpload(upload, this.uploadRepository); - } - return { body, mimeType: upload.hotlink.mimeType }; } - async deleteUpload(id: string, deleteToken: string): Promise { - await this.dataSource.transaction(async manager => { - const uploadRepository = manager.getRepository(Upload); - - const upload = await uploadRepository.findOne({ - where: { id, deleteTokenHash: this.hashDeleteToken(deleteToken) } - }); - - if (!upload) { - throw new NotFoundException(); - } - - await this.purgeUpload(upload, uploadRepository); - }); - } - - private async purgeUpload(upload: Upload, uploadRepository: Repository): Promise { - try { - await this.storageService.unlink(upload.storagePath); - } catch (error: unknown) { - if (!isErrorEnoent(error)) { - throw error; - } - } - - await uploadRepository.remove(upload); - } - - private hashDeleteToken(token: string): string { - return createHash('sha256').update(token, 'utf8').digest('hex'); - } - private async throwIfUnencryptedImage(ciphertext: Buffer): Promise { const detected = await fileTypeFromBuffer(ciphertext); diff --git a/backend/src/utils/hashDeleteToken.ts b/backend/src/utils/hashDeleteToken.ts new file mode 100644 index 0000000..74e6256 --- /dev/null +++ b/backend/src/utils/hashDeleteToken.ts @@ -0,0 +1,4 @@ +import { createHash } from 'node:crypto'; + +export const hashDeleteToken = (token: string): string => + createHash('sha256').update(token, 'utf8').digest('hex'); -- 2.54.0 From 3cf1d5e8aae2116ec13670e945c20b259026305b Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 27 Oct 2026 22:09:45 +0100 Subject: [PATCH 8/9] Fix drop lastViewedAt migration order for fresh databases. Run the column drop after AddUploadTable so TypeORM does not execute ALTER before uploads exists. --- ...ewed-at.ts => 1791230839247-drop-upload-last-viewed-at.ts} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename backend/src/database/migrations/{1761582000000-drop-upload-last-viewed-at.ts => 1791230839247-drop-upload-last-viewed-at.ts} (78%) diff --git a/backend/src/database/migrations/1761582000000-drop-upload-last-viewed-at.ts b/backend/src/database/migrations/1791230839247-drop-upload-last-viewed-at.ts similarity index 78% rename from backend/src/database/migrations/1761582000000-drop-upload-last-viewed-at.ts rename to backend/src/database/migrations/1791230839247-drop-upload-last-viewed-at.ts index c938614..e18bba3 100644 --- a/backend/src/database/migrations/1761582000000-drop-upload-last-viewed-at.ts +++ b/backend/src/database/migrations/1791230839247-drop-upload-last-viewed-at.ts @@ -1,7 +1,7 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -export class DropUploadLastViewedAt1761582000000 implements MigrationInterface { - name = 'DropUploadLastViewedAt1761582000000'; +export class DropUploadLastViewedAt1791230839247 implements MigrationInterface { + name = 'DropUploadLastViewedAt1791230839247'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE "uploads" DROP COLUMN "lastViewedAt"`); -- 2.54.0 From 214fa0f87556c1701cfa996662250a52f7a41557 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 27 Oct 2026 22:09:48 +0100 Subject: [PATCH 9/9] Enforce maxViews atomically when reserving a view slot. Use a conditional UPDATE with parenthesized OR so increment only applies to the target row and fails when the view cap is reached. --- .../upload/services/UploadService.spec.ts | 43 +++++++++++-------- .../modules/upload/services/UploadService.ts | 18 +++++++- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/backend/src/modules/upload/services/UploadService.spec.ts b/backend/src/modules/upload/services/UploadService.spec.ts index e638663..299d8bf 100644 --- a/backend/src/modules/upload/services/UploadService.spec.ts +++ b/backend/src/modules/upload/services/UploadService.spec.ts @@ -54,17 +54,24 @@ describe('UploadService', () => { create: jest.Mock; save: jest.Mock; findOne: jest.Mock; - increment: jest.Mock; + createQueryBuilder: jest.Mock; remove: jest.Mock; }; let transaction: jest.Mock; + let reserveViewExecute: jest.Mock; beforeEach(() => { + reserveViewExecute = jest.fn().mockResolvedValue({ affected: 1 }); + const andWhere = jest.fn().mockReturnValue({ execute: reserveViewExecute }); + const where = jest.fn().mockReturnValue({ andWhere }); + const set = jest.fn().mockReturnValue({ where }); + const update = jest.fn().mockReturnValue({ set }); + uploadRepository = { create: jest.fn((entityLike: unknown) => entityLike), save: jest.fn(async (entity: unknown) => entity), findOne: jest.fn(), - increment: jest.fn().mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }), + createQueryBuilder: jest.fn().mockReturnValue({ update }), remove: jest.fn().mockResolvedValue(undefined) }; @@ -232,7 +239,7 @@ describe('UploadService', () => { relations: ['zk'] }); expect(storageService.read).toHaveBeenCalledWith(fixedId); - expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1); + expect(reserveViewExecute).toHaveBeenCalled(); }); it('includes wrap fields when password protected', async () => { @@ -262,7 +269,7 @@ describe('UploadService', () => { wrapIv: wrapIv.toString('base64'), salt: salt.toString('base64') }); - expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1); + expect(reserveViewExecute).toHaveBeenCalled(); }); it('throws NotFoundException when upload is missing or not zk', async () => { @@ -270,7 +277,7 @@ describe('UploadService', () => { await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException); expect(storageService.read).not.toHaveBeenCalled(); - expect(uploadRepository.increment).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); }); it('throws NotFoundException when ciphertext file is missing', async () => { @@ -282,7 +289,7 @@ describe('UploadService', () => { storageService.read.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException); - expect(uploadRepository.increment).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); }); it('throws NotFoundException when password protected but wrap data is incomplete', async () => { @@ -299,7 +306,7 @@ describe('UploadService', () => { storageService.read.mockResolvedValue(Buffer.alloc(8)); await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException); - expect(uploadRepository.increment).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); }); it('throws NotFoundException when expiresAt is in the past', async () => { @@ -317,7 +324,7 @@ describe('UploadService', () => { await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException); expect(storageService.read).not.toHaveBeenCalled(); - expect(uploadRepository.increment).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); }); it('throws NotFoundException when maxViews is exhausted', async () => { @@ -336,7 +343,7 @@ describe('UploadService', () => { await expect(service.getZkUploadCiphertext(fixedId)).rejects.toThrow(NotFoundException); expect(storageService.read).not.toHaveBeenCalled(); - expect(uploadRepository.increment).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); }); it('does not purge on GET after the last allowed view', async () => { @@ -365,7 +372,7 @@ describe('UploadService', () => { passwordProtected: false }); - expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1); + expect(reserveViewExecute).toHaveBeenCalled(); expect(storageService.unlink).not.toHaveBeenCalled(); expect(uploadRepository.remove).not.toHaveBeenCalled(); }); @@ -395,7 +402,7 @@ describe('UploadService', () => { where: { id: fixedId, mode: UploadMode.Hotlink }, relations: ['hotlink'] }); - expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1); + expect(reserveViewExecute).toHaveBeenCalled(); }); it('throws NotFoundException when upload is missing or not hotlink', async () => { @@ -403,7 +410,7 @@ describe('UploadService', () => { await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException); expect(storageService.read).not.toHaveBeenCalled(); - expect(uploadRepository.increment).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); }); it('throws NotFoundException when secret is wrong but well-formed', async () => { @@ -417,7 +424,7 @@ describe('UploadService', () => { const wrongSecret = Buffer.alloc(32, 0x99).toString('base64url'); await expect(service.getHotlinkImage(fixedId, wrongSecret)).rejects.toThrow(NotFoundException); - expect(uploadRepository.increment).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); }); it('throws NotFoundException when ciphertext file is missing', async () => { @@ -429,7 +436,7 @@ describe('UploadService', () => { storageService.read.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException); - expect(uploadRepository.increment).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); }); it('throws NotFoundException when expiresAt is in the past', async () => { @@ -442,7 +449,7 @@ describe('UploadService', () => { await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException); expect(storageService.read).not.toHaveBeenCalled(); - expect(uploadRepository.increment).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); }); it('throws NotFoundException when maxViews is exhausted', async () => { @@ -456,7 +463,7 @@ describe('UploadService', () => { await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException); expect(storageService.read).not.toHaveBeenCalled(); - expect(uploadRepository.increment).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); }); it('does not purge on GET after the last allowed view', async () => { @@ -477,7 +484,7 @@ describe('UploadService', () => { mimeType: 'image/png' }); - expect(uploadRepository.increment).toHaveBeenCalledWith({ id: fixedId }, 'viewCount', 1); + expect(reserveViewExecute).toHaveBeenCalled(); expect(storageService.unlink).not.toHaveBeenCalled(); expect(uploadRepository.remove).not.toHaveBeenCalled(); }); @@ -497,7 +504,7 @@ describe('UploadService', () => { mimeType: 'image/png' }); - expect(uploadRepository.increment).toHaveBeenCalled(); + expect(reserveViewExecute).toHaveBeenCalled(); expect(storageService.unlink).not.toHaveBeenCalled(); expect(uploadRepository.remove).not.toHaveBeenCalled(); }); diff --git a/backend/src/modules/upload/services/UploadService.ts b/backend/src/modules/upload/services/UploadService.ts index e9f44ca..fa2a9ef 100644 --- a/backend/src/modules/upload/services/UploadService.ts +++ b/backend/src/modules/upload/services/UploadService.ts @@ -165,7 +165,7 @@ export class UploadService { result.salt = toBase64(upload.zk.salt); } - await this.uploadRepository.increment({ id }, 'viewCount', 1); + await this.reserveViewSlot(id); return result; } @@ -210,7 +210,7 @@ export class UploadService { throw new NotFoundException(); } - await this.uploadRepository.increment({ id }, 'viewCount', 1); + await this.reserveViewSlot(id); return { body, @@ -218,6 +218,20 @@ export class UploadService { }; } + private async reserveViewSlot(id: string): Promise { + const { affected } = await this.uploadRepository + .createQueryBuilder() + .update('uploads') + .set({ viewCount: () => '"viewCount" + 1' }) + .where('id = :id', { id }) + .andWhere('("maxViews" IS NULL OR "viewCount" < "maxViews")') + .execute(); + + if (!affected) { + throw new NotFoundException(); + } + } + private async throwIfUnencryptedImage(ciphertext: Buffer): Promise { const detected = await fileTypeFromBuffer(ciphertext); -- 2.54.0