Documentation
¶
Overview ¶
Package handlers — account_rules.go
What an account's e-mail and username may be, and how a refusal is said. Used by the self-service profile (auth_self.go) and by an administrator adding a user (users.go), so the two forms refuse the same things in the same words.
⚠⚠ Why this file exists (release-candidate sweep, 2026-09-21):
- the profile form saved "bu-bir-eposta-degil" as an e-mail address and answered "Profil kaydedildi"; logging in with an e-mail then failed with 401, because the account no longer had one;
- changing to another account's address answered 200 with the success message and changed nothing: UpdateUserEmail's unique-constraint error was thrown away (`_ = h.Store.UpdateUserEmail(...)`);
- a username with "ş" in it came back as raw English — `invalid username: 'ş' is not allowed (use a-z, 0-9, dot, dash, underscore)` — in the Turkish panel.
A refusal is `{"error": <code>, "message": <sentence in the reader's language>, "field": "email"|"username"}`: the code for code, the sentence for the person (extractError prefers `message`), the field so a form can put the sentence under the box it is about.
Package handlers — ai_tags.go
Tags for agents: GET/POST /api/ai/tags and the MCP `file_tags` tool.
Until v0.43.0 an agent could FILTER by tag (`tag:` in file_search) but could neither read a file's tags nor set them — and there was one kind. With two kinds the agent has to be able to say which one it means, so this surface speaks the same {name, kind} items as the explorer's routes and runs the very same rules (tagOps, tags.go): personal = only the token's user sees it; team = the tenant, and needs edit permission on the file.
⚠ It never has a default kind. The explorer's legacy `tags: [names]` shape defaults new names to personal for old clients; an agent is new code, so it must name the kind of every tag it writes — an agent quietly publishing a label to the whole tenant, or hiding one the user meant to share, is exactly the kind of silent audience change the finding was about.
Package handlers — app_plugins.go
User surface for app plugins (internal/wasmplugin):
GET /api/files/plugins/actions — what applies to this caller
POST /api/files/plugins/actions/{plugin}/{action}/run — {storage_id, paths[], params} → 202 op | 200 surface
GET /api/files/plugins/views/{plugin}/{view} — ?storage_id=&path= → surface (event "open")
POST /api/files/plugins/views/{plugin}/{view}/event — {storage_id, path, state, event, action_id, data} → surface | 202 op
Every run is authorised HERE, at submit: tenant ownership of the storage, ACL on every path (viewer to read, editor when the action writes), read-only storages refused for writing actions, encrypted folders refused (the server has no key), and the action's applies rule re-checked against the real files — the menu's client-side filter is a convenience, not a gate. The ops worker that later runs the job has no user of its own.
Package handlers — app_plugins_admin.go
Admin surface for app plugins (internal/wasmplugin, docs/APP-PLUGINS.md):
GET /api/admin/app-plugins — runtime facts + every plugin
POST /api/admin/app-plugins[?dry_run=1] — install: multipart {wasm?, manifest, signature?, grant} | JSON {github_repo, ref?, permissions} | JSON {url?, manifest_url, sha256?, permissions}
(no module — `wasm`/`url` — for a language pack; wire.Manifest.IsLanguagePack)
GET /api/admin/app-plugins/{id}
PATCH /api/admin/app-plugins/{id} — {"enabled": bool}
POST /api/admin/app-plugins/{id}/upgrade — same bodies as install
DELETE /api/admin/app-plugins/{id}
GET/PUT /api/admin/app-plugins/{id}/settings
GET/PUT /api/admin/app-plugins/{id}/overrides
GET /api/admin/app-plugins/{id}/logs?after=N
⚠ Instance-wide, never tenant-scoped, for the same reason storage plugins are: a plugin's actions appear in every tenant's file menu. Only the supertenant may touch this surface.
Package handlers — audit.go ¶
GET /api/admin/audit?user_id=&action=&from=&to=&limit=50&offset=0
Package handlers — audit_targets.go
WHICH thing an audit row is about, in words a person reads.
⚠⚠ The Panel's Recent activity and the Audit page printed the KIND of thing and nothing else — "Kullanıcı: oluşturuldu — Kullanıcı", "Depo: senkron başlatıldı — Depo #1" (release-candidate sweep, 2026-09-21). A row stores a target TYPE and an id; the id of a user, a storage or a share is a number nobody can read, and a create has no id at all (its URL has none and the middleware never reads bodies).
Two halves, one answer (`target_name` on the wire):
- write time: the handler names what it made or removed (auth.SetAuditTarget → metadata.target_name). This is the only answer for something that no longer exists — "which user was deleted?";
- read time: an id that still resolves is named from the live row, so the rows written before this change read properly too.
⚠ Resolved through the SCOPED store the handler already holds: a tenant admin's list is filtered to its own users' rows (audit.go), and a name is only ever looked up for a row that survived that filter.
Package handlers — auth_providers.go
Admin → Identity providers: the sign-in providers this instance runs.
GET /api/admin/auth-providers
PATCH /api/admin/auth-providers/{name}
POST /api/admin/auth-providers/{name}/test
⚠⚠ Until v0.43.0 this page wrote `auth.<name>.*` settings that NOTHING read — sign-in was built from the environment only, once, at boot — and answered "configuration saved; restart the server" to a restart that changed nothing (release-candidate sweep, 2026-09-21). The owner's decision: the page really manages sign-in, and it can never lock the instance out. The rules live in internal/authsetup; this file enforces them at the door:
- a provider the environment defines is read-only here, and says where it comes from (409 environment_managed on a change);
- password sign-in and the recovery sign-in are the environment's — the page has no switch for either (409 not_managed_here);
- a save runs the real test first; switching a provider ON while its test fails needs `confirm_failed_test` (409 test_failed names the steps);
- switching off the last way an administrator can sign in is refused (409 last_sign_in_method);
- a secret is sealed (FILEX_SECRET_KEY) and never sent back;
- a change is applied at once (authsetup.Live.Reload) and audited with the provider, the switch and the NAMES of the fields that changed.
Instance-wide: only the platform operator (supertenant admin) sees or changes any of it. A tenant administrator gets 403 — the sign-in of the whole instance, and its secrets, are not a tenant's to read; a tenant's own sign-in lives on its provider row (/api/admin/providers).
Package handlers — auth_self.go
Self-service auth endpoints (current user). All routes require an authenticated session and act on the principal in the request context.
GET /api/auth/me — current user PATCH /api/auth/profile — update email/username/locale/timezone POST /api/auth/password — change password (requires old) POST /api/auth/totp/enroll — start TOTP enrollment POST /api/auth/totp/verify — confirm TOTP enrollment with code POST /api/auth/totp/disable — turn TOTP off (password + code)
Package handlers — comments.go (calisma:d3, v0.6 "Çalışma" (Work))
Node comment endpoints under /api/files:
GET /api/files/comments?node_id=… (auth) chronological live list
POST /api/files/comments (auth) {node_id, body} add
DELETE /api/files/comments/{id} (auth) author-or-admin soft delete
ACL: anyone who can SEE the node (≥viewer, same aclAllowID gate the other file handlers use) may read and write its thread; delete is author-or-admin regardless of node level (enforced in internal/comments). Trashed/missing nodes 404 on both read and write.
Package handlers — confine_guard.go
The token `root:` confinement, for the surfaces confine.Middleware cannot reach.
confine.Middleware (mounted on /api/files) rewrites the `?path=` query and the path fields of a JSON body, so every path-addressed read and every mutation is confined by construction. It cannot touch two shapes:
- an id-addressed request (`?id=`, `node_id`, a raw `?storage=`), because a numeric id is not a path there is nothing to rewrite; and
- a listing or search that walks a whole storage, because the rows come back AFTER the request the middleware saw.
Those are exactly the shapes handlers/thumb.go, handlers/versions.go, handlers/trash.go, handlers/shared.go and handlers/quota_storages.go already gate with confine.Root.Within. This file is the ONE place the rest of the package answers the same question, so a sixth copy of "resolve the storage, then Within" cannot drift from the other five. It is the confinement twin of the tenant guards in tenantown.go.
Package handlers — dashboard.go ¶
GET /api/admin/dashboard returns aggregated stats for the admin landing page: per-storage summary, user/share/session counts, queue depth, recent activity and a short capability summary.
Package handlers — demo_redact.go
What a public demo shows in the read-only admin pages it deliberately keeps visible. The demo guard (api/demo_guard.go) stops a visitor CHANGING the instance; this file stops the pages a visitor may still READ from handing out other people's data and the operator's secrets.
Package handlers — duplicates.go ¶
GET /api/admin/duplicates?limit=100&min_size=1 — admin-only report of files sharing (size, etag), i.e. byte-identical copies as far as the storage backend can tell (frozen v0.2 "Bul" contract #2).
Grouping runs in SQL (db.Store.ListDuplicateNodes: GROUP BY size, etag HAVING COUNT(*)>1 on live file nodes with a non-empty etag); the handler folds the flat rows into groups, computes total_waste = (count-1)*size and sorts groups by total_waste descending.
Package handlers — external_admin.go
Admin CRUD for external services (OnlyOffice, Drawio, Mermaid, …).
GET /api/admin/external
PATCH /api/admin/external/{name}
POST /api/admin/external/{name}/test
Package handlers contains one file per logical HTTP route group.
Package handlers — meta.go ¶
Starred / Recently-opened endpoints, and the row shape the tag listings share with them. All require an authenticated user; starred and recent are PER-USER (stored on user_node_meta). Tags — personal and team since v0.43.0 — have their own file, tags.go.
POST /api/files/manager/star body {node_id, starred: bool}
GET /api/files/manager/star/list?storage_id=…&limit=
POST /api/files/manager/recent body {node_id}
GET /api/files/manager/recent?limit=
Package handlers — nfsexports.go
GET /api/auth/nfs-exports — list the caller's own exports
POST /api/auth/nfs-exports — mint one (the PATH is shown ONCE)
POST /api/auth/nfs-exports/{id}/state — enable / disable
DELETE /api/auth/nfs-exports/{id} — revoke permanently
⚠⚠ The path returned by POST is the whole credential. Anyone who can reach the NFS port and knows it can mount the export as this account — NFSv3 has no other way to bind an identity that does not require Kerberos. It is shown once, stored hashed, and the UI says all of this plainly rather than letting somebody discover it from a mount table.
Package handlers — plugins.go ¶
Admin surface for storage plugins (internal/plugin, docs/PLUGINS.md):
GET /api/admin/plugins — every plugin with its live state
POST /api/admin/plugins — install: multipart {name, file} | JSON {name,url,sha256} | JSON {name,kind:"remote",address,token}
GET /api/admin/plugins/{id}
PATCH /api/admin/plugins/{id} — {"enabled": bool}
POST /api/admin/plugins/{id}/restart
DELETE /api/admin/plugins/{id}
⚠ Instance-wide, never tenant-scoped: a plugin is a PROCESS filex runs (or a service it trusts with storage credentials), so in multi-tenant mode only the supertenant may touch this surface. A tenant admin gets 403, not an empty list, so the boundary is visible.
Package handlers — protection.go ("Koru" v0.4)
Admin protection-settings surface, frozen contract:
GET /api/admin/protection → {"trash_retention_days":30,"versions_keep_n":0,
"share_max_ttl_days":7,"shares_over_max_ttl":0,
"antivirus":{ … see protectionAntivirusStatus }}
PATCH /api/admin/protection → {trash_retention_days?, versions_keep_n?,
share_max_ttl_days?, av_save_scan_window_minutes?,
av_max_scan_mb?, av_enabled?, av_mode?,
av_clamd_addr?}
The values live in the settings table (`trash.retention_days`, `versions.keep_n`, `share.max_ttl_days`, `antivirus.enabled`, `antivirus.mode`, `antivirus.clamd_addr`, `antivirus.save_scan_window_minutes`, `antivirus.max_scan_mb`). `shares_over_max_ttl` is a read-only count of EXISTING live links that outlive the ceiling — they are reported, never shortened (see share.CountOverMaxTTL).
The antivirus block: STATUS fields and SETTING fields ¶
They are separated on purpose, because they can disagree and the disagreement is the interesting part. A SETTING is what the settings table holds — what an admin last saved. A STATUS field is what this process is actually doing. `scan_enabled` can be true while `enabled` is false (switched on, nothing to reach), and both can be true while `reachable` is false (clamd configured, clamd down).
⚠⚠ `av_enabled`, `av_mode` and `av_clamd_addr` take effect AT THE NEXT RESTART, in both directions, because the scan pipeline is wired once at boot (internal/server registers the queue handler and hands the upload surfaces an enqueue function). The response says so twice: the settings are echoed back immediately, and `restart_pending` is true for as long as the stored configuration differs from what this process booted with. The two live settings — the save-scan window and the size ceiling — are read at the point of use and are never pending.
⚠ The scanner BINARY is still not admin-writable, and for the original reason: it is a path this server EXECUTES, so a writable field for it would turn an admin account into arbitrary command execution. A clamd ADDRESS is a different thing — a dial target, never executed — and is writable, with its shape validated at save time (antivirus.AddrSetting.Check). It does let an admin point scanning at an arbitrary TCP endpoint, which is worth stating plainly, but it is the same authority an admin already has when configuring a storage backend's endpoint.
Package handlers — public_api.go
ONE PUBLIC SURFACE. Everything a stranger reaches through a filex link — a download share, a file request, an app plugin's page — is answered here as JSON, so the SPA can render all three with one shell, one PIN gate, one expiry story and the instance's own branding.
GET /api/public/branding — who this instance says it is
GET /api/public/ui-locales/{code} — one language an app adds: its strings
GET /api/public/s/{token} — a share's state
POST /api/public/s/{token}/pin — answer the PIN → unlock cookie
POST /api/public/s/{token}/event — an app page's surface event
GET /api/public/s/{token}/file/{ref} — a copy an app page exposed
GET /api/public/d/{token} — a file request's state
POST /api/public/d/{token}/pin — answer the PIN → unlock cookie
POST /api/public/d/{token}/upload — the drop itself (multipart)
⚠ NOTHING HERE IS AUTHENTICATED, and nothing here leaks. The token is the only capability; a wrong or unknown one is 404 with no other detail, and a dead one is 410 with the same shape for every reason it could be dead. The creator's address, the storage's name and the file's path never appear.
⚠ Every answer is `Cache-Control: no-store` and `X-Robots-Tag: noindex` EXCEPT /branding and /ui-locales, the two things here that are not about a link: who the instance is and the words it speaks. Both are deliberately cacheable (every public page fetches them).
⚠ The PIN gate is internal/share's — five strikes then ten minutes, counted on the share row, answered with an HMAC cookie that carries no PIN. It is the same gate the no-JS pages use, and the same one the app pages used to have to themselves.
Package handlers — public_shell.go
WHICH ANSWER A PUBLIC LINK GIVES.
/s/{token} and /d/{token} have three possible readers and must keep serving all three:
- a JavaScript browser — gets the SPA shell (the branded public surface: PIN gate, expiry, visit counter, the app page when the link carries one);
- a browser with no JavaScript, or one the shell could not load in — gets the Go pages that have always been here: the file, a PIN form, an explanation. Reduced to that, and kept, because a share link is opened by strangers on whatever browser they have;
- something that is not a browser at all — `curl -O`, wget, a download manager, a backup script — gets the BYTES, exactly as before.
The rules below are the whole decision. They are written as a function rather than as a middleware so both handlers make the same call in one place, and so a test can ask the question without a router.
Package handlers — quota.go ¶
Endpoints:
GET /api/files/quota/me (auth)
GET /api/files/quota/storages (auth, quota_storages.go)
GET /api/admin/users/{id}/quota (admin)
POST /api/admin/users/{id}/quota (admin)
PATCH /api/admin/users/{id}/quota (admin)
POST /api/admin/users/{id}/quota/recompute (admin)
The flat forms — /api/admin/quota/{user_id} and /api/admin/quota/{user_id}/recompute — are the original spelling and are still served. Note both take a USER id: there is no per-provider quota (see docs/MULTI-TENANCY.md, "Not yet").
Package handlers — quota_storages.go
Endpoint:
GET /api/files/quota/storages (auth)
"How full is this drive", for somebody who is not an administrator.
The admin panel has always had this number: `/api/admin/storages` enriches every row with `stats.total_size_bytes` (handlers/storages.go), which is what the Home page's storage card printed for an operator and for nobody else. The only other figure in reach was `/api/files/quota/me`, and that is a per-USER sum across every storage — printing it under one drive's name would be a number about the person wearing a label about the drive.
So this answers the same quantity the admin row carries, for the storages the caller is allowed to see, and nothing about the ones they are not.
Package handlers — replication_targets.go
/api/admin/replication-targets — CRUD for the new backup-only entity. Replication targets are NOT regular storages: operators never write to them, they don't show up in the Depolar list, and the file explorer never lists them as a virtual root. Each entry is a backup sink that a primary storage can fan its writes out to via `storages.replica_target_id`.
Package handlers — s3keys.go ¶
Self-service management of S3 access keys (migration 00026).
GET /api/auth/s3-keys — list the caller's own keys
POST /api/auth/s3-keys — mint one (the secret is shown ONCE)
POST /api/auth/s3-keys/{id}/state — enable / disable without deleting
DELETE /api/auth/s3-keys/{id} — revoke permanently
Self-service and not admin-only on purpose: the requirement is that every credential the product issues can connect at exactly its own permission, and a key that only an administrator can mint is a key most people never get. The permission ceiling is enforced by protocolauth.Issue, not by who is asking — a key can only ever narrow what its owner already has.
Package handlers — save_text.go
`/api/files/save-text` accepts plain-text writes from the SFC's code editor (Monaco) + markdown viewer "edit" mode. The SFC posts:
POST /api/files/save-text
{ "path": "<adapter>://<rel>", "content": "..." }
We resolve the storage from the adapter prefix, write the content through the storage Driver, then refresh the cache row's metadata (size + mtime + etag where the driver supplies one).
Whitelist: only files matching a text/code MIME class are saveable here. Binary edits go through OnlyOffice's callback channel.
Antivirus ¶
This surface does NOT call writehook.OnFileWritten, and that omission was the gap: the hook enqueues an antivirus scan, so routing every Ctrl+S through it would have queued a ClamAV pass per keystroke-burst — and the answer taken instead was to scan nothing, which made the editor a way to introduce a file that is never scanned on an install where every uploaded file is. The two branches below now split it the way the behaviour actually splits:
create (no node row for the path) → scan immediately, like an upload save (node row already existed) → schedule one scan, debounced
The debounce lives in the queue (not_before + a coalescing key), not in a timer in this process, so a restart mid-window does not lose the pending scan. See queue.AntivirusScanner.EnqueueAfterSave.
The write event ¶
The scan is the only thing this surface schedules for itself. The EVENT goes through the shared gate like every other write surface -- writehook.EmitWritten, which is OnFileWritten minus the antivirus half it has already taken care of -- so the editor is not a place where webhook subscribers hear something different from the browser upload that wrote the same file. The two branches split the event exactly as they split the scan:
create (no node row for the path) -> file.uploaded save (node row already existed) -> file.updated
Same `existing` fact, one decision, two consequences. Until this was wired the editor announced NOTHING at all: a file created or rewritten in the browser reached no webhook and no notification bell, so an integration watching a folder simply did not see edits happen.
Package handlers — search_admin.go
Admin endpoints for the embedded Bleve full-text index.
GET /api/admin/search/stats — index stats POST /api/admin/search/rebuild — drop and rebuild the index from nodes
Package handlers — shared.go ¶
"Shared with me": everything the caller reaches through a per-item grant somebody else gave them, rather than through their own role on a storage.
GET /api/files/manager/shared-with-me?limit=&offset=
The data has always existed in `file_grants`, but nothing could answer this question: Grants.List is path-scoped ("who can see THIS folder") and owner-only, so a user had no way to find out what had been shared with them unless they were told the path. That is the one item on a Drive-style navigation panel with no backing endpoint (issue #14).
Package handlers — shares_admin.go
Admin views/actions over all shares (every user, not just current user).
GET /api/admin/shares
POST /api/admin/shares/{id}/revoke
DELETE /api/admin/shares/{id}
Package handlers — shares_mine.go
"Paylaştıklarım" / "My shares" — the USER-scoped half of the share surface, and the one audited door to a link's PIN.
GET /api/shares the links the caller created (paginated)
GET /api/shares/{id}/pin that link's PIN, to its creator or to an admin
Why this file exists ¶
Until now the only list of shares was /api/admin/shares behind the admin panel, so somebody without admin rights could not see their own links at all: they minted a link from the explorer, the dialog showed it once, and after that the only way back to it was to mint another one. Every other self-service credential surface in this product (API tokens, S3 keys, SSH keys, NFS exports) already has this half. Shares did not.
⚠ This is NOT a second admin listing. It reads the SAME store query (ListAllShares) with the caller pinned as the creator, the same tenant filter and the same row envelope. What it drops is the part that is about everybody rather than about the caller — the creator's address, which on this screen is always the person reading it.
Package handlers — sshkeys.go
GET /api/auth/ssh-keys — list the caller's own keys
POST /api/auth/ssh-keys — register one (authorized_keys line)
POST /api/auth/ssh-keys/{id}/state — enable / disable without deleting
DELETE /api/auth/ssh-keys/{id} — remove permanently
⚠ This screen is a PREREQUISITE for the SFTP endpoint, not a follow-up: `ssh-copy-id` cannot work against filex. It appends to `~/.ssh/authorized_keys` over a shell, and filex has no shell — so without a place to paste a key, public-key authentication is unreachable and everybody falls back to sending their account password to a file server.
Package handlers — storage_drivers.go
GET /api/admin/storage-drivers — the config contract of every registered storage driver (see storage/descriptor.go).
Why its own admin route instead of a field on /api/capabilities: capabilities is public (embedders and the share/drop pages fetch it without a session) and already hot; driver config schemas — including which fields hold credentials, and the operator-facing hints for them — are only useful to an admin building a storage. `storage_drivers: []string` stays on capabilities untouched for every existing caller.
Package handlers — storages_admin.go
Extra admin actions on storages beyond plain CRUD:
POST /api/admin/storages/test — try a connection without saving
POST /api/admin/storages/discover — list the folders under a root, to mount several at once
GET /api/admin/storages/{id}/sync-runs — recent runs for one storage
GET /api/admin/storages/{id}/drift — recent sync conflicts
Package handlers — supertenant.go ¶
ONE gate for the admin surfaces that are INSTANCE-WIDE.
The distinction the /api/admin block does not draw for you ¶
Every route under /api/admin has already passed `auth.RequireAdmin`, and `auth.TenantResolver` has attached the requester's tenant scope. Neither of those denies anything by itself: the resolver only labels the request, and the scoped store filters exactly three list queries (storages, enabled storages, users). So in multi-tenant mode "admin" means *an* admin — of any tenant — and a route whose effect is instance-wide is, by default, open to all of them.
Most admin routes are fine with that because their DATA belongs to a tenant. A handful do not: they read and write a single global row, or drive a single process, that every tenant then lives with. Those are the ones that must ask this question, and the consequence of forgetting is not a leak but a takeover — the tenant admin of the smallest customer switching antivirus off for the whole platform, or repointing the shared document server at a host they control.
Single-tenant installs are untouched, by construction ¶
`auth.TenantResolver` returns the next handler unchanged when multi-tenant mode is off (auth/tenant_middleware.go), so NO scope is ever attached and `tenant.FromContext` reports absence. Absence means "unscoped" and passes here. The ordinary admin of a non-multi-tenant instance therefore still administers everything, with no flag to thread and nothing to configure — the gate is invisible until somebody turns tenancy on.
Why the gate lives in the handler and not on the route ¶
Because the route is not the only door. `/api/ai/admin` (handlers/ai_admin.go `Register`) mounts the SAME handler instances behind an `admin`-scoped API token, and the MCP admin tools drive those same methods in-process through `AIAdmin.invoke`. A chi middleware in routes.go would gate the browser's door and leave the other two standing open. A check at the top of the method is on the inside of all three.
Package handlers — surface_conditions.go
The HOST half of `show_when` / `required_when`.
The contract (APP-PLUGINS-API.md → Fields, PLUGIN-KIT.md → Screens) makes a plugin author two promises about a field that depends on another field:
"Both are checked on the client AND re-checked by the host at submit: a value belonging to a hidden field is dropped before the job runs, so it cannot arrive as a surprise, and a `required_when` field left empty refuses the job."
The browser half is `packages/core/src/lib/surfaceConditions.ts`, called from `usePluginSurface` on every event it posts. This file is the other half, and it is a LINE-BY-LINE port of that file rather than a second opinion about the same rules: the two halves have to agree exactly, or a plugin gets one answer on screen and another in its job.
⚠ Why the host has to ask the plugin what the screen looks like. A surface is not in the manifest — the plugin DRAWS it per event — so the host has no standing list of fields to check against. At a submit it therefore asks the plugin to draw the screen these values belong to (one `change` call, the same question the browser asks on every keystroke), reads the form fields out of that answer, and applies the rules to the values before the real event is handed over. The conditions are then evaluated by the same cascade the browser uses, so neither side can be talked into a different answer by a crafted request.
⚠ It strips the VALUES, not the job's params. The params are the plugin's own map — it may compute, rename or default anything in there — and dropping keys out of it would delete the plugin's own work. The values are the person's answers, which is exactly what the promise is about; a plugin that never sees a hidden field's answer cannot put it in a job.
Package handlers — sync_admin.go
Admin views over sync runs (cross-storage list + per-run detail).
GET /api/admin/sync-runs
GET /api/admin/sync-runs/{id}
Package handlers — tags.go ¶
Personal and team tags (v0.43.0, migration 00055).
GET /api/files/manager/tags?node_id=… the tags on one file the caller can see GET /api/files/manager/tags?storage_id=… the tags in use in one storage GET /api/files/manager/tags/all every tag the caller can see POST /api/files/manager/tags set a file's tags GET /api/files/manager/tagged?tag=…[&kind=personal|team]
The finding this answers ¶
⚠⚠ Tags used to be ONE shared label per file (node_meta `tag:<name>`), with no owner and no tenant, although routes.go introduced them as "Per-user metadata". Measured on v0.42 (tester, 2026-09-22, and reproduced here before the fix): user A tagged a file "Müşteri Teklifi"; user B's and the admin's `tags/all` answered `["müşteri teklifi"]`; B's `POST {tags:[]}` answered 200 and the tag was gone for A; and with multi-tenancy on, tenant bravo's `tags/all` listed tenant alpha's tag. The name had also been lower-cased.
# The rules (the owner's decision: "personal ve team olarak ikiye ayrılır, tenant based tagging yapılır")
- PERSONAL — one person's own label, like a star. Only its owner sees it, adds it or removes it. Anyone who can see a file may personal-tag it.
- TEAM — shared with everyone in the TENANT who can see the file. Adding or removing one needs EDIT permission on the file (the ACL level, `editor` or `owner`); a viewer sees team tags and cannot change them.
- A team tag never crosses a tenant boundary. A confined caller (a tenant in multi-tenant mode) sees its own tenant's team tags plus tenant-0 ones ("the instance": made before a tenant existed on that storage); never another tenant's. An unconfined caller — single-tenant mode, where the tenant IS the instance, or the platform supertenant, which already reaches every storage — sees every team tag on the files it can see.
- A tag is only ever named to someone who can see at least one file that carries it: a tag NAME is information too ("Layoffs Q4").
What an old client gets ¶
A `POST {node_id, tags:[names]}` with no `kind` — every client before v0.43.0 — creates PERSONAL tags. Deliberately the narrow default:
- A request that does not say who should see a label must not show it to anyone else. The finding WAS an unintended audience; defaulting the old shape to team would reproduce it for every stale tab and script.
- It always works. Any caller who can see a file may personal-tag it; a team default would turn a viewer's old client into a stream of 403s.
- It is what the code always claimed ("per-user"), and what the tester expected.
Names in that list that match a tag the file ALREADY carries keep that tag as it is (team stays team) — an old client round-trips the list it read, and an untouched team tag in it is not a change. A team tag the old client leaves OUT is a removal, and needs edit permission like any other.
Case ¶
Names keep the case they were typed in; two names are one tag when internal/tagname.Key says so (case-folded, Turkish I/ı/İ/i merged, NFC). See that package for why strings.EqualFold is not the rule.
Package handlers — tenantown.go ¶
ONE ownership predicate for the admin routes that take a raw {id}.
A different question from supertenant.go ¶
`supertenant.go` asks "may this tenant touch an instance-wide switch" and answers 403 when it may not — the SURFACE is being refused and the operator needs to read why. This file asks a narrower and more dangerous question: "does the caller's tenant own the ROW it just named". The routes here are legitimate tenant features — a tenant admin absolutely may reset their own user's password, revoke their own share, purge their own trash. They may not do it to somebody else's, and until this file existed nothing asked.
That distinction is why the failure is worse than an over-broad control. Measured on the pre-fix build (`TestOwnership_ForeignIdsAreRefused`), an admin of one tenant reached FIFTEEN of sixteen id-taking admin routes belonging to another tenant — including `POST /users/{id}/reset-password`, which answered 200 and returned the other customer's new cleartext password in the response body, and `GET /storages/{id}`, which returns the storage's whole config blob (for an S3 or SFTP storage: the access key and secret).
Why 404 and not 403 ¶
A 403 confirms the row exists. Repeat it over an id range and the endpoint becomes a census of the platform's other customers — how many storages, how many users, which ids are live. A foreign id must be indistinguishable from one that never existed, so these refusals borrow the shape of the "not found" the handler would otherwise have produced.
Single-tenant installs are untouched, by construction ¶
Same mechanism as supertenant.go: `auth.TenantResolver` attaches no scope when multi-tenant mode is off, absence means "unscoped", and unscoped passes every predicate here. There is no flag to set. The supertenant passes too — the platform operator must keep being able to administer a tenant's rows, including recovering a locked-out tenant admin.
Why the check lives in the handler ¶
Because the chi route is not the only door — `/api/ai/admin` mounts the same handler instances behind an admin-scoped API token and the MCP admin tools drive them in-process. Same reasoning as supertenant.go; see its header.
Package handlers — trash.go ¶
Endpoints:
GET /api/files/manager/trash (auth) list trashed
POST /api/files/manager/restore (auth) body {node_id}
DELETE /api/admin/trash/{id} (admin) immediate single purge
POST /api/admin/trash/empty?older_than_days=N (admin) start a batch purge
GET /api/admin/trash/empty (admin) that purge's progress
Package handlers — userprefs.go ¶
Endpoints:
GET /api/me/prefs?surface=web (auth) → {"surface": "...", "prefs": <object>}
PUT /api/me/prefs?surface=web (auth) ← {"prefs": <object>}
What a person chose about the interface itself — theme, palette, density, language. One JSON document per person PER SURFACE (`web` | `desktop`), stored by migration 00047.
⚠⚠ WHY THE SERVER. The owner picked a theme in one browser and the other browser did not have it. `localStorage` is per BROWSER, never per person: a second machine, a private window, a cleared site and the desktop app are each a fresh empty set of choices, with nothing to notice and no error.
⚠ NOT a replacement for the view prefs (00039, viewprefs.go). Those are how each FOLDER was left and are written on every navigation; these are the surface's own appearance and are read once at sign-in. Two tables because they change at completely different rates and one of them is per folder.
⚠ A caller only ever reaches THEIR OWN row. There is no user id in the path or the body, and the only identity this file consults is the session's, so there is nothing for a client to substitute.
Package handlers — users_admin.go
Admin-only extra actions on users.
POST /api/admin/users/{id}/reset-password
Package handlers — versions.go ¶
Endpoints under /api/files/versions.
GET /api/files/versions?node_id=… (viewer) list snapshots
POST /api/files/versions/snapshot (editor) snapshot now
POST /api/files/versions/restore (editor) restore one
DELETE /api/admin/versions/{id} (admin) hard delete
⚠⚠ Every one of them takes a raw node id, so every one of them resolves the node and authorizes it FIRST — see authorizedNode. Until that existed the only gate here was tenancy, which meant that on a single-tenant install (the vast majority) there was no gate at all: a `viewer` account could POST /restore and overwrite the live bytes of any file whose node id it could name, and POST /snapshot to write objects into any storage on the box.
Package handlers — viewprefs.go ¶
Endpoints:
GET /api/files/manager/view-prefs (auth) → {"prefs": <object>, "instance_default": <object|null>}
PUT /api/files/manager/view-prefs (auth) ← {"prefs": <object>}
How each person left each folder — view mode, sort key and direction, plus their column widths, order and visibility. One JSON document per user, stored against the user row by migration 00039.
⚠⚠ WHY THIS IS SERVER-SIDE at all, when theme, density and the start page are not. `localStorage` is per BROWSER, not per person: on a shared machine the next account to sign in inherits the previous one's folder arrangements, silently and with no error. The owner ruled that out explicitly ("her user kendi görünümünü görür"), and a server-side document also follows a person to the desktop app and to their other machines, which is what that sentence really asks for.
Index ¶
- Constants
- Variables
- func ArchiveName(paths []string, override string) string
- func ImportTheme(raw []byte) (*model.CustomTheme, error)
- func NewArchiveTicketStore() *archiveTicketStore
- func NewUploadTicketStore() *uploadTicketStore
- func RequirePersonalCaller(next http.Handler) http.Handler
- func ResolveThemeID(requested string, custom []*model.CustomTheme) string
- func RetiredPageAPI() http.Handler
- func RetiredPagePrefix() http.Handler
- func SetAntivirusEnqueue(fn func(ctx context.Context, n *model.Node))
- func SetAntivirusEnqueueAfterSave(fn func(ctx context.Context, n *model.Node))
- func SetAppLabels(f func(string) wire.Text)
- func SetChangeEmitter(e ChangeEmitter)
- func SetLockReasons(f func(*model.AppPluginLock) (string, wire.Text))
- func SetNotifySink(s notify.Service)
- func ThemeAllTokens() []string
- func ThemeAuthoredColorTokens() []string
- func ValidateCustomTheme(t *model.CustomTheme) error
- type AI
- func (h *AI) AttachACL(r *acl.Resolver)
- func (h *AI) AttachBody(b *filebody.Resolver)
- func (h *AI) AttachSearchIndex(idx *search.Index)
- func (h *AI) AttachStaged(s *StagedUpload)
- func (h *AI) AttachTenants(rv tenanturl.Resolver)
- func (h *AI) AttachThumbs(p *thumb.Pipeline)
- func (h *AI) AttachTickets(s *uploadTicketStore)
- func (h *AI) Delete(w http.ResponseWriter, r *http.Request)
- func (h *AI) Download(w http.ResponseWriter, r *http.Request)
- func (h *AI) Info(w http.ResponseWriter, r *http.Request)
- func (h *AI) List(w http.ResponseWriter, r *http.Request)
- func (h *AI) Mkdir(w http.ResponseWriter, r *http.Request)
- func (h *AI) Move(w http.ResponseWriter, r *http.Request)
- func (h *AI) Root(w http.ResponseWriter, r *http.Request)
- func (h *AI) Search(w http.ResponseWriter, r *http.Request)
- func (h *AI) Share(w http.ResponseWriter, r *http.Request)
- func (h *AI) TagsGet(w http.ResponseWriter, r *http.Request)
- func (h *AI) TagsSet(w http.ResponseWriter, r *http.Request)
- func (h *AI) Unshare(w http.ResponseWriter, r *http.Request)
- func (h *AI) Unzip(w http.ResponseWriter, r *http.Request)
- func (h *AI) Upload(w http.ResponseWriter, r *http.Request)
- func (h *AI) UploadTicket(w http.ResponseWriter, r *http.Request)
- func (h *AI) Zip(w http.ResponseWriter, r *http.Request)
- type AIAdmin
- type AIAdminDeps
- type AIMCP
- func (h *AIMCP) AttachACL(r *acl.Resolver)
- func (h *AIMCP) AttachBody(b *filebody.Resolver)
- func (h *AIMCP) AttachSearchIndex(idx *search.Index)
- func (h *AIMCP) AttachStaged(s *StagedUpload)
- func (h *AIMCP) AttachTenants(rv tenanturl.Resolver)
- func (h *AIMCP) AttachThumbs(p *thumb.Pipeline)
- func (h *AIMCP) AttachTickets(s *uploadTicketStore)
- func (h *AIMCP) ServeHTTP(w http.ResponseWriter, r *http.Request)
- type AITokens
- type ActivityRow
- type AppPlugins
- func (h *AppPlugins) Actions(w http.ResponseWriter, r *http.Request)
- func (h *AppPlugins) Catalogue(ctx context.Context, storageID int64, rel string) (*model.Node, error)
- func (h *AppPlugins) CommitSibling(ctx context.Context, storageID int64, dir, name string, r io.Reader, ...) (string, error)
- func (h *AppPlugins) CommitVersion(ctx context.Context, storageID int64, rel string, r io.Reader, size int64, ...) error
- func (h *AppPlugins) Run(w http.ResponseWriter, r *http.Request)
- func (h *AppPlugins) Users(w http.ResponseWriter, r *http.Request)
- func (h *AppPlugins) View(w http.ResponseWriter, r *http.Request)
- func (h *AppPlugins) ViewEvent(w http.ResponseWriter, r *http.Request)
- type AppPluginsAdmin
- func (h *AppPluginsAdmin) Delete(w http.ResponseWriter, r *http.Request)
- func (h *AppPluginsAdmin) Get(w http.ResponseWriter, r *http.Request)
- func (h *AppPluginsAdmin) GetOverrides(w http.ResponseWriter, r *http.Request)
- func (h *AppPluginsAdmin) GetSettings(w http.ResponseWriter, r *http.Request)
- func (h *AppPluginsAdmin) ImportSigningCA(w http.ResponseWriter, r *http.Request)
- func (h *AppPluginsAdmin) Install(w http.ResponseWriter, r *http.Request)
- func (h *AppPluginsAdmin) List(w http.ResponseWriter, r *http.Request)
- func (h *AppPluginsAdmin) Locks(w http.ResponseWriter, r *http.Request)
- func (h *AppPluginsAdmin) Logs(w http.ResponseWriter, r *http.Request)
- func (h *AppPluginsAdmin) Patch(w http.ResponseWriter, r *http.Request)
- func (h *AppPluginsAdmin) PutOverrides(w http.ResponseWriter, r *http.Request)
- func (h *AppPluginsAdmin) PutSettings(w http.ResponseWriter, r *http.Request)
- func (h *AppPluginsAdmin) RotateSigningCA(w http.ResponseWriter, r *http.Request)
- func (h *AppPluginsAdmin) SigningCA(w http.ResponseWriter, r *http.Request)
- func (h *AppPluginsAdmin) SigningCAs(w http.ResponseWriter, r *http.Request)
- func (h *AppPluginsAdmin) Unlock(w http.ResponseWriter, r *http.Request)
- func (h *AppPluginsAdmin) Upgrade(w http.ResponseWriter, r *http.Request)
- type Appearance
- type AppearancePayload
- type AppearanceSource
- type Archive
- func (a *Archive) Add(w http.ResponseWriter, r *http.Request)
- func (a *Archive) AttachACL(r *acl.Resolver)
- func (a *Archive) AttachArchiveEngine(engine *archivecli.Service)
- func (a *Archive) AttachBody(b *filebody.Resolver)
- func (a *Archive) AttachDownloadTickets(s *archiveTicketStore)
- func (a *Archive) AttachOps(service *ops.Service)
- func (a *Archive) AttachSearchIndex(i *search.Index)
- func (a *Archive) AttachThumbs(p *thumb.Pipeline)
- func (a *Archive) Create(w http.ResponseWriter, r *http.Request)
- func (a *Archive) DownloadArchive(w http.ResponseWriter, r *http.Request)
- func (a *Archive) DownloadTicket(w http.ResponseWriter, r *http.Request)
- func (a *Archive) Extract(w http.ResponseWriter, r *http.Request)
- func (a *Archive) List(w http.ResponseWriter, r *http.Request)
- type ArchiveAdmin
- type Audit
- type Auth
- func (h *Auth) Login(w http.ResponseWriter, r *http.Request)
- func (h *Auth) Logout(w http.ResponseWriter, r *http.Request)
- func (h *Auth) OIDCCallback(w http.ResponseWriter, r *http.Request)
- func (h *Auth) OIDCStart(w http.ResponseWriter, r *http.Request)
- func (h *Auth) WhoAmI(w http.ResponseWriter, r *http.Request)
- type AuthProviders
- type AuthSelf
- func (h *AuthSelf) ChangePassword(w http.ResponseWriter, r *http.Request)
- func (h *AuthSelf) Me(w http.ResponseWriter, r *http.Request)
- func (h *AuthSelf) TotpDisable(w http.ResponseWriter, r *http.Request)
- func (h *AuthSelf) TotpEnroll(w http.ResponseWriter, r *http.Request)
- func (h *AuthSelf) TotpVerify(w http.ResponseWriter, r *http.Request)
- func (h *AuthSelf) UpdateProfile(w http.ResponseWriter, r *http.Request)
- type Branding
- type BrandingConfig
- type BrandingSource
- type Capabilities
- type CapabilitiesShort
- type ChangeEmitter
- type Cloud
- func (h *Cloud) Checkout(w http.ResponseWriter, r *http.Request)
- func (h *Cloud) Plans(w http.ResponseWriter, r *http.Request)
- func (h *Cloud) Register(r chi.Router)
- func (h *Cloud) Signup(w http.ResponseWriter, r *http.Request)
- func (h *Cloud) Status(w http.ResponseWriter, r *http.Request)
- func (h *Cloud) Verify(w http.ResponseWriter, r *http.Request)
- func (h *Cloud) Webhook(w http.ResponseWriter, r *http.Request)
- type Comments
- type CustomCSS
- type Dashboard
- type DesktopAuth
- type DiscoveredFolder
- type Drop
- func (h *Drop) AttachBranding(b *BrandingSource)
- func (h *Drop) AttachLocale(def string)
- func (h *Drop) AttachShell(shell http.Handler)
- func (h *Drop) AttachTenants(rv tenanturl.Resolver)
- func (h *Drop) Page(w http.ResponseWriter, r *http.Request)
- func (h *Drop) Upload(w http.ResponseWriter, r *http.Request)
- type Duplicates
- type E2E
- type ExternalAdmin
- type FTPSFacts
- type Grants
- func (h *Grants) AdminDelete(w http.ResponseWriter, r *http.Request)
- func (h *Grants) AdminList(w http.ResponseWriter, r *http.Request)
- func (h *Grants) AttachInvite(sh *share.Service, m *mailer.Service, publicURL string)
- func (h *Grants) AttachTenants(rv tenanturl.Resolver)
- func (h *Grants) Create(w http.ResponseWriter, r *http.Request)
- func (h *Grants) Delete(w http.ResponseWriter, r *http.Request)
- func (h *Grants) Invite(w http.ResponseWriter, r *http.Request)
- func (h *Grants) List(w http.ResponseWriter, r *http.Request)
- func (h *Grants) Resolve(w http.ResponseWriter, r *http.Request)
- func (h *Grants) SearchUsers(w http.ResponseWriter, r *http.Request)
- func (h *Grants) ShareMail(w http.ResponseWriter, r *http.Request)
- func (h *Grants) Update(w http.ResponseWriter, r *http.Request)
- type LazyCatalogue
- type Manager
- func (h *Manager) AttachACL(r *acl.Resolver)
- func (h *Manager) AttachBody(b *filebody.Resolver)
- func (h *Manager) AttachChangeLog(l *realtime.ChangeLog)
- func (h *Manager) AttachLazy(l LazyCatalogue)
- func (h *Manager) AttachSearchIndex(idx *search.Index)
- func (h *Manager) AttachStaged(s *StagedUpload)
- func (h *Manager) AttachThumbPipeline(p ThumbPipeline)
- func (h *Manager) EnsureDir(ctx context.Context, st *model.Storage, rel string) (*int64, error)
- func (h *Manager) IngestFile(ctx context.Context, st *model.Storage, destRel, filename string, ...) (*model.Node, error)
- func (h *Manager) List(w http.ResponseWriter, r *http.Request)
- func (h *Manager) Mutate(w http.ResponseWriter, r *http.Request)
- func (h *Manager) Read(w http.ResponseWriter, r *http.Request)
- func (h *Manager) Stat(w http.ResponseWriter, r *http.Request)
- func (h *Manager) SyncCopy(ctx context.Context, storageID int64, src, dst string)
- func (h *Manager) SyncCopyAcross(ctx context.Context, srcStorageID int64, src string, dstStorageID int64, ...)
- func (h *Manager) SyncHardDelete(ctx context.Context, storageID int64, src string)
- func (h *Manager) SyncMove(ctx context.Context, storageID int64, src, dst string)
- func (h *Manager) SyncSoftDelete(ctx context.Context, storageID int64, src, trashRel string)
- type Meta
- func (h *Meta) AttachACL(r *acl.Resolver)
- func (h *Meta) AttachThumbSigner(s *thumb.Signer)
- func (h *Meta) GetTags(w http.ResponseWriter, r *http.Request)
- func (h *Meta) ListAllTags(w http.ResponseWriter, r *http.Request)
- func (h *Meta) ListRecent(w http.ResponseWriter, r *http.Request)
- func (h *Meta) ListStarred(w http.ResponseWriter, r *http.Request)
- func (h *Meta) SetRecent(w http.ResponseWriter, r *http.Request)
- func (h *Meta) SetStar(w http.ResponseWriter, r *http.Request)
- func (h *Meta) SetTags(w http.ResponseWriter, r *http.Request)
- func (h *Meta) TaggedNodes(w http.ResponseWriter, r *http.Request)
- type NFSExports
- type Notifications
- func (h *Notifications) AdminList(w http.ResponseWriter, r *http.Request)
- func (h *Notifications) AdminTest(w http.ResponseWriter, r *http.Request)
- func (h *Notifications) AdminUpdateWebhookConfig(w http.ResponseWriter, r *http.Request)
- func (h *Notifications) AdminWebhookConfig(w http.ResponseWriter, r *http.Request)
- func (h *Notifications) GetSettings(w http.ResponseWriter, r *http.Request)
- func (h *Notifications) List(w http.ResponseWriter, r *http.Request)
- func (h *Notifications) MarkAllRead(w http.ResponseWriter, r *http.Request)
- func (h *Notifications) MarkRead(w http.ResponseWriter, r *http.Request)
- func (h *Notifications) UnreadCount(w http.ResponseWriter, r *http.Request)
- func (h *Notifications) UpdateSettings(w http.ResponseWriter, r *http.Request)
- type OnlyOffice
- func (h *OnlyOffice) AttachACL(r *acl.Resolver)
- func (h *OnlyOffice) AttachBody(b *filebody.Resolver)
- func (h *OnlyOffice) Callback(w http.ResponseWriter, r *http.Request)
- func (h *OnlyOffice) Config(w http.ResponseWriter, r *http.Request)
- func (h *OnlyOffice) Fetch(w http.ResponseWriter, r *http.Request)
- func (h *OnlyOffice) Probe(w http.ResponseWriter, r *http.Request)
- type Ops
- func (o *Ops) AttachACL(r *acl.Resolver)
- func (o *Ops) Cancel(w http.ResponseWriter, r *http.Request)
- func (o *Ops) List(w http.ResponseWriter, r *http.Request)
- func (o *Ops) Status(w http.ResponseWriter, r *http.Request)
- func (o *Ops) Submit(w http.ResponseWriter, r *http.Request)
- func (o *Ops) SubmitCopy(w http.ResponseWriter, r *http.Request)
- func (o *Ops) SubmitDelete(w http.ResponseWriter, r *http.Request)
- func (o *Ops) SubmitMove(w http.ResponseWriter, r *http.Request)
- type Plugins
- func (h *Plugins) Delete(w http.ResponseWriter, r *http.Request)
- func (h *Plugins) Get(w http.ResponseWriter, r *http.Request)
- func (h *Plugins) Install(w http.ResponseWriter, r *http.Request)
- func (h *Plugins) List(w http.ResponseWriter, r *http.Request)
- func (h *Plugins) Patch(w http.ResponseWriter, r *http.Request)
- func (h *Plugins) Restart(w http.ResponseWriter, r *http.Request)
- func (h *Plugins) Upgrade(w http.ResponseWriter, r *http.Request)
- type Protection
- type Providers
- func (h *Providers) Create(w http.ResponseWriter, r *http.Request)
- func (h *Providers) Delete(w http.ResponseWriter, r *http.Request)
- func (h *Providers) LinkStorage(w http.ResponseWriter, r *http.Request)
- func (h *Providers) List(w http.ResponseWriter, r *http.Request)
- func (h *Providers) UnlinkStorage(w http.ResponseWriter, r *http.Request)
- func (h *Providers) Update(w http.ResponseWriter, r *http.Request)
- type PublicAPI
- func (h *PublicAPI) AttachApps(a *AppPlugins)
- func (h *PublicAPI) AttachBranding(b *BrandingSource)
- func (h *PublicAPI) AttachDrop(d *Drop)
- func (h *PublicAPI) AttachLocale(def string)
- func (h *PublicAPI) Branding(w http.ResponseWriter, r *http.Request)
- func (h *PublicAPI) DropState(w http.ResponseWriter, r *http.Request)
- func (h *PublicAPI) DropUpload(w http.ResponseWriter, r *http.Request)
- func (h *PublicAPI) Entries(w http.ResponseWriter, r *http.Request)
- func (h *PublicAPI) Event(w http.ResponseWriter, r *http.Request)
- func (h *PublicAPI) File(w http.ResponseWriter, r *http.Request)
- func (h *PublicAPI) PIN(w http.ResponseWriter, r *http.Request)
- func (h *PublicAPI) Share(w http.ResponseWriter, r *http.Request)
- func (h *PublicAPI) UILocale(w http.ResponseWriter, r *http.Request)
- type PublicApp
- type PublicBranding
- type PublicDrop
- type PublicEntry
- type PublicNode
- type PublicShare
- type PublicUILocale
- type Queue
- func (h *Queue) AttachStore(s db.Store)
- func (h *Queue) Cancel(w http.ResponseWriter, r *http.Request)
- func (h *Queue) Get(w http.ResponseWriter, r *http.Request)
- func (h *Queue) List(w http.ResponseWriter, r *http.Request)
- func (h *Queue) Retry(w http.ResponseWriter, r *http.Request)
- func (h *Queue) Stats(w http.ResponseWriter, r *http.Request)
- type QueueStats
- type Quota
- func (h *Quota) AdminGet(w http.ResponseWriter, r *http.Request)
- func (h *Quota) AdminRecompute(w http.ResponseWriter, r *http.Request)
- func (h *Quota) AdminSet(w http.ResponseWriter, r *http.Request)
- func (h *Quota) AttachACL(r *acl.Resolver)
- func (h *Quota) AttachLazy(l LazyCatalogue)
- func (h *Quota) Me(w http.ResponseWriter, r *http.Request)
- func (h *Quota) StorageUsage(w http.ResponseWriter, r *http.Request)
- type Replica
- func (h *Replica) CountFailures(w http.ResponseWriter, r *http.Request)
- func (h *Replica) CreateRule(w http.ResponseWriter, r *http.Request)
- func (h *Replica) DeleteRule(w http.ResponseWriter, r *http.Request)
- func (h *Replica) FixAll(w http.ResponseWriter, r *http.Request)
- func (h *Replica) FixOne(w http.ResponseWriter, r *http.Request)
- func (h *Replica) GetReport(w http.ResponseWriter, r *http.Request)
- func (h *Replica) GetSettings(w http.ResponseWriter, r *http.Request)
- func (h *Replica) ListFailures(w http.ResponseWriter, r *http.Request)
- func (h *Replica) ListRules(w http.ResponseWriter, r *http.Request)
- func (h *Replica) RunReportNow(w http.ResponseWriter, r *http.Request)
- func (h *Replica) UpdateRule(w http.ResponseWriter, r *http.Request)
- func (h *Replica) UpdateSettings(w http.ResponseWriter, r *http.Request)
- type ReplicationTargets
- func (h *ReplicationTargets) Create(w http.ResponseWriter, r *http.Request)
- func (h *ReplicationTargets) Delete(w http.ResponseWriter, r *http.Request)
- func (h *ReplicationTargets) Get(w http.ResponseWriter, r *http.Request)
- func (h *ReplicationTargets) List(w http.ResponseWriter, r *http.Request)
- func (h *ReplicationTargets) Update(w http.ResponseWriter, r *http.Request)
- type Response
- type S3Keys
- type SSHKeys
- type SanitizedCSS
- type SaveText
- type Search
- type SearchAdmin
- type SelfTokens
- type Settings
- func (h *Settings) AttachAppearance(a *AppearanceSource)
- func (h *Settings) AttachBranding(b *BrandingSource)
- func (h *Settings) AttachMailer(m *mailer.Service)
- func (h *Settings) List(w http.ResponseWriter, r *http.Request)
- func (h *Settings) SMTPTest(w http.ResponseWriter, r *http.Request)
- func (h *Settings) Set(w http.ResponseWriter, r *http.Request)
- func (h *Settings) Update(w http.ResponseWriter, r *http.Request)
- type Share
- func (h *Share) AttachACL(r *acl.Resolver)
- func (h *Share) AttachApps(a *AppPlugins)
- func (h *Share) AttachBody(b *filebody.Resolver)
- func (h *Share) AttachBranding(b *BrandingSource)
- func (h *Share) AttachIndex(idx *search.Index)
- func (h *Share) AttachLocale(def string)
- func (h *Share) AttachShell(shell http.Handler)
- func (h *Share) AttachTenants(rv tenanturl.Resolver)
- func (h *Share) AttachThumbs(p *thumb.Pipeline)
- func (h *Share) HandleBrowseFile(w http.ResponseWriter, r *http.Request)
- func (h *Share) HandleCreate(w http.ResponseWriter, r *http.Request)
- func (h *Share) HandleDelete(w http.ResponseWriter, r *http.Request)
- func (h *Share) HandleDownload(w http.ResponseWriter, r *http.Request)
- func (h *Share) HandleList(w http.ResponseWriter, r *http.Request)
- func (h *Share) HandleMetadata(w http.ResponseWriter, r *http.Request)
- type ShareX
- func (h *ShareX) AttachACL(r *acl.Resolver)
- func (h *ShareX) AttachBody(b *filebody.Resolver)
- func (h *ShareX) AttachSearchIndex(idx *search.Index)
- func (h *ShareX) AttachStaged(s *StagedUpload)
- func (h *ShareX) AttachTenants(rv tenanturl.Resolver)
- func (h *ShareX) AttachThumbs(p *thumb.Pipeline)
- func (h *ShareX) Upload(w http.ResponseWriter, r *http.Request)
- type Shared
- type SharesAdmin
- func (h *SharesAdmin) AttachApps(reg *wasmplugin.Registry)
- func (h *SharesAdmin) AttachTenants(rv tenanturl.Resolver)
- func (h *SharesAdmin) Delete(w http.ResponseWriter, r *http.Request)
- func (h *SharesAdmin) List(w http.ResponseWriter, r *http.Request)
- func (h *SharesAdmin) ListAppPluginShares(w http.ResponseWriter, r *http.Request)
- func (h *SharesAdmin) Revoke(w http.ResponseWriter, r *http.Request)
- type SharesMine
- type StagedUpload
- func (h *StagedUpload) Abort(w http.ResponseWriter, r *http.Request)
- func (h *StagedUpload) AttachACL(r *acl.Resolver)
- func (h *StagedUpload) Begin(w http.ResponseWriter, r *http.Request)
- func (h *StagedUpload) Commit(w http.ResponseWriter, r *http.Request)
- func (h *StagedUpload) CommitUpload(ctx context.Context, uploadID string) error
- func (h *StagedUpload) IngestStream(ctx context.Context, storageID int64, storageKey string, src io.Reader, ...) (*model.Node, error)
- func (h *StagedUpload) Put(w http.ResponseWriter, r *http.Request)
- func (h *StagedUpload) RecoverUnstored(ctx context.Context) int
- func (h *StagedUpload) RunSweeper(ctx context.Context, interval time.Duration)
- func (h *StagedUpload) ShouldStage(size int64) bool
- func (h *StagedUpload) StagedReady() bool
- func (h *StagedUpload) StagedThreshold() int64
- func (h *StagedUpload) Status(w http.ResponseWriter, r *http.Request)
- func (h *StagedUpload) Sweep(ctx context.Context) (int, int)
- type StorageDrivers
- type StorageSummary
- type StorageUsageRow
- type Storages
- func (h *Storages) Create(w http.ResponseWriter, r *http.Request)
- func (h *Storages) Delete(w http.ResponseWriter, r *http.Request)
- func (h *Storages) Get(w http.ResponseWriter, r *http.Request)
- func (h *Storages) List(w http.ResponseWriter, r *http.Request)
- func (h *Storages) TriggerSync(w http.ResponseWriter, r *http.Request)
- func (h *Storages) Update(w http.ResponseWriter, r *http.Request)
- type StoragesAdmin
- type SyncAdmin
- type ThemeExport
- type ThemeWire
- type Themes
- type Thumb
- type ThumbPipeline
- type TicketUpload
- func (h *TicketUpload) AttachACL(r *acl.Resolver)
- func (h *TicketUpload) AttachBody(b *filebody.Resolver)
- func (h *TicketUpload) AttachSearchIndex(idx *search.Index)
- func (h *TicketUpload) AttachStaged(s *StagedUpload)
- func (h *TicketUpload) AttachThumbs(p *thumb.Pipeline)
- func (h *TicketUpload) Upload(w http.ResponseWriter, r *http.Request)
- type Trash
- func (h *Trash) AdminEmpty(w http.ResponseWriter, r *http.Request)
- func (h *Trash) AttachACL(r *acl.Resolver)
- func (h *Trash) AttachOps(o *ops.Service)
- func (h *Trash) AttachSearchIndex(i *search.Index)
- func (h *Trash) EmptyStatus(w http.ResponseWriter, r *http.Request)
- func (h *Trash) List(w http.ResponseWriter, r *http.Request)
- func (h *Trash) Purge(w http.ResponseWriter, r *http.Request)
- func (h *Trash) Restore(w http.ResponseWriter, r *http.Request)
- type Update
- type Upload
- type Usage
- type UserPrefs
- type Users
- func (h *Users) Create(w http.ResponseWriter, r *http.Request)
- func (h *Users) Delete(w http.ResponseWriter, r *http.Request)
- func (h *Users) Get(w http.ResponseWriter, r *http.Request)
- func (h *Users) List(w http.ResponseWriter, r *http.Request)
- func (h *Users) Update(w http.ResponseWriter, r *http.Request)
- type UsersAdmin
- type VersionSnapshotter
- type Versions
- func (h *Versions) AttachACL(r *acl.Resolver)
- func (h *Versions) AttachSearchIndex(i *search.Index)
- func (h *Versions) HardDelete(w http.ResponseWriter, r *http.Request)
- func (h *Versions) List(w http.ResponseWriter, r *http.Request)
- func (h *Versions) Restore(w http.ResponseWriter, r *http.Request)
- func (h *Versions) Snapshot(w http.ResponseWriter, r *http.Request)
- type ViewPrefs
- type WS
- type WebhooksAdmin
- func (h *WebhooksAdmin) Create(w http.ResponseWriter, r *http.Request)
- func (h *WebhooksAdmin) Delete(w http.ResponseWriter, r *http.Request)
- func (h *WebhooksAdmin) List(w http.ResponseWriter, r *http.Request)
- func (h *WebhooksAdmin) Test(w http.ResponseWriter, r *http.Request)
- func (h *WebhooksAdmin) Update(w http.ResponseWriter, r *http.Request)
Constants ¶
const ( // SurfaceWeb is the browser. SurfaceWeb = "web" // SurfaceDesktop is the desktop application. SurfaceDesktop = "desktop" )
Surfaces a preference document may belong to.
const AuditActionPinReveal = share.AuditActionPinReveal
AuditActionPinReveal is the audit row every PIN read writes — success or refusal-after-authorization alike. Exported so a test can name it rather than repeat the string, and so an operator grepping the audit table has one spelling to grep for.
const BrandingSettingTheme = "branding.theme"
BrandingSettingTheme is the settings key holding the public shell's default appearance.
const BuiltinDefaultThemeID = "default"
BuiltinDefaultThemeID is the stock palette: the product's own colours, and the answer whenever a named theme cannot be resolved.
const CustomCSSEnabledSettingKey = "ui.custom_css_enabled"
CustomCSSEnabledSettingKey is the switch. Absent, or anything other than a true-ish string, means OFF — the direction that makes a missing row safe.
const CustomCSSImmuneClass = "fe-css-immune"
CustomCSSImmuneClass marks a subtree the operator stylesheet may not match inside. The admin Appearance panel and destructive confirmations carry it.
⚠ A plain class and not a data attribute, so a single `@scope` limit can name it; `fe-`prefixed like every other class the packages own.
const CustomCSSMaxBytes = 64 * 1024
CustomCSSMaxBytes caps the stored sheet. 64 KiB is roughly ten times the largest hand-written token override we ship, and small enough that a sheet with Turkish comments in it is never what makes a page slow. The admin page shows the same number and counts UTF-8 bytes, so the two agree.
const CustomCSSSettingKey = "ui.custom_css"
CustomCSSSettingKey is the settings row holding the operator stylesheet.
const CustomThemePrefix = "custom:"
CustomThemePrefix marks a custom theme's palette id apart from every built-in one. Stored WITHOUT it (custom_themes.theme_key), served WITH it.
const DefaultFolderViewSettingKey = "ui.default_folder_view"
DefaultFolderViewSettingKey is the operator's default folder view: what a folder opens as for a person who has neither changed that folder nor set a default of their own. Resolution, per field: the folder's own memory → the person's default → THIS → filex's built-in (list, name ↑).
⚠ Instance-wide, so `allowSettingWrite` refuses it to a tenant admin for the same reason it refuses `ui.default_theme`: it is not `branding.*`, so it has no per-tenant home, and a tenant admin writing it would set the view every other tenant's people open their folders in.
const DefaultThemeSettingKey = "ui.default_theme"
DefaultThemeSettingKey names the palette a person sees before they have chosen one — and the ONLY palette an anonymous visitor's share page can follow, since that page has no account to read a preference from.
const PrepareOptInHeader = "X-Filex-Accept-Prepare"
PrepareOptInHeader is how a programmatic client says it can use the "preparing" answer (202 + JSON + Retry-After) instead of a slow stream.
const ProbeTimeout = 10 * time.Second
ProbeTimeout bounds one "Test connection" attempt.
⚠ This is NOT a driver setting, and lowering the drivers' own retry budgets would be the wrong fix. The S3 driver retries six times with a backoff capped at ten seconds (see s3.newRetryer) precisely so a *sync run* rides out an object store's transient 503 instead of dying on one listing page — measured against Hetzner Object Storage, twelve times in a month. That budget is right for a background worker and wrong for a person waiting on a button: with an endpoint that refuses the connection outright, the six attempts ran to completion and this endpoint answered in 23.6s and 29.7s on two consecutive measured calls. Cypress spec 43-storages-crud spends its whole duration here.
So the retry budget stays and the PROBE gets a deadline. Every driver honours the context — the AWS SDK abandons its remaining attempts, and a hung SFTP or WebDAV dial is bounded by the same line — and a local-path probe, which answered in 6ms, never comes near it. Ten seconds is far more than a healthy remote store needs to list one prefix and short enough that the answer arrives while the operator is still looking at the form.
const ThemeExportVersion = 1
ThemeExportVersion is the shape marker carried by an exported file. A theme built for one customer is meant to be carried to another, which means the file outlives the version that wrote it; a marker is what lets a later filex refuse a file it cannot read instead of importing half of it.
Variables ¶
ErrStagingUnavailable means this instance cannot stage: no staging directory, no ops queue, or no storage resolver. Callers treat it as "write synchronously instead", never as a failure — an operator who has not configured staging must still be able to upload.
var ScopedRescanTimeout = 10 * time.Minute
ScopedRescanTimeout bounds a folder rescan (TriggerSync with ?path=), which answers inside the request.
Functions ¶
func ArchiveName ¶
ArchiveName picks the filename for an archive of `paths`.
The rule, in order:
- an explicit name from the caller, sanitized;
- one source → that source's own basename, so downloading the folder "Faturalar" gives you "Faturalar.zip";
- many sources sharing a parent → the parent's basename, because that is the folder the person was standing in;
- anything else → "filex-N-items".
A name that says something is not decoration. "download.zip" in a downloads folder next to four other "download (3).zip" is a file nobody can identify a week later.
func ImportTheme ¶
func ImportTheme(raw []byte) (*model.CustomTheme, error)
ImportTheme parses and validates a portable document.
func NewArchiveTicketStore ¶
func NewArchiveTicketStore() *archiveTicketStore
NewArchiveTicketStore builds the in-memory store.
func NewUploadTicketStore ¶
func NewUploadTicketStore() *uploadTicketStore
NewUploadTicketStore builds the in-memory ticket store.
func RequirePersonalCaller ¶
RequirePersonalCaller refuses a request that arrived on an app token. Mounted on the self-service credential routes in routes.go — one place, so a new credential surface joins the rule by being registered inside the group rather than by remembering to copy a check.
func ResolveThemeID ¶
func ResolveThemeID(requested string, custom []*model.CustomTheme) string
ResolveThemeID answers "which palette should this be?" for an id that may name a theme which no longer exists.
⚠⚠ THIS IS HOW DELETION IS SAFE. Nothing rewrites the stored choices of the people who were using a deleted theme; every read RESOLVES instead. A cleanup pass has to know every place an id can be written — localStorage in one browser, a per-user document in the database, the instance default in settings — and it is wrong the moment a fourth place appears. Resolution is right by construction: an id that is not in the list of themes that exist is the stock palette, everywhere.
func RetiredPageAPI ¶
RetiredPageAPI answers the retired /api/p/<token>/… addresses. The JSON surface moved to /api/public/s/<token>, so the redirect points there.
func RetiredPagePrefix ¶
RetiredPagePrefix answers the retired /p/<token> address.
An app plugin's public page is a share now, so its link is /s/<token>. The old prefix redirects there permanently: a link already sitting in somebody's inbox then lands on a page that can explain itself (and, for the pages that existed before the change, say honestly that they are gone) instead of on a 404 with no context.
⚠ 301 and not a rewrite. The address changed for good, and a redirect is what makes a browser, a mail client and a crawler all agree on that.
func SetAntivirusEnqueue ¶
SetAntivirusEnqueue installs the enqueue function used after writes. Call once at startup; passing nil disables scanning.
func SetAntivirusEnqueueAfterSave ¶
SetAntivirusEnqueueAfterSave installs the debounced enqueue used when the browser's text editor SAVES over a file that already exists.
Why a second sink rather than a flag on the first: the two have different contracts, and confusing them is how the gap this closes got created. The immediate one scans now, and every upload surface wants it. This one schedules one scan per file per editing window and drops the requests that arrive inside it — correct for Ctrl+S, wrong for anything that writes a whole file once.
func SetAppLabels ¶
SetAppLabels wires the registry's AppLabel.
func SetChangeEmitter ¶
func SetChangeEmitter(e ChangeEmitter)
SetChangeEmitter installs the realtime emitter. Call once at startup after constructing the hub, e.g. handlers.SetChangeEmitter(hub). Passing nil disables emission.
func SetLockReasons ¶
func SetLockReasons(f func(*model.AppPluginLock) (string, wire.Text))
SetLockReasons wires the registry's LockReason.
func SetNotifySink ¶
SetNotifySink installs the notify service used for file-event emission. Call once at startup; passing nil disables emission.
func ThemeAllTokens ¶
func ThemeAllTokens() []string
ThemeAllTokens exposes the whole allowlist, in declaration order.
func ThemeAuthoredColorTokens ¶
func ThemeAuthoredColorTokens() []string
ThemeAuthoredColorTokens exposes the authored colour list so a token added here and not to the editor is caught by a test rather than discovered.
func ValidateCustomTheme ¶
func ValidateCustomTheme(t *model.CustomTheme) error
ValidateCustomTheme checks a whole theme before it is stored or exported. It normalises in place (trimmed name, lowercased key) so callers store exactly what was checked.
Types ¶
type AI ¶
type AI struct {
// contains filtered or unexported fields
}
AI is the token-authenticated REST surface consumed by AI agents and the work.example.com FilexClient. It is a thin HTTP adapter over aiOps; the routes are mounted under /api/ai behind auth.APITokenMiddleware + RequireScope.
Contract (all JSON unless noted):
GET /api/ai/files?path=<adapter://dir> → {entries:[…]}
GET /api/ai/info?path=<adapter://file> → {entry:{…}}
GET /api/ai/download?path=<adapter://file> → raw bytes (stream)
POST /api/ai/upload → {entry:{…}} (see body below)
POST /api/ai/upload/ticket {"path":"…"} → {url,curl,…} (credential-free
upload URL; upload_ticket.go)
POST /api/ai/delete {"path":"…"} → {ok:true}
POST /api/ai/mkdir {"path":"…"} → {entry:{…}}
POST /api/ai/move {"src":"…","dst":"…"} → {entry:{…}}
GET /api/ai/search?path=<adapter://>&q=… → {entries:[…]}
POST /api/ai/zip {"sources":[…],"dest":"…"} → {entry:{…}} (server-side)
POST /api/ai/unzip {"src":"…","dest":"…"} → {ok,extracted}
func NewAI ¶
func NewAI(store db.Store, resolver func(int64) (storage.Driver, error), shareSvc *share.Service, publicURL string, convertURL func(context.Context) string) *AI
NewAI constructs the AI REST handler. shareSvc + publicURL power the share endpoints (pass nil shareSvc to disable sharing); convertURL is surfaced via /api/ai/root so agents learn conversion is an external (non-API) operation.
func (*AI) AttachACL ¶
AttachACL wires the RBAC resolver into the AI REST surface's ops core so every /api/ai file op is gated by the bound user's grants + role ceiling.
func (*AI) AttachBody ¶
AttachBody wires the byte-source resolver so /api/ai reads serve a file that is still being transferred out of staging.
func (*AI) AttachSearchIndex ¶
AttachSearchIndex wires the search index into the ops core, so a file an agent writes is searchable at once rather than when the next storage sync walks the folder.
func (*AI) AttachStaged ¶
func (h *AI) AttachStaged(s *StagedUpload)
AttachStaged routes writes above the chunk threshold through filex's staging area, exactly as the browser and the CLI do (nil = synchronous writes).
func (*AI) AttachTenants ¶
AttachTenants wires the shared origin resolver (internal/tenanturl).
func (*AI) AttachThumbs ¶
AttachThumbs wires the thumbnail pipeline so AI-surface writes dispatch generation like manager uploads (nil = thumbnails skipped).
func (*AI) AttachTickets ¶
func (h *AI) AttachTickets(s *uploadTicketStore)
AttachTickets wires the shared ticket store into the AI REST surface.
func (*AI) Delete ¶
func (h *AI) Delete(w http.ResponseWriter, r *http.Request)
Delete → POST /api/ai/delete {"path":"…"}.
func (*AI) Download ¶
func (h *AI) Download(w http.ResponseWriter, r *http.Request)
Download → GET /api/ai/download?path=<adapter://file> (streams bytes).
func (*AI) Info ¶
func (h *AI) Info(w http.ResponseWriter, r *http.Request)
Info → GET /api/ai/info?path=<adapter://file>
func (*AI) List ¶
func (h *AI) List(w http.ResponseWriter, r *http.Request)
List → GET /api/ai/files?path=<adapter://dir>
func (*AI) Mkdir ¶
func (h *AI) Mkdir(w http.ResponseWriter, r *http.Request)
Mkdir → POST /api/ai/mkdir {"path":"…"}.
func (*AI) Move ¶
func (h *AI) Move(w http.ResponseWriter, r *http.Request)
Move → POST /api/ai/move {"src":"…","dst":"…"}.
func (*AI) Root ¶
func (h *AI) Root(w http.ResponseWriter, r *http.Request)
Root → GET /api/ai/root. Reports the caller's confinement root + reachable storages so a confined agent knows how to address paths instead of guessing.
func (*AI) Search ¶
func (h *AI) Search(w http.ResponseWriter, r *http.Request)
Search → GET /api/ai/search?path=<adapter://>&q=…
Goes through aiNameSearch rather than aiOps.Search so this speaks the same query language as the MCP file_search tool and the web endpoints: separator-blind text and `tag:` filters. The response shape is unchanged — entries, no snippets; content search stays on the MCP tool.
func (*AI) Share ¶
func (h *AI) Share(w http.ResponseWriter, r *http.Request)
Share → POST /api/ai/share. Mints a public /s/<token> link for a file/folder (folders download as a ZIP). Returns the URL + a one-time PIN if requested.
func (*AI) TagsGet ¶
func (h *AI) TagsGet(w http.ResponseWriter, r *http.Request)
TagsGet → GET /api/ai/tags?path=
func (*AI) TagsSet ¶
func (h *AI) TagsSet(w http.ResponseWriter, r *http.Request)
TagsSet → POST /api/ai/tags {path, tags:[{name, kind}]}. `tags` is the whole set the user can see afterwards; [] clears it.
func (*AI) Unshare ¶
func (h *AI) Unshare(w http.ResponseWriter, r *http.Request)
Unshare → POST /api/ai/unshare {"token":"…"}. Revokes a share by token.
func (*AI) Unzip ¶
func (h *AI) Unzip(w http.ResponseWriter, r *http.Request)
Unzip → POST /api/ai/unzip {"src":"…","dest":"…"}. Extracts a stored zip into the dest dir ON THE SERVER (zip-slip protected, confined to the token root).
func (*AI) Upload ¶
func (h *AI) Upload(w http.ResponseWriter, r *http.Request)
Upload → POST /api/ai/upload. Accepts JSON (base64/text) or multipart.
A multipart body is STREAMED: the part is handed to WriteStream as a reader, so a large capture never has to exist in memory, and above the chunk threshold it goes through filex's staging area like every other client.
func (*AI) UploadTicket ¶
func (h *AI) UploadTicket(w http.ResponseWriter, r *http.Request)
UploadTicket → POST /api/ai/upload/ticket (scope: write).
type AIAdmin ¶
type AIAdmin struct {
// contains filtered or unexported fields
}
AIAdmin exposes the full admin panel over the token-authenticated AI surface — both as REST endpoints under /api/ai/admin/* and as admin_* MCP tools — so a single bearer token (scope `admin`) lets an AI agent drive users, storages, settings, replica, queue, notifications, audit … exactly like the native /admin SPA does.
Design: AIAdmin owns one instance of every existing admin handler (Dashboard, Users, Storages, …). The REST routes mount those handler methods directly behind an admin-elevating middleware (chi fills the URL params, the body/query pass straight through). The MCP tools invoke the very same handler methods IN-PROCESS via `invoke` — a synthetic request + buffered recorder, no network round-trip, no logic duplicated.
Authorization: the admin surface is gated by RequireScope("admin") at the route / getServer level. Once past that gate the bound user is elevated to an admin principal (elevatedPrincipal) so the underlying handler logic runs with an admin-authorized context — we deliberately call the handler logic directly rather than the RequireAdmin-wrapped HTTP route.
func NewAIAdmin ¶
func NewAIAdmin(d AIAdminDeps) *AIAdmin
NewAIAdmin constructs the admin AI surface from shared deps. Each wrapped handler is the same type the native /admin routes use, so behaviour is identical — only the auth front-door differs.
type AIAdminDeps ¶
type AIAdminDeps struct {
Store db.Store
Caps *capability.Service
Worker *syncpkg.Worker
Queue queue.Driver
Notify notify.Service
Trash *trash.Service
Ops *ops.Service
Index *search.Index
ReplicaService *replica.Service
ReplicaCron *replica.CronScheduler
ReplicaReloader *replica.RulesReloader
// External + EnvManagedExternal mirror what the native /admin routes get,
// so the MCP admin surface reports and applies external-service changes the
// same way the UI does.
External *external.Resolver
EnvManagedExternal map[string]bool
// DemoMode marks a public playground, so the wrapped handlers redact the
// same things they redact on the native /admin routes. The token surface
// is not a way around a demo's rules.
DemoMode bool
// PublicURL + PublicURLSet feed the external-service advisories: the MCP
// admin surface must report the same three-address picture the UI does,
// or an agent reading it back gets the narrower answer the UI stopped
// giving (issue #17).
PublicURL string
PublicURLSet bool
// AuthLive is the running set of sign-in providers, so the admin MCP
// tools change sign-in through the same handler, with the same guards, as
// the page (handlers/auth_providers.go).
AuthLive *authsetup.Live
}
AIAdminDeps carries the shared services the wrapped admin handlers need. Mirrors the subset of api.Deps relevant to the admin surface (declared here to avoid an api→handlers→api import cycle).
type AIMCP ¶
type AIMCP struct {
// contains filtered or unexported fields
}
AIMCP exposes filex as a Model Context Protocol server over streamable HTTP (JSON-RPC). The same aiOps core that backs the REST handler powers each MCP tool, so AI agents can drive filex directly while work.example.com's FilexClient uses the REST surface.
Transport: streamable HTTP in stateless + JSON-response mode (one JSON-RPC request → one JSON response), which is what laravel/mcp's HTTP client speaks. Mounted at POST/GET /api/ai/mcp behind auth.APITokenMiddleware + RequireScope("mcp").
Auth model: the route's middleware has already validated the AI token and stashed the principal on the request context by the time getServer runs. getServer reads that principal and builds a per-request server whose tools close over an aiOps bound to the store + resolver. If the principal is absent (should never happen behind the middleware) getServer returns nil and the SDK serves 400.
func NewAIMCP ¶
func NewAIMCP(store db.Store, resolver func(int64) (storage.Driver, error), admin *AIAdmin, shareSvc *share.Service, publicURL string, convertURL func(context.Context) string) *AIMCP
NewAIMCP builds the MCP HTTP handler. `admin` powers the admin_* tools, which are only registered for tokens carrying the `admin` scope; pass nil to disable the admin tool surface entirely. shareSvc + publicURL power the file_share / file_unshare tools; convertURL is surfaced via file_root.
func (*AIMCP) AttachACL ¶
AttachACL wires the RBAC resolver so every per-request MCP tool op is gated by the bound user's grants + role ceiling (same enforcement as the REST AI).
func (*AIMCP) AttachBody ¶
AttachBody wires the byte-source resolver so MCP reads serve a file that is still being transferred out of staging.
func (*AIMCP) AttachSearchIndex ¶
AttachSearchIndex wires the search index; every aiOps this handler builds per tool call gets it.
func (*AIMCP) AttachStaged ¶
func (h *AIMCP) AttachStaged(s *StagedUpload)
AttachStaged routes MCP tool writes above the chunk threshold through the staging area, so an agent gets the same acknowledge-then-transfer behaviour as the browser and the CLI (nil = synchronous writes).
func (*AIMCP) AttachTenants ¶
AttachTenants wires the shared origin resolver (internal/tenanturl). The per-call ops core below is rebuilt for every request, so the resolver is held here and stamped onto each one.
func (*AIMCP) AttachThumbs ¶
AttachThumbs wires the thumbnail pipeline so MCP tool writes dispatch generation like manager uploads (nil = thumbnails skipped).
func (*AIMCP) AttachTickets ¶
func (h *AIMCP) AttachTickets(s *uploadTicketStore)
AttachTickets wires the shared upload-ticket store so the MCP surface can mint credential-free upload URLs for large local files.
type AITokens ¶
type AITokens struct {
// contains filtered or unexported fields
}
AITokens is the admin handler for issuing / listing / revoking API tokens used by AI agents, the MCP server, and the work.example.com FilexClient.
Routes (admin-only):
GET /api/admin/ai-tokens → {tokens:[…]} (no secret)
POST /api/admin/ai-tokens → {token:"<plain>", row:{…}} (plaintext ONCE)
DELETE /api/admin/ai-tokens/{id} → {ok:true}
func NewAITokens ¶
func NewAITokens(store db.Store, pauth *protocolauth.Resolver) *AITokens
NewAITokens constructs the admin token handler.
func (*AITokens) Create ¶
func (h *AITokens) Create(w http.ResponseWriter, r *http.Request)
Create issues a new token. The plaintext value is returned ONCE — only its sha256 hash is persisted.
func (*AITokens) Delete ¶
func (h *AITokens) Delete(w http.ResponseWriter, r *http.Request)
Delete revokes a token by id.
type ActivityRow ¶
type ActivityRow struct {
*model.AuditEntry
UserEmail string `json:"user_email,omitempty"`
// TargetName: which thing, in words (handlers/audit_targets.go).
TargetName string `json:"target_name,omitempty"`
// UserName is who acted, as every screen names a person (PersonLabel).
UserName string `json:"user_name,omitempty"`
}
ActivityRow is one Recent activity line: the audit row, flat, plus the e-mail of the account that acted.
⚠ The page prints the actor under every action, and the payload never had one — the audit LIST joins users.email (db.AuditEntryWithUser), the dashboard read the bare rows — so each line said "— · Profile".
type AppPlugins ¶
type AppPlugins struct {
Registry *wasmplugin.Registry
Store db.Store
ACL *acl.Resolver
Ops *ops.Service
StorageResolver func(int64) (storage.Driver, error)
Index *search.Index
Thumbs *thumb.Pipeline
}
AppPlugins is the user-facing handler set + the OutputSink the registry commits job outputs through.
func NewAppPlugins ¶
func NewAppPlugins(reg *wasmplugin.Registry, store db.Store, aclr *acl.Resolver, opsSvc *ops.Service, resolver func(int64) (storage.Driver, error), index *search.Index, thumbs *thumb.Pipeline) *AppPlugins
NewAppPlugins constructs the handler and wires it as the registry's sink.
func (*AppPlugins) Actions ¶
func (h *AppPlugins) Actions(w http.ResponseWriter, r *http.Request)
Actions answers the menu list for this caller.
func (*AppPlugins) Catalogue ¶
func (h *AppPlugins) Catalogue(ctx context.Context, storageID int64, rel string) (*model.Node, error)
Catalogue records a file the storage has and the catalogue has not seen yet (wasmplugin.Cataloguer): share_create's answer when a job's own input has no node for the link to point at.
func (*AppPlugins) CommitSibling ¶
func (h *AppPlugins) CommitSibling(ctx context.Context, storageID int64, dir, name string, r io.Reader, size int64, actor *int64) (string, error)
CommitSibling writes a new file into dir, picking a free name, and runs the shared post-write bookkeeping.
func (*AppPlugins) CommitVersion ¶
func (h *AppPlugins) CommitVersion(ctx context.Context, storageID int64, rel string, r io.Reader, size int64, actor *int64) error
CommitVersion overwrites rel in place; the previous bytes become a version when versioning is on (writehook.BeforeOverwrite).
func (*AppPlugins) Run ¶
func (h *AppPlugins) Run(w http.ResponseWriter, r *http.Request)
Run submits an action. An action that declares a view answers the view's opening surface instead of queueing; the surface's submit comes back through ViewEvent, which queues.
func (*AppPlugins) Users ¶
func (h *AppPlugins) Users(w http.ResponseWriter, r *http.Request)
Users is the people-picker's directory search: GET /api/files/plugins/users?plugin=<name>&q=. It answers only for a running plugin that holds users:lookup (403 otherwise), through the tenant-scoped store — a tenant's picker never lists another tenant's people.
func (*AppPlugins) View ¶
func (h *AppPlugins) View(w http.ResponseWriter, r *http.Request)
View answers a view's opening surface (event "open").
func (*AppPlugins) ViewEvent ¶
func (h *AppPlugins) ViewEvent(w http.ResponseWriter, r *http.Request)
ViewEvent answers a change/submit/action event. A surface carrying a job request is queued with the same checks as Run.
type AppPluginsAdmin ¶
type AppPluginsAdmin struct {
Registry *wasmplugin.Registry
// DisabledReason is shown when Registry is nil (config flag vs. arch).
DisabledReason string
// Audit records an administrator's forced unlock; nil = not recorded.
Audit func(ctx context.Context, userID *int64, action string, storageID int64, rel, ip string) error
}
AppPluginsAdmin is the handler set. Registry may be nil when the runtime is disabled by configuration; every route then answers 503.
func NewAppPluginsAdmin ¶
func NewAppPluginsAdmin(reg *wasmplugin.Registry, disabledReason string) *AppPluginsAdmin
NewAppPluginsAdmin constructs the handler.
func (*AppPluginsAdmin) Delete ¶
func (h *AppPluginsAdmin) Delete(w http.ResponseWriter, r *http.Request)
func (*AppPluginsAdmin) Get ¶
func (h *AppPluginsAdmin) Get(w http.ResponseWriter, r *http.Request)
func (*AppPluginsAdmin) GetOverrides ¶
func (h *AppPluginsAdmin) GetOverrides(w http.ResponseWriter, r *http.Request)
func (*AppPluginsAdmin) GetSettings ¶
func (h *AppPluginsAdmin) GetSettings(w http.ResponseWriter, r *http.Request)
func (*AppPluginsAdmin) ImportSigningCA ¶
func (h *AppPluginsAdmin) ImportSigningCA(w http.ResponseWriter, r *http.Request)
ImportSigningCA takes over signing with an authority the operator already has: POST /api/admin/app-plugins/signing/ca/import, multipart with `cert` and `key` (PEM), or JSON {cert_pem, key_pem}.
The current authority is retired, never deleted. An encrypted key is refused with the command that decrypts it rather than a shrug: the encrypted container formats in the wild are more varied than any one library reads, and half-working import is worse than a clear instruction.
func (*AppPluginsAdmin) Install ¶
func (h *AppPluginsAdmin) Install(w http.ResponseWriter, r *http.Request)
func (*AppPluginsAdmin) List ¶
func (h *AppPluginsAdmin) List(w http.ResponseWriter, r *http.Request)
func (*AppPluginsAdmin) Locks ¶
func (h *AppPluginsAdmin) Locks(w http.ResponseWriter, r *http.Request)
Locks lists the live app-plugin file locks: GET /api/admin/app-plugins/locks[?storage_id=].
func (*AppPluginsAdmin) Logs ¶
func (h *AppPluginsAdmin) Logs(w http.ResponseWriter, r *http.Request)
func (*AppPluginsAdmin) Patch ¶
func (h *AppPluginsAdmin) Patch(w http.ResponseWriter, r *http.Request)
func (*AppPluginsAdmin) PutOverrides ¶
func (h *AppPluginsAdmin) PutOverrides(w http.ResponseWriter, r *http.Request)
func (*AppPluginsAdmin) PutSettings ¶
func (h *AppPluginsAdmin) PutSettings(w http.ResponseWriter, r *http.Request)
func (*AppPluginsAdmin) RotateSigningCA ¶
func (h *AppPluginsAdmin) RotateSigningCA(w http.ResponseWriter, r *http.Request)
RotateSigningCA retires the current CA; the next signature mints a new one. Old signatures stay verifiable with the old certificate.
func (*AppPluginsAdmin) SigningCA ¶
func (h *AppPluginsAdmin) SigningCA(w http.ResponseWriter, r *http.Request)
SigningCA answers the tenant CA certificate (PEM) that readers import to trust the signatures app plugins make here. The CA is created on first use. Supertenant only (the admin surface), per the caller's tenant.
func (*AppPluginsAdmin) SigningCAs ¶
func (h *AppPluginsAdmin) SigningCAs(w http.ResponseWriter, r *http.Request)
SigningCAs lists the tenant's signing authorities, live and retired: GET /api/admin/app-plugins/signing/cas.
Retired ones are listed on purpose. They are what a signature made before the last rotation verifies against, so an operator has to be able to see that they are still there — and that nothing is quietly deleting them.
func (*AppPluginsAdmin) Unlock ¶
func (h *AppPluginsAdmin) Unlock(w http.ResponseWriter, r *http.Request)
Unlock lifts one lock by force: DELETE /api/admin/app-plugins/locks with {storage_id, path}. The audit row names who did it.
func (*AppPluginsAdmin) Upgrade ¶
func (h *AppPluginsAdmin) Upgrade(w http.ResponseWriter, r *http.Request)
type Appearance ¶
type Appearance struct{ Source *AppearanceSource }
Appearance serves the public themes endpoint.
func NewAppearance ¶
func NewAppearance(src *AppearanceSource) *Appearance
NewAppearance constructs the public handler.
func (*Appearance) Get ¶
func (h *Appearance) Get(w http.ResponseWriter, r *http.Request)
Get returns the instance's themes and default. Public (no auth): the login page and every share page need it before a session exists.
⚠ It carries NO raw CSS. That used to ride /api/branding, which is how the login page ended up wearing an operator stylesheet it must not wear — see custom_css.go.
type AppearancePayload ¶
type AppearancePayload struct {
Themes []ThemeWire `json:"themes"`
DefaultThemeID string `json:"default_theme_id"`
}
AppearancePayload is GET /api/appearance.
type AppearanceSource ¶
AppearanceSource resolves and caches the instance's themes.
ONE cache entry, not one per host: unlike branding there is no tenant overlay, because the table has no tenant column and the admin routes are supertenant-only. A nil receiver is safe and yields the stock answer.
func NewAppearanceSource ¶
func NewAppearanceSource(store db.Store) *AppearanceSource
NewAppearanceSource constructs the shared source.
func (*AppearanceSource) DefaultTokens ¶
DefaultTokens returns the token map of the instance default theme for the requested variant, or nil when the default is a built-in palette.
⚠ This is what the PUBLIC pages use, and it reads the instance default and nothing else. A share page, a PIN gate and a signing page are looked at by people with no account here; resolving anybody's personal palette on them would be both impossible and a disclosure — the visitor would learn which palette the sender had picked.
func (*AppearanceSource) Invalidate ¶
func (a *AppearanceSource) Invalidate()
Invalidate drops the cache — called after any theme or default write.
func (*AppearanceSource) Payload ¶
func (a *AppearanceSource) Payload(ctx context.Context) AppearancePayload
Payload returns the effective appearance for this instance.
type Archive ¶
type Archive struct {
Store db.Store
StorageResolver func(int64) (storage.Driver, error)
ACL *acl.Resolver
// Body resolves where a member's bytes are: the driver, or filex's
// staging area while a staged upload is still transferring. Nil-safe.
Body *filebody.Resolver
// Index and Thumbs feed the shared catalogue bookkeeper below. Both
// optional.
Index *search.Index
Thumbs *thumb.Pipeline
// Tickets holds the minted "download this selection as one archive"
// authorizations (archive_download.go). Nil disables that endpoint pair
// rather than crashing it.
Tickets *archiveTicketStore
// Engine supplies optional 7-Zip/RAR processing and the live archive
// policy. Nil keeps the historical built-in ZIP-only behaviour.
Engine *archivecli.Service
// Ops moves expensive archive creation out of the request. Nil retains the
// synchronous path for lightweight embedders and focused handler tests.
Ops *ops.Service
}
Archive handles zip-listing, zip-extract, and zip-create operations.
All zip ops materialize the source archive to a tmp file (since archive/zip needs an io.ReaderAt + Seeker), then stream extracts back to storage.
func NewArchive ¶
NewArchive constructs an Archive handler.
func (*Archive) Add ¶
func (a *Archive) Add(w http.ResponseWriter, r *http.Request)
Add packs members into a (new or existing) zip archive on the same storage.
If the destination zip exists, we download it, append the new entries, then re-upload. Names are zip-slip protected on the read side.
func (*Archive) AttachACL ¶
AttachACL wires the RBAC resolver: list needs ≥viewer on the archive, extract/add need ≥editor on the write target (+ ≥viewer on sources read).
func (*Archive) AttachArchiveEngine ¶ added in v0.44.2
func (a *Archive) AttachArchiveEngine(engine *archivecli.Service)
AttachArchiveEngine wires optional external archive providers.
func (*Archive) AttachBody ¶
AttachBody wires the byte-source resolver so a file that is still being transferred can be zipped/extracted like any other.
func (*Archive) AttachDownloadTickets ¶
func (a *Archive) AttachDownloadTickets(s *archiveTicketStore)
AttachDownloadTickets wires the ticket store. Without it the mint endpoint answers 503 rather than panicking — a handler assembled without its store is a wiring bug, and a nil map write is a worse way to find out.
func (*Archive) AttachOps ¶ added in v0.44.2
AttachOps wires the shared long-running file-operation queue.
func (*Archive) AttachSearchIndex ¶
AttachSearchIndex / AttachThumbs wire the two optional halves of the bookkeeper.
func (*Archive) AttachThumbs ¶
AttachThumbs wires thumbnail generation for extracted members.
func (*Archive) Create ¶ added in v0.44.2
func (a *Archive) Create(w http.ResponseWriter, r *http.Request)
Create writes a new archive from files and folders already in filex. It refuses an occupied destination rather than silently replacing user data; the historical /archive/add endpoint retains ZIP update semantics, which solid 7z and RAR archives cannot share safely.
func (*Archive) DownloadArchive ¶
func (a *Archive) DownloadArchive(w http.ResponseWriter, r *http.Request)
DownloadArchive streams a minted archive. Public route (`GET /z/{ticket}`) — the ticket is the authority, see the package comment.
func (*Archive) DownloadTicket ¶
func (a *Archive) DownloadTicket(w http.ResponseWriter, r *http.Request)
DownloadTicket resolves a selection into an archive and returns the URL that streams it.
Refusals, and why each is its own answer rather than a shared 400:
403 one of the named paths is not readable by this caller 404 the storage is not this tenant's 409 the selection resolved to no readable file at all 413 more members than archiveMaxMembers
The 409 matters: a selection of three empty folders is not an error the user made, but an empty ZIP arriving as a "successful" download is the kind of thing people file bugs about six months later. Say so at the click.
type ArchiveAdmin ¶ added in v0.44.2
type ArchiveAdmin struct {
Store db.Store
Service *archivecli.Service
}
ArchiveAdmin exposes validated archive policy and read-only provider status. Executable paths are deliberately absent: they are process configuration, not data an HTTP administrator may turn into a command.
func NewArchiveAdmin ¶ added in v0.44.2
func NewArchiveAdmin(store db.Store, service *archivecli.Service) *ArchiveAdmin
func (*ArchiveAdmin) Get ¶ added in v0.44.2
func (h *ArchiveAdmin) Get(w http.ResponseWriter, r *http.Request)
func (*ArchiveAdmin) Patch ¶ added in v0.44.2
func (h *ArchiveAdmin) Patch(w http.ResponseWriter, r *http.Request)
func (*ArchiveAdmin) Test ¶ added in v0.44.2
func (h *ArchiveAdmin) Test(w http.ResponseWriter, r *http.Request)
Test performs an actual encrypted create/list/extract round trip. A version command alone proves that a file exists, not that its codecs and crypto work.
type Audit ¶
type Audit struct {
Store db.Store
// DemoMode marks a public playground, where this page is readable by
// whoever read the credentials off the landing page — and the addresses
// in it belong to the other visitors. See maskAuditEntries.
DemoMode bool
}
Audit handles /api/admin/audit.
type Auth ¶
type Auth struct {
Store db.Store
LocalAuth auth.LoginDriver
OIDCAuth auth.OIDCDriver
PublicURL string
MultiTenant bool
// CookieDomain (FILEX_COOKIE_DOMAIN) is the GLOBAL session-cookie Domain
// attribute. In multi-tenant mode it is only the last-resort fallback —
// see cookieDomain() for the per-provider resolution. Applied on clear
// too — a logout without the matching Domain leaves the old cookie
// behind.
CookieDomain string
// Tenants resolves the per-request origin for absolute URLs. See
// internal/tenanturl — it is the one implementation of this rule, and
// redirectBase below is now just its caller.
Tenants tenanturl.Resolver
// OIDCLocalLogout (FILEX_OIDC_LOGOUT=local) keeps sign-out inside filex:
// the IdP's session is left open, as it was before RP-initiated logout.
OIDCLocalLogout bool
}
Auth handles login/logout/oidc routes.
func NewAuth ¶
func NewAuth(store db.Store, local auth.LoginDriver, oidc auth.OIDCDriver, publicURL string, multiTenant bool, cookieDomain string) *Auth
NewAuth constructs an Auth handler.
func (*Auth) Login ¶
func (h *Auth) Login(w http.ResponseWriter, r *http.Request)
Login authenticates email + password and sets the session cookie.
When the resolved user has TOTP enabled, a valid second-factor code is mandatory: password success alone does NOT grant a session. The session is minted by LocalAuth.Login (which owns the password check), so on a missing/invalid TOTP code we revoke that just-created session before returning — no usable cookie is ever handed out and no orphan session lingers.
func (*Auth) Logout ¶
func (h *Auth) Logout(w http.ResponseWriter, r *http.Request)
Logout clears the cookie + revokes the server-side session — and, for an OIDC session, answers with the IdP's end-session URL as `logout_url` so the web app can end the IdP's session too (see idpLogoutURL).
Optional body: {"return_to": "/admin/login" | "/drive/login"} — the sign-in page of the front door the person was using, where the IdP sends the browser back once it is done.
func (*Auth) OIDCCallback ¶
func (h *Auth) OIDCCallback(w http.ResponseWriter, r *http.Request)
OIDCCallback completes the OIDC flow.
type AuthProviders ¶
type AuthProviders struct {
Store db.Store
// Live is the running set of providers; nil on a harness that has none,
// where nothing can be changed.
Live *authsetup.Live
// DemoMode marks a public playground: nothing about sign-in changes there.
DemoMode bool
}
AuthProviders handles /api/admin/auth-providers.
func NewAuthProviders ¶
func NewAuthProviders(store db.Store, live *authsetup.Live) *AuthProviders
NewAuthProviders constructs the handler.
func (*AuthProviders) List ¶
func (h *AuthProviders) List(w http.ResponseWriter, r *http.Request)
List returns every sign-in provider and how it stands.
func (*AuthProviders) Test ¶
func (h *AuthProviders) Test(w http.ResponseWriter, r *http.Request)
Test checks a provider's configuration for real, step by step.
⚠⚠ It used to answer `{"ok": true}` for anything (release-candidate sweep, 2026-09-21). A test button that always passes is worse than none.
A page provider is tested on the form as it stands (unsaved edits laid over what is stored; a secret left blank is the stored one, opened for the test and never returned). A provider the environment defines is tested on the environment's configuration — the form is read-only there, so a draft is ignored.
func (*AuthProviders) Update ¶
func (h *AuthProviders) Update(w http.ResponseWriter, r *http.Request)
Update saves one provider, applies it at once, and audits it.
type AuthSelf ¶
AuthSelf wraps the self-service profile/password/TOTP routes.
func (*AuthSelf) ChangePassword ¶
func (h *AuthSelf) ChangePassword(w http.ResponseWriter, r *http.Request)
ChangePassword verifies the old password then writes a new bcrypt hash and revokes other sessions to force re-login.
func (*AuthSelf) Me ¶
func (h *AuthSelf) Me(w http.ResponseWriter, r *http.Request)
Me returns the authenticated user.
Wire shape mirrors LoginResponse: `{user: {…}}`. The frontend auth store reads `me.user`; without the wrapper user.value stayed undefined and TopNav fell back to "? —" forever.
func (*AuthSelf) TotpDisable ¶
func (h *AuthSelf) TotpDisable(w http.ResponseWriter, r *http.Request)
TotpDisable clears the user's TOTP secret if both password + code match.
func (*AuthSelf) TotpEnroll ¶
func (h *AuthSelf) TotpEnroll(w http.ResponseWriter, r *http.Request)
TotpEnroll generates a new pending secret + QR SVG + recovery codes.
The user must call /totp/verify with a valid code from their authenticator app to actually activate it; this endpoint does NOT enable TOTP yet.
func (*AuthSelf) TotpVerify ¶
func (h *AuthSelf) TotpVerify(w http.ResponseWriter, r *http.Request)
TotpVerify activates TOTP if the given code matches the pending secret.
func (*AuthSelf) UpdateProfile ¶
func (h *AuthSelf) UpdateProfile(w http.ResponseWriter, r *http.Request)
UpdateProfile patches the current user's profile fields.
type Branding ¶
type Branding struct {
Source *BrandingSource
}
Branding serves the public branding endpoint.
func NewBranding ¶
func NewBranding(src *BrandingSource) *Branding
NewBranding constructs the public branding handler.
type BrandingConfig ¶
type BrandingConfig struct {
Name string `json:"name"`
LogoURL string `json:"logo_url"`
Accent string `json:"accent"`
HidePoweredBy bool `json:"hide_powered_by"`
// SSOLabel is the operator's own text for the sign-in page's SSO button
// (issue #28). Empty means the product's translated default.
SSOLabel string `json:"sso_label"`
}
BrandingConfig is the effective branding payload — the JSON shape of GET /api/branding and the input to the public-page chrome builder.
type BrandingSource ¶
type BrandingSource struct {
Store db.Store
MultiTenant bool
// Appearance supplies the instance THEME for the server-rendered public
// pages (tema:v1). It hangs off the branding source rather than being
// threaded through every public handler because those handlers already
// hold exactly one appearance-ish dependency, and adding a second
// constructor argument to eight call sites to express "and also the
// theme" would be ceremony, not clarity. Nil-safe.
Appearance *AppearanceSource
// contains filtered or unexported fields
}
BrandingSource resolves the effective branding for a request host from the settings store, with a small TTL cache (public share pages are hot paths). A nil *BrandingSource is safe everywhere and yields the zero config — unwired handlers keep their exact pre-branding output.
func NewBrandingSource ¶
func NewBrandingSource(store db.Store, multiTenant bool) *BrandingSource
NewBrandingSource constructs a BrandingSource.
func (*BrandingSource) AttachAppearance ¶
func (b *BrandingSource) AttachAppearance(a *AppearanceSource)
AttachAppearance wires the theme source used by the public-page chrome.
func (*BrandingSource) For ¶
func (b *BrandingSource) For(ctx context.Context, host string) BrandingConfig
For resolves the effective branding for a host: global branding.* keys, overlaid (in multi-tenant mode) by the host's tenant.<id>.branding.* keys.
func (*BrandingSource) ForRequest ¶
func (b *BrandingSource) ForRequest(r *http.Request) BrandingConfig
ForRequest resolves branding for an HTTP request (host-scoped in multi-tenant mode). Nil-receiver safe.
func (*BrandingSource) Invalidate ¶
func (b *BrandingSource) Invalidate()
Invalidate drops the cache — called after any branding settings write.
type Capabilities ¶
type Capabilities struct {
Service *capability.Service
// Store + MultiTenant power the per-tenant branding block: in multi-tenant
// mode the (pre-auth, host-resolved) capabilities answer carries only THIS
// host's tenant identity — never the existence of other tenants
// (docs/MULTI-TENANCY.md §12 + isolation checklist).
Store db.Store
MultiTenant bool
// Mail, when wired, answers whether outgoing mail can be sent right now
// (configured and verified). Published as `mail.ready`, a boolean with no
// host in it. Nil = the field is absent and clients keep offering mail.
Mail interface {
Ready(ctx context.Context) bool
}
/* kimlik:e3 cloud */
// CloudEnabled mirrors FILEX_CLOUD (set by BuildRouter only when the flag
// is on). While false — the default — the capabilities payload carries NO
// cloud field at all, keeping the flag-off wire format byte-identical.
CloudEnabled bool
/* wiring:e2 */
// E2EEscrow is the installation escrow PUBLIC key, or nil when escrow is
// off. It is published deliberately: the browser needs it to wrap a new
// encrypted folder's master key to the operator, and a user about to
// create such a folder is entitled to know, before they create it, that
// their operator holds a second key to it.
E2EEscrow *e2e.EscrowKey
// Tenants + PublicURLSet feed `public_url`: the address this deployment is
// reached at, for the connection guides (see Get). Zero values publish
// nothing, which is what every test that builds this handler by hand gets.
Tenants tenanturl.Resolver
PublicURLSet bool
// Archive publishes the non-sensitive creation policy used by the explorer
// so the create dialog honours the operator's configured default.
Archive *archivecli.Service
}
Capabilities exposes /api/capabilities.
func NewCapabilities ¶
func NewCapabilities(svc *capability.Service, store db.Store, multiTenant bool) *Capabilities
NewCapabilities constructs a Capabilities handler.
func (*Capabilities) Get ¶
func (h *Capabilities) Get(w http.ResponseWriter, r *http.Request)
Get returns the runtime feature snapshot.
We emit BOTH the rich nested shape (filex-core admin SPA) AND a flat alias set (legacy embed.js + filex-core SFC fallback expected: `ffmpeg / ghostscript / libreoffice / max_chunk_mb / upload_limit_mb / onlyoffice_url / drawio_url`). Cheap to ship both — keeps the SFC happy without breaking the existing admin UI bindings.
type CapabilitiesShort ¶
type CapabilitiesShort struct {
FFmpeg bool `json:"ffmpeg"`
Ghostscript bool `json:"ghostscript"`
LibreOffice bool `json:"libreoffice"`
OnlyOfficeReachable bool `json:"onlyoffice_reachable"`
}
CapabilitiesShort is the trimmed capability summary embedded in the dashboard payload (full capabilities are at /api/capabilities).
type ChangeEmitter ¶
type ChangeEmitter interface {
EmitChange(storageID int64, dir string, ev realtime.ChangeEvent)
}
ChangeEmitter is the minimal surface the file-mutation handlers need to publish folder-change events to realtime (WebSocket) subscribers. The realtime hub (*realtime.Hub) satisfies it. Keeping it an interface means the mutation handlers don't hard-depend on a concrete hub — a nil emitter simply disables live updates (the default in tests and when realtime is unwired).
type Cloud ¶
type Cloud struct {
Svc *cloud.Service
Stripe *cloud.StripeClient
// MultiTenant is echoed in /status — a real cloud launch requires
// FILEX_MULTI_TENANT=1 (a signed-up tenant is a provider row).
MultiTenant bool
}
Cloud handles /api/cloud.
func (*Cloud) Checkout ¶
func (h *Cloud) Checkout(w http.ResponseWriter, r *http.Request)
Checkout drafts a Stripe checkout session. Without STRIPE_SECRET → 503.
func (*Cloud) Plans ¶
func (h *Cloud) Plans(w http.ResponseWriter, r *http.Request)
Plans returns the configured catalog.
func (*Cloud) Signup ¶
func (h *Cloud) Signup(w http.ResponseWriter, r *http.Request)
Signup provisions a disabled tenant + mints the verification token.
func (*Cloud) Status ¶
func (h *Cloud) Status(w http.ResponseWriter, r *http.Request)
Status is the simple state snapshot (the brief's "durum endpoint'i").
type Comments ¶
Comments wraps the node-comment HTTP routes.
func NewComments ¶
NewComments constructs the handler (service built in-place — no other subsystem needs it, the trash purge hook talks to the store directly).
func (*Comments) AttachACL ¶
AttachACL wires the RBAC resolver (nil = ACL unwired → allow, matching every other file handler).
func (*Comments) Create ¶
func (h *Comments) Create(w http.ResponseWriter, r *http.Request)
Create adds a comment to a node the caller can see and emits the comment.added event (webhook v2 targets filter on the name).
POST /api/files/comments {node_id, body} → {comment: {…}}
type CustomCSS ¶
CustomCSS serves the operator stylesheet to signed-in browsers.
func NewCustomCSS ¶
NewCustomCSS constructs the handler.
func (*CustomCSS) Get ¶
func (h *CustomCSS) Get(w http.ResponseWriter, r *http.Request)
Get returns the effective stylesheet for this installation.
⚠ `Cache-Control: no-store`. This is per-installation rather than per-person, so a cache would be correct — but it is also the thing an operator turns off in a hurry, and a sheet still sitting in a shared cache after the switch was flipped is the exact failure the switch exists to end.
type Dashboard ¶
type Dashboard struct {
Store db.Store
Caps *capability.Service
// Queue answers "Queue depth": the jobs waiting or running in the op queue
// (the same counters the Queue page's Pending and Running cards show).
// Nil reads as an empty queue.
Queue QueueStats
// DemoMode marks a public playground. The `recent_activity` block below
// is the same audit rows the Audit page serves, client addresses and all,
// on the FIRST page of the admin panel. See maskAuditRecent.
DemoMode bool
}
Dashboard handles /api/admin/dashboard.
func NewDashboard ¶
NewDashboard constructs the handler. q is the op queue ("Queue depth"); nil reads as an empty queue.
type DesktopAuth ¶
type DesktopAuth struct {
// contains filtered or unexported fields
}
DesktopAuth authorizes the desktop app through the BROWSER instead of a native form.
Why not a native login form: it can only ever do username+password. An install that authenticates with Keycloak/OIDC — passkeys, MFA, corporate SSO — cannot be signed into that way at all. The browser already knows how to do all of it (and usually already has the session), so the desktop app sends the user there and waits to be handed a credential.
The flow, and why each piece exists:
desktop → picks verifier + state, opens the browser at
<server>/admin/login?desktop_state=…&desktop_challenge=sha256(verifier)
browser → user signs in HOWEVER this server does it (local, OIDC, passkey)
SPA → POST /api/auth/desktop/complete (session-authenticated)
server mints a durable token, parks it behind a ONE-TIME code
SPA → navigates to filex://auth?state=…&code=…
desktop → POST /api/auth/desktop/exchange {state, code, verifier}
server checks sha256(verifier) == challenge, returns the token
The deep link carries only a one-time code, never the token: a custom-scheme URL is handed around by the OS and lands in shell histories and logs. The verifier never leaves the desktop process, so a code someone else captures is worthless to them (PKCE, RFC 7636 in spirit).
func NewDesktopAuth ¶
func NewDesktopAuth(store db.Store) *DesktopAuth
func (*DesktopAuth) Complete ¶
func (h *DesktopAuth) Complete(w http.ResponseWriter, r *http.Request)
Complete is called BY THE SPA once the browser session is authenticated, so it runs with whatever identity the server established — password, OIDC, it does not matter here. It mints the desktop's durable token and parks it behind a one-time code.
⚠⚠ A browser SESSION, and nothing else. The route sits in the group that also accepts API tokens (routes.go), and until this check any token could call it and walk away with a brand-new read,write,delete token for its owner — no `root:` confinement, whatever scopes the calling token had. Measured with a read-only integration token: 200, and a full token parked for collection. Now that the minted token is a PERSON's (kind `user`), the same hole would also let an embed's shared proxy token turn itself into a personal credential, which is exactly what RequirePersonalCaller exists to prevent. A token of either kind is refused, and nothing is minted or parked.
POST /api/auth/desktop/complete
func (*DesktopAuth) Exchange ¶
func (h *DesktopAuth) Exchange(w http.ResponseWriter, r *http.Request)
Exchange trades the one-time code for the token. Unauthenticated on purpose — the desktop app has no session yet; possession of the verifier IS the proof.
POST /api/auth/desktop/exchange
type DiscoveredFolder ¶
DiscoveredFolder is one folder directly under a probed root, with the root value a storage mounted on it would carry.
type Drop ¶
type Drop struct {
Store db.Store
Manager *Manager
Service *share.Service
Notify notify.Service
Mailer *mailer.Service
PublicURL string
// Branding resolves the public pages' identity (wiring:e1). Nil-safe.
Branding *BrandingSource
// DefaultLocale is the fallback language for the drop pages — used when
// the visitor's browser asks for neither of the two we ship. Same
// contract as Share.DefaultLocale; see publicLocale.
DefaultLocale string
// Tenants resolves which origin the owner-notification e-mail links to.
Tenants tenanturl.Resolver
// Shell is the SPA document a JavaScript browser gets at /d/{token}; the
// page in this file is the no-JS answer behind it. nil (frontend not
// bundled) means every visitor gets the Go page. See public_shell.go.
Shell http.Handler
// contains filtered or unexported fields
}
Drop serves the public file-drop (upload link) endpoints — the inverse of the Share download link. A visitor with a /d/{token} link can drop one or more files INTO the linked folder without an account and, critically, without ever seeing, listing or downloading the folder's existing contents ("blind drop"). The target folder is resolved server-side from the token; the anonymous client can never influence the destination path.
func NewDrop ¶
func NewDrop(store db.Store, mgr *Manager, svc *share.Service, nf notify.Service, ml *mailer.Service, publicURL string) *Drop
NewDrop constructs the file-drop handler. mgr provides the shared ingest path (IngestFile / EnsureDir); notify + mailer are optional (nil disables that channel).
func (*Drop) AttachBranding ¶
func (h *Drop) AttachBranding(b *BrandingSource)
AttachBranding wires the shared branding source (wiring:e1).
func (*Drop) AttachLocale ¶
AttachLocale sets the fallback language for the drop pages.
func (*Drop) AttachShell ¶
AttachShell wires the SPA document served to JavaScript browsers.
func (*Drop) AttachTenants ¶
AttachTenants wires the shared per-request origin resolver (internal/tenanturl).
func (*Drop) Page ¶
func (h *Drop) Page(w http.ResponseWriter, r *http.Request)
Page renders the public upload UI (or a PIN gate). Mirrors the download side's /s/{token}: a PIN-protected drop shows the PIN form first; once the PIN is accepted (via POST) the uploader page is rendered with the PIN embedded so the browser's upload carries it.
type Duplicates ¶
Duplicates serves the admin duplicate-file report.
func NewDuplicates ¶
func NewDuplicates(store db.Store) *Duplicates
NewDuplicates constructs the handler.
func (*Duplicates) Report ¶
func (h *Duplicates) Report(w http.ResponseWriter, r *http.Request)
Report handles GET /api/admin/duplicates.
limit — max groups returned (default 100, capped at 1000)
min_size — minimum file size in bytes to consider (default 1, so
zero-byte files never flood the report)
type E2E ¶
type E2E struct {
Store db.Store
ACL *acl.Resolver
Escrow *e2e.EscrowKey // nil when escrow is disabled for this installation
// contains filtered or unexported fields
}
E2E serves the escrow challenge/report pair. Everything else about end-to-end encryption happens in the browser.
func (*E2E) EscrowChallenge ¶
func (h *E2E) EscrowChallenge(w http.ResponseWriter, r *http.Request)
EscrowChallenge mints a proof-of-possession challenge for an encrypted folder.
POST /api/files/e2e/escrow/challenge {path} → {id, challenge}
`challenge` is base64 RSA-OAEP(escrow public key, nonce). A caller without the private half learns nothing from it.
func (*E2E) EscrowUsed ¶
func (h *E2E) EscrowUsed(w http.ResponseWriter, r *http.Request)
EscrowUsed redeems the challenge and notifies the folder's owner.
POST /api/files/e2e/escrow/used {path, id, nonce} → {ok, notified}
`notified` reports whether an owner could be identified. When the folder has no recorded owner the event still goes out — as an admin-visible broadcast rather than to nobody.
type ExternalAdmin ¶
type ExternalAdmin struct {
Store db.Store
Caps *capability.Service
// Live is the runtime resolver every consumer reads through. Invalidated
// on write so an operator's change takes effect on the next request.
Live *external.Resolver
// EnvManaged names the services pinned by env/YAML. Those rows are
// re-asserted from the environment at every boot, so a change made here
// applies live but does not survive a restart — and the API says so.
EnvManaged map[string]bool
// PublicURL is the address filex hands to the document server for the
// document fetch and the save callback. It is the third address in the
// three-address problem (see internal/external/advisory.go) and the one
// nothing used to check, so it travels with every List and Test response.
PublicURL string
// PublicURLSet is false when nobody chose PublicURL and it defaulted to
// http://localhost:5212. Worth saying out loud: an operator who never set
// it does not know a default is in play.
PublicURLSet bool
// ReversePath measures the third leg — the document server's route BACK to
// filex — by asking it to download a one-shot URL of ours and watching for
// the request. Nil when OnlyOffice is not wired, and only meaningful for
// that service: nothing else calls filex back.
ReversePath func(ctx context.Context) onlyoffice.ReverseResult
}
ExternalAdmin handles /api/admin/external.
func NewExternalAdmin ¶
func NewExternalAdmin(store db.Store, caps *capability.Service, live *external.Resolver, envManaged map[string]bool) *ExternalAdmin
NewExternalAdmin constructs the handler.
func (*ExternalAdmin) AttachPublicURL ¶
func (h *ExternalAdmin) AttachPublicURL(publicURL string, set bool)
AttachPublicURL wires the process's public URL into the advisories. Kept out of NewExternalAdmin so the two call sites (routes.go and the MCP admin surface) cannot silently disagree about the constructor signature.
func (*ExternalAdmin) List ¶
func (h *ExternalAdmin) List(w http.ResponseWriter, r *http.Request)
List returns every configured external service.
func (*ExternalAdmin) Test ¶
func (h *ExternalAdmin) Test(w http.ResponseWriter, r *http.Request)
Test runs an immediate health probe and returns the state.
func (*ExternalAdmin) Update ¶
func (h *ExternalAdmin) Update(w http.ResponseWriter, r *http.Request)
Update upserts a row and re-runs the health probe.
type FTPSFacts ¶
type FTPSFacts struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
// ⚠ The passive range matters as much as the port: a firewall that blocks
// it makes every transfer HANG with no error on either side, which is the
// classic FTP failure and impossible to guess at from the client end.
PasvMin int `json:"pasv_min"`
PasvMax int `json:"pasv_max"`
// SelfSigned is true when no certificate was configured, so the guide can
// say the channel is encrypted but unverified rather than letting somebody
// discover it from a client warning.
SelfSigned bool `json:"self_signed"`
}
FTPSFacts is what an FTP client needs to be told, computed on the server.
type Grants ¶
type Grants struct {
Store db.Store
ACL *acl.Resolver
Mailer *mailer.Service
PublicURL string
// Tenants resolves which origin the invite e-mails link to. ⚠ The
// account-created mail carries a temporary password next to a login URL —
// on the operator's host that login cannot succeed, and the operator's
// hostname leaks to every tenant that adds a user.
Tenants tenanturl.Resolver
}
Grants is the per-file/per-folder permission-management API backing the explorer's right-side "İzinler" (Permissions) panel. It is mounted under /api/files/permissions inside the authenticated group (session OR token), so confine.Middleware still applies to any path fields.
Authorization for every endpoint: the caller must be an admin OR hold owner-level (acl.LevelOwner) on the target path. Viewer/editor accounts and non-owning users get 403 and never see the panel.
func (*Grants) AdminDelete ¶
func (h *Grants) AdminDelete(w http.ResponseWriter, r *http.Request)
AdminDelete revokes any grant (admin override).
DELETE /api/admin/grants/{id}
func (*Grants) AdminList ¶
func (h *Grants) AdminList(w http.ResponseWriter, r *http.Request)
AdminList returns every grant across all storages, enriched with storage name + user email, for the admin panel's global "İzinler" overview (who has what, where). Admin-only via the /api/admin route group.
GET /api/admin/grants → {grants:[{id, storage_name, path_prefix, user_email, level, …}]}
func (*Grants) AttachInvite ¶
AttachInvite wires the share service + mailer + public URL used by the email-invite flow (existing user → grant, admin → create user, else share).
func (*Grants) AttachTenants ¶
AttachTenants wires the shared per-request origin resolver (internal/tenanturl).
func (*Grants) Create ¶
func (h *Grants) Create(w http.ResponseWriter, r *http.Request)
Create (upsert) a grant for a user on a path.
POST /api/files/permissions {path, user_id, level, is_dir?}
func (*Grants) Delete ¶
func (h *Grants) Delete(w http.ResponseWriter, r *http.Request)
Delete revokes a grant.
DELETE /api/files/permissions/{id}
func (*Grants) Invite ¶
func (h *Grants) Invite(w http.ResponseWriter, r *http.Request)
Invite grants access to an email address. Three outcomes (owner/admin only):
- existing account → a direct ACL grant (mode "granted")
- no account + caller is admin + create_user → new account + grant, temp password mailed (or returned for on-screen display) (mode "user_created")
- otherwise → a public share link, mailed or returned (mode "shared")
Mail is sent only when SMTP is configured AND verified; otherwise the link / temp password comes back in the response for the UI to show.
POST /api/files/permissions/invite {path, email, level, create_user?, role?}
func (*Grants) List ¶
func (h *Grants) List(w http.ResponseWriter, r *http.Request)
List returns the direct + inherited grants for a path so the panel can show who has access (including permissions cascading from parent folders).
GET /api/files/permissions?path=<adapter://rel>
func (*Grants) Resolve ¶
func (h *Grants) Resolve(w http.ResponseWriter, r *http.Request)
Resolve looks up a user by email so the panel can decide between a direct grant (existing account) and the invite flow (no account).
GET /api/files/permissions/resolve?email=<addr>
func (*Grants) SearchUsers ¶
func (h *Grants) SearchUsers(w http.ResponseWriter, r *http.Request)
SearchUsers returns existing accounts matching q (email or display name) so the permissions panel can autocomplete as the owner types. Any authenticated user may call it (the panel itself is owner-gated); results are capped and carry no secrets.
GET /api/files/permissions/users?q=<substr>
func (*Grants) ShareMail ¶
func (h *Grants) ShareMail(w http.ResponseWriter, r *http.Request)
ShareMail emails an already-created public share link to an address. It does NOT create a share — it delivers a link the caller just made (with their chosen expiry/PIN) in the share tab. Gated editor+ on the path, the same capability that created the link. Best-effort: returns {emailed:false} when SMTP isn't verified so the UI keeps showing the link for manual delivery.
POST /api/files/permissions/share-mail {path, email, url}
type LazyCatalogue ¶ added in v0.44.2
type LazyCatalogue interface {
// CatalogueOpened: somebody listed dir of a lazy storage. Never blocks.
CatalogueOpened(storageID int64, dir string)
// CatalogueCurrent: is the storage lazy, and does its catalogue vouch
// for dir right now?
CatalogueCurrent(storageID int64, dir string) (lazy, current bool)
// CatalogueCoverage: how much of st the catalogue covers; nil = all.
CatalogueCoverage(ctx context.Context, st *model.Storage) *syncpkg.CatalogueCoverage
// CatalogueSizePartial: which of these folders' sizes leave something out.
CatalogueSizePartial(ctx context.Context, storageID int64, dirs []string) map[string]bool
// CatalogueSubtree: catalogue everything under dir (a desktop sync pair).
CatalogueSubtree(storageID int64, dir string)
// NoteActivity: somebody is using the storage (slows the filler).
NoteActivity(storageID int64)
}
LazyCatalogue is what the file handlers ask the sync worker about a storage's catalogue (internal/sync lazy_api.go; docs/LAZY-CATALOGUE.md). *sync.Worker satisfies it. nil everywhere means "the catalogue is whatever the last scan left", which is how every handler behaved before it existed.
type Manager ¶
type Manager struct {
Store db.Store
StorageResolver func(int64) (storage.Driver, error)
// Index is consulted by `vfSearch` BEFORE falling back to SQL LIKE.
// nil is fine — search degrades to LIKE-only.
Index *search.Index
// Thumbs, when wired, generates a thumbnail asynchronously after a
// successful upload. nil is fine — uploads still succeed, callers
// just don't get an automatic preview in the grid view.
Thumbs ThumbPipeline
// ACL enforces per-user/per-item access control. nil disables
// enforcement (tests / list-only environments) → legacy all-access.
ACL *acl.Resolver
// ThumbSigner stamps the `thumb_url` this listing hands out, so a bare
// `<img src>` in an embed can fetch it with no header and no cookie. nil
// emits an unsigned URL, which authenticated clients still fetch fine.
ThumbSigner *thumb.Signer
// Staged, when wired, takes large whole-body uploads into filex's own
// staging area and lets the ops worker move them to the driver. nil (or a
// deployment with no staging directory configured) keeps the synchronous
// write — the feature degrades, it never blocks an upload.
Staged *StagedUpload
// Body resolves where a file's bytes actually are — the storage driver,
// or filex's staging area while a staged upload is still transferring.
// nil is fine and degrades to driver-only reads (filebody.Resolver).
Body *filebody.Resolver
// Changes answers `action=changes` (manager_changes.go). nil = 501, and a
// sync client falls back to walking its tree.
Changes *realtime.ChangeLog
// Quota enforces the per-user ceiling on the SYNCHRONOUS write paths.
// Large writes reach the staged path, which checks at `begin`; without
// this the small-file path had no ceiling at all, and a user could sail
// past their limit a few megabytes at a time. nil disables enforcement.
Quota *quota.Service
// Lazy answers what a listing needs from the catalogue's own state: is it
// complete, can it vouch for this folder, and (on a lazy storage) please
// catalogue what was just opened. See lazy_listing.go. nil = the catalogue
// is whatever the last scan left.
Lazy LazyCatalogue
}
Manager handles read-only browsing endpoints under /api/files/manager.
func NewManager ¶
NewManager constructs a Manager handler.
resolver may be nil for tests / list-only environments — the Read handler will return 503 in that case.
func (*Manager) AttachACL ¶
AttachACL wires the RBAC/ACL resolver so listings are filtered and reads are gated by the caller's grants. Optional — nil means no enforcement.
func (*Manager) AttachBody ¶
AttachBody wires the byte-source resolver, so every read surface on this handler serves a file that is still being transferred out of staging instead of asking a driver that does not have it yet.
func (*Manager) AttachChangeLog ¶
AttachChangeLog wires the log `action=changes` answers from. Unwired, the action answers 501, which clients read as "walk the tree as before".
func (*Manager) AttachLazy ¶ added in v0.44.2
func (h *Manager) AttachLazy(l LazyCatalogue)
AttachLazy wires the lazy catalogue. nil keeps the handler as it was.
func (*Manager) AttachSearchIndex ¶
AttachSearchIndex wires the Bleve index into the manager. Optional — without it, vfSearch falls back to SQL LIKE only.
func (*Manager) AttachStaged ¶
func (h *Manager) AttachStaged(s *StagedUpload)
AttachStaged wires the staged ingest path so every surface that writes through IngestFile — today the public drop link — answers as soon as the bytes are safe inside filex rather than after the driver write. One switch, every surface: see the note at the top of upload_staged_ingest.go.
func (*Manager) AttachThumbPipeline ¶
func (h *Manager) AttachThumbPipeline(p ThumbPipeline)
AttachThumbPipeline wires the pipeline so vfUpload can dispatch a generation job per uploaded file.
func (*Manager) EnsureDir ¶
EnsureDir makes sure a directory exists on the driver AND has a node row, returning its node id. The file-drop handler calls it to materialise a per-submission subfolder before ingesting files into it, so the owner sees the folder (and its parent link) immediately without waiting for a sync. Idempotent: returns the existing node id when the dir is already known.
func (*Manager) IngestFile ¶
func (h *Manager) IngestFile(ctx context.Context, st *model.Storage, destRel, filename string, src io.Reader, size int64) (*model.Node, error)
IngestFile writes one uploaded file into destRel/filename on the given storage and upserts + indexes + thumbnails its node. It is the shared ingest path behind the authenticated multipart upload (vfUpload's loop) and the public file-drop handler, so both surface identical mime sniffing, node caching and thumbnail dispatch. Parent dir nodes are looked up lazily — call EnsureDir first when writing into a freshly-created folder so the new file's node links to the right parent.
func (*Manager) List ¶
func (h *Manager) List(w http.ResponseWriter, r *http.Request)
List dispatches between two query shapes on the same path:
Native (admin SPA, trash, etc.): ?storage=<id>&parent=<id> Returns {nodes:[…model.Node]} from the DB cache.
Vuefinder/FileExplorer SFC: ?action=<verb>&path=<adapter://rel> (?q=<verb> is also accepted as a legacy alias.) Returns the {adapter, storages, dirname, read_only, files:[FileNode]} shape that @brftech/filex-core expects. Only `index`, `search`, `subfolders` are wired today — other actions return 501 so the UI can still render and warn rather than 404.
Keeping both behind one route avoids breaking the existing Explore page contract while letting the SFC mount unchanged.
func (*Manager) Mutate ¶
func (h *Manager) Mutate(w http.ResponseWriter, r *http.Request)
Mutate handles the POST verbs the FileExplorer SFC fires from its toolbar: newfolder, rename, move, delete, upload.
All bodies use the @brftech/filex-core wire format (adapter://path). On success each verb re-renders the parent dir via vfIndex so the SFC's reactive store updates without a follow-up GET.
Routes that hit this dispatcher live behind the auth middleware in routes.go (POST /api/files/manager?action=…). Reads stay on the GET dispatcher in manager.go to preserve cache semantics.
func (*Manager) Read ¶
func (h *Manager) Read(w http.ResponseWriter, r *http.Request)
Read streams a file by node ID or by storage_id+path.
Query params:
?id=<node id> primary lookup (preferred) ?storage=<id>&path=<p> fallback when caller has the path but no id ?download=1 force attachment Content-Disposition
Auth: requires an authenticated user (route is mounted behind the auth middleware). Future RBAC checks slot in here once per-storage ACLs land.
func (*Manager) Stat ¶
func (h *Manager) Stat(w http.ResponseWriter, r *http.Request)
Stat returns metadata for a single node.
Query: ?id=<id>
func (*Manager) SyncCopyAcross ¶
func (h *Manager) SyncCopyAcross(ctx context.Context, srcStorageID int64, src string, dstStorageID int64, dst string)
SyncCopy inserts a DB node for a freshly written copy, cloning the source node's type/size/mime. Idempotent: a node already at dst is left alone (a later background sync would reconcile anyway). SyncCopyAcross mirrors a copy whose ends live in two different storages — the cross-depo paste. The node is created under the DESTINATION storage, with the type/size/mime read from the source storage's node when there is one; there need not be (a folder nobody has browsed yet has no row), so the driver's own Stat is the fallback rather than a reason to skip the mirror. Skipping it is what leaves a pasted file invisible until the next scan.
func (*Manager) SyncHardDelete ¶
SyncHardDelete flags the node deleted when the driver couldn't move the file to trash and deleted the bytes outright. Mirrors vfDelete's no-mover branch.
func (*Manager) SyncMove ¶
SyncMove updates the moved node's path/parent. Delegates to the same helper vfMove uses.
func (*Manager) SyncSoftDelete ¶
SyncSoftDelete flags the node deleted and retags it to the trash path, preserving the original path in storage_key so trash.Service.Restore can move the bytes back. Mirrors vfDelete's soft-delete DB branch.
type Meta ¶
type Meta struct {
Store db.Store
// ACL is the RBAC resolver the folder listing consults for its per-row
// `perm`. Optional — nil means no enforcement and no `perm` on the wire,
// exactly as the folder listing behaves with the resolver unwired.
ACL *acl.Resolver
// ThumbSigner stamps the `thumb_url` these rows hand out, as the folder
// listing's does (thumb_url.go). Nil emits it unsigned.
ThumbSigner *thumb.Signer
}
Meta hosts the tags / star / recent endpoints.
func (*Meta) AttachACL ¶
AttachACL wires the RBAC/ACL resolver so every starred / recent / tag row carries the caller's effective level on it.
func (*Meta) AttachThumbSigner ¶
AttachThumbSigner wires the thumbnail URL stamp.
func (*Meta) GetTags ¶
func (h *Meta) GetTags(w http.ResponseWriter, r *http.Request)
GetTags lists the tags for one node (?node_id=) or the tags in use in a storage (?storage_id=), as the caller can see them. Exactly one of the two.
Both answers keep the pre-v0.43 `tags: []string` and add `items` (name + kind). The node answer also says `can_edit_team`, so the picker offers "team" only where saving it would succeed.
func (*Meta) ListAllTags ¶
func (h *Meta) ListAllTags(w http.ResponseWriter, r *http.Request)
ListAllTags lists every tag the caller can see, both kinds, alphabetical. Powers the navigation panel's Tags section and the Tagged files page.
func (*Meta) ListRecent ¶
func (h *Meta) ListRecent(w http.ResponseWriter, r *http.Request)
ListRecent returns nodes the current user opened recently (newest-first).
func (*Meta) ListStarred ¶
func (h *Meta) ListStarred(w http.ResponseWriter, r *http.Request)
ListStarred returns the user's starred nodes (newest-first by star time).
func (*Meta) SetRecent ¶
func (h *Meta) SetRecent(w http.ResponseWriter, r *http.Request)
SetRecent records that the current user opened a node.
func (*Meta) SetStar ¶
func (h *Meta) SetStar(w http.ResponseWriter, r *http.Request)
SetStar toggles the starred flag for the current user on a node.
func (*Meta) SetTags ¶
func (h *Meta) SetTags(w http.ResponseWriter, r *http.Request)
SetTags sets the tags on a file that the caller can see: their personal tags and their tenant's team tags (see tagOps.set).
func (*Meta) TaggedNodes ¶
func (h *Meta) TaggedNodes(w http.ResponseWriter, r *http.Request)
TaggedNodes lists the live files carrying a tag (?tag=…), newest-first, capped by ?limit=. ?kind=personal|team narrows to one kind; absent = both (a `#.tag~x` link from before v0.43 keeps working). Empty tag → 400.
type NFSExports ¶
type NFSExports struct {
Store db.Store
Auth *protocolauth.Resolver
// Enabled mirrors the FILEX_NFS kill switch.
Enabled bool
// Host and Port are what a mount line needs.
Host string
Port int
}
NFSExports is the self-service export handler.
func NewNFSExports ¶
func NewNFSExports(store db.Store, res *protocolauth.Resolver, enabled bool, host string, port int) *NFSExports
NewNFSExports constructs the handler.
func (*NFSExports) Create ¶
func (h *NFSExports) Create(w http.ResponseWriter, r *http.Request)
Create mints an export and returns its path exactly once.
func (*NFSExports) Delete ¶
func (h *NFSExports) Delete(w http.ResponseWriter, r *http.Request)
Delete revokes an export.
⚠ It takes effect on the mount's next request rather than by tearing the mount down: NFS has no session to end — the client holds a file handle and keeps sending RPCs, and there is no connection the server owns. So the live mount is MARKED revoked (protocolauth.Kick) and every operation on it starts refusing from that moment. The client sees an access error and its mount goes stale, which is the honest end state and worth saying in the UI.
func (*NFSExports) List ¶
func (h *NFSExports) List(w http.ResponseWriter, r *http.Request)
List returns the caller's own exports. The paths are NOT among them and cannot be: only their hashes are stored.
func (*NFSExports) SetState ¶
func (h *NFSExports) SetState(w http.ResponseWriter, r *http.Request)
SetState disables or re-enables an export.
type Notifications ¶
type Notifications struct {
Service notify.Service
// Store and ACL answer "may this member see the file a broadcast names" —
// with the same grants the explorer filters its listings by. With either
// one nil there is nothing to prove it with, and no such row reaches a
// member.
Store db.Store
ACL *acl.Resolver
}
Notifications wraps the notify.Service for the HTTP layer.
func NewNotifications ¶
NewNotifications constructs the handler.
func (*Notifications) AdminList ¶
func (h *Notifications) AdminList(w http.ResponseWriter, r *http.Request)
AdminList exposes the global view (broadcasts + every user's notifications) to admins.
GET /admin/api/notifications?unread=true&limit=50&offset=0
func (*Notifications) AdminTest ¶
func (h *Notifications) AdminTest(w http.ResponseWriter, r *http.Request)
AdminTest emits a manual event so the admin can verify the webhook URL works.
POST /admin/api/notifications/test
func (*Notifications) AdminUpdateWebhookConfig ¶
func (h *Notifications) AdminUpdateWebhookConfig(w http.ResponseWriter, r *http.Request)
AdminUpdateWebhookConfig overrides the URL/token at runtime. Pass empty url to disable webhook delivery without taking the in-app channel down. token may be left empty to keep the existing one (sentinel: empty in body means clear; supply "__keep__" to retain).
PATCH /admin/api/notifications/webhook-config
body: {url: "...", token: "..." or "__keep__" or ""}
func (*Notifications) AdminWebhookConfig ¶
func (h *Notifications) AdminWebhookConfig(w http.ResponseWriter, r *http.Request)
AdminWebhookConfig returns the current webhook URL + a token-set flag (the token itself is never exposed back to the UI).
GET /admin/api/notifications/webhook-config
func (*Notifications) GetSettings ¶
func (h *Notifications) GetSettings(w http.ResponseWriter, r *http.Request)
GetSettings returns the user's preferences.
GET /api/notifications/settings
func (*Notifications) List ¶
func (h *Notifications) List(w http.ResponseWriter, r *http.Request)
List paginates the current user's bell history (broadcasts + user-scoped). Admin-global view is exposed via /admin/api/notifications (passing nil userID to the service).
GET /api/notifications?unread=true&limit=50&offset=0
It is also what the browser notification and the desktop toast read (the web store diffs this list; the desktop polls `unread=true`), so what a reader may learn of a broadcast is decided here once for all three.
func (*Notifications) MarkAllRead ¶
func (h *Notifications) MarkAllRead(w http.ResponseWriter, r *http.Request)
MarkAllRead clears the user's unread queue.
POST /api/notifications/read-all
func (*Notifications) MarkRead ¶
func (h *Notifications) MarkRead(w http.ResponseWriter, r *http.Request)
MarkRead marks a single notification read.
POST /api/notifications/{id}/read
func (*Notifications) UnreadCount ¶
func (h *Notifications) UnreadCount(w http.ResponseWriter, r *http.Request)
UnreadCount returns the bell badge number for the current user.
GET /api/notifications/unread-count
The badge has to agree with the list, and a bare SQL COUNT cannot where the broadcasts get a per-row pass: it has no way to look inside meta_json, let alone at the caller's grants. So it is the list's own total (bellRead) — a badge saying 3 over a list of 1 is both a hint at how much is happening where the caller cannot look and a number they can never clear, because they cannot mark read what they cannot see.
func (*Notifications) UpdateSettings ¶
func (h *Notifications) UpdateSettings(w http.ResponseWriter, r *http.Request)
UpdateSettings replaces the user's preferences.
PATCH /api/notifications/settings
body: {in_app_enabled: bool, muted_events: ["replica_fail", ...]}
type OnlyOffice ¶
type OnlyOffice struct {
Service *onlyoffice.Service
Store db.Store
StorageResolver func(int64) (storage.Driver, error)
ACL *acl.Resolver
// Body resolves where the document's bytes are: the driver, or filex's
// staging area while a staged upload is still transferring. Nil-safe.
Body *filebody.Resolver
}
OnlyOffice exposes the editor config + fetch + callback endpoints.
func NewOnlyOffice ¶
func NewOnlyOffice(svc *onlyoffice.Service, store db.Store, resolver func(int64) (storage.Driver, error)) *OnlyOffice
NewOnlyOffice constructs the handler.
⚠ The handler is ALWAYS wired now, even when nothing is configured yet: what "configured" means is a question for the `external_services` row at request time, not for whatever env carried at boot. Every entry point asks Service.EnabledCtx, which is nil-safe, so a nil service still answers 503.
func (*OnlyOffice) AttachACL ¶
func (h *OnlyOffice) AttachACL(r *acl.Resolver)
AttachACL wires the RBAC resolver: opening a document needs ≥viewer; an editable config additionally needs ≥editor (else it's downgraded to view).
func (*OnlyOffice) AttachBody ¶
func (h *OnlyOffice) AttachBody(b *filebody.Resolver)
AttachBody wires the byte-source resolver so a document that is still being transferred opens in the editor.
func (*OnlyOffice) Callback ¶
func (h *OnlyOffice) Callback(w http.ResponseWriter, r *http.Request)
Callback receives save events from the OnlyOffice document server.
POST /api/files/onlyoffice/callback?node=<id>
Public — relies on the JWT in the body or Authorization header for integrity.
func (*OnlyOffice) Config ¶
func (h *OnlyOffice) Config(w http.ResponseWriter, r *http.Request)
Config returns the editor descriptor for an iframe to render.
Accepts both forms:
GET /api/files/onlyoffice/config?id=<node-id>&lang=tr
POST /api/files/onlyoffice/config { "path": "adapter://rel", "mode": "edit"|"view" }
The GET form is what the standalone Editor.vue route hands the SFC's PreviewModal when the embedder passes a numeric node id. The POST form is what the modal itself sends from inside the explore page — it has the adapter-qualified path handy but not the node id, so the handler must resolve path → node before continuing.
func (*OnlyOffice) Fetch ¶
func (h *OnlyOffice) Fetch(w http.ResponseWriter, r *http.Request)
Fetch streams document bytes back to the OnlyOffice document server.
Public: no session required, but the URL must be HMAC-signed via the onlyoffice service.
GET /api/files/onlyoffice/fetch?n=<id>&exp=<unix>&sig=<b64url>
Every refusal here is logged with its reason. The only person who ever sees this endpoint fail is an operator reading "Download failed" in the editor — a message from the document server that says nothing about which of the five things went wrong. Without a line naming the reason, the status code in the access log is all they have, and a 500 from a bad signature and a 500 from an unreachable bucket look identical (issue #17).
func (*OnlyOffice) Probe ¶
func (h *OnlyOffice) Probe(w http.ResponseWriter, r *http.Request)
Probe answers the reverse-path check: a one-shot, unguessable URL the document server is asked to download so filex can see whether the request arrives (see onlyoffice.VerifyReversePath).
⚠ Unauthenticated by necessity — the caller is another container, not a person — and therefore it serves a fixed sentence and nothing else. An unknown or expired token is a plain 404: a stranger who guesses at this endpoint learns nothing, not even that it exists for something.
type Ops ¶
type Ops struct {
Service *ops.Service
Store db.Store // for path → storage_id resolution in the per-verb endpoints
ACL *acl.Resolver
}
Ops handles async copy/move/delete tasks.
State is persisted in the pending_ops table — restart-safe so a crash doesn't lose in-flight work. The actual execution happens in the worker goroutine launched in server.New (see ops.Service.Run).
func (*Ops) AttachACL ¶
AttachACL wires the RBAC resolver so async copy/move/delete require ≥editor on their sources (and destination) at submit time — the async worker itself runs without a user, so authorization is a submit-time gate.
func (*Ops) Cancel ¶
func (o *Ops) Cancel(w http.ResponseWriter, r *http.Request)
Cancel ends a pending or running op the caller may see. A row already finished answers 409; an unknown (or another tenant's) id 404, in the same words Status uses so the id range cannot be probed.
func (*Ops) List ¶
func (o *Ops) List(w http.ResponseWriter, r *http.Request)
List returns ops filtered by ?status=… (e.g. "running"). Used by the SPA's PendingOpsTray which polls every 2 s. Empty status returns the most-recent rows across all statuses (capped at 200 service-side).
Response shape mirrors what the SPA's `opsApi.list` already understands: `{ "ops": [Op, …] }`, each row with its sources cut to a preview and counted (opListRow). The frontend's `normalizeOp` adapter then translates the backend's raw shape into the SPA's `PendingOp` contract.
func (*Ops) Status ¶
func (o *Ops) Status(w http.ResponseWriter, r *http.Request)
Status returns the live or final state of a submitted op.
func (*Ops) Submit ¶
func (o *Ops) Submit(w http.ResponseWriter, r *http.Request)
Submit queues a new op and returns the opID.
func (*Ops) SubmitCopy ¶
func (o *Ops) SubmitCopy(w http.ResponseWriter, r *http.Request)
func (*Ops) SubmitDelete ¶
func (o *Ops) SubmitDelete(w http.ResponseWriter, r *http.Request)
func (*Ops) SubmitMove ¶
func (o *Ops) SubmitMove(w http.ResponseWriter, r *http.Request)
type Plugins ¶
type Plugins struct {
Manager *plugin.Manager
// MultiTenant is kept because the constructor is called with it from two
// places and the value is genuinely about this handler, but the gate no
// longer reads it: requireSupertenant decides from the request's tenant
// scope, which is attached only in multi-tenant mode, so the flag and the
// scope can never disagree.
MultiTenant bool
}
Plugins is the handler set. Manager may be nil when the subsystem is disabled by configuration; every route then answers 503 with the reason.
func NewPlugins ¶
NewPlugins constructs the handler.
type Protection ¶
Protection handles GET/PATCH /api/admin/protection.
func NewProtection ¶
func NewProtection(store db.Store) *Protection
NewProtection constructs the handler.
func (*Protection) Get ¶
func (h *Protection) Get(w http.ResponseWriter, r *http.Request)
Get returns the current protection settings + antivirus status.
func (*Protection) Patch ¶
func (h *Protection) Patch(w http.ResponseWriter, r *http.Request)
Patch updates one or both settings and echoes the fresh GET shape.
type Providers ¶
Providers handles /api/admin/providers — the tenant lifecycle API (docs/MULTI-TENANCY.md §8, §11): create/suspend/delete tenants, link storages, transfer the supertenant flag.
Guards enforced here (not scattered):
- only a supertenant admin may manage providers in multi-tenant mode;
- at most ONE supertenant — setting the flag on another provider is a TRANSFER (the old supertenant becomes a regular tenant atomically);
- the supertenant cannot be deleted, disabled, or directly un-flagged;
- deleting a tenant with users requires ?force=1 and then cascades its users and storage LINKS. Storage rows and file data are never touched — unlinking is reversible, deleting files is not.
func NewProviders ¶
NewProviders constructs the handler.
func (*Providers) Create ¶
func (h *Providers) Create(w http.ResponseWriter, r *http.Request)
Create provisions a tenant.
func (*Providers) Delete ¶
func (h *Providers) Delete(w http.ResponseWriter, r *http.Request)
Delete removes a tenant. Users require ?force=1 (then cascade); storage LINKS are removed but storage rows + file data are never touched.
func (*Providers) LinkStorage ¶
func (h *Providers) LinkStorage(w http.ResponseWriter, r *http.Request)
LinkStorage links a storage to the tenant (POST {storage_id}).
func (*Providers) List ¶
func (h *Providers) List(w http.ResponseWriter, r *http.Request)
List returns every provider (tenant) with its storage links + user count.
func (*Providers) UnlinkStorage ¶
func (h *Providers) UnlinkStorage(w http.ResponseWriter, r *http.Request)
UnlinkStorage removes a storage link (never the storage itself).
type PublicAPI ¶
type PublicAPI struct {
Store db.Store
Service *share.Service
// Brand answers /api/public/branding. Nil-safe: unwired yields the stock
// filex identity, which is what an instance that never opened the
// Corporate identity page has anyway.
Brand *BrandingSource
// Apps answers the app-plugin half. Nil (or app plugins disabled) turns
// every /event and /file call into a 404 and leaves /s/{token} answering
// about the share alone.
Apps *AppPlugins
// Drop reuses the file-request ingest so the JSON upload path and the
// no-JS form path are the same code — limits, quota, notifications and
// all.
Drop *Drop
// DefaultLocale is the instance's fallback language, reported in
// /branding so the shell can pick one before a person has.
DefaultLocale string
}
PublicAPI serves /api/public/*.
func NewPublicAPI ¶
NewPublicAPI constructs the handler.
func (*PublicAPI) AttachApps ¶
func (h *PublicAPI) AttachApps(a *AppPlugins)
AttachApps wires the app-plugin handler (nil = app pages unavailable).
func (*PublicAPI) AttachBranding ¶
func (h *PublicAPI) AttachBranding(b *BrandingSource)
AttachBranding wires the shared branding source.
func (*PublicAPI) AttachDrop ¶
AttachDrop wires the file-request ingest.
func (*PublicAPI) AttachLocale ¶
AttachLocale sets the instance's fallback language.
func (*PublicAPI) Branding ¶
func (h *PublicAPI) Branding(w http.ResponseWriter, r *http.Request)
Branding answers the public shell's identity. Unauthenticated and cacheable — it is fetched by every public page and changes when an administrator says so, not per visitor.
func (*PublicAPI) DropState ¶
func (h *PublicAPI) DropState(w http.ResponseWriter, r *http.Request)
DropState answers a file-request link's state.
func (*PublicAPI) DropUpload ¶
func (h *PublicAPI) DropUpload(w http.ResponseWriter, r *http.Request)
DropUpload receives the files. The body, the limits, the quota accounting and the owner's notification are the file-request handler's — there is one ingest path, and the JSON screen does not get a second one.
func (*PublicAPI) Entries ¶
func (h *PublicAPI) Entries(w http.ResponseWriter, r *http.Request)
Entries lists one directory of a shared FOLDER: GET /api/public/s/{token}/entries?path=<relative>
A folder share that can only be downloaded whole is not a folder share — the no-JS page has browsed since the beginning, and the shell needs the same thing as data. The path is relative to the share's root and cannot leave it; the PIN gate, the expiry and the visit ceiling are the share's own, checked before anything is read.
func (*PublicAPI) Event ¶
func (h *PublicAPI) Event(w http.ResponseWriter, r *http.Request)
Event runs the app's page_event for an unlocked visitor. `GET`-shaped opens are POSTs with `{"event":"open"}` — one endpoint, because the opening surface and every later one are the same call to the app.
func (*PublicAPI) File ¶
func (h *PublicAPI) File(w http.ResponseWriter, r *http.Request)
File serves a copy an app page exposed (`pub:N`), Range-capable.
func (*PublicAPI) PIN ¶
func (h *PublicAPI) PIN(w http.ResponseWriter, r *http.Request)
PIN answers the PIN and, on a hit, mints the unlock cookie.
func (*PublicAPI) Share ¶
func (h *PublicAPI) Share(w http.ResponseWriter, r *http.Request)
Share answers a share link's state.
func (*PublicAPI) UILocale ¶
func (h *PublicAPI) UILocale(w http.ResponseWriter, r *http.Request)
UILocale answers one added language's strings. Unauthenticated for the same reason /branding is — a public page is offered the language too — and cacheable for the same minute. 404 when no running app ships the tag, so a language that left with its app stops being served at once rather than lingering as a table nothing lists.
type PublicApp ¶
type PublicApp struct {
Plugin string `json:"plugin"`
Page string `json:"page"`
Title wire.Text `json:"title,omitempty"`
Files []wire.FileRef `json:"files"`
}
PublicApp describes the app surface a link carries, once it is unlocked.
type PublicBranding ¶
type PublicBranding struct {
Name string `json:"name"`
LogoURL string `json:"logo_url"`
Accent string `json:"accent"`
HidePoweredBy bool `json:"hide_powered_by"`
// Theme is the appearance a visitor gets before they choose one:
// "system" (follow the device), "light" or "dark". Settings key
// `branding.theme`; anything else is read as "system".
Theme string `json:"theme"`
// Locale is the instance's own default language, and Locales are the
// languages the public pages can be rendered in.
Locale string `json:"locale"`
Locales []string `json:"locales"`
// UILocales are the languages installed apps add to filex itself — one
// row each (tag, the app that brought it, whether it is right to left),
// and NO strings. The strings of one language are GET
// /api/public/ui-locales/{code}, fetched when somebody picks it.
//
// ⚠⚠ This field used to be `{tag: {key: text}}` — every string of every
// added language, on the one answer every public page and every panel
// start reads — while the browser read it as a LIST (`ui_locales?.length`)
// and so never saw a single string: an installed language joined the
// picker and then spoke English (measured 2026-09-21). It is a list now,
// the same PublicLocaleOption rows the browser types, and the wire fixture
// the web tests read is written by this package (public_wire_test.go).
UILocales []wasmplugin.UILocaleInfo `json:"ui_locales,omitempty"`
}
PublicBranding is what the public shell needs to look like this instance rather than like filex. It is the SAME record the admin "Corporate identity" page writes — whitelabel means whitelabel: a signature request from a renamed instance does not say "filex".
⚠ Deliberately NOT the same payload as /api/branding: that one also carries the SSO button label, which a stranger has no business receiving.
⚠ It no longer differs by the custom stylesheet, and the sentence that said so was left standing for a while after it stopped being true. Since v0.43.0 the sheet is on NEITHER payload: it is served from GET /api/me/custom-css behind authentication, and the field was REMOVED from /api/branding rather than blanked, so an old client fails loudly instead of quietly rendering nothing. A comment that describes a retired boundary is worse than no comment, because the next person reasons from it.
type PublicDrop ¶
type PublicDrop struct {
Kind string `json:"kind"` // always "drop"
NeedsPIN bool `json:"needs_pin"`
Unlocked bool `json:"unlocked"`
Expired bool `json:"expired"`
Revoked bool `json:"revoked"`
Locked bool `json:"locked"`
// Folder is the destination's NAME — never its path, and never a listing.
Folder string `json:"folder,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
UploadsLeft *int `json:"uploads_left"`
Limits dropLimits `json:"limits"`
}
PublicDrop is GET /api/public/d/{token}: everything the upload screen needs and nothing about what is already in the folder ("blind drop").
type PublicEntry ¶
type PublicEntry struct {
Name string `json:"name"`
Path string `json:"path"` // relative to the share's root
IsDir bool `json:"is_dir"`
Size int64 `json:"size,omitempty"`
Mime string `json:"mime,omitempty"`
}
PublicEntry is one row of a shared folder: what a visitor needs to decide whether to open it, and nothing about where it lives on the server.
type PublicNode ¶
type PublicNode struct {
Name string `json:"name"`
Size int64 `json:"size"`
Mime string `json:"mime,omitempty"`
}
PublicNode is the little a visitor learns about the file behind a link: what it is called, how big it is and what kind of thing it is. Never its path, never its storage, never who owns it.
type PublicShare ¶
type PublicShare struct {
// Locked reports the PIN gate being shut after five wrong answers. It is
// NOT a reason the link is dead — it lifts by itself.
// Node is the file or folder behind the link, once unlocked. ⚠ Omitted
// for an app link: there the node is the ANCHOR the app's state and its
// follow-up job hang on, and the only bytes a visitor may have are the
// copies the app exposed. No storage driver is opened for an anonymous
// request, which is the property the v2 app pages had and this keeps.
}
PublicShare is GET /api/public/s/{token}.
`expired` and `revoked` are both reported because they are different sentences to a visitor, and they mean exactly this:
- expired — the clock ran out (`expires_at` has passed). An administrator's Revoke is expressed by moving the expiry to the moment it happened, so it lands here too; there is no second column and one that could disagree with this would be worse than one word doing both.
- revoked — the link is dead for a reason that is NOT the clock: its visit/download ceiling is spent, or the app that answers it has been stopped or removed.
A live link has both false. A caller that only wants "can I use this" reads `expired || revoked`.
type PublicUILocale ¶
PublicUILocale is GET /api/public/ui-locales/{code}: the strings of one language the installed apps add, merged, by filex's own keys.
type Queue ¶
type Queue struct {
Driver queue.Driver
// Store names what a job is about (see subjectOf). Nil leaves the
// payload as the only description.
Store db.Store
}
Queue exposes admin endpoints over the persistent queue driver.
Only admin role hits these — wired under /api/admin/queue/... in routes.go behind auth.Middleware + auth.RequireAdmin.
func NewQueue ¶
NewQueue constructs the handler. driver may be nil when the bootstrap disabled the queue (config.Queue.Enabled = false) — handlers respond 503 in that case so the UI can render a graceful warning.
func (*Queue) AttachStore ¶
AttachStore wires the store the list names its jobs' subjects from.
func (*Queue) Cancel ¶
func (h *Queue) Cancel(w http.ResponseWriter, r *http.Request)
Cancel marks a pending op cancelled.
DELETE /api/admin/queue/{id}
func (*Queue) Get ¶
func (h *Queue) Get(w http.ResponseWriter, r *http.Request)
Get returns a single op.
GET /api/admin/queue/{id}
func (*Queue) List ¶
func (h *Queue) List(w http.ResponseWriter, r *http.Request)
List paginates ops with optional status filter.
GET /api/admin/queue?status=pending&limit=50&offset=0
type QueueStats ¶
QueueStats is the one question the dashboard asks the job queue: how many jobs are waiting or running. queue.Driver satisfies it.
type Quota ¶
type Quota struct {
Service *quota.Service
// Store answers the tenancy question the quota service cannot: the
// admin routes take a raw user id, and in multi-tenant mode a tenant
// admin must not read or clamp another tenant's user. quota.Service
// knows about bytes, not about who owns the account.
Store db.Store
// ACL answers the other tenancy question, the per-user one: which
// storages this caller may see at all. Wired by AttachACL (quota_storages.go);
// nil means no RBAC enforcement, as everywhere else.
ACL *acl.Resolver
// Lazy says when a storage's catalogue — and so its usage figure — covers
// only part of it (docs/LAZY-CATALOGUE.md). nil: a figure is a figure.
Lazy LazyCatalogue
// contains filtered or unexported fields
}
Quota wires quota HTTP routes.
func (*Quota) AdminGet ¶
func (h *Quota) AdminGet(w http.ResponseWriter, r *http.Request)
AdminGet returns the target user's current quota + usage snapshot. (The admin routes bind {user_id}; reading "id" here was a long-standing mismatch that made these endpoints always 400 — fixed alongside.)
func (*Quota) AdminRecompute ¶
func (h *Quota) AdminRecompute(w http.ResponseWriter, r *http.Request)
AdminRecompute rescans nodes owned by id and rewrites usage_bytes.
func (*Quota) AdminSet ¶
func (h *Quota) AdminSet(w http.ResponseWriter, r *http.Request)
AdminSet writes a new quota_bytes for the target user.
func (*Quota) AttachACL ¶
AttachACL wires the RBAC resolver. nil (ACL unwired) means no grant filtering, matching every other file handler — but see StorageUsage: an unwired resolver is the single-tenant "no RBAC configured" install, where every enabled storage is already everybody's.
func (*Quota) AttachLazy ¶ added in v0.44.2
func (h *Quota) AttachLazy(l LazyCatalogue)
AttachLazy wires the catalogue-coverage source.
func (*Quota) Me ¶
func (h *Quota) Me(w http.ResponseWriter, r *http.Request)
Me returns the caller's quota snapshot.
func (*Quota) StorageUsage ¶
func (h *Quota) StorageUsage(w http.ResponseWriter, r *http.Request)
StorageUsage answers per-storage usage for the signed-in caller.
⚠⚠ The filter is the SAME one the explorer's own root listing applies (handlers/manager.go → listVuefinder): list the enabled storages, then drop every one whose `acl.Set` is not `StorageVisible()`. It has to be the same one, because the guarantee is not "this endpoint is careful" — it is "the set of drives you can measure is exactly the set of drives you can open". Two independently-written filters would be two chances for one of them to answer for a drive the other hides.
Two dimensions are already closed before this code runs and are named here so the next reader does not add a second, weaker copy of them:
- Tenancy. h.Store is the tenant-scoped store (server.go → tenantstore), and ListEnabledStorages is one of the methods it confines. A tenant admin never sees another provider's storages in the list at all.
- Root confinement. A token carrying `root:<adapter>://<rel>` (or a trusted proxy's X-Filex-Root) is not looking at a storage, it is looking at one folder inside one — so a whole-storage total is not its number. Rather than invent a subtree total nothing else in the product reports, such a caller is answered with the storage it is confined to ONLY when the confinement is the storage root, and an empty list otherwise.
⚠ What this figure is NOT: it is the drive's total, not the caller's share of it, and item-level grants do not narrow it. Somebody granted one folder on an RBAC storage learns how full that storage is. That is deliberate — the card says "this drive is X full", which is a property of the drive, and the alternative (per-grant subtotals) would make the same card mean a different thing for every reader. The line that must not move is the storage one: a drive you cannot open is not reported at all.
type Replica ¶
type Replica struct {
Store db.Store
Service *replica.Service
Cron *replica.CronScheduler
Reloader *replica.RulesReloader
}
Replica wires the admin endpoints over the replica subsystem.
func NewReplica ¶
func NewReplica(store db.Store, svc *replica.Service, cron *replica.CronScheduler, reloader *replica.RulesReloader) *Replica
NewReplica constructs a handler. Components may be nil when replica is disabled — endpoints return 503 in that case.
func (*Replica) CountFailures ¶
func (h *Replica) CountFailures(w http.ResponseWriter, r *http.Request)
CountFailures returns the unresolved failure count.
GET /admin/api/replica/failures/count
func (*Replica) CreateRule ¶
func (h *Replica) CreateRule(w http.ResponseWriter, r *http.Request)
CreateRule inserts a new rule.
POST /admin/api/replica/rules
func (*Replica) DeleteRule ¶
func (h *Replica) DeleteRule(w http.ResponseWriter, r *http.Request)
DeleteRule removes a rule.
DELETE /admin/api/replica/rules/{id}
func (*Replica) FixAll ¶
func (h *Replica) FixAll(w http.ResponseWriter, r *http.Request)
FixAll enqueues retry ops for every unresolved failure.
POST /admin/api/replica/fix
func (*Replica) FixOne ¶
func (h *Replica) FixOne(w http.ResponseWriter, r *http.Request)
FixOne enqueues a single retry.
POST /admin/api/replica/fix-one
body: {path: "...", op: "write|delete|move|copy"}
func (*Replica) GetReport ¶
func (h *Replica) GetReport(w http.ResponseWriter, r *http.Request)
GetReport returns the latest singleton status report. nil → 204.
GET /admin/api/replica/report
func (*Replica) GetSettings ¶
func (h *Replica) GetSettings(w http.ResponseWriter, r *http.Request)
GetSettings returns the singleton replica_settings row.
GET /admin/api/replica/settings
func (*Replica) ListFailures ¶
func (h *Replica) ListFailures(w http.ResponseWriter, r *http.Request)
ListFailures paginates the failures table.
GET /admin/api/replica/failures?unresolved=true&limit=50&offset=0
func (*Replica) ListRules ¶
func (h *Replica) ListRules(w http.ResponseWriter, r *http.Request)
ListRules paginates rules.
GET /admin/api/replica/rules
func (*Replica) RunReportNow ¶
func (h *Replica) RunReportNow(w http.ResponseWriter, r *http.Request)
RunReportNow triggers GenerateReport synchronously.
POST /admin/api/replica/report/run-now
func (*Replica) UpdateRule ¶
func (h *Replica) UpdateRule(w http.ResponseWriter, r *http.Request)
UpdateRule replaces a rule by id.
PATCH /admin/api/replica/rules/{id}
func (*Replica) UpdateSettings ¶
func (h *Replica) UpdateSettings(w http.ResponseWriter, r *http.Request)
UpdateSettings rewrites the singleton replica_settings row and reloads the cron schedule + rules engine.
PATCH /admin/api/replica/settings
body: {report_cron, report_enabled, default_mode}
type ReplicationTargets ¶
func NewReplicationTargets ¶
func NewReplicationTargets(store db.Store) *ReplicationTargets
func (*ReplicationTargets) Create ¶
func (h *ReplicationTargets) Create(w http.ResponseWriter, r *http.Request)
func (*ReplicationTargets) Delete ¶
func (h *ReplicationTargets) Delete(w http.ResponseWriter, r *http.Request)
func (*ReplicationTargets) Get ¶
func (h *ReplicationTargets) Get(w http.ResponseWriter, r *http.Request)
func (*ReplicationTargets) List ¶
func (h *ReplicationTargets) List(w http.ResponseWriter, r *http.Request)
func (*ReplicationTargets) Update ¶
func (h *ReplicationTargets) Update(w http.ResponseWriter, r *http.Request)
type Response ¶
type Response struct {
Storages []StorageSummary `json:"storages"`
TotalUsers int64 `json:"total_users"`
ActiveSessions int64 `json:"active_sessions"`
QueueDepth int `json:"queue_depth"`
TotalFiles int64 `json:"total_files"`
TotalBytes int64 `json:"total_bytes"`
RecentActivity []ActivityRow `json:"recent_activity"`
Capabilities CapabilitiesShort `json:"capabilities"`
}
Response is the shape returned to the admin UI.
type S3Keys ¶
type S3Keys struct {
Store db.Store
Auth *protocolauth.Resolver
// Endpoint is the S3 URL to hand back with a freshly minted key, so the
// UI can render a working `aws configure` / rclone block instead of a
// placeholder the user has to fill in. Empty = the UI falls back to the
// page origin.
//
// ⚠ It is the ENDPOINT, not the application URL: with a dedicated host
// they differ, and a client pointed at the application root reaches the web
// app. See s3api.EndpointURL.
Endpoint string
// Enabled mirrors the FILEX_S3 kill switch, so the UI can say "your
// operator has turned this off" instead of handing out a key for an
// endpoint that answers 404.
Enabled bool
// PathStyle is true when clients must be told to force path-style
// addressing (no dedicated domain, so `bucket.host` does not resolve).
PathStyle bool
}
S3Keys is the self-service access-key handler.
func NewS3Keys ¶
func NewS3Keys(store db.Store, res *protocolauth.Resolver, cfg config.S3Config, publicURL string) *S3Keys
NewS3Keys constructs the handler.
func (*S3Keys) Create ¶
func (h *S3Keys) Create(w http.ResponseWriter, r *http.Request)
Create mints a key and returns the secret exactly once.
func (*S3Keys) Delete ¶
func (h *S3Keys) Delete(w http.ResponseWriter, r *http.Request)
Delete revokes a key permanently.
type SSHKeys ¶
type SSHKeys struct {
Store db.Store
// Auth is the shared credential resolver. Held only so revoking a key can
// reach an SFTP session that key already opened — without it, deleting a
// key means "cannot log in again", not "is logged out".
Auth *protocolauth.Resolver
// Enabled mirrors the FILEX_SFTP kill switch, so the UI can say the
// endpoint is off instead of collecting keys for a port that is closed.
Enabled bool
// Addr and Host are what a client needs to connect, echoed back so the
// instructions name this deployment rather than a placeholder.
Host string
Port int
// FTPS is the other non-HTTP endpoint's shape.
FTPS FTPSFacts
}
SSHKeys is the self-service SSH key handler.
It also reports the FTPS endpoint, because the two are one screen for the user: "how do I reach this server from a program that is not a browser?". Splitting the facts across two endpoints would mean the guide had to make two calls to answer one question.
func NewSSHKeys ¶
func NewSSHKeys(store db.Store, res *protocolauth.Resolver, enabled bool, host string, port int, ftps FTPSFacts) *SSHKeys
NewSSHKeys constructs the handler.
func (*SSHKeys) Create ¶
func (h *SSHKeys) Create(w http.ResponseWriter, r *http.Request)
Create registers a key.
func (*SSHKeys) Delete ¶
func (h *SSHKeys) Delete(w http.ResponseWriter, r *http.Request)
Delete removes a key.
type SanitizedCSS ¶
type SanitizedCSS struct {
// Hoisted holds the top-level `@font-face` and `@keyframes` blocks, which
// are emitted OUTSIDE the scope wrapper.
//
// ⚠ They have to be hoisted or they stop working: both at-rules define a
// NAME rather than matching elements, and their behaviour inside `@scope`
// is at best inconsistent across engines. Hoisting is safe precisely
// because the url() pass has already run — a hoisted `@font-face` can only
// reference a `data:` URI, so moving it out of the scope cannot give it a
// network request it did not already lack.
Hoisted string
// Scoped holds everything else, to be wrapped in the `@scope` rule.
Scoped string
// Removed names what the pass took out, so the admin screen can say so
// instead of silently serving something other than what was typed.
Removed []string
}
SanitizedCSS is the result of a guard pass over an operator stylesheet.
func SanitizeCustomCSS ¶
func SanitizeCustomCSS(raw string) (SanitizedCSS, error)
SanitizeCustomCSS runs the guard pass over an operator stylesheet.
It is a small CSS-aware scanner rather than a set of regexes, and it has to be: a regex for `url\(` cannot tell the one in a comment from the one in a declaration, and a regex that counts braces cannot tell the `}` inside a quoted string from the one that closes a rule. Both differences are load-bearing — the brace count is what stops a sheet from ending the `@scope` wrapper early and escaping the guard, which is the single most important thing this function does.
The pass:
- comments are COPIED THROUGH but never scanned inside (an operator's notes are theirs, and nothing in a comment is fetched or matched)
- quoted strings are copied verbatim, and braces inside them do not count
- `@import` is removed, at-rule and all
- `url(…)` and `image-set(…)` that are not `data:` or `#fragment` are made inert
- top-level `@font-face` / `@keyframes` are hoisted out of the scope
- the sheet is refused if its braces do not balance, if it contains `</style`, or if it is over the byte cap
func (SanitizedCSS) Empty ¶
func (s SanitizedCSS) Empty() bool
Empty reports whether there is nothing to serve.
func (SanitizedCSS) Render ¶
func (s SanitizedCSS) Render() string
Render produces the text injected into the browser's <style> element.
type SaveText ¶
type SaveText struct {
Store db.Store
StorageResolver func(int64) (storage.Driver, error)
Versions VersionSnapshotter
ACL *acl.Resolver
// Index keeps the saved text searchable. Optional; nil skips indexing.
Index *search.Index
}
SaveText handles plain-text edits from the SFC's code/markdown viewer.
func NewSaveText ¶
NewSaveText constructs the handler.
func (*SaveText) AttachACL ¶
AttachACL wires the RBAC resolver so save-text requires ≥editor on the file.
func (*SaveText) AttachSearchIndex ¶
AttachSearchIndex wires the search index. ⚠ Without it an edit saved from the built-in editor never reaches Bleve: the file keeps whatever text it had when it was last indexed, so a content search finds the OLD words and misses the new ones — and a name search only appears to work because the search endpoint falls back to a SQL LIKE over node rows.
func (*SaveText) AttachVersions ¶
func (h *SaveText) AttachVersions(v VersionSnapshotter)
AttachVersions wires the versioning service so save-text snapshots the previous content before writing. Without it edits silently overwrite history (the SFC's "Sürüm geçmişi" / Versions page would show no entries even after multiple saves).
func (*SaveText) Save ¶
func (h *SaveText) Save(w http.ResponseWriter, r *http.Request)
Save writes `content` as the new body of the addressed file.
Body shape mirrors brf-mono's `FilesController::saveText`. We do NOT version on save — the SFC's preview re-renders against the new bytes immediately, and a future versions endpoint can snapshot the previous payload before the write if requested.
type Search ¶
Search handles /api/files/search.
func (*Search) AttachACL ¶
AttachACL wires the RBAC resolver so search results are filtered to the paths the caller may see (prevents cross-user enumeration via search).
func (*Search) Search ¶
func (h *Search) Search(w http.ResponseWriter, r *http.Request)
Search returns up to N matching nodes.
Strategy: try Bleve first; on miss/empty, fall back to SQL LIKE on the `nodes.name` column.
Accepts both POST {query, storage_id, limit} (canonical) and GET ?q=…&storage_id=…&limit=… (admin SPA's toolbar search). The GET form lets the SFC degrade gracefully when the embedder hasn't wired the POST flow.
type SearchAdmin ¶
SearchAdmin holds the Bleve admin actions.
There is deliberately no rebuild lock here. The index owns that guard, because filex also rebuilds on its own when it finds an index written by an older document schema, and a handler-local flag could only ever see the rebuilds this handler started — the two would happily run at once and race to swap their replacement index in.
func NewSearchAdmin ¶
func NewSearchAdmin(idx *search.Index, store db.Store) *SearchAdmin
NewSearchAdmin constructs the handler.
func (*SearchAdmin) Rebuild ¶
func (h *SearchAdmin) Rebuild(w http.ResponseWriter, r *http.Request)
Rebuild reindexes every node row into a replacement index and swaps it in when it is complete. Search keeps answering from the existing index for the whole rebuild, and extracted text is carried across, so this is no longer the destructive operation it was before v0.30.
?content=1 additionally re-enqueues content extraction for every eligible node once the new index is live — for an operator who has just added an extractor or raised FILEX_SEARCH_CONTENT_MAX and wants the text derived again rather than copied.
func (*SearchAdmin) Stats ¶
func (h *SearchAdmin) Stats(w http.ResponseWriter, r *http.Request)
Stats returns index document counts and size.
type SelfTokens ¶
type SelfTokens struct {
// contains filtered or unexported fields
}
SelfTokens is the self-service API-token surface at /api/tokens. Any authenticated user (including non-admin user/viewer accounts) may mint tokens bound to THEMSELVES — capped so a token can never exceed the creator's account-role ceiling nor their own file grants. Admins get the richer /api/admin/ai-tokens surface; this is the safe capped subset for everyone.
func NewSelfTokens ¶
func NewSelfTokens(store db.Store, resolver *acl.Resolver, pauth *protocolauth.Resolver) *SelfTokens
NewSelfTokens constructs the self-service token handler.
func (*SelfTokens) Create ¶
func (h *SelfTokens) Create(w http.ResponseWriter, r *http.Request)
Create mints a token bound to the caller with capped scopes.
func (*SelfTokens) Delete ¶
func (h *SelfTokens) Delete(w http.ResponseWriter, r *http.Request)
Delete revokes one of the caller's own tokens (ownership enforced).
func (*SelfTokens) List ¶
func (h *SelfTokens) List(w http.ResponseWriter, r *http.Request)
List returns the caller's own tokens (never any secret).
func (*SelfTokens) Update ¶
func (h *SelfTokens) Update(w http.ResponseWriter, r *http.Request)
Update edits one of the caller's own tokens (label / usernames; ownership enforced — the credential and scopes stay immutable here).
PATCH /api/tokens/{id}
type Settings ¶
type Settings struct {
Store db.Store
Mailer *mailer.Service
// Branding is invalidated on branding.* writes so public pages pick the
// new identity up immediately (wiring:e1). Nil-safe.
Branding *BrandingSource
// Appearance is invalidated on ui.default_theme writes, so an operator who
// sets the instance default sees it take effect on the next page load
// rather than up to fifteen seconds later (tema:v1). Nil-safe.
//
// ⚠ Two caches, not one: branding is keyed per HOST and overlaid per
// tenant, while the appearance payload is a single instance-wide answer.
Appearance *AppearanceSource
}
Settings handles /api/admin/settings.
func NewSettings ¶
NewSettings constructs a Settings handler.
func (*Settings) AttachAppearance ¶
func (h *Settings) AttachAppearance(a *AppearanceSource)
AttachAppearance wires the shared theme source (tema:v1).
func (*Settings) AttachBranding ¶
func (h *Settings) AttachBranding(b *BrandingSource)
AttachBranding wires the shared branding source (wiring:e1).
func (*Settings) AttachMailer ¶
AttachMailer wires the mailer so the SMTP "Test" button can verify / send.
func (*Settings) List ¶
func (h *Settings) List(w http.ResponseWriter, r *http.Request)
List returns all key/value pairs.
func (*Settings) SMTPTest ¶
func (h *Settings) SMTPTest(w http.ResponseWriter, r *http.Request)
SMTPTest verifies the SMTP config (auth handshake) and, when a `to` address is given, sends a real test message end-to-end.
POST /api/admin/settings/smtp-test { "to": "you@example.com" } (to optional)
func (*Settings) Set ¶
func (h *Settings) Set(w http.ResponseWriter, r *http.Request)
Set upserts a single setting.
Body: {"value":"…"}
func (*Settings) Update ¶
func (h *Settings) Update(w http.ResponseWriter, r *http.Request)
Update upserts multiple settings in a single request — the collection-level counterpart of Set. The admin Settings page PATCHes the whole form here; the old API only had GET / + PUT /{key}, so the page's save 405'd.
Body: a flat JSON object {key: value, …}. Non-string values (numbers, bools) are JSON-encoded to text so they round-trip through the string-valued settings store. Returns the redacted settings map after the write.
type Share ¶
type Share struct {
// signature) so downloads don't re-zip the whole folder every time. A nil
// or disabled cache falls back to streaming a fresh zip. See serveFolderZip.
Zip *sharezip.Cache
// from settings (wiring:e1). Nil-safe: unwired = stock filex chrome.
Branding *BrandingSource
// unwired, that page falls back to streaming the ORIGINALS — which is what
// it always did, and what made a folder of photos crawl on open.
Thumbs *thumb.Pipeline
// `default_locale`). The visitor's request wins over it — see publicLocale.
DefaultLocale string
// staging area while a staged upload is still transferring. Nil-safe.
Body *filebody.Resolver
Tenants tenanturl.Resolver
// pages in this file are the no-JS answer behind it. nil (frontend not
// bundled) means every visitor gets the Go pages. See public_shell.go for
// which request gets which.
Shell http.Handler
// an app link falls back to "this needs a browser with JavaScript".
Apps *AppPlugins
// catalogue had not seen yet (catalogueOnDemand). nil-safe.
Index *search.Index
}
Share handles share creation and the public viewer endpoints.
func NewShare ¶
func NewShare(svc *share.Service, store db.Store, resolver func(int64) (storage.Driver, error), publicURL string, zipCache *sharezip.Cache) *Share
NewShare constructs a Share handler. zipCache enables the folder-share ZIP cache (pass a disabled/nil cache to stream fresh on every folder download).
func (*Share) AttachACL ¶
AttachACL wires the RBAC resolver so minting a public share link requires ≥editor on the target node (sharing grants outside access — a write action).
func (*Share) AttachApps ¶
func (h *Share) AttachApps(a *AppPlugins)
AttachApps wires the app-plugin handler for the no-JS app page.
func (*Share) AttachBody ¶
AttachBody wires the byte-source resolver so a share link serves a file that is still being transferred to the backend.
func (*Share) AttachBranding ¶
func (h *Share) AttachBranding(b *BrandingSource)
AttachBranding wires the shared branding source (wiring:e1).
func (*Share) AttachIndex ¶
AttachIndex wires the search index for on-demand cataloguing.
func (*Share) AttachLocale ¶
AttachLocale sets the fallback language for the public pages — used when the visitor's browser asks for neither of the two we ship.
func (*Share) AttachShell ¶
AttachShell wires the SPA document served to JavaScript browsers.
func (*Share) AttachTenants ¶
AttachTenants wires the shared per-request origin resolver (internal/tenanturl).
func (*Share) AttachThumbs ¶
AttachThumbs wires the thumbnail pipeline used by the public folder page.
func (*Share) HandleBrowseFile ¶
func (h *Share) HandleBrowseFile(w http.ResponseWriter, r *http.Request)
HandleBrowseFile streams a single file UNDER a shared folder: GET /s/{token}/f/{rel...}. Media endpoint → plain-text errors (the browse page is the human-facing surface).
func (*Share) HandleCreate ¶
func (h *Share) HandleCreate(w http.ResponseWriter, r *http.Request)
HandleCreate mints a new share token.
The SFC's `useFileApi.createShare` posts:
{ path: "<adapter>://<rel>", password: true|false, expires_at: …, max_downloads: … }
and reads `body.share.url` / `body.share.password_pin` afterwards. The legacy embed.js posts `{ node_id, pin, expires_in, … }` and reads the flat fields. We support both.
func (*Share) HandleDelete ¶
func (h *Share) HandleDelete(w http.ResponseWriter, r *http.Request)
HandleDelete revokes a share.
func (*Share) HandleDownload ¶
func (h *Share) HandleDownload(w http.ResponseWriter, r *http.Request)
HandleDownload streams the shared file (after PIN check).
On a PIN-protected share without a PIN, GET renders an HTML form. POST (with a PIN field) is what the form submits to. ?pin= and X-Filex-Pin are also accepted for programmatic access.
func (*Share) HandleList ¶
func (h *Share) HandleList(w http.ResponseWriter, r *http.Request)
HandleList returns the current caller's active share links for one item, so the permissions modal's "Existing links" section can list (and revoke) them.
GET /api/files/share?path=<adapter://rel> (or ?node_id=<n>)
Non-admins only see links they created; admins see every link on the item. A path with no indexed node (or no links yet) returns an empty list rather than an error so the modal shows "none" instead of failing. The `uuid` field carries the numeric share id (what DELETE /share/{id} expects), while `url` is built from the token — matching the ShareInfo shape the SFC consumes.
func (*Share) HandleMetadata ¶
func (h *Share) HandleMetadata(w http.ResponseWriter, r *http.Request)
HandleMetadata returns metadata for a share token (no PIN check).
Used by the embed.js viewer to decide whether to render a PIN prompt.
type ShareX ¶
type ShareX struct {
// contains filtered or unexported fields
}
ShareX is the token-authenticated endpoint that lets ShareX (the Windows screenshot / upload tool) push a file, image, or text capture into filex and get back a public, browser-viewable link in one round trip.
It is a thin wrapper over the same aiOps core the /api/ai surface uses, so an upload is stored AND indexed (a plain driver write into an un-indexed folder never creates the DB cache row a share lookup needs), then a public /s/<token> share is minted for it. The reply is the exact JSON ShareX's custom-uploader URL parser expects:
POST /api/sharex/upload (multipart/form-data)
file → the captured bytes (ShareX default FileFormName)
folder → optional target directory (default: "sharex")
→ 200 {"url":"<PublicURL>/s/<token>?inline=1"}
The link carries ?inline=1 so images/text render in the browser instead of forcing a download (see Share.HandleDownload). Every capture is stored under a random-prefixed filename so a fresh, collision-free share is minted each time (re-using a name would silently repoint the previous share at the new bytes).
func NewShareX ¶
func NewShareX(store db.Store, resolver func(int64) (storage.Driver, error), shareSvc *share.Service, publicURL string) *ShareX
NewShareX constructs the ShareX upload handler. publicURL is the base used to build the returned /s/<token> link (empty yields a relative link). shareSvc must be non-nil for sharing to work — it is the same *share.Service the rest of the app uses.
func (*ShareX) AttachACL ¶
AttachACL wires the RBAC resolver so the write + share both enforce the bound user's grants (≥editor on the target), mirroring the AI surface.
func (*ShareX) AttachBody ¶
AttachBody wires the byte-source resolver (staging-aware reads).
func (*ShareX) AttachSearchIndex ¶
AttachSearchIndex wires the search index into the ops core — a ShareX capture is a write like any other and must be searchable immediately.
func (*ShareX) AttachStaged ¶
func (h *ShareX) AttachStaged(s *StagedUpload)
AttachStaged routes captures above the chunk threshold through filex's staging area — a long screen recording is answered as soon as filex holds it instead of after the driver write (nil = synchronous writes).
func (*ShareX) AttachTenants ¶
AttachTenants wires the shared origin resolver (internal/tenanturl).
func (*ShareX) AttachThumbs ¶
AttachThumbs wires the thumbnail pipeline so captures get grid thumbnails like manager uploads (nil = thumbnails skipped).
type Shared ¶
type Shared struct {
// unsigned URL, which authenticated clients still fetch fine.
ThumbSigner *thumb.Signer
}
Shared serves the shared-with-me listing. Mounted inside the authenticated /api/files group, so the session/token middleware and confine.Middleware are already in force.
func (*Shared) AttachThumbSigner ¶
AttachThumbSigner wires the thumbnail URL stamp.
func (*Shared) SharedWithMe ¶
func (h *Shared) SharedWithMe(w http.ResponseWriter, r *http.Request)
SharedWithMe lists the items the caller holds a grant on, newest grant first, paginated with ?limit= (default 100, max 500) and ?offset=.
Four decisions worth stating, because each of them is a place where the obvious implementation is wrong:
A grant on a FOLDER lists the folder itself, not its contents. That is what the explorer needs: the row is a `dir` whose `path` is adapter-qualified, so opening it navigates into the folder through the ordinary listing endpoint (which applies the same grants again). Listing the contents here would flatten the tree and make the row unopenable.
"Does not own" is expressed the way filex actually models ownership. There is no per-node owner: `nodes.owner_id` exists for quota accounting only and is nil for everything a sync discovered. What separates "shared with me" from "mine" is where the access comes from — on an RBAC-off storage every authenticated user already reaches everything by role, so a grant row there is inert and the items are not "shared", they are just the user's files. Hence: RBAC-enabled storages only.
A grant with an empty path_prefix is a whole-storage grant. It is not listed as an item (its name would be empty); it is what makes the storage appear in `storages` below — the "shared drive" that the panel shows in its storage list rather than as a file row.
Tenancy is filtered explicitly, not assumed. tenantstore wraps exactly three methods, and per-grant reads are not among them, so an unfiltered listing here would hand a tenant the names and paths of another tenant's shared folders — the same cross-tenant leak the search handler filters out at handlers/search.go. Filtering only in ListEnabledStorages would be filtering in a place the test harness does not even wrap.
type SharesAdmin ¶
type SharesAdmin struct {
// resolver the share dialog's links use (handlers.Share). Zero value yields
// relative "/s/<token>" links, which every existing test constructing this
// handler by hand gets.
Tenants tenanturl.Resolver
// the app wakes and finds out (appLinkEnded). nil: app plugins are off.
Apps *wasmplugin.Registry
}
SharesAdmin handles /api/admin/shares.
func NewSharesAdmin ¶
func NewSharesAdmin(store db.Store) *SharesAdmin
NewSharesAdmin constructs the handler.
func (*SharesAdmin) AttachApps ¶
func (h *SharesAdmin) AttachApps(reg *wasmplugin.Registry)
AttachApps wires the app-plugin registry (nil = app plugins disabled).
func (*SharesAdmin) AttachTenants ¶
func (h *SharesAdmin) AttachTenants(rv tenanturl.Resolver)
AttachTenants wires the shared per-request origin resolver (internal/tenanturl).
func (*SharesAdmin) Delete ¶
func (h *SharesAdmin) Delete(w http.ResponseWriter, r *http.Request)
Delete hard-deletes a share row.
func (*SharesAdmin) List ¶
func (h *SharesAdmin) List(w http.ResponseWriter, r *http.Request)
List returns all shares with optional creator/active filters. Rows carry `plugin_name` / `page_id` when an app plugin opened the link (00046), so the Shares table can show a "plugin / page" column without a lookup per row.
func (*SharesAdmin) ListAppPluginShares ¶
func (h *SharesAdmin) ListAppPluginShares(w http.ResponseWriter, r *http.Request)
ListAppPluginShares is the same rows, narrowed to the links APPS opened, so an app's own admin panel can draw its table (`?plugin=sign`). It is the same query, the same tenant filter and the same envelope as List — there is no second listing to keep in step.
⚠ `?plugin=` takes the app's NAME, not its row id: a panel drawn for an app knows what the app is called and should not have to look its id up first.
func (*SharesAdmin) Revoke ¶
func (h *SharesAdmin) Revoke(w http.ResponseWriter, r *http.Request)
Revoke soft-revokes by setting expiration to now (keeps audit trail).
type SharesMine ¶
type SharesMine struct {
// PIN endpoint answers "cannot be shown" rather than panicking, which is
// also what an instance with no secret key gets.
Service *share.Service
// resolver the share dialog and the admin listing use, never
// window.location (issue #32).
Tenants tenanturl.Resolver
// app plugins are off: every row then lists as a plain share.
Apps *wasmplugin.Registry
}
SharesMine handles the caller's own shares.
func NewSharesMine ¶
func NewSharesMine(store db.Store, svc *share.Service) *SharesMine
NewSharesMine constructs the handler.
func (*SharesMine) AttachApps ¶
func (h *SharesMine) AttachApps(reg *wasmplugin.Registry)
AttachApps wires the app-plugin registry.
func (*SharesMine) AttachTenants ¶
func (h *SharesMine) AttachTenants(rv tenanturl.Resolver)
AttachTenants wires the shared per-request origin resolver (internal/tenanturl).
func (*SharesMine) List ¶
func (h *SharesMine) List(w http.ResponseWriter, r *http.Request)
List returns the shares the CALLER created, newest first.
⚠ The creator filter is applied in the STORE (`created_by = me`), not by dropping rows after the fact. A post-filter would have to paginate over everybody's links to fill one person's page, which means the total would be wrong and, worse, that the query the database ran was the admin one.
func (*SharesMine) Pin ¶
func (h *SharesMine) Pin(w http.ResponseWriter, r *http.Request)
Pin hands back ONE link's PIN, in plain, to the person who created it or to an administrator — and to nobody else.
Owner's decision, 2026-09-20: *"paylaşımın sahibi ve admin alabilir şifreyi… Admin değilse göremez, kopyalayamaz."*
The shape of the answer ¶
An authorized caller always gets 200, with a body that either carries the PIN or says in one machine-readable word why it cannot:
{"pin":"834595"}
{"pin":null,"reason":"no_pin"} this link has no PIN
{"pin":null,"reason":"not_recoverable"} minted before 00049 / key rotated
{"pin":null,"reason":"no_secret_key"} this instance has no FILEX_SECRET_KEY
The three refusals are 200 rather than an error status because they are not failures of the request: the caller is allowed, the link exists, and the honest answer is a sentence the screen must show. An error status here would be indistinguishable — to a client's interceptor — from "you may not".
Why every read is audited ¶
A PIN is the second factor on a public link. Reading one is not like reading a row of a table, and the audit row is what makes "the owner and an admin" a statement somebody can CHECK afterwards rather than a claim about code. The row is written whatever the answer was, because an attempt that found nothing is still an attempt.
type StagedUpload ¶
type StagedUpload struct {
Store db.Store
Manager *Manager
Area *staging.Area
Ops *ops.Service
Quota *quota.Service
ACL *acl.Resolver
// ChunkSize is the default part size handed to clients that do not ask.
ChunkSize int64
// TTL is how long an idle staging directory survives the sweeper.
TTL time.Duration
}
StagedUpload is the HTTP surface of the staged upload protocol plus the transfer worker that ops calls back into.
func NewStagedUpload ¶
func NewStagedUpload(store db.Store, mgr *Manager, area *staging.Area, opsSvc *ops.Service, q *quota.Service, chunkSize int64, ttl time.Duration) *StagedUpload
NewStagedUpload constructs the handler. mgr carries the post-write hooks (search index, thumbnails, parent lookup, realtime) so the committed bytes go through exactly the same door as a plain upload rather than a parallel one.
func (*StagedUpload) Abort ¶
func (h *StagedUpload) Abort(w http.ResponseWriter, r *http.Request)
Abort drops the staging directory and the row.
func (*StagedUpload) AttachACL ¶
func (h *StagedUpload) AttachACL(r *acl.Resolver)
AttachACL wires the RBAC resolver. Begin gates on the destination directory through the manager's resolveAdapterDir; every later call re-checks the stored key, because an upload id is not a capability.
func (*StagedUpload) Begin ¶
func (h *StagedUpload) Begin(w http.ResponseWriter, r *http.Request)
Begin reserves a staging directory for a new upload.
func (*StagedUpload) Commit ¶
func (h *StagedUpload) Commit(w http.ResponseWriter, r *http.Request)
Commit verifies the staged bytes, publishes the node immediately as `staged` and hands the actual transfer to a background op.
func (*StagedUpload) CommitUpload ¶
func (h *StagedUpload) CommitUpload(ctx context.Context, uploadID string) error
CommitUpload streams one staged upload into the driver and fires every post-write hook the synchronous path fires. Called by the ops worker with a server-lifetime context — never a request context.
On success: transfer_state → "stored", staging deleted, row deleted. On failure: the row goes to "failed" with the message, the staging directory is KEPT (so a retry costs no bytes) and the node stays "staged".
func (*StagedUpload) IngestStream ¶
func (h *StagedUpload) IngestStream( ctx context.Context, storageID int64, storageKey string, src io.Reader, size int64, userID int64, declaredMime string, ) (*model.Node, error)
IngestStream stages src (exactly size bytes) for storageKey on storageID, publishes the node immediately with transfer_state="staged" and hands the driver write to the ops worker. The returned node is listable before a single byte has reached the backend — that is the point.
A short or over-long body is refused and the staging directory removed: the same rule as a chunk that arrives short, for the same reason.
declaredMime is advisory. As on every other write path the bytes decide, sniffed from staging once they are all there.
func (*StagedUpload) Put ¶
func (h *StagedUpload) Put(w http.ResponseWriter, r *http.Request)
Put stores one chunk. The chunk's position comes from Content-Range, and the part number is derived from it against the grid `begin` handed out — so parts may arrive out of order and a retried chunk simply overwrites itself.
func (*StagedUpload) RecoverUnstored ¶
func (h *StagedUpload) RecoverUnstored(ctx context.Context) int
RecoverUnstored settles the rows a staged upload left "staged" or "failed" although their bytes are on the storage, and reports how many it settled.
transfer() now makes the "stored" flip a condition of releasing staging, but a process that died between the driver write and the flip, or ran a version that released staging regardless, can leave such a row with no session behind it: listed, and unreadable. Nothing else settles it on a storage nobody scans (sync_mode `ondemand`), so this runs once at boot, from RunSweeper, after the first sweep.
A row is settled only on the evidence the storage sync uses: no session references it (a session owns its bytes — in flight, or retryable), and the object at its key has the committed size and is not older than the commit (model.TransferLanded). Anything short of that is left exactly as it is and logged: an error a person can read beats a wrong file served as the right one.
func (*StagedUpload) RunSweeper ¶
func (h *StagedUpload) RunSweeper(ctx context.Context, interval time.Duration)
RunSweeper sweeps once at boot and then on every tick until ctx is done.
func (*StagedUpload) ShouldStage ¶
func (h *StagedUpload) ShouldStage(size int64) bool
ShouldStage is the single decision every whole-body surface asks.
func (*StagedUpload) StagedReady ¶
func (h *StagedUpload) StagedReady() bool
StagedReady reports whether IngestStream can run at all.
func (*StagedUpload) StagedThreshold ¶
func (h *StagedUpload) StagedThreshold() int64
StagedThreshold is the size above which a whole-body upload is staged rather than written straight to the driver. It is the same chunk size the chunked protocol hands out, so "large" means the same thing on every surface.
func (*StagedUpload) Status ¶
func (h *StagedUpload) Status(w http.ResponseWriter, r *http.Request)
Status is the resume oracle: a client that lost its state asks here and continues from `offset`.
func (*StagedUpload) Sweep ¶
func (h *StagedUpload) Sweep(ctx context.Context) (int, int)
Sweep removes staging that has seen no activity for longer than TTL, and the rows that describe it. Returns (rows, orphan directories) removed.
`committing` rows are never swept: their bytes are being read right now. `failed` rows are, once they too have been idle for a full TTL — otherwise a permanently failing upload keeps its bytes forever.
type StorageDrivers ¶
type StorageDrivers struct{}
StorageDrivers serves the driver descriptor catalogue.
func NewStorageDrivers ¶
func NewStorageDrivers() *StorageDrivers
NewStorageDrivers constructs the handler.
func (*StorageDrivers) List ¶
func (h *StorageDrivers) List(w http.ResponseWriter, r *http.Request)
List returns every registered driver's descriptor, driver-name sorted.
A driver a storage may be catalogued lazily on (model.LazyDrivers) also carries `lazy_fields`, the settings of sync_mode `lazy`; the storage form offers that mode only where they are (docs/LAZY-CATALOGUE.md).
type StorageSummary ¶
type StorageSummary struct {
ID int64 `json:"id"`
Name string `json:"name"`
Driver string `json:"driver"`
MountPath string `json:"mount_path"`
Enabled bool `json:"enabled"`
TotalFiles int64 `json:"total_files"`
TotalBytes int64 `json:"total_bytes"`
LastSyncAt any `json:"last_sync_at,omitempty"`
LastSyncStatus string `json:"last_sync_status,omitempty"`
State string `json:"state"` // ok | stale | error | running
}
StorageSummary is a per-storage row in the dashboard response.
type StorageUsageRow ¶
type StorageUsageRow struct {
Name string `json:"name"`
UsedBytes int64 `json:"used_bytes"`
FileCount int64 `json:"file_count"`
// Coverage is set when the figure counts only part of the drive: its
// first scan is still running, or it is catalogued lazily and not all of
// it has been (docs/LAZY-CATALOGUE.md). The card then draws it as a lower
// bound and says why.
Coverage *syncpkg.CatalogueCoverage `json:"coverage,omitempty"`
}
StorageUsageRow is one drive's answer.
type Storages ¶
type Storages struct {
Store db.Store
Worker *syncpkg.Worker
// Plugins and StorageResolver are set when the plugin subsystem is on.
// They exist for ONE reason: a storage saved on a plugin driver is probed
// against its own configuration first (see plugin_gate.go). Nil means the
// subsystem is off and nothing is probed.
Plugins *plugin.Manager
StorageResolver func(int64) (storage.Driver, error)
// ForgetStorage drops the cached driver the resolver built for a storage.
// Set by the server; nil where nothing caches.
ForgetStorage func(int64)
// DemoMode marks a public playground, where "admin" is whoever read the
// credentials off the landing page. See denyOnDemo.
DemoMode bool
}
Storages handles /api/admin/storages.
func NewStorages ¶
NewStorages constructs a Storages handler.
func (*Storages) Create ¶
func (h *Storages) Create(w http.ResponseWriter, r *http.Request)
Create adds a new storage.
func (*Storages) Delete ¶
func (h *Storages) Delete(w http.ResponseWriter, r *http.Request)
Delete removes a storage and its descendant nodes (cascade).
func (*Storages) Get ¶
func (h *Storages) Get(w http.ResponseWriter, r *http.Request)
Get returns a single storage by id. The admin Storages list page fetches /api/admin/storages/{id} when the user clicks a row to open the edit view; without this route chi returned 405.
func (*Storages) List ¶
func (h *Storages) List(w http.ResponseWriter, r *http.Request)
List returns all configured storages. Each entry carries a `stats` blob with the file count + total byte sum so the admin Storages list page can render real "12 files, 4.2 MB" labels instead of static placeholders.
Accepts `?role=primary` / `?role=replica` so the Depolar page can hide replica targets (operators never write to them directly) and the Replikasyon page can list replica candidates separately.
func (*Storages) TriggerSync ¶
func (h *Storages) TriggerSync(w http.ResponseWriter, r *http.Request)
TriggerSync forces an immediate sync run for a storage — or, with ?path=<folder>, a rescan of that one catalogued folder (rescanFolder).
type StoragesAdmin ¶
type StoragesAdmin struct {
Store db.Store
// Plugins is set when the plugin subsystem is on; Test then probes a
// plugin driver's real capabilities instead of only listing its root.
Plugins *plugin.Manager
}
StoragesAdmin holds extra admin actions on storages.
func NewStoragesAdmin ¶
func NewStoragesAdmin(store db.Store) *StoragesAdmin
NewStoragesAdmin constructs the handler.
func (*StoragesAdmin) Discover ¶
func (h *StoragesAdmin) Discover(w http.ResponseWriter, r *http.Request)
Discover lists the folders directly under the given driver+config's root without saving anything, so the operator can mount several of them as separate storages in one go.
Issue #31: a bucket holding N top-level folders meant filling in the storage form N times, once per folder, because the bucket ROOT is never mounted (storage.ErrRootPathForbidden — filex would otherwise take ownership of everything at the root of a shared namespace). This is how "the whole bucket" is offered instead: the same credentials once, every folder listed, each one ticked becomes its own storage with its own sidebar entry. The probe itself may look at the root — looking is not mounting — and the rows the caller then creates go through Create and its root guard like any other.
POST /api/admin/storages/discover {driver, config}
→ {ok, root_key, folders:[{name, root}]}
Folders whose name starts with "." (filex's own `.filex-trash`, a `.git`, an editor's scratch) are left out: they are nobody's document root.
func (*StoragesAdmin) Drift ¶
func (h *StoragesAdmin) Drift(w http.ResponseWriter, r *http.Request)
Drift returns recent unresolved sync conflicts for a storage.
func (*StoragesAdmin) SyncRuns ¶
func (h *StoragesAdmin) SyncRuns(w http.ResponseWriter, r *http.Request)
SyncRuns returns the recent sync_runs for a single storage.
func (*StoragesAdmin) Test ¶
func (h *StoragesAdmin) Test(w http.ResponseWriter, r *http.Request)
Test connects to the given driver+config without saving and lists the root.
type SyncAdmin ¶
SyncAdmin handles /api/admin/sync-runs.
func NewSyncAdmin ¶
NewSyncAdmin constructs the handler.
type ThemeExport ¶
type ThemeExport struct {
Filex int `json:"filex_theme"`
Key string `json:"key"`
Name string `json:"name"`
Light map[string]string `json:"light"`
Dark map[string]string `json:"dark"`
}
ThemeExport is the JSON form of a theme — what "Export" downloads and what "Import" reads.
⚠ It carries the KEY, so importing the same file twice replaces rather than duplicates. Carrying a theme to another instance and then carrying a fix over is the ordinary case, and a second "Acme (1)" would be the wrong answer to it; the admin screen names the key it is about to overwrite.
func ExportTheme ¶
func ExportTheme(t *model.CustomTheme) ThemeExport
ExportTheme renders a stored theme as its portable document.
type ThemeWire ¶
type ThemeWire struct {
ID string `json:"id"`
Name string `json:"name"`
Light map[string]string `json:"light"`
Dark map[string]string `json:"dark"`
}
ThemeWire is one palette as the browser receives it — the same shape as a built-in `ThemeDef`, with the id already prefixed.
type Themes ¶
type Themes struct {
Store db.Store
Appearance *AppearanceSource
}
Themes handles /api/admin/themes.
func NewThemes ¶
func NewThemes(store db.Store, src *AppearanceSource) *Themes
NewThemes constructs the admin handler.
func (*Themes) Delete ¶
func (h *Themes) Delete(w http.ResponseWriter, r *http.Request)
Delete removes a theme.
⚠ It does NOT touch anybody's stored preference — see ResolveThemeID. The one thing it does clean is the instance DEFAULT, and only because that is a choice an operator made deliberately: leaving `ui.default_theme` pointing at a deleted theme would resolve correctly but read as a bug in the admin screen, which would show a default nobody can find in the list.
type Thumb ¶
type Thumb struct {
Store db.Store
Pipeline *thumb.Pipeline
// ACL gates the authenticated path. Nil = unwired (tests) and, per the
// aclAllowID contract, allows — the tenancy and confinement checks above it
// still run.
ACL *acl.Resolver
// Signer verifies the URL stamp. Nil is the fail-CLOSED state: unsigned and
// signed URLs alike then need an authenticated caller.
Signer *thumb.Signer
}
Thumb serves cached thumbnail JPEGs.
⚠⚠ Every request is authorized, by one of exactly two proofs:
- a live signature on the URL (`?exp=…&sig=…`), stamped by a listing that had already cleared tenancy + ACL for that node. This is what lets a bare `<img src>` — which carries no Authorization header, and no cookie either when it sits in a third-party embed, because the session cookie is SameSite=Lax — render at all; or
- an authenticated caller (session cookie, bearer/API token) who passes the same tenancy, root-confinement and ACL checks the file's own listing applies. This is the path every in-repo consumer actually takes: the SPA, the desktop app and the embedded explorer all fetch thumbnails through useThumbs, i.e. `fetch()` with credentials and auth headers.
Neither proof → 401. It used to be neither proof → 200: `checkSig` returned true when `sig` was absent, `manager.go` emitted `thumb_url` with no signature, and the signing key was never seeded, so the whole scheme was inert and anyone could walk the dense node-id range and collect the rendered first page of every file on the instance. That was true of single-tenant installs too.
⚠ The public folder-share page does NOT come through here: it serves the same cached artefact via `/s/{token}/f/<path>?thumb=1`, scoped to the share token (see share_browse.go). An anonymous share viewer is therefore unaffected by this gate, and must stay that way.
func (*Thumb) AttachSigner ¶
AttachSigner wires the URL signature verifier.
type ThumbPipeline ¶
ThumbPipeline is the narrow surface manager_mutate needs to fire a thumbnail job after upload. Kept as an interface so the package doesn't have to import `internal/thumb` (which would create an import cycle through the storage resolver wiring).
type TicketUpload ¶
type TicketUpload struct {
// contains filtered or unexported fields
}
TicketUpload serves PUT/POST /u/{ticket}: the credential-free half of the ticket flow. It is deliberately the thinnest possible surface — one write to one pinned path — and it never lists, reads or reveals anything about the destination.
func NewTicketUpload ¶
func NewTicketUpload(store db.Store, resolver func(int64) (storage.Driver, error), tickets *uploadTicketStore) *TicketUpload
NewTicketUpload builds the public redeem handler over the same ops core the authorized surfaces use, so a ticket upload gets identical staging, thumbnailing, write-hook and DB-cache behaviour.
func (*TicketUpload) AttachACL ¶
func (h *TicketUpload) AttachACL(r *acl.Resolver)
AttachACL wires the RBAC resolver so the write runs under the minter's grants.
func (*TicketUpload) AttachBody ¶
func (h *TicketUpload) AttachBody(b *filebody.Resolver)
AttachBody wires the byte-source resolver.
func (*TicketUpload) AttachSearchIndex ¶
func (h *TicketUpload) AttachSearchIndex(idx *search.Index)
AttachSearchIndex wires the search index. ⚠ Easy to forget, and the symptom is quiet: a ticket upload without it lands a row and a change frame — so the folder listing and every open explorer look right — while the file is findable by name only through the search endpoint's SQL LIKE fallback, and not at all by its content.
func (*TicketUpload) AttachStaged ¶
func (h *TicketUpload) AttachStaged(s *StagedUpload)
AttachStaged routes big transfers through the staging area — the whole point of tickets is big files, so this is the common path, not the exception.
func (*TicketUpload) AttachThumbs ¶
func (h *TicketUpload) AttachThumbs(p *thumb.Pipeline)
AttachThumbs mirrors the manager-upload thumbnail dispatch.
func (*TicketUpload) Upload ¶
func (h *TicketUpload) Upload(w http.ResponseWriter, r *http.Request)
Upload handles PUT /u/{ticket} (raw body, what `curl -T` sends) and POST /u/{ticket} (multipart `file`, what a browser or `curl -F` sends).
type Trash ¶
type Trash struct {
Service *trash.Service
Store db.Store
ACL *acl.Resolver
// Index puts a restored file back into search. Optional; nil skips it.
Index *search.Index
// Ops is the queue "empty the trash now" runs on (ops.SubmitTrashEmpty).
// Without it AdminEmpty answers 503, like every queued operation.
Ops *ops.Service
// EmptyWait is how long AdminEmpty waits for the purge it started before
// answering 202 with the run's progress instead. Zero is defaultEmptyWait.
EmptyWait time.Duration
}
Trash wires trash retention HTTP routes.
func (*Trash) AdminEmpty ¶
func (h *Trash) AdminEmpty(w http.ResponseWriter, r *http.Request)
AdminEmpty starts "empty the trash now" and answers when it is over or when EmptyWait has passed, whichever comes first: 200 with the final counts, or 202 with the run's progress so far — which GET on the same path (EmptyStatus) keeps reporting until the run ends. 409 BUSY while the caller's tenant already has one going (with that run as `job`); 400 for a request it cannot read.
`older_than_days` and `storage_id` arrive in the query or the JSON body (the admin SPA's trashApi.empty posts {storage_id, older_than_days}). 0/missing days wipes everything soft-deleted when it is asked; 0/missing storage is every storage the caller can reach.
⚠⚠ The purge used to run inside this request, and a large trash cannot be purged inside any request. 61,844 files needed the better part of an hour; nginx answered 504 at sixty seconds, the request's context was cancelled, and the purge died mid-batch — while the admin page, whose HTTP client had given up at thirty, showed nothing at all. The run is an ops job now (ops.SubmitTrashEmpty): in the operations centre and the admin tray, cancellable like every op, its tenant's alone, resumed after a restart — and this request only watches it for a moment.
func (*Trash) AttachACL ¶
AttachACL wires the RBAC resolver so the trash list is filtered to nodes the caller may see and restore requires ≥editor on the node's original path.
func (*Trash) AttachSearchIndex ¶
AttachSearchIndex wires the search index. ⚠ Deleting a file removes its document from the index (correctly). Restoring it never put the document back, so a restored file was in the listing, on the storage, and unfindable — and looked findable anyway, because a name search falls back to a SQL LIKE over node rows.
func (*Trash) EmptyStatus ¶
func (h *Trash) EmptyStatus(w http.ResponseWriter, r *http.Request)
EmptyStatus reports the caller's tenant's latest "empty the trash now": queued, running, or how it ended. `{"running": false}` alone when it has asked for none.
func (*Trash) List ¶
func (h *Trash) List(w http.ResponseWriter, r *http.Request)
List returns soft-deleted nodes for the admin trash view.
Query: ?storage_id=…&limit=…&offset=…
type Update ¶
type Update struct {
// contains filtered or unexported fields
}
Update is the admin-facing surface of the release checker:
GET /api/admin/update → what we know (cached, no network) POST /api/admin/update/check → check now POST /api/admin/update/apply → install the pending release (binary installs)
The split matters: reading the status must never block on a slow or absent network, and applying must never be a side effect of looking.
func NewUpdate ¶
NewUpdate builds the handler. A nil service means the feature is compiled in but disabled by config; the endpoints then answer with a quiet "disabled" status rather than 404, so the UI can explain the state.
func (*Update) Apply ¶
func (h *Update) Apply(w http.ResponseWriter, r *http.Request)
Apply installs the pending release. Refused with 409 when the install cannot replace itself (container, package manager) — a permanent condition the UI turns into instructions, and 409 keeps it distinct from a transient 5xx.
func (*Update) Check ¶
func (h *Update) Check(w http.ResponseWriter, r *http.Request)
Check forces a fetch. Used by the "check now" button; a failure is reported in the payload (check_error) rather than as an HTTP error, because "I could not reach the update server" is information, not a server fault.
type Upload ¶
type Upload struct {
Store db.Store
StorageResolver func(int64) (storage.Driver, error)
Thumbs *thumb.Pipeline
ACL *acl.Resolver
}
Upload handles browser to S3 multipart uploads.
Flow:
- Init — caller posts {storage_id, path, size}. Backend computes part count, calls MultipartUploader.InitMultipart, persists a chunked_uploads row and returns the per-part presigned URLs.
- Browser uploads each part directly to S3 via the presigned URLs and captures each part's ETag from the response header.
- Finalize — caller posts {upload_id, parts:[{part_number, etag}]}. Backend asks S3 to assemble the parts via CompleteMultipartUpload, then upserts a node row and dispatches a thumbnail job.
- Abort — caller cancels mid-flight; AbortMultipartUpload is called and the chunked_uploads row deleted.
func NewUpload ¶
func NewUpload(store db.Store, resolver func(int64) (storage.Driver, error), pipeline *thumb.Pipeline) *Upload
NewUpload constructs an Upload handler.
pipeline may be nil; in that case node insertion still happens but no thumbnail job is dispatched.
func (*Upload) Abort ¶
func (u *Upload) Abort(w http.ResponseWriter, r *http.Request)
Abort cancels an in-progress upload.
func (*Upload) AttachACL ¶
AttachACL wires the RBAC resolver so chunked uploads require ≥editor on the destination at init time (finalize/abort continue an already-authorized id).
type Usage ¶
Usage serves /api/admin/usage.
Configuration is written through the ordinary settings endpoint (the keys are ordinary settings rows, and `allowSettingWrite` already reserves every non-branding key to the supertenant); this handler only reads.
func (*Usage) Report ¶
func (h *Usage) Report(w http.ResponseWriter, r *http.Request)
Report answers GET /api/admin/usage.
?days=30 the last N days ending today (default 30, max 400) ?from=&to= an explicit range, YYYY-MM-DD, inclusive
⚠ The default range ENDS TODAY even though today's report does not exist yet: a page that silently shifted the window back a day would show a total that does not match the dates on its own axis. The absent day is a note in the payload instead.
type UserPrefs ¶
UserPrefs wires the per-user, per-surface preference routes.
func NewUserPrefs ¶
NewUserPrefs constructs the handler.
func (*UserPrefs) Get ¶
func (h *UserPrefs) Get(w http.ResponseWriter, r *http.Request)
Get returns the caller's document for one surface. A caller with nothing stored gets an empty object rather than a 404: "nothing chosen yet" is the ordinary state of every new account, and a 404 would make the client treat its own first day as a failure.
type Users ¶
Users handles /api/admin/users.
func (*Users) Create ¶
func (h *Users) Create(w http.ResponseWriter, r *http.Request)
Create makes a new user.
func (*Users) Delete ¶
func (h *Users) Delete(w http.ResponseWriter, r *http.Request)
Delete removes a user. The last remaining admin can never be deleted — not even by itself — so the instance can't be locked out of its own admin surface. A non-existent id is reported as 404 rather than a silent 200.
func (*Users) Get ¶
func (h *Users) Get(w http.ResponseWriter, r *http.Request)
Get returns a single user by id. The admin UI's UserEdit page hits this when the row is clicked; without it chi returned 405 (only PATCH/DELETE were wired).
type UsersAdmin ¶
UsersAdmin holds admin-only user actions.
func NewUsersAdmin ¶
func NewUsersAdmin(store db.Store) *UsersAdmin
NewUsersAdmin constructs the handler.
func (*UsersAdmin) ResetPassword ¶
func (h *UsersAdmin) ResetPassword(w http.ResponseWriter, r *http.Request)
ResetPassword generates a fresh random password for the user, persists the hash, returns the cleartext password ONCE in the response (admin must save).
type VersionSnapshotter ¶
type VersionSnapshotter interface {
Snapshot(ctx context.Context, nodeID int64) (*model.NodeVersion, error)
}
VersionSnapshotter is the narrow surface save-text needs to capture a snapshot before a destructive write.
type Versions ¶
type Versions struct {
Store db.Store
Service *versioning.Service
// Index keeps the restored content searchable. Optional; nil skips it.
Index *search.Index
// ACL is the RBAC resolver. Nil = unwired (tests) and, per the aclAllowID
// contract, allows — the existence, tenancy and confinement checks above it
// still run.
ACL *acl.Resolver
}
Versions wraps version-history HTTP routes.
func NewVersions ¶
func NewVersions(store db.Store, svc *versioning.Service) *Versions
NewVersions constructs the handler.
func (*Versions) AttachACL ¶
AttachACL wires the RBAC resolver. ⚠ This handler shipped without one: it was the only file surface in routes.go with no AttachACL call, which is precisely why nobody noticed that a read-only account could roll files back.
func (*Versions) AttachSearchIndex ¶
AttachSearchIndex wires the search index. ⚠ Restoring a version rewrites the file's BYTES at an unchanged path, and nothing else ever revisits a document whose path did not change — so without this the index keeps the text of the version that was just rolled back. Measured: after restoring v1, a content search for a phrase only v2 ever contained still returned the file, and a phrase that IS in the restored file did not.
func (*Versions) HardDelete ¶
func (h *Versions) HardDelete(w http.ResponseWriter, r *http.Request)
HardDelete erases a version row + its storage object (admin only).
func (*Versions) List ¶
func (h *Versions) List(w http.ResponseWriter, r *http.Request)
List returns the version timeline for a node.
type ViewPrefs ¶
ViewPrefs wires the per-user view-preference routes.
func NewViewPrefs ¶
NewViewPrefs constructs the handler.
func (*ViewPrefs) Get ¶
func (h *ViewPrefs) Get(w http.ResponseWriter, r *http.Request)
Get returns the caller's document. A caller with no stored document gets an empty object rather than a 404: "nothing arranged yet" is the ordinary state of every new account, and a 404 would make the client treat its own first day as a failure.
type WS ¶
type WS struct {
Store db.Store
ACL *acl.Resolver
Hub *realtime.Hub
Tickets *realtime.TicketStore // ticket auth for embedded/cross-origin clients
PublicURL string // used to advertise the wss:// URL in ticket responses
// Tenants resolves which origin the advertised wss:// URL is built on.
// In multi-tenant mode a tenant's browser must be told to open ITS OWN
// host — the operator's would fail the origin check and, on a separate
// certificate, not connect at all.
Tenants tenanturl.Resolver
// PublicURLSet reports whether PublicURL was chosen by the operator. False
// means it is the built-in guess, and wsURL then takes the origin from the
// request instead of announcing an address nothing is listening on.
PublicURLSet bool
// MultiTenant mirrors the config switch, and it is needed here for the same
// reason auth.TenantResolver takes it: a TICKETED upgrade authenticates
// after every middleware has run, so this handler is the only place that
// can attach the ticket user's tenant scope — and it must attach one ONLY
// when the resolver would have. auth.ScopeForUser fails closed
// (tenant.DenyAll) for a user with no provider, which is the normal shape
// on a single-tenant install; attaching that unconditionally would take
// every embedded client on every single-tenant install off the air.
MultiTenant bool
// Lazy, when wired, is told about every accepted recursive watch: a
// desktop sync pair on a lazily catalogued storage needs its whole subtree
// in the catalogue, because the upload precondition it sends is judged
// against catalogue rows (docs/LAZY-CATALOGUE.md). nil changes nothing.
Lazy LazyCatalogue
}
WS is the live-collaboration WebSocket handler. It upgrades GET /api/ws to a WebSocket, then relays the connected user's intents (subscribe to a folder, focus a file, ping) into the realtime Hub and streams the Hub's change + presence frames back to the browser.
The route MUST be mounted in the AUTHENTICATED group so auth.UserFrom(ctx) resolves the caller (the browser's session cookie authenticates the upgrade; it is same-origin, so no bearer token is involved for the native panel).
func NewWS ¶
func NewWS(store db.Store, resolver *acl.Resolver, hub *realtime.Hub, tickets *realtime.TicketStore, publicURL string) *WS
NewWS constructs the WebSocket handler. A nil hub makes Handle reply 503 so the route can be registered unconditionally.
func (*WS) AttachLazy ¶ added in v0.44.2
func (h *WS) AttachLazy(l LazyCatalogue)
AttachLazy wires the lazy catalogue.
func (*WS) AttachMultiTenant ¶
AttachMultiTenant records whether the instance runs in multi-tenant mode. Kept out of NewWS for the same reason as AttachPublicURLConfigured: the existing tests construct a WS directly and must keep the single-tenant (zero-value) behaviour without knowing this exists.
func (*WS) AttachPublicURLConfigured ¶
AttachPublicURLConfigured records whether PublicURL was chosen rather than defaulted. ⚠ Kept out of NewWS deliberately: every test constructs a WS with an explicit URL, and a constructor that guessed at this would have them all disagree with the running server about the one thing being fixed.
func (*WS) AttachTenants ¶
AttachTenants wires the shared per-request origin resolver (internal/tenanturl).
func (*WS) Handle ¶
func (h *WS) Handle(w http.ResponseWriter, r *http.Request)
Handle upgrades the request and runs the per-connection read loop + write pump until the socket closes.
func (*WS) Ticket ¶
func (h *WS) Ticket(w http.ResponseWriter, r *http.Request)
Ticket mints a short-lived, single-use WebSocket auth ticket for the caller, bound to their identity + confinement. Embedded consumers fetch this through the host's HTTP proxy (which injects the real token) and then open `wss://.../api/ws?ticket=<t>` directly — the durable token never reaches the browser. Same-origin callers (the native panel) can use it too.
POST /api/files/ws-ticket → {"ticket": "...", "ws_url": "wss://host/api/ws"}
type WebhooksAdmin ¶
WebhooksAdmin is the admin CRUD surface for webhook v2 targets (multi-destination, event-filtered, HMAC-signed deliveries). The legacy single global webhook keeps its own endpoints under /api/admin/notifications/webhook-config.
func NewWebhooksAdmin ¶
func NewWebhooksAdmin(store db.Store, svc notify.Service) *WebhooksAdmin
NewWebhooksAdmin constructs the handler.
func (*WebhooksAdmin) Create ¶
func (h *WebhooksAdmin) Create(w http.ResponseWriter, r *http.Request)
Create adds a new target.
POST /api/admin/webhooks
body: {name, url, secret?, events?: ["file.uploaded",...], enabled?}
func (*WebhooksAdmin) Delete ¶
func (h *WebhooksAdmin) Delete(w http.ResponseWriter, r *http.Request)
Delete removes a target.
DELETE /api/admin/webhooks/{id}
func (*WebhooksAdmin) List ¶
func (h *WebhooksAdmin) List(w http.ResponseWriter, r *http.Request)
List returns every target (enabled or not), secrets masked, plus the in-memory last-delivery status per target.
GET /api/admin/webhooks
func (*WebhooksAdmin) Test ¶
func (h *WebhooksAdmin) Test(w http.ResponseWriter, r *http.Request)
Test fires a sample payload at one target synchronously (single attempt) and returns the outcome so the admin sees pass/fail with the concrete error inline.
POST /api/admin/webhooks/{id}/test
func (*WebhooksAdmin) Update ¶
func (h *WebhooksAdmin) Update(w http.ResponseWriter, r *http.Request)
Update patches a target.
PATCH /api/admin/webhooks/{id}
Source Files
¶
- account_rules.go
- acl_guard.go
- ai.go
- ai_admin.go
- ai_mcp.go
- ai_ops.go
- ai_tags.go
- ai_tokens_admin.go
- antivirus_emit.go
- app_plugins.go
- app_plugins_admin.go
- app_plugins_badges.go
- app_plugins_words.go
- apptoken_gate.go
- archive.go
- archive_admin.go
- archive_download.go
- archive_external.go
- audit.go
- audit_targets.go
- auth.go
- auth_providers.go
- auth_self.go
- body_length.go
- branding.go
- branding_contrast.go
- cache_wait.go
- capabilities.go
- catalogue_on_demand.go
- cloud.go
- comments.go
- confine_guard.go
- custom_css.go
- custom_css_serve.go
- dashboard.go
- demo_redact.go
- desktop_auth.go
- drop.go
- duplicates.go
- e2e.go
- external_admin.go
- filebody_errors.go
- grants.go
- image_ref.go
- lazy_listing.go
- mail_templates.go
- manager.go
- manager_changes.go
- manager_mutate.go
- manager_newdoc.go
- manager_opsync.go
- meta.go
- name_taken.go
- nfsexports.go
- notifications.go
- notify_emit.go
- onlyoffice.go
- ops.go
- pathguard.go
- person_names.go
- plugin_gate.go
- plugins.go
- protection.go
- providers.go
- public_api.go
- public_cache.go
- public_i18n.go
- public_shell.go
- queue.go
- quota.go
- quota_storages.go
- range_stream.go
- realtime_emit.go
- replica.go
- replication_targets.go
- s3keys.go
- save_text.go
- search.go
- search_admin.go
- settings.go
- share.go
- share_app_page.go
- share_browse.go
- shared.go
- shares_admin.go
- shares_mine.go
- sharex.go
- sshkeys.go
- storage_drivers.go
- storages.go
- storages_admin.go
- supertenant.go
- surface_conditions.go
- sync_admin.go
- tags.go
- tenantown.go
- themes.go
- thumb.go
- thumb_url.go
- token_ceiling.go
- tokens_self.go
- trash.go
- update.go
- upload.go
- upload_expect.go
- upload_staged.go
- upload_staged_ingest.go
- upload_ticket.go
- usage.go
- userprefs.go
- users.go
- users_admin.go
- versions.go
- viewprefs.go
- webhooks_admin.go
- write_gate.go
- ws.go