Appearance
Download: a short URL, two headers, and a line in the trail
GrydFiles never serves file content. GET /files/{id}/download-url hands out a presigned GET URL, and the browser fetches the bytes straight from the bucket (RN-GF-06). The URL is the credential: anyone who has it can read the object until it expires. Everything on this page follows from that.
The route
GET /api/v1/files/{id}/download-url?purpose=… — permission read:files.
| The file is | Answer |
|---|---|
Available | 200 with url and expiresAt, and nothing else |
Pending, Scanning, Failed | 409 FILE_NOT_AVAILABLE_CONFLICT — "wait a moment" |
Infected | 409 FILE_INFECTED_CONFLICT — for every caller, admin:system included (RN-GF-04) |
Purged | 409 FILE_PURGED_CONFLICT, with purgedAt, the reason and the rejectionCode when there was one |
| not in this tenant | 404 FILE_NOT_FOUND — the same answer whether the file does not exist or belongs to another tenant |
purpose is required and goes to the trail. A missing, blank, or over-200-character purpose is a 400 with no code: it is the caller's contract error, and the error catalogue has no code for one.
read:files is not business authorization. Whether this user may see this document is the consumer's decision, made before it asks for the URL. The module cannot answer it, because it does not know what a purchase requisition is.
A product file (tenantId null) is read with read:files alone. admin:system guards creating, changing and purging one, not reading it.
Validity
Five minutes by default; each profile can change it with DownloadUrlTtlMinutes. Keep it short: the longer a URL lives, the longer it keeps working after someone pastes it into a chat.
A file whose profile has since been removed from configuration is still served, with the default validity. The file already has its verdict, and the validity is the only thing a download reads from its profile.
The two headers the bucket sends
The URL carries two response-header overrides, and together they are the whole defence against a file being executed in the browser:
| Header | Value |
|---|---|
Content-Disposition | attachment; filename*=UTF-8''<name> — the requested record's original name |
Content-Type | application/octet-stream, always, never the detected type |
attachment tells the browser to save the file, not open it. octet-stream tells it these are bytes: an HTML page or an SVG served this way is not rendered, whatever a content sniffer would have made of it.
The name is written in the RFC 8187 extended form, and every byte outside the attr-char set is percent-encoded, including ', (, ) and *, which Uri.EscapeDataString leaves alone. The apostrophe is the delimiter of filename* itself, so a name like relatório d'água.pdf would otherwise end the value halfway. With every non-attr-char encoded, a quote, a semicolon or a line break in a name can never close the parameter or start a header of its own. There is no plain filename= next to it: every browser in use reads the extended form, and a second, lossy encoding of the same name would be one more place for the two to disagree.
X-Content-Type-Options: nosniff is the bucket's job, not the module's
Its absence from the module's code is deliberate — do not add it back as a "fix".
A presigned URL can only override a closed set of response headers. On S3 and the S3-compatible stores that set is response-content-type, response-content-disposition, response-cache-control, response-expires, response-content-encoding and response-content-language. Azure's SAS (rscd, rsct, …) and Google Cloud Storage's V4 signed URLs have the same kind of closed list. X-Content-Type-Options is in none of them. No code in the module can put it on the response, because the module never produces the response: the bucket does.
The header is still worth having, as a second line behind the two above. It belongs where the response is produced or passes through, and there it applies to every object, including anything the module did not issue:
| In front of the bucket | Where the header is set |
|---|---|
| Amazon CloudFront | a response headers policy with the content-type-options header enabled |
| Azure Front Door | a rule set action that modifies the response header |
| Google Cloud CDN / external Application Load Balancer | custom response headers on the backend bucket |
| A reverse proxy in front of MinIO (nginx) | add_header X-Content-Type-Options nosniff always; |
A bucket served with no CDN or proxy in front cannot send the header at all. That is acceptable, because attachment plus octet-stream is the defence that does not depend on it. It is also a reason to put one in front when the deployment allows.
The bucket policy has one more job, unrelated to this header: deny reads under _quarantine/ to everything but the module's own credentials. The module never signs a URL for that prefix — the refusal lives in a single class, FilePresignedUrlIssuer, and an architecture test fails if a second one appears. But a prefix that is merely "never linked to" is not a policy.
Deduplicated files
A deduplicated record has no object of its own. The URL is signed for the canonical record's object, but the name in Content-Disposition and the trail entry are the record the caller asked about, and the "only Available" rule is judged on that record too. A reuser whose owner has already been purged is still served: the owner's tombstone keeps the location, and the bytes live until the last member of the set dies (see retention and purge).
The trail
Every URL issued writes one audit entry through the module's IFileAuditTrail: the file, the user, the IP, the caller's tenant and the purpose (RN-GF-12). Issuing a URL changes no row, so the SaveChanges interceptor would never see it; the entry is explicit.
The entry is written after the URL is signed and before it is returned. If the audit store fails, the caller gets an error and no URL — a URL nobody recorded is the one outcome this rule exists to prevent. The tenant recorded is the caller's: for a tenant's file it is also the file's, and for a product file it is the only thing that says which tenant's user took it.
What the trail records is the issuance, not the download. Once the URL leaves the module, the bytes travel between the bucket and the browser, and the module cannot see them.
GET /files/{id} and GET /files/{id}/scan-attempts
Both answer in every state, the tombstone included: reading a record is how a screen learns why a file cannot be used. Neither response carries the object key, the bucket or the provider (RN-GF-07). The scan attempts come newest first, unpaged: a file has at most four.
The decisions behind this page are in ADR 0010; the contract is in the specification.