fix(files): 模组包上传也逐段核对 SHA-256、长文件名能写、卡住的导出按时停掉、世界被占用时文件页说明原因并等它结束

This commit is contained in:
Lemon-miaow committed 2026-09-29 01:26:24 +08:00
1 parent 1d1549cea2
commit cb3065da1d
59 files changed
+2538 -338

No files matched your search

+135 -23
View File
@@ -746,13 +746,25 @@ components:
FileUploadSession:
type: object
description: Where an upload session stands (internal/api/handlers_fileops.go fileSessionView).
required: [id, path, size, received, part_max_bytes]
required: [id, path, size, received, part_max_bytes, parts]
properties:
id: { type: string, description: 32 hex characters. }
path: { type: string, description: Where the file lands, relative to the world root. }
size: { type: integer, format: int64, description: The file's length. }
received: { type: integer, format: int64, description: Bytes here so far; the next part starts here. }
part_max_bytes: { type: integer, format: int64, description: The most one part may carry. }
parts:
type: array
description: >-
The parts taken so far, in order, each with the SHA-256 it arrived
with. A client resuming from a file it still holds hashes the same
ranges and starts over when one differs.
items:
type: object
required: [size, sha256]
properties:
size: { type: integer, format: int64 }
sha256: { type: string, pattern: '^[0-9a-f]{64}$' }
StartFileOp:
type: object
@@ -795,7 +807,7 @@ components:
description: On file_exists from an extraction, the first 200 files it would replace, sorted.
conflict_count: { type: integer, description: How many files it would replace in all. }
need: { type: integer, format: int64, description: On volume_full, the bytes needed. }
avail: { type: integer, format: int64, description: On volume_full, the bytes free. }
avail: { type: integer, format: int64, description: On volume_full, the bytes free; left out when none are. }
ExportStatus:
type: object
@@ -1362,8 +1374,8 @@ paths:
spent token are all the same 404, so the route says nothing about which
uploads exist. An upload session committed through POST
…/files/uploads/{id}/commit is fetched here the same way, under the
session id; it stays staged until it has been sent whole once, so a Job
that failed before then can be committed again.
session id; it stays staged until its Job reports the file landed
(DELETE), so a Job that failed at any point can be committed again.
x-felis-face: [internal]
x-felis-tier: public
security: []
@@ -1382,6 +1394,30 @@ paths:
schema: { type: string, format: binary }
'404':
$ref: '#/components/responses/NotFound'
delete:
tags: [files]
operationId: internalFileUploadLanded
summary: The Job reports a staged upload landed (the same one-time bearer token).
description: >-
Sent once the file is in place. An upload session is then dropped from
felis-api's disk; a single-request upload goes when its request ends in
any case. Only the token of the session's latest commit is taken. As for
the fetch, every refusal is the same 404.
x-felis-face: [internal]
x-felis-tier: public
security: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- name: Authorization
in: header
required: true
description: Bearer followed by the token the Job fetched the upload with.
schema: { type: string }
responses:
'204':
$ref: '#/components/responses/NoContent'
'404':
$ref: '#/components/responses/NotFound'
/api/v1/internal/exports/{id}:
put:
@@ -1389,8 +1425,13 @@ paths:
operationId: internalExportUpload
summary: Hand one export's archive over for download (one-time bearer token).
description: >-
The export Job PUTs the tar.gz here, chunked for a world and with its
Content-Length for a backup. The Job holds no service token, so the route
The export Job PUTs the archive or file here, always chunked, with its
size as X-Felis-Export-Length when it knows it and, once the body has
ended, the SHA-256 of all it sent as the Content-Digest trailer
(sha-256=:<base64>:). felis-api holds the last bytes back from the
browser until the bytes it received number and hash as the Job said, so
a body changed on the way, or one without the trailer, ends the
download short and the browser reports it failed. The Job holds no service token, so the route
is public on the internal face and the bearer token minted with the
export is the whole check; an unknown id, a wrong or missing token and a
token already used are all the same 404. The request then waits, body
@@ -1408,6 +1449,11 @@ paths:
required: true
description: Bearer followed by the token minted with the export.
schema: { type: string }
- name: X-Felis-Export-Length
in: header
required: false
description: The body's length in bytes, when the Job knows it; the download then carries it as Content-Length.
schema: { type: integer, format: int64, minimum: 0 }
requestBody:
required: true
content:
@@ -1416,6 +1462,11 @@ paths:
responses:
'204':
$ref: '#/components/responses/NoContent'
'400':
description: X-Felis-Export-Length is not a byte count (bad_request); the token is not spent.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'404':
$ref: '#/components/responses/NotFound'
'409':
@@ -1424,7 +1475,7 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'410':
description: Nobody opened the download within 90 seconds, or the browser left before the archive ended (export_expired).
description: Nobody opened the download within 90 seconds, the browser left before the archive ended, or what arrived did not number or hash as the Job declared, so the download was cut off (export_expired).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
@@ -4347,8 +4398,10 @@ paths:
description: >-
The first request spends the ticket, whatever becomes of it. The archive
streams as the Job sends it, with Content-Length when it is known; a
download that cannot finish (the Job died, or a backup did not match its
recorded sha256) is cut off, so the browser reports it failed. HEAD is
download that cannot finish (the Job died, a backup did not match its
recorded sha256, or the bytes did not hash to the SHA-256 the Job sent
with them) is cut off before its last bytes, so the browser reports it
failed. HEAD is
refused, since it would spend the ticket on no body.
x-felis-face: [external]
x-felis-tier: app
@@ -4496,7 +4549,7 @@ paths:
properties:
path: { type: string }
truncated: { type: boolean, description: The listing hit the entry cap and is incomplete. }
free_bytes: { type: integer, format: int64, description: Bytes free on the world volume, for a client to check an upload fits before sending it. }
free_bytes: { type: integer, format: int64, nullable: true, description: Bytes free on the world volume, for a client to check an upload fits before sending it; 0 is a full volume, and null a volume whose free space the Job could not read. }
entries:
type: array
items:
@@ -4971,8 +5024,11 @@ paths:
token, so the world lock is taken only after the body has arrived and a slow
upload holds off no backup. The file lands atomically: a synced temporary
sibling is checked against the staged size and SHA-256, then renamed into
place, so a failed upload leaves the old file whole. Same stopped-gate and
os.Root containment as a write. Audited as file.upload.
place, so a failed upload leaves the old file whole. The body carries its
SHA-256 as Content-Digest; felis-api checks it as the body arrives, and
the Job checks the same digest again as it fetches the staged copy, so
every hop between the browser and the world volume is verified. Same
stopped-gate and os.Root containment as a write. Audited as file.upload.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
@@ -4988,6 +5044,14 @@ paths:
required: false
description: true replaces an existing file, keeping its mode. Anything else refuses to.
schema: { type: string, enum: ["true", "false"] }
- name: Content-Digest
in: header
required: true
description: >-
The SHA-256 of the body as RFC 9530 sends it, sha-256=:<base64>:.
Other algorithms listed beside it are ignored. Bytes that do not hash
to it were changed on the way and are refused whole.
schema: { type: string, example: 'sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:' }
requestBody:
required: true
content:
@@ -5009,8 +5073,10 @@ paths:
'400':
description: >-
Missing path, invalid server name, a folder or the world root at the path,
a path that escapes the world root, or a body that ended before
Content-Length bytes arrived (upload_incomplete).
a path that escapes the world root, a body that ended before
Content-Length bytes arrived (upload_incomplete), no Content-Digest
(digest_required), a malformed one (bad_digest), or bytes that do not
hash to it (digest_mismatch; nothing is staged, so send it again).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
@@ -5170,8 +5236,9 @@ paths:
summary: Send one part of an upload session (owner-or-admin, the account that began it).
description: >-
The raw body is appended at offset, which must be where the session
ends. Content-Length is required, and the part is taken whole or not at
all: one cut short leaves the session where it was. Parts go one at a
ends. Content-Length and the part's own Content-Digest are required, and
the part is taken whole or not at all: one cut short, or one whose bytes
do not hash to its digest, leaves the session where it was. Parts go one at a
time (409 upload_busy while one arrives). Needs no stopped server, so
starting the server midway costs only the commit's refusal until it is
stopped again.
@@ -5186,6 +5253,14 @@ paths:
required: true
description: The byte position the part starts at, the session's received.
schema: { type: integer, format: int64, minimum: 0 }
- name: Content-Digest
in: header
required: true
description: >-
The SHA-256 of the body as RFC 9530 sends it, sha-256=:<base64>:.
Other algorithms listed beside it are ignored. Bytes that do not hash
to it were changed on the way and are refused whole.
schema: { type: string, example: 'sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:' }
requestBody:
required: true
content:
@@ -5198,7 +5273,12 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/FileUploadSession' }
'400':
description: A missing or malformed offset (bad_request), a body that ended before its Content-Length (upload_incomplete), or a malformed server name (bad_name).
description: >-
A missing or malformed offset (bad_request), a body that ended before
its Content-Length (upload_incomplete), no Content-Digest
(digest_required), a malformed one (bad_digest), bytes that do not hash
to it (digest_mismatch; the part was not taken, so send it again), or a
malformed server name (bad_name).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
@@ -7287,13 +7367,22 @@ paths:
the API, a larger context goes through the chunked upload at
/api/v1/me/submissions/{id}/context/upload instead. An upload that would push the
caller past their per-user stored-context budget is refused with 403
before the excess is persisted. Returns 503 when the deployment's context
store has no implemented upload transport.
before the excess is persisted. The body's SHA-256 is required as
Content-Digest; bytes that do not hash to it were changed on the way,
and none of them replace the context stored before. Returns 503 when
the deployment's context store has no implemented upload transport.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- name: Content-Digest
in: header
required: true
description: >-
The SHA-256 of the body as RFC 9530 sends it, sha-256=:<base64>:.
Other algorithms listed beside it are ignored.
schema: { type: string, example: 'sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:' }
requestBody:
required: true
content:
@@ -7306,7 +7395,14 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/Submission' }
'400':
$ref: '#/components/responses/BadRequest'
description: >-
A body that is not a gzip tarball or is over the context cap
(bad_request), no Content-Digest (digest_required), a malformed one
(bad_digest), or bytes that do not hash to it (digest_mismatch;
nothing was stored, so send it again).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
@@ -7367,8 +7463,9 @@ paths:
upload_offset_mismatch and the client reads GET for where to resume. The
first part must open with the gzip magic (400). The staged total meets
the same context cap (400) and storage budget (403) as a single upload.
A part that breaks off is cut back off, so the staged bytes are always a
prefix of the file. One request per upload at a time (409 upload_busy).
A part that breaks off is cut back off, and so is one whose bytes do not
hash to its Content-Digest, so the staged bytes are always a prefix of
the file. One request per upload at a time (409 upload_busy).
Staged bytes untouched for 24 hours are deleted. The budget check reads
blob sizes remembered for up to a minute; when a size has to be read and
the uploads store does not answer, the answer is 503
@@ -7380,6 +7477,13 @@ paths:
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: offset, in: query, required: true, schema: { type: integer, format: int64, minimum: 0 } }
- name: Content-Digest
in: header
required: true
description: >-
The SHA-256 of the part as RFC 9530 sends it, sha-256=:<base64>:.
Other algorithms listed beside it are ignored.
schema: { type: string, example: 'sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:' }
requestBody:
required: true
content:
@@ -7392,7 +7496,15 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/ContextUploadProgress' }
'400':
$ref: '#/components/responses/BadRequest'
description: >-
A missing or malformed offset, a first part without the gzip magic
or a total over the context cap (bad_request), no Content-Digest
(digest_required), a malformed one (bad_digest), or bytes that do
not hash to it (digest_mismatch; the part was cut back off, so read
where the upload stands and send it again).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':