feat(files): 大文件分片上传、停服解压 zip 先列冲突再覆盖、文件和文件夹可下载;导出和下载不再带出 RCON 密码与转发密钥

This commit is contained in:
Lemon-miaow committed 2026-09-28 22:44:03 +08:00
1 parent 3abf146321
commit 1d1549cea2
76 files changed
+10542 -569

No files matched your search

+535 -12
View File
@@ -743,6 +743,60 @@ components:
state: { type: string, const: pending }
filename: { type: string, description: What the download saves as. }
FileUploadSession:
type: object
description: Where an upload session stands (internal/api/handlers_fileops.go fileSessionView).
required: [id, path, size, received, part_max_bytes]
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. }
StartFileOp:
type: object
properties:
overwrite: { type: boolean, description: Replace files already there. }
FileOp:
type: object
description: One background upload or extraction (internal/api/handlers_fileops.go fileOpView).
required: [id, op, path, state, started_at, done, total]
properties:
id: { type: string }
op: { type: string, enum: [upload, unzip] }
path: { type: string, description: The file landed, or the archive extracted. }
state: { type: string, enum: [running, succeeded, failed] }
started_at: { type: string, format: date-time }
finished_at: { type: string, format: date-time, description: Omitted while it runs. }
done: { type: integer, format: int64, description: Bytes landed or extracted so far; 0 before the first report. }
total: { type: integer, format: int64, description: Bytes in all; 0 before the first report. }
files: { type: integer, description: Files an extraction wrote. Omitted otherwise. }
bytes: { type: integer, format: int64, description: Bytes an extraction wrote. Omitted otherwise. }
error: { $ref: '#/components/schemas/FileOpError' }
FileOpError:
type: object
description: >-
Why an op failed (internal/api/handlers_fileops.go fileOpError). code is
what the synchronous file routes answer for the same refusal
(file_exists, volume_full, file_changed, not_found, bad_path), an
extraction's own (archive_invalid, archive_unsafe, archive_symlink,
type_conflict), or job_failed for a Job that ended without saying why.
required: [code, message]
properties:
code: { type: string }
message: { type: string }
entry: { type: string, description: The archive entry refused, or the path it collides with. }
conflicts:
type: array
items: { type: string }
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. }
ExportStatus:
type: object
description: Where an export stands (internal/api/exports.go exportStatusView).
@@ -1306,7 +1360,10 @@ paths:
face and the bearer token minted with the upload is the whole check. The
token opens its upload once. An unknown id, a wrong or missing token and a
spent token are all the same 404, so the route says nothing about which
uploads exist.
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.
x-felis-face: [internal]
x-felis-tier: public
security: []
@@ -4149,11 +4206,14 @@ paths:
description: >-
Starts a Job that reads the archive from the backup store and hands it to
felis-api, which streams it to the browser (poll GET /exports/{ticket},
then open its download). The archive is checked against the sha256
recorded when it was written as it streams; a mismatch aborts the
download. A user gets 404 for a backup outside their scope, as their
list never shows it. One export per user at a time, 2 across the
install, 6 per user per hour.
then open its download). The Job checks the archive against the sha256
recorded when it was written as it streams; a mismatch cuts the
download off short of its end. On the way out config/paper-global.yml
(the cluster's forwarding secret) is left out and server.properties has
its rcon.password redacted, so the download carries no Content-Length.
A user gets 404 for a backup outside their scope, as their list never
shows it. One export per user at a time, 2 across the install, 6 per
user per hour.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
@@ -4206,7 +4266,9 @@ paths:
it to felis-api, which streams it to the browser (poll GET
/exports/{ticket}, then open its download). The server must be fully
stopped, and it cannot start until the download has ended or the Job's
2 hour deadline passes. Same limits as a backup export.
2 hour deadline passes. The same two files are guarded as in a backup
export, matched by the file itself, so a link to either under another
name is guarded too. Same limits as a backup export.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
@@ -4295,13 +4357,19 @@ paths:
- { name: ticket, in: path, required: true, schema: { type: string } }
responses:
'200':
description: The tar.gz, as an attachment.
description: >-
The export as an attachment: a world or a backup as a tar.gz, a
downloaded folder as a zip, a downloaded file as its bytes.
headers:
Content-Disposition:
schema: { type: string }
content:
application/gzip:
schema: { type: string, format: binary }
application/zip:
schema: { type: string, format: binary }
application/octet-stream:
schema: { type: string, format: binary }
'401':
$ref: '#/components/responses/Unauthorized'
'404':
@@ -4355,7 +4423,7 @@ paths:
required: [name, kind, state]
properties:
name: { type: string }
kind: { type: string, enum: [backup, restore, export_world, export_backup] }
kind: { type: string, enum: [backup, restore, export_world, export_backup, export_files] }
state: { type: string, enum: [running, succeeded, failed] }
message: { type: string }
started_at: { type: string, format: date-time }
@@ -4424,10 +4492,11 @@ paths:
application/json:
schema:
type: object
required: [path, entries, truncated]
required: [path, entries, truncated, free_bytes]
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. }
entries:
type: array
items:
@@ -4818,6 +4887,75 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/servers/{name}/files/download:
post:
tags: [files]
operationId: downloadServerFile
summary: Start downloading one file or folder of a stopped server's world (owner-or-admin).
description: >-
An export (poll GET /exports/{ticket}, then open its download): a Job
reads the file, or zips the folder, from the world volume read-only and
hands it to felis-api, which streams it to the browser. A file saves
under its own name with its length; a folder as NAME.zip, streamed
without one, with symbolic links, devices and sockets left out.
config/paper-global.yml, the cluster's forwarding secret, is refused as
a file and left out of a folder, and server.properties goes out with
its rcon.password redacted; both are matched by the file itself, so a
link to either under another name is guarded too. The server cannot
start until the download has ended. Two file downloads per user at a
time, 4 across the install, 30 per user per hour, counted apart from
world and backup exports. Audited as file.download.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: path
in: query
required: true
description: File or folder to download, relative to the world root. The root itself is refused.
schema: { type: string }
- name: dir
in: query
required: false
description: true when path is a folder, which is sent as a zip. The Job refuses a path that is not what dir says.
schema: { type: string, enum: ["true", "false"] }
responses:
'202':
description: Download started.
content:
application/json:
schema: { $ref: '#/components/schemas/ExportTicket' }
'400':
description: Missing path (bad_request), the world root (bad_path), or a malformed server name (bad_name).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: Server is not stopped (not_stopped), has no world volume yet (no_world_volume), or a restore, backup, file change or another export already holds its world volume (maintenance_in_progress).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
description: A file download limit is reached (export_busy); Retry-After gives the seconds to wait.
headers:
Retry-After:
schema: { type: integer }
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/servers/{name}/files/upload:
put:
tags: [files]
@@ -4825,7 +4963,8 @@ paths:
summary: Upload a file into a server's world volume (owner-or-admin; server must be stopped).
description: >-
Lands the raw request body as the file at path, up to 64 MiB — a plugin jar,
a datapack, a world region. Content-Length is required (411
a datapack, a world region; a bigger file goes up as an upload session
(POST …/files/uploads). Content-Length is required (411
length_required). An existing file is 409 file_exists unless overwrite=true;
a folder at the path is 400 bad_path either way. The body is staged on
felis-api's disk first and then fetched by the file Job with a one-time
@@ -4895,7 +5034,7 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'413':
description: The file is over 64 MiB (too_large).
description: The file is over 64 MiB, the most one request carries (too_large); send it as an upload session instead.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
@@ -4915,6 +5054,390 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/servers/{name}/files/uploads:
post:
tags: [files]
operationId: beginServerFileUpload
summary: Begin an upload session for a file too big for one request (owner-or-admin; server must be stopped).
description: >-
A file of any size goes up in parts: this begins a session for path and
the file's size, PUT …/uploads/{id}?offset= sends each part (at most
part_max_bytes, 32 MiB, so each fits the edge's body limit), and POST
…/uploads/{id}/commit lands it. There is no size ceiling but felis-api's
staging disk, and room for the whole file is reserved here, so an upload
that begins is one the disk can finish (507 upload_staging_full
otherwise). A session belongs to the account and server it was begun
for, answers no one else, and is dropped after 6 hours untouched. Four
sessions per account at a time. Sessions do not survive a felis-api
restart.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: path
in: query
required: true
description: File to create, relative to the world root. It must stay inside it (400 bad_path); its folder is checked when the file lands.
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [size]
properties:
size: { type: integer, format: int64, minimum: 0, description: The file's length in bytes. }
responses:
'201':
description: Session begun.
content:
application/json:
schema: { $ref: '#/components/schemas/FileUploadSession' }
'400':
description: Missing path or size, or a negative size (bad_request), a path leaving the world folder or naming the folder itself (bad_path), or a malformed server name (bad_name).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: Server is not stopped (not_stopped) or has no world volume yet (no_world_volume).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
description: The account already has 4 uploads in progress (too_many_uploads).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
'507':
description: felis-api's staging disk has no room for a file this size right now (upload_staging_full).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/servers/{name}/files/uploads/{id}:
get:
tags: [files]
operationId: getServerFileUpload
summary: Where an upload session stands (owner-or-admin, the account that began it).
description: >-
received is where the next part starts: after a lost answer or a 409
upload_offset_mismatch, read it here and continue from there. Needs no
stopped server.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: The session.
content:
application/json:
schema: { $ref: '#/components/schemas/FileUploadSession' }
'400':
description: Malformed server name (bad_name).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server, or no such session for this account on this server (upload_not_found).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
put:
tags: [files]
operationId: putServerFileUploadPart
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
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.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- { name: id, in: path, required: true, schema: { type: string } }
- name: offset
in: query
required: true
description: The byte position the part starts at, the session's received.
schema: { type: integer, format: int64, minimum: 0 }
requestBody:
required: true
content:
application/octet-stream:
schema: { type: string, format: binary }
responses:
'200':
description: Part taken.
content:
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).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server, or no such session for this account on this server (upload_not_found).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: offset is not where the session ends (upload_offset_mismatch), or another part is still arriving (upload_busy).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'411':
description: The request has no Content-Length (length_required).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'413':
description: The part is over part_max_bytes, or runs past the size the session began with (part_too_large).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
'507':
description: felis-api's staging disk ran out of room (upload_staging_full).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
delete:
tags: [files]
operationId: deleteServerFileUpload
summary: Cancel an upload session and free its room (owner-or-admin, the account that began it).
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'204':
description: Cancelled.
'400':
description: Malformed server name (bad_name).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server, or no such session for this account on this server (upload_not_found).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: A part is still arriving (upload_busy).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/servers/{name}/files/uploads/{id}/commit:
post:
tags: [files]
operationId: commitServerFileUpload
summary: Land a finished upload session in the world volume (owner-or-admin; server must be stopped).
description: >-
Starts the Job that fetches the session's bytes from felis-api and lands
them at its path, checked against their size and SHA-256 and renamed into
place, so a failed landing leaves the old file whole. It answers at once
with the op; GET …/files/ops reports how it ends (file_exists when a file
is at the path and overwrite is not true). The Job holds the world volume
while it runs, so the server cannot start meanwhile. A Job that fails
before it has every byte leaves the session to commit again; once the
bytes have gone to the Job the session is gone. Audited as file.upload.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/StartFileOp' }
responses:
'202':
description: Landing started.
content:
application/json:
schema:
type: object
required: [op]
properties:
op: { $ref: '#/components/schemas/FileOp' }
'400':
description: Malformed body, or a malformed server name (bad_name).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server, or no such session for this account on this server (upload_not_found).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: >-
Not every byte has arrived (upload_incomplete; the world lock is not
asked for), a part is still arriving (upload_busy), the server is not stopped (not_stopped) or has
no world volume yet (no_world_volume), or a restore, backup, file
change or export already holds its world volume, this session's
earlier commit included (maintenance_in_progress).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/servers/{name}/files/unzip:
post:
tags: [files]
operationId: unzipServerFile
summary: Extract a .zip into the folder holding it (owner-or-admin; server must be stopped).
description: >-
Starts a Job that extracts the archive into a temporary folder beside it
and moves the result into place, and answers at once with the op; GET
…/files/ops reports how it ends. Nothing changes unless every entry is
safe: an entry leaving the folder, an absolute path, or a link ends
archive_unsafe or archive_symlink; an entry whose size differs from what
the archive declares ends archive_invalid; a file where the archive has
a folder, or the reverse, ends type_conflict. Without overwrite an
archive that would replace any file ends file_exists with the files it
would replace, for the caller to confirm and run again with overwrite.
Names stored in GBK, as Windows zips in a Chinese locale have them, are
read as such. The Job holds the world volume while it runs. Audited as
file.unzip.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: path
in: query
required: true
description: The .zip to extract, relative to the world root.
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/StartFileOp' }
responses:
'202':
description: Extraction started.
content:
application/json:
schema:
type: object
required: [op]
properties:
op: { $ref: '#/components/schemas/FileOp' }
'400':
description: Missing path or malformed body (bad_request), a path not ending in .zip (bad_path), or a malformed server name (bad_name).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: Server is not stopped (not_stopped), has no world volume yet (no_world_volume), or a restore, backup, file change or export already holds its world volume (maintenance_in_progress).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/servers/{name}/files/ops:
get:
tags: [files]
operationId: listServerFileOps
summary: A server's background uploads and extractions (owner-or-admin).
description: >-
Newest first: the one running, if any, and those that ended within the
last 30 minutes, at most 10. Needs no stopped server.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
responses:
'200':
description: The ops.
content:
application/json:
schema:
type: object
required: [ops]
properties:
ops:
type: array
items: { $ref: '#/components/schemas/FileOp' }
'400':
description: Malformed server name (bad_name).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
# --------------------------------------------------- scheduled tasks (app) ---
/api/v1/servers/{name}/schedules:
get:
+63 -8
View File
@@ -3164,9 +3164,9 @@ for 10 seconds (the Free plan's limits).
The panel's Files page is for the server's owner or an admin, and only while
the server is fully stopped. Each call runs a one-shot `felis files` Job in the
`minecraft` namespace, labelled `app.kubernetes.io/managed-by=felis-files` and
`felis.lolicon.best/files-mode=<list|read|write|mkdir|delete|rename|upload>`.
`felis.lolicon.best/files-mode=<list|read|write|mkdir|delete|rename|upload|unzip>`.
A listing or a read holds nothing. Every change (a save, a new file or folder,
a rename, a delete, an upload) holds the world for its Job (§3b), so a wake or a
a rename, a delete, an upload, an unzip) holds the world for its Job (§3b), so a wake or a
second change in the meantime gets `409 maintenance_in_progress`. The panel
sends uploads one at a time and greys its other changes until they finish.
@@ -3178,13 +3178,19 @@ sends uploads one at a time and greys its other changes until they finish.
| `409` | `file_changed` | The file changed after the editor read it | The editor offers to load the latest or overwrite it |
| `400` | `bad_path` | The path leaves the world volume (`..`, an absolute path, a link pointing out), or it would move `server.properties`, `config` or `config/paper-global.yml`, or read `config/paper-global.yml` | Those three keep their names: the read path withholds their secrets by name, and `paper-global.yml` holds the proxy forwarding secret every server shares |
| `404` | `not_found` | The path, or a new folder's parent, is gone | Refresh the listing |
| `413` | `too_large` | A read over 1 MiB, a save over 256 KiB, or an upload over 64 MiB | Upload a large file whole instead of editing it |
| `413` | `too_large` | A read over 1 MiB, a save over 256 KiB, or a one-request upload over 64 MiB | Upload a large file whole instead of editing it; the panel sends a file over 64 MiB in parts on its own |
| `411` | `length_required` | An upload without `Content-Length` (a chunked body) | Upload from the panel, or with `curl -T`, which sends the length |
| `400` | `upload_incomplete` | The body ended before its declared length | Retry; nothing was changed |
| `507` | `upload_staging_full` | Staging this upload would leave felis-api's staging filesystem under 10% free | Free space on the uploads volume |
| `507` | `volume_full` | The world volume ran out of space; the old file is left as it was | Delete files the server no longer needs, or grow its volume |
| `504` | `files_timeout` | felis-api stopped waiting after 90 s | See below: the Job may still finish |
| `503` | `files_unavailable` | felis-api runs without the file Job runner, or (for an upload) without a staging directory or its internal address | Check felis-api's startup log |
| `404` | `upload_not_found` | A large upload's session is gone: cancelled, already landed, idle for 6 hours, or felis-api restarted | Upload the file again |
| `409` | `upload_offset_mismatch` | A part did not start where the session ends (a lost answer, a second tab) | The panel reads where it stands and continues; nothing to do |
| `409` | `upload_busy` | Another part of the same upload is still arriving | Same |
| `413` | `part_too_large` | A part over 32 MiB, or past the size the upload began with | A client bug; upload from the panel |
| `409` | `upload_incomplete` | The commit came before every part had arrived | Same |
| `429` | `too_many_uploads` | The account already has 4 large uploads in progress | Finish or cancel one |
**An upload travels in two legs.** The browser sends the body to felis-api,
which stages it under `/var/lib/felis/uploads/.file-staging` on the uploads
@@ -3208,9 +3214,58 @@ change waits on it with `maintenance_in_progress`; refresh the listing once it
is gone to see whether the change landed. A Job is kept for two minutes after
it ends, with its log.
**A file over 64 MiB goes up in parts.** The panel begins an upload session
(`POST …/files/uploads`), which reserves room for the whole file on the staging
filesystem at once, so an upload that starts can finish (`507
upload_staging_full` otherwise). It then sends 32 MiB parts, each under the
Cloudflare edge's 100 MB body limit, retrying a part that fails and resuming
from where the session ends; there is no size cap beyond the room. Starting the
server midway costs only the commit, which needs it stopped again. The commit
starts the Job and answers at once (`202`); the Job fetches the file the same
way as above and runs up to 2 hours. A session belongs to the account and server
it was begun for, and one untouched for 6 hours is dropped (felis-api logs
`dropped N upload session(s) left idle`). A Job that fails before it has every
byte leaves the session, so committing again does not mean sending it again.
Folders cannot be uploaded: the panel asks for a `.zip` instead, because loose
files cut off midway would leave half a world.
**Unzip** (`POST …/files/unzip`, `.zip` only) extracts into a hidden
`.felis-unzip-*` folder beside the archive and moves the result into place only
once every entry has been written and checked, so a failure changes nothing and
the working folder is removed. It checks, before writing a byte, that no entry
leaves the folder or is a link (`archive_unsafe`, `archive_symlink`), that the
archive does not put a file where the server has a folder or the reverse
(`type_conflict`), and that the volume has room (`volume_full`); an entry whose
size differs from what the archive declares ends `archive_invalid`. Names stored
in GBK, as Windows zips in a Chinese locale have them, are read as such. Without
replace, an archive that would overwrite files ends `file_exists` with the list,
which the panel shows for confirmation before running it again with replace.
Large uploads and unzips run in the background: they keep going when the page is
closed, and `GET …/files/ops` lists the one running and those that ended in the
last 30 minutes, with bytes done and, on failure, the code above or `job_failed`
with the Job's condition (`DeadlineExceeded` after 2 hours). Their Jobs carry
`felis.lolicon.best/files-async=true`. A Job that fails without a result keeps
its log for 30 minutes:
```sh
kubectl -n minecraft get jobs -l felis.lolicon.best/server=<name>,felis.lolicon.best/files-async=true
```
**Downloading a file or folder** (`POST …/files/download`) is an export (§10,
"Downloading a backup or the world"): a `felis-export` Job with
`felis.lolicon.best/export-mode=files` reads the file, or zips the folder, from
the world read-only and felis-api streams it to the browser. It needs the server
stopped and holds the world until the download ends. `config/paper-global.yml`
is refused as a file and left out of a folder, and `server.properties` goes out
with `rcon.password` redacted, matched by the file itself so a link to either is
guarded too; world and backup exports filter the same two files. Two per user at
a time, four across the install, 30 per user per hour (`429 export_busy`).
Every change is audited as `file.write`, `file.mkdir`, `file.delete`,
`file.rename` (with `to`) or `file.upload` (with `size_bytes`, `sha256` and
`overwrite`), with `server_name` set to `<server>:<path>`:
`file.rename` (with `to`), `file.upload` (with `size_bytes`, `sha256` and
`overwrite`, in one request or in parts), `file.unzip` (with `overwrite`) or
`file.download`, with `server_name` set to `<server>:<path>`:
```sh
sudo k3s kubectl -n felis exec deploy/felis-postgres -c postgres -- psql -U postgres felis -c "
@@ -3218,8 +3273,8 @@ sudo k3s kubectl -n felis exec deploy/felis-postgres -c postgres -- psql -U post
FROM audit_logs WHERE action LIKE 'file.%' ORDER BY created_at DESC LIMIT 20;"
```
[GO-TESTED: `internal/fileedit`, `handlers_files_test.go`, `cmd/felis/files_test.go`,
`internal/maintenance`.] [VM-TESTED: a pod labelled as a files Job in `minecraft`
[GO-TESTED: `internal/fileedit`, `handlers_files_test.go`, `handlers_fileops_test.go`,
`cmd/felis/files_test.go`, `TestExpireFileSessions`, `internal/maintenance`.] [VM-TESTED: a pod labelled as a files Job in `minecraft`
reaches `felis-api-internal:8081`; a 256 KiB save's content, split across six
variables, lands byte for byte through the real binary, where one 140 KB variable
fails with `argument list too long`. An upload through the API, both legs end to
@@ -3331,5 +3386,5 @@ PG-TESTED: `TestScheduleStoreRunCAS`, `TestDueSchedules`, `TestSchedulesFollowTh
| `FelisAuditWriteFailing` | §17 |
| `felis breakGlass` sends no code / shows `Root override`; `otp_skipped` in the audit | §17 |
| How long sessions, codes and audit rows are kept; export audit rows | §17 |
| Files page: a change or upload refused (`file_exists`, `bad_path`, `too_large`, `upload_staging_full`, `volume_full`, `files_timeout`) | §18 |
| Files page: a change, upload or unzip refused (`file_exists`, `bad_path`, `too_large`, `upload_staging_full`, `upload_not_found`, `volume_full`, `archive_unsafe`, `job_failed`, `files_timeout`) | §18 |
| A scheduled task shows `skipped`, `missed` or `failed`; a task switched itself off after an owner change | §19 |