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.
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.
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>.webpor<uuid>.gif. Nothing from the request reaches the path.
How a file gets out
One route, and it authorizes before it touches the disk:
Two independent guards stand between a request and a file:
- The strict filename pattern. Both components must match a UUID, and the
filename must end
.webpor.gif. Anything else is a 404 before any filesystem call. - 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:
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_DIRon 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.createdAtfor 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.