Where dispute evidence and chat attachments actually live

The two file stores behind every P2P ruling — a backend-private attachment directory set by P2P_ATTACHMENT_DIR, and the public /uploads tree — with their access rules, backup and retention.

8 min readUpdated 6 August 2026attachments, evidence, uploads, backup, storage

Every P2P dispute ruling rests on images, and none of those images are in the database. They are files on the box, in two different stores with two different exposure levels, and one of them is controlled by an environment variable that appears nowhere in the admin panel.

An operator who backs up MySQL and nothing else restores a platform whose dispute record has no evidence in it.

The two stores

Trade-chat attachments Dispute evidence
Written by either trader, from the trade chat an admin, from /admin/p2p/dispute/<id> — nobody else
Where backend/storage/p2p/attachments/<tradeId>/<uuid>.webp (or .gif) frontend/public/uploads/… — the admin uploader writes disputes/<disputeId>/evidence/
Overridable yes — P2P_ATTACHMENT_DIR no
Served by GET /api/p2p/trade/{id}/message/attachment/{file} the web server, statically, from /uploads/…
Authorization the requester must be the buyer or the seller of that trade none
Cacheable no — Cache-Control: private, max-age=0, no-store yes, like any static file

They are not interchangeable, and the split is deliberate.

Anything in frontend/public is readable by URL alone — no sign-in, no expiry, no check that the trade has ended. Trade chats carry payment screenshots, bank slips and ID photos, and until this was fixed every one of them was world-readable to anyone holding or guessing the link.

If you are moving the attachment directory, P2P_ATTACHMENT_DIR must point somewhere outside both web roots. Pointing it back into frontend/public re-creates the leak, and nothing in the product will warn you.

One user-side path still writes into the public tree. The optional payment receipt a buyer attaches when they press "I've sent it" does not go through the attachment endpoint at all — it goes through the platform's ordinary uploader to /uploads/p2p/receipts/<tradeId>/, and is then posted into the trade chat as an image pointing at that public URL. That is a bank slip on a permanent unauthenticated link. Treat a receipt the way this page treats dispute evidence, not the way it treats a chat attachment.

The private store

Where it resolves to

P2P_ATTACHMENT_DIR is an absolute path and wins outright when set. With it unset, the path is probed from the working directory rather than assumed, because the backend runs with different ones in different environments:

process.cwd() Directory used
ends in backend <cwd>/storage/p2p/attachments
anything else (the project root, as under PM2) <cwd>/backend/storage/p2p/attachments

Those two resolve to the same place on a standard layout. On a non-standard one — a symlinked release directory, a PM2 cwd you set yourself, a container whose workdir is neither — they do not, and the symptom is silent: uploads succeed into a directory nobody backs up, and older attachments 404.

Absolute path overriding where P2P trade-chat attachments are stored. Must be outside both web roots.

Set it explicitly. It is the only way to know where the files are, and the environment reference is where it belongs alongside your other paths. Restart the backend after changing it — nothing re-reads the variable at runtime, and files already written stay where they were written.

How a file gets in

The upload endpoint is POST /api/p2p/trade/{id}/message/upload, and it only accepts a base64 image from a party to that trade, on a trade that is not COMPLETED, CANCELLED or EXPIRED.

  • 5 MB ceiling, checked twice — once against the encoded payload before it is decoded into memory, once against the decoded bytes.
  • JPEG, PNG, GIF and WebP only, and the declared mime type is used only to pick which byte-signature test to run. The bytes decide what is stored.
  • Everything except GIF is re-encoded to WebP at quality 80, resized to fit within 1200×1200. The re-encode is the real content check — a file that merely starts with a valid signature fails it — and it strips EXIF, so GPS coordinates and device identifiers do not survive into the store. GIFs are stored as uploaded, animation intact and EXIF untouched.
  • The stored name is generated entirely server-side: <uuid>.webp or <uuid>.gif. Nothing from the request reaches the path.

How a file gets out

One route, and it authorizes before it touches the disk:

Streams one chat attachment to a party of that trade

Two independent guards stand between a request and a file:

  1. The strict filename pattern. Both components must match a UUID, and the filename must end .webp or .gif. Anything else is a 404 before any filesystem call.
  2. Path confinement. The resolved absolute path is checked to be inside the attachment directory, so a traversal attempt cannot escape even if the pattern is ever loosened.

The membership check runs first, and a non-party gets exactly the same 404 as a file that does not exist — the endpoint never confirms that an attachment is there to someone not entitled to it. The URL rendered into the chat is a same-origin API path, so the browser sends session cookies with it and no signed URL or token is involved.

The attachment endpoint authorizes on trade membership only — buyer or seller. There is no administrator bypass in that handler, so an operator reading a trade at /admin/p2p/trade/<id> sees the chat text but the inline images fail to load, because their own account is not a party to the trade.

