Files, uploads, and integrity¶
Path jailing, listings and downloads, the upload protocols, integrity headers, and directory and symlink endpoints. The endpoint groups are on the Site API overview; the authentication that fronts them is in 10-security.
Path handling and jailing¶
The API serves {path} values jailed to the Site content root
(/www/html, fixed):
- Gin URL-decodes the path once; if the result still contains only
well-formed
%XXescapes, the API decodes a second time (so a double encoded%252E%252Ereaches the checks as..; a bare%such as100% donestays literal). - Every parsed path is cleaned and checked with
filepath.IsLocal; an escape answers 400 (ParseMiddlewareaborts). - For paths that already exist, a second check resolves symlinks with
filepath.EvalSymlinksand rejects any resolution outside the root. - For paths that do not exist yet (uploads, new directories, renames), the check resolves every symlink on the path that exists, also a dangling symlink at the path itself, because a write follows it. A path whose location on disk is outside the root, or that has more than 40 levels of links, answers 400.
- The router collapses duplicate slashes and fixes trailing slashes
(
RemoveExtraSlash,RedirectFixedPath), so encoded separators cannot smuggle a second root. - The API does each file operation on the content through one
os.Rootthat it opens on the content root (api/site_root.go). This applies to open, create, stat, remove, rename, directory creation, symlink operations, temporary upload files, downloads, archives, and checksums. The kernel resolves each path component below the root. Thus, a tenant that changes a directory to a symlink after the checks above cannot make the operation read, write, or delete a file outside the root. The operation fails. os.Rootdoes not follow an absolute symlink, also when the absolute target is inside the root. A download, an archive, or a write through such a link fails. Use a relative link. The symlink endpoints always store relative links.- Recursive scans and the size estimate of a recursive delete use paths and
do not follow symlinked directories. The public
.htpasswdprotection check also resolves the physical path, so a symlink whose target sits under a protected tree is hidden too (baseOrAncestorProtected,resolvedHtpasswdProtected,symlinkResolvesToProtected).
(TestRootRelPath, TestResolveRootPath,
TestResolveRootPathRejectsMissingPathsThatEscape, and
TestDeleteFile_DoubleEncodedTraversal_Rejected cover the rules;
TestDeleteFile_DoubleEncodedSpaces_PathFromClient_Success shows that an
encoded space that is not a traversal still works.)
Listing and download¶
GET /files/{path} returns a directory listing (JSON) for a directory, or a
file body for a file. File downloads set a Content-Disposition attachment
header; a requested path that ends with index.html is served inline
instead. ?list=1 on a file returns a
single metadata entry instead of the body. HEAD on a directory answers
409 with X-Error: path is a directory; a zero-byte file answers
Content-Range: bytes */0.
| Query parameter | Behaviour |
|---|---|
list |
true requests a listing (default false; a bare ?list counts as true) |
match |
Glob-like (doublestar) filter over entry names; an invalid pattern yields an empty listing, not an error. A pattern with a .. segment is refused with 400, because the pattern is joined with the requested path and could match entries outside of it. A name that only contains two dots, such as a..b.log, is allowed |
recursive |
Descend the whole tree |
type |
f files, d directories |
limit |
1..500, default 100; invalid or out-of-range falls back to 100 |
continuation_token |
Next page of a cached scan |
archive |
zip, tar, or tgz, case-insensitive (present-but-empty means zip; anything else → 400) |
v2 listings return {files: [{type, path, stats: {size, mode, mtime}}],
continuation_token}; v1 returns a flat path array. Paths are relative to
the requested directory. Listing entries follow symlinks for the type and
stat data (a link to a directory lists as d); a dangling link is skipped
with a warning, and recursive scans do not descend symlinked directories.
The scan passes doublestar.WithNoFollow() to the glob, so ** lists a
link to a directory but does not enter it. A link loop such as a -> .
therefore cannot make a recursive listing or archive run without a limit
(api/symlink_loop_test.go). A symlink in the requested base path is still
followed, so a client can list a linked directory directly. An archive
contains only regular files, so a link to a directory is not in it.
A scan skips every entry that resolves outside the root through a symlink.
Listings, archives, and pattern deletes use the same scan, so they cannot
read or delete files outside the root. A pattern delete also requires that
the parent of each entry resolves inside the root; for a symlink entry, it
deletes only the link.
Pagination caches the scan remainder in process memory for 3 minutes, keyed
by the continuation token itself: the next token is the SHA-256 of the
previous one (api/cache_utils.go, a random UUID on the first call), and
the used token is dropped. The limit is re-applied on every page, so
changing it mid-pagination keeps the token valid and only changes the page
sizes. An expired or
unknown token yields an empty listing with an
empty token, not an error. The last page returns an empty
token. The first call therefore pays for the scan.
GET /files/{dir}?archive=zip streams a fresh archive; combine with
recursive for whole trees. Archives also work on a single file, contain
files only (directory entries are excluded), and answer 404 with a hint
about recursive when the scan is empty. Responses carry the archive MIME
type, a Content-Disposition: attachment; filename=archive.<ext>, and
X-Accel-Buffering: no, flushed in 256 KiB blocks. Streaming means archives carry no ETag
and no Repr-Digest. A proxy shortcut exists for the listing UI:
.../download-folder rewrites to the files endpoint with
archive=zip and recursive=false; it rejects other methods with 405,
applies the site type's access module first, and on public sites it uses the
public router, so protected trees answer 404 like other public reads.
Uploads¶
PUT /files/{path} dispatches on headers, in this order:
Content-Type: multipart/form-data...(header prefix, checked first) streams the parts namedfileas atomic uploads; everyfilepart is stored (last write wins on repeated names); a malformed body or a missingfilepart answers 400.- Otherwise, a valid
Content-Range: bytes start-end/totalheader selects the resumable chunk path. In a valid header,startis 0 or more,endis not less thanstart, andtotalis*(unknown) or a positive integer that is larger thanend. - Anything else, including a malformed
Content-Range, is a raw full upload body.
Full uploads write a *.upload.tmp file, fsync it, remove any target,
rename it atomically, and sync the directory. Parent directories are created
automatically. Writers racing on the same target path are serialized by a
striped in-process lock. A PUT onto an existing directory answers 400 (the
directory is not replaced). Status is 201 for a new file and 200
for a replacement. backup_if_present (optionally with rename_to, which
names the backup target; default suffix YYYYMMDDHHMMSS) moves the old file
aside first and returns 201; rename_to alone has no effect.
The resumable protocol is offset-strict:
sequenceDiagram
participant C as Client
participant A as Site API
C->>A: HEAD /files/{path}
A-->>C: Upload-Offset: 1024 (current size)
C->>A: PUT with Content-Range: bytes 1024-2047/2048
alt offset matches
A-->>C: 200/201, Upload-Offset: 2048
else offset mismatch
A-->>C: 409 Conflict, Upload-Offset: actual size
Note over C: resume from the returned offset
end
A Content-Length that does not match the range length is a 400 (as is a
short write). A range whose length is larger than the maximum int64 value
(bytes 0-9223372036854775807/*) is a 416 with Upload-Offset; the API
does not open the target file. The first chunk truncates the target, and every accepted chunk
is fsynced for NFS durability. When total is known, the free-space
pre-check sizes the remaining bytes, not the chunk.
Write buffering and backpressure¶
flowchart LR
N["Network read"] --> Q["Per-upload chunk queue (UPLOAD_BUFFER_PER_UPLOAD_MB, default 8 MiB)"]
Q --> G["Global token bucket (UPLOAD_BUFFER_GLOBAL_MB, default 64 MiB)"]
G --> F["Background flusher: write + fsync"]
F --> D[("Storage volume")]
F -- "no flush for UPLOAD_STALL_TIMEOUT_SEC (30 s)" --> X["503 + Retry-After: UPLOAD_RETRY_AFTER_SEC (60)"]
| Environment variable | Default | Valid range |
|---|---|---|
UPLOAD_BUFFER_GLOBAL_MB |
64 | 10–500 |
UPLOAD_BUFFER_PER_UPLOAD_MB |
8 | 1–50 |
UPLOAD_CHUNK_KB |
256 | 64–4096 |
UPLOAD_STALL_TIMEOUT_SEC |
30 | 5–600 |
UPLOAD_RETRY_AFTER_SEC |
60 | 1–120 |
Out-of-range values are ignored silently and the default applies
(api/upload_buffer.go). When storage is slow, the queue and the global
budget fill up, and Write blocks — backpressure slows the client instead
of failing. When no chunk has reached disk for the idle timeout, the API
returns 503 Storage system not responding, please retry. with
Retry-After. Treat that response as retryable.
File integrity¶
- Every file
GET/HEADresponse carries a weakETagofW/"<size hex>-<mtime nanos hex>". It costs no I/O. GET/HEADwithWant-Repr-Digest: sha-256addsRepr-Digest: sha-256=:<base64>:per RFC 9530. Onlysha-256is supported, and the negotiation token must be the bare value (parameters are rejected; other algorithms in the list are skipped, not rejected). Zero-byte files omit the header. Digests are cached for 3 minutes, keyed by path + size + mtime, and warmed on upload. A single file delete invalidates its cache entry; bulk deletes do not (entries expire). On the v1 upload path the cache warm-up honoursENABLE_CHECKSUM_CACHE; the v2 path always warms.- Archive and listing responses never carry these headers. Listings include no per-file digests.
Directories and symlinks¶
POST /directories/{path} creates the directory tree under the root (an
existing directory answers with success; a file collision answers through the
error map; the volume root itself is rejected).
PUT /api/v2/symlinks/{path} creates a symbolic link at {path} pointing
to a target inside the volume. The target must exist. An existing link is
replaced only with force (a query parameter; otherwise 409). The stored
link is relative to its directory. GET returns the current target;
DELETE removes the link and answers 400 when the path exists but is not a
symlink. Targets and link paths outside the volume are rejected
(api/symlink.go).
The parent directory of the link is resolved through symlinks before the
operation, and it must be inside the root; otherwise all three endpoints
answer 400. The link is created in that resolved directory, and the relative
target is computed from it, not from the request path. Thus a link below a
symlinked directory (for example a/l when a points to the root) points to
the requested target. After creation, the API resolves the new link; if it
resolves outside the root, the API removes it and answers 400
(api/symlink_jail_test.go). The endpoints create, read, and remove the
link through the content root os.Root, so a parent that changes to a
symlink after the checks causes an error and not a link outside the root
(api/site_root_test.go).