aka. Dyson Network File System
Go implementation of the Dyson Network file service.
master: HTTP API, gRPC, uploads, file serving, health checkworker: post-upload media processing, derived file generation, cleanupstorage: optional local storage node for filesystem-backed deployments
Use the first positional argument as the command.
go run ./cmd master
go run ./cmd migrate-legacy --config ./config.toml --legacy-dsn "$LEGACY_DATABASE_DSN"
go run ./cmd reanalyze-missing --config ./config.toml
go run ./cmd validate-storage --config ./config.tomlZEROLOG_PRETTY=trueenables console-style pretty logsLOG_LEVEL=debug|info|warn|errorsets the log level
go run ./cmd masterUse the one-shot migrator to import data from the old C# database into the new schema:
go run ./cmd migrate-legacy --config ./config.toml --legacy-dsn "$LEGACY_DATABASE_DSN"Flags:
--dry-runto simulate without writing--skip-derivedto skip thumbnail/compression child reconstruction--batch-sizeto tune import batch size--continue-on-errorto keep going after row-level failures
Repair missing image/video metadata from stored source files:
go run ./cmd reanalyze-missing --config ./config.tomlIt shows a preview first, then asks for confirmation before changing anything.
Flags:
--reanalyze-limitto cap the preview/repair batch size--file-idto target one or more file IDs, comma-separated--preview-countto control how many candidates are shown first--yesto skip the confirmation prompt
Both direct upload and chunked upload creation accept the same metadata payload:
{
"hash": "...",
"file_name": "clip.mov",
"file_size": 12345,
"content_type": "video/quicktime",
"pool_id": "...",
"expired_at": "2026-05-17T12:34:56Z",
"chunk_size": 5242880,
"parent_id": "...",
"overwrite_id": "...",
"fast_mode": true,
"usage": "...",
"application_type": "...",
"index": true
}directupload uses multipart form data with the same field names, plusfileindexcontrols whether the file is indexed; defaults tofalse, but automatically becomestruewhenparent_idpoints to an indexed folderparent_idis optional and can still be resolved server-side when omittedoverwrite_idis optional; when set, the upload replaces the content of an existing file instead of creating a newcloud_filesrowfast_modeis optional; when used withoverwrite_id, the server tries to overwrite the existing backing object in place- fast mode is applied only when the target
object_idis referenced by exactly one live file - if the target object is shared, fast mode automatically falls back to recreate-and-swap behavior
- overwrite keeps the existing file record and swaps in a new
file_objectafter analysis completes - overwrite deletes stale derived children so thumbnails/compressed variants can be regenerated from the new source
hashis stored on the created file/task when provided- upload quota is checked before task creation or direct upload processing
- quota refusal returns
403 Forbidden - if
pool_idis omitted, the quota check uses the resolved default pool
Direct overwrite example:
curl -X POST "http://localhost:8080/api/files/upload/direct" \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "file=@./edited.txt" \
-F "overwrite_id=FILE_ID" \
-F "fast_mode=true"Chunked overwrite task creation example:
{
"overwrite_id": "FILE_ID",
"fast_mode": true,
"file_name": "ignored-on-overwrite",
"file_size": 12345,
"content_type": "text/plain",
"chunk_size": 5242880
}Fast mode notes:
- in fast mode, the existing
file_objectrecord is updated in place instead of creating a new one - the server updates
hash,size,mime_type, and analyzed metadata on that existing object - stale derived children are removed before worker regeneration
These endpoints share the same list query parser:
GET /api/files/meGET /api/files/root/childrenGET /api/files/:id/childrenGET /api/files/unindexed
Base query params:
offsettakeorder:date,name,sizeorderDesc:true|falseor1|0query: case-insensitive substring match on file namename: exact file name matchextension: file extension, with or without the leading.usageapplication_typecontent_type: exact response MIME type matchmime_type: alias ofcontent_typepool_idparent_idindexed:true|falseor1|0recycled:true|falseor1|0is_folder:true|falseor1|0has_thumbnail:true|falseor1|0has_compression:true|falseor1|0min_sizemax_sizecreated_after: RFC3339 orYYYY-MM-DDcreated_before: RFC3339 orYYYY-MM-DDupdated_after: RFC3339 orYYYY-MM-DDupdated_before: RFC3339 orYYYY-MM-DD
Notes:
content_type/mime_typematches the file response MIME type, which comes from the backing object or the folder sentinel type/api/files/unindexedalso acceptspoolas an alias ofpool_id/api/files/unindexeddefaults torecycled=falsewhen the query param is omitted- size filters use the backing object size in bytes
Example:
curl "http://localhost:8080/api/files/me?content_type=image/png&extension=png&has_thumbnail=1&min_size=1024"DysonFS can expose WOPI host endpoints for Collabora Online / CODE.
Config:
[wopi]
enabled = true
publicUrl = "https://files.example.com"
collaboraUrl = "https://collabora.example.com"
tokenTtl = "15m"
requireProof = false
proofCacheTtl = "1h"Notes:
publicUrlmust be the externally reachable DysonFS base URL used by Collabora for WOPI callbacks- DysonFS builds the WOPI callback base directly from
publicUrl, for examplehttps://files.example.com/wopi/files/:id collaboraUrlmust point at the Collabora server base URL- proof validation is not implemented yet; keep
requireProof = false
Launch endpoint:
POST /api/files/:id/editcreates a WOPI-backed editing session for the authenticated user- the response includes
action_url,method,form_fields, andwopi_src - the client should POST
form_fieldstoaction_url, typically into an iframe or popup - Collabora callbacks may authenticate with either
access_tokenform/query parameters orAuthorization: Bearer <access_token>
Example response:
{
"action_url": "https://collabora.example.com/browser/edit?WOPISrc=https%3A%2F%2Ffiles.example.com%2Fwopi%2Ffiles%2FFILE_ID",
"action": "edit",
"method": "POST",
"form_fields": {
"access_token": "TOKEN",
"access_token_ttl": "1770000000000"
},
"wopi_src": "https://files.example.com/wopi/files/FILE_ID",
"expires_at": "2026-05-29T12:00:00Z"
}Example client flow:
<iframe id="collabora-frame" name="collabora-frame"></iframe>
<script>
async function openEditor(fileId, token) {
const response = await fetch(`/api/files/${fileId}/edit`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) throw new Error("failed to create edit session");
const session = await response.json();
const form = document.createElement("form");
form.method = session.method || "POST";
form.action = session.action_url;
form.target = "collabora-frame";
for (const [key, value] of Object.entries(session.form_fields || {})) {
const input = document.createElement("input");
input.type = "hidden";
input.name = key;
input.value = value;
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
form.remove();
}
</script>WOPI endpoints:
GET /wopi/files/:idGET /wopi/files/:id/contentsPOST /wopi/files/:id/contentsPOST /wopi/files/:id
Quota values are reported in MB.
Base quota is the sum of:
- leveling quota
- perk quota
Leveling quota uses account.profile.level, clamped to 0..120, with these milestones:
Lv0:512MBLv10:1GBLv60:5GBLv120:10GB
Between those milestones, the quota is interpolated piecewise.
Perk quota is added on top of leveling quota:
- perk
1:10GB - perk
2:25GB - perk
3:50GB
Extra quota comes from active quota_records and is added after the base quota.
GET /api/billing/quota returns:
{
"based_quota": 15360,
"extra_quota": 25,
"total_quota": 15385
}GET /api/billing/usage returns:
{
"used_quota": 300,
"total_quota": 15385,
"total_file_count": 2,
"total_usage_bytes": 209715200
}Usage accounting rules:
used_quotais billable usage in MB, not raw bytes- raw file bytes are returned separately as
total_usage_bytes - pool billing
cost_multiplieraffects billable usage and quota checks - the multiplier is applied per file based on the file's pool
Folders are created with POST /folders.
Request body:
{
"name": "Projects",
"parent_id": "..."
}- A folder is stored as a
cloud_filesrow withis_folder = trueandindexed = true parent_idis optional- The current implementation does not yet validate that the parent exists or is a folder
- Root folders automatically get a private read permission record
Files expose read/write/manage permissions through GET /files/:id/permissions and PUT /files/:id/permissions.
- No file permission rows means the file is public
- A
privatepermission row withreadmakes a file private by default - Permission checks inherit from ancestor folders
PUT /files/:id/permissionsreplaces the full permission set in one batch
Request body:
{
"items": [
{
"id": "...",
"file_id": "...",
"subject_type": "account",
"subject_id": "...",
"permission": "read"
},
{
"id": "...",
"file_id": "...",
"subject_type": "scope",
"subject_id": "files.manage",
"permission": "manage"
}
]
}subject_typecan bepublic,private,account, orscopepermissionis typicallyread,write, ormanage- Send the full desired list; omitted rows are removed
Rename a file with PATCH /api/files/:id:
{
"name": "renamed-file.txt"
}- rename updates only the file name
- rename currently requires the file owner or a superuser
Batch operations use POST /api/files/<operation>/batch with a JSON body.
Recycle files:
{
"file_ids": ["file-id-1", "file-id-2"]
}Restore files:
{
"file_ids": ["file-id-1", "file-id-2"]
}Purge files:
{
"file_ids": ["file-id-1", "file-id-2"]
}Move files into a parent:
{
"file_ids": ["file-id-1", "file-id-2"],
"parent_id": "...",
"indexed": true
}Available operations:
POST /api/files/recycle/batchPOST /api/files/restore/batchPOST /api/files/delete/batchPOST /api/files/move/batch
Notes:
file_idsis required for every batch operationparent_idis optional formove; omit it or set it tonullto move files back to the rootindexedis optional formove; set totrueto mark files as indexed,falseto mark as unindexed, or omit to leave unchanged
Files have an indexed flag that controls visibility in the file tree:
- Indexed files appear in
GET /api/files/root/childrenandGET /api/files/:id/children(the normal folder browse experience) - Unindexed files only appear in
GET /api/files/unindexed(a flat listing of orphaned uploads) - Folders are always indexed
- The
indexedflag can be set at upload time via theindexfield, or changed later via the move batch endpoint
List responses include extra metadata for navigation and access UI:
children_countfor immediate child countpermission_statusfor current access state
Performance notes:
- Child counts and inherited permission status are resolved in batches for list responses to avoid per-file query fan-out.
- Postgres should have a composite index on
cloud_files(parent_id, deleted_at)sochildren_countlookups do not fall back to full table scans. - Postgres should have a composite index on
file_permissions(file_id, permission, deleted_at)so inherited permission-source lookups stay index-backed. - These indexes are declared in the GORM models and are created by
AutoMigrate, but existing deployments need a restart or migration run before the new indexes appear. - If file-list or permission logs still show slow SQL after rollout, verify the indexes exist with
pg_indexesand inspect the hot queries withEXPLAIN (ANALYZE, BUFFERS).
Example:
"permission_status": {
"readable": true,
"writable": false,
"manageable": false,
"visibility": "private",
"inherited_from": "..."
}Validate file_objects.storage_key against remote S3 objects and clean up orphans:
go run ./cmd validate-storage --config ./config.toml --yesIt snapshots remote keys first, then compares the snapshot against the database in batches.
Flags:
--validate-snapshotto choose the snapshot file path--validate-prefixto limit the remote listing prefix--validate-batchto control DB batch size--yesto skip the confirmation prompt
Use --config or CONFIG_PATH for a TOML config file.
Key settings:
app.namedatabase.dsnhttp.portgrpc.portgrpc.useTLSgrpc.certFilegrpc.keyFilestorage.tempDirstorage.localDirauth.targetauth.useTLSpassport.targetpassport.useTLSquota.leveling.level1quota.leveling.level10quota.leveling.level60quota.leveling.level120nats.urlmode.mastermode.workermode.storage
Pool storage is configured with [[pools]] and seeded into the database at startup.
Example:
[[pools]]
id = "500e5ed8-bd44-4359-bc0a-ec85e2adf447"
name = "Default"
default = true
hidden = false
[pools.storage]
endpoint = "http://minio:9000"
bucket = "dyson-files"
enableSigned = true
enableSsl = false
secretId = "minio"
secretKey = "minio123"- Public read is the default.
- Explicit ACL rows restrict access when present.
masterresolves storage from pool config stored in the database.workerlistens for file upload events and builds thumbnails, blurhash, and other derived artifacts.mastercan use S3 directly; local storage is still supported.- The Docker image expects
ffmpegandlibvipsruntime packages. - The E2EE file route has been removed.