feat: tip message media support (images, videos, GIFs) - #122
Merged
Merged
Conversation
|
@Abulbayty Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
Contributor
|
@Abulbayty well done and thanks for contributing, resolve the conflicts so we can check and merge |
Resolves merge conflicts against Dorisio/backend@c6cc8e7 (22 commit(s) behind) so the PR is mergeable.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
Tip messages can now carry images and short videos. A tipper reserves an upload slot, puts the bytes in object storage, the API verifies what the bytes actually are, scans them for malware and charges the user's storage quota, and only then can the media be attached to a tip.
The repository had nothing for this: no media model, no upload path, no scanning, no storage driver, no quota. This adds the whole vertical slice —
TipMediaplusMediaQuota, a media domain (types,storage,scanner,processor,queue,service,factory,routes), the payment-side wiring soPOST /api/v1/transactions/tipacceptsmediaIds, and the worker job that turns a verified upload into renditions.Three decisions are worth calling out. The declared
contentTypeand the file name are both attacker-controlled, sodetectMimeTypesniffs the magic bytes and the result must match the declared type exactly — a file whose bytes disagree is refused and deleted, and a rejected or in-flight upload is never served even to its owner. A scanner that cannot run is not a pass: an unreachable clamd marks the mediafailedand surfaces the error, andMEDIA_SCANNER=nonebuilds a scanner that refuses everything, so a misconfigured deployment fails closed instead of accepting anything. And media is attached once — the lookup enforces ownership,readystatus and "not already on a tip" in one query, so an id that fails any of those rules makes the whole tip request fail with every problem listed rather than half-attaching.Related Issue
Addresses the behaviour described in #64.
Changes
Model and persistence
prisma/migrations/20260927200000_tip_media/migration.sql/ [MODIFY]prisma/schema.prismaTipMedia: the row is created before the bytes exist (it is the upload reservation) and trackskind,status, the declaredmimeType/fileName/storageKey, the measuredsizeBytes, probedwidth/height/durationSeconds, the derivative list,processingStatus/processingError, the scan verdict (scanner,scanSignature,scanCompletedAt),uploadedAt/attachedAtand the owningtipId.MediaQuotaper user (usedBytes,fileCount,limitBytes).Media domain
[ADD]
src/domains/media/media.types.tsWEBPmarker, MP4 forftyp, and a short buffer is not accepted), per-kind byte limits,MAX_MEDIA_PER_TIP, and the Zod schemas for reservation, proxy upload and listing.[ADD]
src/domains/media/media.storage.tsS3MediaStoragepresigns SigV4 query URLs (UNSIGNED-PAYLOAD, expiry capped at the 7-day maximum, session token, custom endpoint/path-style for MinIO and R2) so the browser PUTs straight to the bucket and reads redirect to a short-lived GET.LocalMediaStoragewrites under a configurable root behind a guard that refuses any key escaping it.buildCdnUrlswitches response URLs to the CDN when one is configured.[ADD]
src/domains/media/media.scanner.tsClamAvScannerspeaks theINSTREAMprotocol toCLAMAV_HOST, bounded byCLAMAV_TIMEOUT_MSand a byte cap.EicarScannerrecognises the EICAR test file and nothing else, and records itself aseicarso it can never be mistaken for a real engine.FailClosedScannerrefuses every upload, and is what aclamavconfiguration without a host falls back to.NoScanScannerexists for the processing worker, which uploads already passed.[ADD]
src/domains/media/media.processor.tsSharpImageProcessor(optional dependency, loaded dynamically) produces preview/optimised webp derivatives and records dimensions, skipping images already within the derivative sizes.FfmpegVideoProcessorprobes dimensions and duration, extracts a poster frame, and transcodes to webm only when asked; missing binaries skip with an explanation instead of failing the upload.KindRoutingProcessorsends each kind to the right one.[ADD]
src/domains/media/media.queue.ts,src/domains/media/media.factory.tsimage-processingqueue and carry only amediaId, so a retry processes the row's current state rather than a stale payload. The factory builds the storage/scanner/processor stack from configuration in one place, so routes and worker cannot drift apart.[ADD]
src/domains/media/media.service.tsrequestUpload()checks the declared type and the quota before any byte moves and reserves apendingrow;writeUploadedContent()handles the proxy path;completeUpload()verifies the stored size against the real limit, sniffs the magic bytes against the declared type, scans, commits the quota, queues processing and returns thereadyview. Rejects delete the bytes; an unreachable scanner marks the mediafailedrather than clean.processMedia()runs from the worker and records both the derivatives and why they were skipped.listMine()/getMedia()/findById()/canRead()cover reads, with visibility tied to the owning tip.attachToTip()enforces owner +ready+ unattached and reports every problem at once.deleteMedia()refuses media a tip is using and refunds the quota.getQuota()separates committed from reserved bytes, andpruneStaleUploads()closes abandoned reservations.[ADD]
src/domains/media/media.routes.ts/ [MODIFY]src/index.tsPOST /api/v1/media/uploads,PUT /api/v1/media/uploads/:mediaId/content,POST .../complete,GET /api/v1/media/quota,GET /api/v1/media(status/kind/attached filters, paged),GET /api/v1/media/:mediaId,GET .../content(owner, or anyone for media attached to a visible tip; redirects to a presigned URL on S3),DELETE /api/v1/media/:mediaIdand the admin-onlyPOST /api/v1/media/maintenance/prune. Registered viaregisterMediaRoutesin both registration blocks.Tip integration
[MODIFY]
src/domains/payments/payment.types.ts,payment.schemas.tsCreateTipSchemaacceptsmediaIds(at most 4) on both the request-validation schema and the service-facing one, andTipResponsecarriesmedia.[MODIFY]
src/domains/payments/payment.service.tscreateTip()resolves the requested media against owner +ready+ unattached before writing the tip, so a foreign, in-flight or already-attached id fails the request instead of silently dropping; the tip is created with the media connected,attachedAtis stamped afterwards as best-effort bookkeeping (a failure there is logged, not surfaced, because the tip is already committed).TIP_RESPONSE_SELECTincludes thereadymedia in attachment order andformatTipResponse()renders it asMediaViews.[MODIFY]
src/domains/payments/payment.routes.tsmediais now declared, otherwise the client would never see the media it just attached.[MODIFY]
src/lib/workers/image-processing.worker.ts,src/lib/workers/index.tsstartWorkers()forwards the Prisma client to it, and a standalone worker process creates its own.Config, tests and docs
[MODIFY]
src/config/env.ts,.env.exampleMEDIA_STORAGE,MEDIA_LOCAL_ROOT, theMEDIA_S3_*set,MEDIA_CDN_BASE_URL,MEDIA_UPLOAD_MODE/MEDIA_UPLOAD_TTL_SECONDS, the size and quota limits,MEDIA_SCANNER(defaulting toclamavin production andeicarelsewhere) withCLAMAV_*, andMEDIA_PROCESSOR/MEDIA_TRANSCODE_VIDEO/MEDIA_FFMPEG_PATH/MEDIA_FFPROBE_PATH.[ADD]
src/domains/media/__tests__/(6 suites) andsrc/domains/payments/__tests__/tip-media.test.ts[ADD]
docs/MEDIA.mdmediablock inTipResponseand deletion rules.Verification Results
Closes #64