feat(files): 大文件分片上传、停服解压 zip 先列冲突再覆盖、文件和文件夹可下载;导出和下载不再带出 RCON 密码与转发密钥
This commit is contained in:
76 files changed
+10542
-569
No files matched your search
+535
-12
@@ -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
@@ -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 |
|
||||
Reference in new issue
Block a user