To read a chat attachment as an operator you go to the file on the server: <attachment dir>/<tradeId>/. If it is going to decide a dispute, copy it off the server and attach it to the dispute record yourself, from /admin/p2p/dispute/<id>. A trader cannot do that — there is no evidence upload anywhere on their side of the product — so until an operator does it, the image stays something only two people can see.

The public store

Dispute evidence takes the platform's ordinary upload path and ends up in frontend/public/uploads. Only an admin ever puts anything there. The shipped dispute-filing dialog collects a reason and a description and nothing else — it has no file input, and the user-side store posts only { reason, description }. So every image on a dispute record was uploaded by an operator from /admin/p2p/dispute/<id>, and each one is stored stamped submittedBy: "admin" with that admin's id and name.

The admin evidence endpoint validates the URL it is given:

Attaches an evidence file to a dispute record

The fileUrl must begin with /uploads/, must not begin //, and must contain no .., no backslash and no ://. That guard exists because the admin dispute UI opens the URL directly — an arbitrary origin in that field would be an admin-facing drive-by fetch handed to the platform by a request body. It is not an access control. It confirms the file is on this platform; it does not make it private.

The backend intercepts any URL beginning /uploads/ and serves it from frontend/public/uploads before route processing runs, and the path is on the licence-exempt list. The Next.js frontend serves the same directory. Neither consults a session.

So a dispute evidence URL is a permanent public link. Treat one the way you would treat a public S3 object: fine to paste into an internal ticket on a private tracker, never fine to send to a counterparty or an outside party, and never assume it has expired.

Only image types are accepted on a dispute evidence record — JPEG, PNG, GIF and WebP.

The filing endpoint POST /api/p2p/trade/{id}/dispute does accept an optional evidence array — up to five items, each typed screenshot, document or text — and stores whatever it is handed on the new dispute row. Nothing in the product sends it. If a filer tells you they attached proof when they opened the case, they did not: all they had was the description box, capped at 1,000 characters. Their images are in the trade chat, and the only way one becomes part of the case file is an operator adding it here.

Backup and restore

The p2p_disputes row stores a path. The image is a file. Restoring the database without the files gives you a dispute record whose evidence gallery is a wall of broken images, and a ruling nobody can audit afterwards.

Path Contains Back up
P2P_ATTACHMENT_DIR, or backend/storage/p2p/attachments/ every trade chat image ever posted yes
frontend/public/uploads/ dispute evidence, plus KYC documents, avatars and every other platform upload yes

The wider list of everything a Bicrypto install keeps state in is on Backup and restore.

frontend/public is part of the frontend source tree. Any deployment that replaces it — a clean checkout into a fresh release directory, a container image rebuild, an rsync with --delete, an update that extracts over the app — takes the uploads directory with it unless you have deliberately excluded it or moved it out to a volume and symlinked it back.

This is the single most common way an install loses its evidence, and it loses KYC documents and avatars at the same time. Verify your deploy step preserves frontend/public/uploads before you need to prove what it held.

The private attachment store does not have this problem on a standard layout — backend/storage/ is not rebuilt by a frontend build — but it has the mirror problem: an update that extracts over backend/ may not preserve it either. A P2P_ATTACHMENT_DIR pointing outside the application tree entirely is the robust answer to both.

Files uploaded before the private store existed stay where they were originally written, in the public tree. Migrating them is not automatic. If the contents of the old public P2P uploads folder matter to you, deal with them deliberately — either move them and accept that the old chat links break, or clear them and accept that the old images are gone.

Retention: there is none

Nothing prunes either store. No archival job, no age-based cleanup, no retention setting anywhere in the P2P settings screen. A completed trade's chat images stay on disk forever, and so does the evidence on a dispute resolved two years ago.

Disk growth is therefore unbounded and roughly proportional to trade volume. Each image is capped at 5 MB on the way in and most land far under that after the WebP re-encode — but a busy board writes a lot of files, and each one gets its own directory per trade.

What to do about it:

  • Monitor free space on both paths as part of ordinary server monitoring. Neither directory is visible from the admin panel, so nothing on the platform will tell you when it fills up.
  • Put P2P_ATTACHMENT_DIR on its own volume if you expect volume. That is what the variable is for.
  • Do not write a cleanup job that deletes by age alone. A dispute filed eleven months after a trade — and the chargeback windows on some fiat rails make that possible — is decided on that trade's chat. Decide your retention against p2p_trades.createdAt for trades that reached a terminal status, and keep anything attached to a dispute.
  • Deleting a file does not remove the record. The dispute row still lists its evidence and the chat still contains the message; only the image goes. There is no repair tool for that state.