handlers

package
v0.53.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 7, 2026 License: MIT Imports: 133 Imported by: 0

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_doors.go

The explorer's operations behind the AI surface (task #114): copy, an app's action (file_convert), the operations queue, the trash, version history, 7z/TAR archives, one's own links and file requests - each an MCP tool and a REST route under /api/ai, both answering what the explorer's own route answers.

⚠⚠ They are not written again here. Each one runs the handler the explorer calls (Ops, AppPlugins, Trash, Versions, Archive, SharesMine, Share), in process, behind the same root confinement middleware - so the permission checks, the tenant boundary, the app rules (min_role, admin_only, applies, hidden actions) and the answers are the explorer's by construction, and a fix to one door is a fix to all three. What this file adds is only what the AI surface owes on top, before the handler runs:

  • paths in the AI surface's own form (aiOps.resolveStorage): a bare path of a `root:` token resolves under its root, the bound user needs at least viewer, filex's own folders do not exist;
  • end-to-end encryption with the AI surface's codes (ai_e2e.go): E2E_BOUNDARY, E2E_PLAINTEXT_REFUSED, E2E_ENCRYPTED - the explorer's route refuses some of these too, but without the code an agent matches on;
  • no key: every write the handler judges through gate() - and every member an archive extraction lands - is judged as the AI surface's own (syspath.Keyless), so an encrypted folder's key file is never written, copied over or restored from here (keylessDoor).

The audit row of a write is its REST route's (auth.ActionForPath), written by the /api/ai group's AuditMiddleware for REST and by mcpAuditRow for the MCP tool, with the facts the handler adds (SetAuditTarget, AddAuditDetail) in both - the door passes the recorder through, it never opens a second one.

Package handlers - ai_doors_people.go

What an agent does around a file besides its bytes (task #119, item 4): the bell (notifications_list, notification_read), a star (file_star), comments (file_comments, file_comment_add, file_comment_delete) and who may open an item (file_permissions, file_permission_users, file_permission_set, file_permission_revoke) - each an MCP tool and a REST route under /api/ai.

Like ai_doors.go these run the explorer's own handlers in process: the bell's (/api/notifications, root-confined), the star's, the comments' (with the comments.write permission its route asks) and the item permissions' (owner level and share.users, RBAC.md) - nothing is decided a second time here.

Package handlers - ai_e2e.go

End-to-end encryption on the AI surface: GET/POST /api/ai/* and the MCP file tools, and everything that writes through the same core (ShareX, the upload ticket).

The surface holds no key and never will, so it cannot decrypt a file or encrypt one. Before this file it did not know that either: file_read handed an agent an encrypted PDF as `mime: application/pdf, encoding: base64` (the agent took it for a broken PDF), file_list showed the folder's key file, and file_write put plaintext into an encrypted folder without a word (measured on v0.48.1, task #113). What it does now, each from one place:

  • every row says `encrypted` and `e2e_root` (e2eRoots.mark, the rules the explorer's rows use);
  • a read of ciphertext is refused with E2E_ENCRYPTED (encryptedRefusal);
  • a write into an encrypted folder is refused with E2E_PLAINTEXT_REFUSED unless the caller says `allow_plaintext` (plaintextRefusal);
  • a copy across the boundary is refused with E2E_BOUNDARY (e2e.GuardTransfer, the rule every transfer surface asks);
  • the key file is left out of listings (syspath.Unlisted) and never written, moved or deleted from here (syspath.Keyless, through aiOps.gate);
  • a public link into an encrypted folder is refused (publicLinkRefusal).

Recognising any of it is a catalogue lookup for the marker's row and a name test for `.fxe`: no content is read and no key is asked for.

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?, ui?, 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}/rollback  — back to the version the last upgrade replaced
POST   /api/admin/app-plugins/{id}/upgrade   — same bodies as install
POST   /api/admin/app-plugins/updates/check  — check every app's source now (updates.go)
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
GET/PUT /api/admin/app-plugins/{id}/thumbnails - the app's thumbnail limits (internal/wasmplugin/thumbnails.go)

⚠ 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.

⚠⚠ Installing, upgrading, going back, switching, removing and the action overrides need an administrator SIGNED IN to the panel (requireSession): an API key gets 403 and is pointed at /api/admin/plugin-requests. The review (?dry_run=1), the list, a detail, the logs and the update check stay open to an admin-scoped key.

Package handlers - app_store_intent.go

Installing an app from a store, and the licenses of paid apps (internal/appstore, docs/APP-PLUGINS.md → Installing from a store, Paid apps; docs/APP-PLUGINS-API.md → The store contract):

GET    /api/admin/app-plugins/stores                 - the trusted stores
POST   /api/admin/app-plugins/stores                 - {store, fingerprints}: trust a store with the keys shown
DELETE /api/admin/app-plugins/stores?store=<origin>  - stop trusting it
POST   /api/admin/app-plugins/store-intent           - {store, token}: read an install link → its review
POST   /api/admin/app-plugins/store-intent/install   - {handle, permissions, associations?, license_key?}
POST   /api/admin/app-plugins/store-intent/cancel    - {handle}
GET    /api/admin/app-plugins/licenses               - every paid app's license (the panel's warning band)
GET    /api/admin/app-plugins/{id}/license
PUT    /api/admin/app-plugins/{id}/license           - {key}: a new key, checked at once
POST   /api/admin/app-plugins/{id}/license/verify    - check now
GET    /api/files/plugins/license/{plugin}           - what the app reads about itself (fx.license.get())

Who may (the reasoning of app_plugins_admin.go and plugin_session_gate.go)

Every admin route here is the PLATFORM OPERATOR's (requireSupertenant: an app runs for every tenant, so a tenant administrator gets 403) and needs an administrator SIGNED IN to the panel (requireSession): an API key of any scope - an admin key, a `root:`-confined key, an app token - is refused 403 session_required, on the reads too. Installing is already a person's decision; trusting a store decides whose install links are shown to that person at all, and a license key is not for a program to read back.

⚠ GET changes nothing here. Trusting a store is its own POST, naming the fingerprints the administrator was shown; the session cookie is SameSite Lax and every state-changing request passes the origin guard (internal/originguard), so a link or a page elsewhere cannot trust a store for anybody.

Package handlers - app_store_view.go

The embedded store screen (#162, docs/APP-PLUGINS.md → The store screen): filex draws a trusted store's catalog with its own components, read on the SERVER from the store's signed index (the browser never talks to the store; the Content-Security-Policy does not change), and a person who sees it leaves a request on the same Install requests list an API key uses.

The administrator's routes, inside /api/admin/app-plugins (the platform operator, signed in to the panel - AppStore.gate, as every store route):

GET    /store-view[?tenant=<id>]             - who sees the screen in a scope
PUT    /store-view                           - {tenant?, settings}
GET    /stores/connection?store=<origin>     - is this filex connected to the store
POST   /stores/connection                    - {store, code}: connect with the store's one-time code
DELETE /stores/connection?store=<origin>     - disconnect

A person's routes, /api/app-store (signed in to filex in a browser, or the desktop app's own pairing; any other API key is refused - this is a person's screen, its requests a person's words; personCaller):

GET  /api/app-store                          - {visible, stores}
GET  /api/app-store/catalog?store=<origin>   - the catalog, as filex verified it
GET  /api/app-store/media?store=&file=       - an icon of that catalog
GET  /api/app-store/requests                 - the requests this person left
POST /api/app-store/requests                 - {store, app, reason}: leave one

⚠ Nothing here installs, trusts or asks a store for an install link: those stay the administrator's (app_store_intent.go, plugin_requests.go).

Package handlers — app_ui.go

The server half of an app's OWN interface (docs/APP-PLUGINS-API.md → An app's own interface). The interface runs in a sandboxed frame with no network; what it asks for goes through filex's frame (AppFrame, the explorer), which calls these with the PERSON's session:

PUT  /api/files/plugins/ui/{plugin}/{view}/save?path=<qualified>        — a new version of an opened file
PUT  /api/files/plugins/ui/{plugin}/{view}/save?dir=<qualified>&name=   — a NEW file there ("save as")
POST /api/files/plugins/ui/{plugin}/{view}/call                        — the app's module (`ui_call`)

⚠⚠ Every check is made again HERE, whatever the frame already decided: the app is running, the view is its interface, the app was granted what the call needs (files:write for a save), the file is one the view applies to, and the person may write it (confinement, ACL editor, the write gate — locks, filex's own folders, a draft only its owner's). The frame is the person's own page, but a check that lives only in a page is a check a crafted request skips.

Package handlers — app_ui_call.go

POST /api/files/plugins/ui/{plugin}/{view}/call — an app's own interface asking its module (`ui_call`, docs/APP-PLUGINS-API.md → An app's own interface). The save half is app_ui.go.

Package handlers — app_ui_chunks.go

An app's interface saving a LARGE file: the explorer streams what the interface hands it (a ReadableStream, transferred into the explorer's page) in chunks of at most 8 MiB, each one request — a reverse proxy with a body limit (nginx's default is 1 MiB) lets every one of them through — into a spool file, and the file is written to the storage ONCE, when the last chunk arrives, through the same checks and the same commit as a one-shot save (app_ui.go):

PUT …/save?path=<q>&chunk=start            (body: first chunk)  → {session, received}
PUT …/save?session=<id>&offset=<n>          (body: next chunk)   → {session, received}
PUT …/save?session=<id>&offset=<n>&final=1  (body: last chunk)   → the save's answer

A session belongs to the person, the app, the view and the target it was started for; another caller naming it gets 404. An abandoned one is removed after uiChunkTTL.

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
POST   /api/admin/auth-providers                  (another instance of a driver)
PATCH  /api/admin/auth-providers/{name}
DELETE /api/admin/auth-providers/{name}           (an instance made with POST)
PUT    /api/admin/auth-providers/{name}/tenants   (which tenants sign in through it)
POST   /api/admin/auth-providers/{name}/test

{name} is the provider's slug: the driver's name for its first instance (`ldap`, `oidc`, ...), so every client that addressed "the LDAP" still does (docs/TENANT-ADMIN.md).

⚠⚠ 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) - except an operating-system provider (windows), whose only proof is a real sign-in: its test must PASS, with `test_account` {username, password} sent on that request (used once, never stored or logged), and the account that passed becomes the super administrator (409 test_failed, no way round);
  • switching off the last way an administrator can sign in is refused (409 last_sign_in_method); on a multi-tenant install a tenant's last way in is refused too unless the operator confirms it (`confirm_tenant_lockout`): the operator can always bind something back;
  • 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.

An API token is held to its `comments` permission besides (task #157): reading asks auth.CommentsRead, adding and deleting auth.CommentsWrite - `comments:rw` in the token's list; the verb `write` does not stand in for it. The question is asked HERE, first thing, because this handler is every door's: the explorer's route, and /api/ai and the MCP tools, which run it in process (ai_doors_people.go). A browser session is judged by the account.

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 — e2e_policy_admin.go

Who may encrypt (internal/e2epolicy), the administrator's half:

GET   /api/admin/e2e               — the policy the caller administers
PATCH /api/admin/e2e               — SESSION ONLY: {"policy": "off|admins|permitted|approval"}
GET   /api/admin/e2e/tenants       — SUPERTENANT: every tenant's ceiling and policy
PATCH /api/admin/e2e/tenants/{id}  — SUPERTENANT, SESSION ONLY: {"e2e_allowed": bool}
GET   /api/admin/e2e/requests[?status=pending|approved|rejected|expired|used|all]
POST  /api/admin/e2e/requests/{id}/approve — SESSION ONLY: {"note"?: "…"}
POST  /api/admin/e2e/requests/{id}/reject  — SESSION ONLY: {"reason"?: "…"}

A tenant administrator lists and decides their own tenant's requests only; another tenant's id answers 404, like an id nobody has (tenantown.go). The platform operator (the supertenant's administrator on a multi-tenant install) lists every tenant's requests and decides only the platform's own: another tenant's answers 403 not_decidable, and its row says `decidable: false` (operator decision 2026-10-03).

Whose policy "the caller administers" is decided by the tenant scope auth.TenantResolver attached, never by the request: a tenant administrator holds their own tenant's row, the supertenant's administrator the supertenant's own row — another tenant's policy is that tenant's decision; the operator's lever is the ceiling — and a single-tenant install's administrator the instance setting `e2e.policy`.

⚠ A change is a person's (sessionOnly): an admin-scoped API key may read the policy, not change it. The policy decides who may make data readable only by its key holders, and the ceiling is the operator's.

⚠ requireSupertenant and sessionOnly are called in the handler, never as a route middleware — the reasoning of supertenant.go.

The audit rows are written here through e2epolicy.Audit, not by the audit middleware (auth.ActionForPath skips /api/admin/e2e): one change is one row that names the tenant and says what the value was and what it became.

Package handlers — e2e_policy_files.go

Who may encrypt (internal/e2epolicy), the explorer's half:

POST /api/files/e2e/allowed   - may I encrypt here? {"items":[{"path":"Depo://a","kind"?:"folder"|"new_folder"|"file"}]} → {"encrypt":["allowed"|"request"|"denied",…]}
POST /api/files/e2e/requests  - leave a request: {"path":"Depo://a/b","kind":"folder"|"new_folder"|"file","reason":"…"}
GET  /api/files/e2e/requests  — the caller's own requests

A request is what the approval policy asks of somebody who holds files.encrypt: an administrator of their tenant approves it on Admin → Encryption (handlers/e2e_policy_admin.go), and the approval opens one encryption of its kind there, once, for seven days.

The service writes the audit rows; /api/files/e2e/requests is not one of the file paths the audit middleware records (auth.shouldAudit), so one event is one row.

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 - file_types_admin.go

Admin → Plugins → Default apps (internal/assoc, docs/APP-PLUGINS.md → Default apps):

GET    /api/admin/file-types           - every kind something besides filex handles, and every kind with a rule
PUT    /api/admin/file-types/{ext}     - {open?: {order, off} | null, thumbnail?: {order, off} | null}; null = back to the default
DELETE /api/admin/file-types/{ext}     - both capabilities back to the default

⚠ Instance-wide, like the apps whose handlers it orders: a tenant administrator gets 403 supertenant_only. A change needs an administrator signed in to the panel (an API key reads, and gets 403 session_required): switching a thumbnail handler back on hands an app the bytes of every file of that kind, which is the same kind of decision as an action override (PutOverrides). Refused on the demo instance. Each change writes its audit row (file_association.update / .reset) with what it was and what it is.

Package handlers — login_security.go

The administrator's side of the sign-in attempt limit (internal/loginguard): its settings, the locks in force, and the recent wrong attempts. Frozen contract for the admin SPA and for /api/ai/admin (and its MCP tools):

GET   /api/admin/login-security
      → {settings, limits, trusted_proxies_effective, trusted_proxies_source,
         trusted_defaults:{auto, loopback, private, link_local},
         trusted_addresses, trusted_proxies_auto:{in_use, environment,
         runtime, networks, excluded_gateways, excluded_self, interfaces,
         warning, warning_detail, resolved_at}, untrusted_forwarders:
         [{address, first_seen, last_seen, count, public, relay}],
         untrusted_forwarders_total, your_ip, your_ip_allowlisted}
PATCH /api/admin/login-security   partial body, same keys as `settings`
      → the GET answer (400 {error:"invalid_setting", field, message} on a
        bad value; nothing is written unless every field is valid)
GET   /api/admin/login-security/locks?scope=&locked=1&limit=
      → {items:[{id, scope, subject, fails, limit, lock_level, locked,
         locked_until, retry_after, window_start, last_fail_at, last_ip,
         last_protocol}], now}
POST  /api/admin/login-security/unlock   {scope, subject} | {all:true}
      → {ok, unlocked}
GET   /api/admin/login-security/attempts?action=&from=&to=&limit=&offset=
      → {items:[{id, action, identifier, ip, protocol, reason, scope, via,
         changed_fields, at, metadata}], total}

On a public demo (DemoMode) every address a visitor or the operator wrote is "hidden on the demo" in all of these answers (demo_redact.go); your_ip, the reader's own, is not.

Instance-wide

Every value here is one global row, and a lock or an address is not a tenant's own: the whole surface is the platform operator's (requireSupertenant, asked INSIDE each method because /api/ai/admin and the MCP tools mount these same handler instances).

Memory first

With a running limiter (Guard) every answer here is the limiter's memory — the settings and the counters it decides with — and every change goes through it: a setting is written to the table and put in force at once, an unlock lifts the lock in memory and deletes the row. Without one (a store with no limiter) the table is read and written directly.

What is never here

No password, ever: the counters hold an identifier and an address, the audit rows hold the same plus the door and the reason.

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 — notification_digest.go

The notification digest's HTTP half (internal/notify digest.go):

GET   /api/notifications/settings       — the person's own urgent choices beside their mutes (notifications.go)
PATCH /api/notifications/settings       — {urgent_overrides: {"<event>": bool}}; absent keeps them
GET   /api/admin/notifications/digest   — the defaults the caller administers
PATCH /api/admin/notifications/digest   — {window_minutes: 1-15, urgent_events: [...] | null}

and the View the digest's background pass judges a person's rows through (DigestViewer): the bell this handler gives them, built without a request.

Whose defaults an administrator holds is decided by the tenant scope auth.TenantResolver attached, never by the request — the rule of Admin → Encryption (e2e_policy_admin.go): a tenant's administrator holds their own tenant's, the supertenant's administrator the supertenant's, a single-tenant install's administrator the instance's (scope 0).

Package handlers - openwith.go

Endpoints (0.50, docs/APP-PLUGINS.md → Default apps):

GET    /api/me/open-with         (auth) → {"choices": {ext: handler}}
PUT    /api/me/open-with/{ext}   (auth) ← {"handler": "builtin" | "onlyoffice" | "app:<app>/<view>"} → {"choices": …}
DELETE /api/me/open-with/{ext}   (auth) → {"choices": …}
DELETE /api/me/open-with         (auth) → {"choices": {}}

"Always open this kind with this app" - ONE record per ACCOUNT (the maintainer, 2026-10-01): the browser, the desktop app and every embed read and write the same choices. A person who picked an app for .board files in the browser gets it in the desktop app too; where that app cannot open the file (it is not offered there, or the administrator switched it off for the kind) the next handler that is on opens it, and the choice is kept for where it can.

⚠⚠ NOT a key of the per-surface document. That document is PUT whole, so a shared key in it would be overwritten by whichever surface wrote last with whatever it read at boot - a choice made on the desktop undone by a palette change in a browser tab opened before it. So the choices are their own row (user_prefs, surface "account", `{"openWith": {…}}`), changed one kind at a time, and the surface document only CARRIES them on the way out: GET /api/me/prefs puts them in as `openWith` (the JSON string the client has always read), PUT /api/me/prefs drops the key (userprefs.go).

⚠ Adoption, once: an account whose row has never held a choice takes the `openWith` of its most recently written surface document (web or desktop), the one that was in use last. Choices are not merged across documents: which device's choice for a kind is "right" is not knowable, and the last one written is the one the person saw last.

Package handlers - panel_search.go

The admin panel's search (task #168, docs/ADMIN-PANEL.md → Search): the people, groups, API keys, apps, storages and shares that match a query, in one answer, and the person's recent searches.

⚠⚠ Nothing is listed again here. Each kind is read by running, in process, the very handler the panel's own page calls (the Users page's GET /api/admin/users, the API / MCP page's GET /api/admin/ai-tokens, …) behind the very gate its route has (handlers.RequireAdminPermission for the delegated pages, auth.RequireAdmin for the rest) - routes.go registers each source next to the route it reads, with the same gate value. So who may find what is, by construction, who may open what: a delegated administrator holding admin.users finds people and groups and nothing else, a tenant's administrator finds their own tenant's (the handlers' tenant filters and the tenant-scoped store), and a kind whose list refuses the reader is left out of the answer - never an error. The pattern is the AI surface's doors (ai_doors.go).

What this file adds is only the matching, and the shape of a row: an id, a label, a detail line, the other words a row answers to. A row never carries what the list it came from must not hand on - an API key's value is not in that list to begin with (only its hash is stored), a share's link token and address are dropped here, and nothing else is copied but the named fields.

Pages, settings and files are not here: the panel finds its own pages and settings from its menu (web/src/lib/adminSearch.ts), and files through the explorer's own search (GET /api/files/search).

Package handlers — plugin_requests.go

Plugin install requests (internal/pluginreq, docs/APP-PLUGINS.md → Install requests, docs/PLUGINS.md → Install requests):

POST /api/admin/plugin-requests                — leave a request (API key or session)
GET  /api/admin/plugin-requests[?status=…]     — list (pending by default; `all` for every state)
GET  /api/admin/plugin-requests/{id}           — one request, with its frozen manifest and review
POST /api/admin/plugin-requests/{id}/approve   - SESSION ONLY: install what the request froze; {"associations"?: [...]}
                                                 (a request from the store screen: {"license_key"?} - asks the
                                                 store for a fresh install link; the install is the store review's)
POST /api/admin/plugin-requests/{id}/reject    — SESSION ONLY: {"reason"?: "…"}

⚠⚠ Why this exists (owner, 2026-09-28): an API key may no longer install, upgrade, remove or re-permission a plugin (requireSession). It may leave a request; an administrator signed in to the panel decides. Approve and reject are therefore session-only here AND there is no MCP tool for either (ai_admin.go): a key that could approve its own request would be the old hole with an extra step.

⚠ Instance-wide like the plugins themselves: in multi-tenant mode only the supertenant may leave, read or decide a request (requireSupertenant). A tenant administrator gets 403.

The service writes the audit rows itself (created, approved, rejected, expired, superseded), so these routes are skipped by the audit middleware (auth.ActionForPath) and one event is one row, whichever door it came in by.

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[, source]} | JSON {name,url,sha256[,source]} | JSON {name,source} | JSON {name,kind:"remote",address,token}
GET    /api/admin/plugins/{id}
PATCH  /api/admin/plugins/{id}         — {"enabled"?: bool, "source"?: string}
POST   /api/admin/plugins/{id}/restart
POST   /api/admin/plugins/{id}/upgrade — multipart {file} | JSON {"from_source": true}
POST   /api/admin/plugins/updates/check — read every source now; installs nothing

⚠⚠ Nothing updates itself (plugin/updates.go): a binary plugin that names a source is checked daily and its row says when a newer version is there; the administrator's {"from_source": true} upgrade installs it.

DELETE /api/admin/plugins/{id}

⚠⚠ Install, upgrade, PATCH and DELETE need an administrator SIGNED IN to the panel (requireSession): an API key gets 403 and is pointed at /api/admin/plugin-requests, where it may leave a request instead. Reading (the list, one plugin, the update check) stays open to an admin-scoped key.

⚠ 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,
                               "drafts_limit":50,"drafts_limit_min":1,
                               "drafts_limit_max":1000,
                               "antivirus":{ … see protectionAntivirusStatus }}
PATCH /api/admin/protection → {trash_retention_days?, versions_keep_n?,
                               share_max_ttl_days?, drafts_limit?,
                               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`, `drafts.limit`, `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:

  1. a JavaScript browser — gets the SPA shell (the branded public surface: PIN gate, expiry, visit counter, the app page when the link carries one);
  2. 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;
  3. 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:

  1. 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.
  2. 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.
  3. 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: tenant_self.go

A tenant runs itself (docs/TENANT-ADMIN.md):

GET    /api/admin/tenant                                  the tenant: addresses, sign-in, domains
POST   /api/admin/tenant/auth-providers                   its own OIDC or LDAP
PATCH  /api/admin/tenant/auth-providers/{name}
DELETE /api/admin/tenant/auth-providers/{name}
POST   /api/admin/tenant/auth-providers/{name}/test
POST   /api/admin/tenant/domains                          {domain}: an own domain, pending
POST   /api/admin/tenant/domains/{id}/check               look at its CNAME now
PUT    /api/admin/tenant/domains/{id}/certificate         {cert_pem, key_pem}: bring a certificate
DELETE /api/admin/tenant/domains/{id}/certificate
DELETE /api/admin/tenant/domains/{id}
PUT    /api/admin/tenant/insecure                         {allow}: the operator's switch

WHOSE tenant: a tenant's administrator acts on their own and nothing else; the platform operator (an unconfined administrator) names one with `?tenant=<id>`. Another tenant's id from a tenant's administrator is 404, the same answer as a tenant that does not exist (handlers/tenantown.go).

The rules the owner decided (2026-09-30, 2026-10-01):

  • a tenant's administrator adds their OWN OIDC or LDAP only - the operating-system providers and the header proxy are the operator's, and binding a shared provider is the operator's too;
  • a tenant's own provider is guarded (internal/netguard, LDAP only over ldaps:// or StartTLS, the CA pasted, never a file on the server) unless the operator ticked "allow insecure and internal-network providers" for the tenant - which only the operator may change, audited;
  • the last way a tenant's administrator can sign in is never switched off by that administrator;
  • an own domain is proven by a CNAME to the tenant's platform subdomain, belongs to one tenant at a time, and may carry the tenant's own certificate.

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. The account's "open with" choices (0.50) are NOT part of it: GET carries them as `openWith`, PUT drops the key, and /api/me/open-with changes them (openwith.go).

⚠⚠ 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

View Source
const (
	// SurfaceWeb is the browser.
	SurfaceWeb = "web"
	// SurfaceDesktop is the desktop application.
	SurfaceDesktop = "desktop"
)

Surfaces a preference document may belong to.

View Source
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.

View Source
const BrandingSettingTheme = "branding.theme"

BrandingSettingTheme is the settings key holding the public shell's default appearance.

View Source
const BuiltinDefaultThemeID = "default"

BuiltinDefaultThemeID is the stock palette: the product's own colours, and the answer whenever a named theme cannot be resolved.

View Source
const CodeEntryUnavailable = "ENTRY_UNAVAILABLE"

CodeEntryUnavailable is the refusal's code.

View Source
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.

View Source
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.

View Source
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.

View Source
const CustomCSSSettingKey = "ui.custom_css"

CustomCSSSettingKey is the settings row holding the operator stylesheet.

View Source
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.

View Source
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.

View Source
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.

View Source
const PluginRequestsPath = "/api/admin/plugin-requests"

PluginRequestsPath is where somebody who may not install a plugin leaves a request for it (plugin_requests.go). The refusals below name it.

View Source
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.

View Source
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.

View Source
const SurfaceAccount = "account"

SurfaceAccount is the user_prefs row that holds what belongs to the account rather than to one surface. It is not a surface a client may name in /api/me/prefs (prefsSurface refuses it): it is written one kind at a time, here.

View Source
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

View Source
var ErrStagingUnavailable = errors.New("staged ingest is not available")

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.

View Source
var ScopedRescanTimeout = 10 * time.Minute

ScopedRescanTimeout bounds a folder rescan (TriggerSync with ?path=), which answers inside the request.

Functions

func AppNewDocTypes added in v0.48.0

func AppNewDocTypes(reg *wasmplugin.Registry) []newdoc.Type

AppNewDocTypes is every row the running apps add, as document types; nil without a registry.

func ArchiveName

func ArchiveName(paths []string, override string) string

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 DemoRefusal added in v0.50.0

func DemoRefusal() map[string]string

DemoRefusal is a public demo's answer to a state-changing request on an operator surface: api.DemoGuard writes it for the routes, and the admin MCP tools - which reach the same handlers in-process, past every route - answer it too (AIAdmin.invoke).

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 RequireAdminPermission added in v0.49.0

func RequireAdminPermission(resolver *acl.Resolver, p perm.Perm) func(http.Handler) http.Handler

RequireAdminPermission gates one route of the delegated admin area. A full administrator (auth.CallerMayAdminister — the account role, and a token that may administer) passes as before. Anybody else passes only with the admin permission p, and only from a signed-in session: a self-service token can never carry the admin scope (tokens_self.go), so a delegated administrator using one would be the only token that administers without it.

⚠ This is the ONLY door into the routes it guards: they are registered outside the RequireAdmin group precisely so this can admit a non-admin. Every handler behind it must therefore hold its own line against escalation (see refuseAdminTarget) — the route cannot know which account a request is about.

func RequirePermission added in v0.49.0

func RequirePermission(resolver *acl.Resolver, p perm.Perm) func(http.Handler) http.Handler

RequirePermission is route middleware for a permission that is not about a path: the whole door is the action (minting an S3 key, the agent API, posting a comment). It must run after the auth middleware has put the user on the context. A nil resolver (tests) allows.

func RequirePersonalCaller

func RequirePersonalCaller(next http.Handler) http.Handler

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

func RetiredPageAPI() http.Handler

RetiredPageAPI answers the retired /api/p/<token>/… addresses. The JSON surface moved to /api/public/s/<token>, so the redirect points there.

func RetiredPagePrefix

func RetiredPagePrefix() http.Handler

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

func SetAntivirusEnqueue(fn func(ctx context.Context, n *model.Node))

SetAntivirusEnqueue installs the enqueue function used after writes. Call once at startup; passing nil disables scanning.

func SetAntivirusEnqueueAfterSave

func SetAntivirusEnqueueAfterSave(fn func(ctx context.Context, n *model.Node))

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

func SetAppLabels(f func(string) wire.Text)

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

func SetNotifySink(s notify.Service)

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) *AI

NewAI constructs the AI REST handler. shareSvc + publicURL power the share endpoints (pass nil shareSvc to disable sharing).

func (*AI) AppActions added in v0.50.0

func (h *AI) AppActions(w http.ResponseWriter, r *http.Request)

AppActions → GET /api/ai/apps/actions?path=

func (*AI) AppRun added in v0.50.0

func (h *AI) AppRun(w http.ResponseWriter, r *http.Request)

AppRun → POST /api/ai/apps/run {plugin, action, paths, params?}.

func (*AI) ArchiveCreate added in v0.50.0

func (h *AI) ArchiveCreate(w http.ResponseWriter, r *http.Request)

ArchiveCreate → POST /api/ai/archive/create {sources, dest, format?, password?, allow_plaintext?}.

func (*AI) ArchiveExtract added in v0.50.0

func (h *AI) ArchiveExtract(w http.ResponseWriter, r *http.Request)

ArchiveExtract → POST /api/ai/archive/extract {path, dest?, members?, password?, allow_plaintext?}.

func (*AI) AttachACL

func (h *AI) AttachACL(r *acl.Resolver)

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

func (h *AI) AttachBody(b *filebody.Resolver)

AttachBody wires the byte-source resolver so /api/ai reads serve a file that is still being transferred out of staging.

func (*AI) AttachDoors added in v0.50.0

func (h *AI) AttachDoors(d *AIDoors)

AttachDoors wires the explorer's handlers into the AI REST surface.

func (*AI) AttachE2EPolicy added in v0.51.0

func (h *AI) AttachE2EPolicy(s *e2epolicy.Service)

AttachE2EPolicy wires the rule into the agent REST surface: WriteStream (upload), Zip and Unzip ask it.

func (*AI) AttachSearchIndex

func (h *AI) AttachSearchIndex(idx *search.Index)

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

func (h *AI) AttachTenants(rv tenanturl.Resolver)

AttachTenants wires the shared origin resolver (internal/tenanturl).

func (*AI) AttachThumbs

func (h *AI) AttachThumbs(p *thumb.Pipeline)

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) CommentAdd added in v0.50.0

func (h *AI) CommentAdd(w http.ResponseWriter, r *http.Request)

CommentAdd → POST /api/ai/comments {path, text}.

func (*AI) CommentDelete added in v0.50.0

func (h *AI) CommentDelete(w http.ResponseWriter, r *http.Request)

CommentDelete → POST /api/ai/comments/{id}/delete

func (*AI) CommentsList added in v0.50.0

func (h *AI) CommentsList(w http.ResponseWriter, r *http.Request)

CommentsList → GET /api/ai/comments?path=

func (*AI) Convert added in v0.50.0

func (h *AI) Convert(w http.ResponseWriter, r *http.Request)

Convert → POST /api/ai/convert {path, target}.

func (*AI) Copy added in v0.50.0

func (h *AI) Copy(w http.ResponseWriter, r *http.Request)

Copy → POST /api/ai/copy {src, dst}.

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) FileRequest added in v0.50.0

func (h *AI) FileRequest(w http.ResponseWriter, r *http.Request)

FileRequest → POST /api/ai/share/request {path, max_uploads?, drop_settings?, expires_in_days?, pin?}.

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) NotificationRead added in v0.50.0

func (h *AI) NotificationRead(w http.ResponseWriter, r *http.Request)

NotificationRead → POST /api/ai/notifications/read {id} | {all: true}.

func (*AI) NotificationsList added in v0.50.0

func (h *AI) NotificationsList(w http.ResponseWriter, r *http.Request)

NotificationsList → GET /api/ai/notifications?unread=&limit=&offset=

func (*AI) OpCancel added in v0.50.0

func (h *AI) OpCancel(w http.ResponseWriter, r *http.Request)

OpCancel → POST /api/ai/ops/{id}/cancel

func (*AI) OpGet added in v0.50.0

func (h *AI) OpGet(w http.ResponseWriter, r *http.Request)

OpGet → GET /api/ai/ops/{id}

func (*AI) OpsList added in v0.50.0

func (h *AI) OpsList(w http.ResponseWriter, r *http.Request)

OpsList → GET /api/ai/ops?status=

func (*AI) PermissionRevoke added in v0.50.0

func (h *AI) PermissionRevoke(w http.ResponseWriter, r *http.Request)

PermissionRevoke → POST /api/ai/permissions/{id}/revoke {group?}.

func (*AI) PermissionSet added in v0.50.0

func (h *AI) PermissionSet(w http.ResponseWriter, r *http.Request)

PermissionSet → POST /api/ai/permissions {path, user_id | group_id, level}.

func (*AI) PermissionUsers added in v0.50.0

func (h *AI) PermissionUsers(w http.ResponseWriter, r *http.Request)

PermissionUsers → GET /api/ai/permissions/users?q=

func (*AI) PermissionsList added in v0.50.0

func (h *AI) PermissionsList(w http.ResponseWriter, r *http.Request)

PermissionsList → GET /api/ai/permissions?path=

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) SharesList added in v0.50.0

func (h *AI) SharesList(w http.ResponseWriter, r *http.Request)

SharesList → GET /api/ai/shares?active=&limit=&offset=

func (*AI) Star added in v0.50.0

func (h *AI) Star(w http.ResponseWriter, r *http.Request)

Star → POST /api/ai/star {path, star?} (star defaults to true).

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) TrashList added in v0.50.0

func (h *AI) TrashList(w http.ResponseWriter, r *http.Request)

TrashList → GET /api/ai/trash?storage=&limit=&offset=

func (*AI) TrashRestore added in v0.50.0

func (h *AI) TrashRestore(w http.ResponseWriter, r *http.Request)

TrashRestore → POST /api/ai/trash/restore {node_ids}.

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).

func (*AI) VersionRestore added in v0.50.0

func (h *AI) VersionRestore(w http.ResponseWriter, r *http.Request)

VersionRestore → POST /api/ai/versions/restore {path, version_id, snapshot_current?}.

func (*AI) VersionSnapshot added in v0.50.0

func (h *AI) VersionSnapshot(w http.ResponseWriter, r *http.Request)

VersionSnapshot → POST /api/ai/versions/snapshot {path}.

func (*AI) VersionsList added in v0.50.0

func (h *AI) VersionsList(w http.ResponseWriter, r *http.Request)

VersionsList → GET /api/ai/versions?path=

func (*AI) Zip

func (h *AI) Zip(w http.ResponseWriter, r *http.Request)

Zip → POST /api/ai/zip {"sources":[…],"dest":"…"}. Packs the sources into a .zip ON THE SERVER (folders recurse); the bytes never travel over the wire. To download the result, mint a share link for `dest`.

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.

func (*AIAdmin) Register

func (a *AIAdmin) Register(r chi.Router)

Register wires every admin REST route onto r (mounted at /api/ai/admin by the caller). chi populates {id}/{name}/{key} URL params automatically and the request body/query stream straight into the reused handler methods.

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
	// ForgetStorage and ReplicaLinks: what the native /admin routes are
	// given, so a storage or target edited over the token surface is applied
	// to the running process the same way (the driver rebuilt, replication
	// relinked, the initial copy started). Nil is legal.
	ForgetStorage func(int64)
	ReplicaLinks  ReplicaLinks
	// 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
	// AppUIOrigin is FILEX_APP_UI_ORIGIN, for the same advisories: whether the
	// editor's api.js runs in a frame of its own (task #92).
	AppUIOrigin string
	// OfficeFrameOrigin is FILEX_ONLYOFFICE_FRAME_ORIGIN, for the same
	// advisories (task #92).
	OfficeFrameOrigin string
	// ReversePath is the panel's third-leg check (ExternalAdmin.ReversePath),
	// so admin_external_test measures the document server's route back to
	// filex and its JWT the way the page's Test does. Nil = not measured.
	ReversePath func(ctx context.Context, t *onlyoffice.Target) onlyoffice.ReverseResult
	// 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
	// Plugins / AppPlugins / PluginRequests back the plugin tools: reading
	// the installed plugins and leaving install requests. Nil = that kind is
	// off (the handlers answer 503, as on the panel's routes).
	Plugins                  *plugin.Manager
	AppPlugins               *wasmplugin.Registry
	AppPluginsDisabledReason string
	PluginRequests           *pluginreq.Service
	// Assoc backs the Default apps tool (read only): which app opens and
	// draws which kind of file. Nil = app plugins are off.
	Assoc *assoc.Service
	// LoginGuard + EnvTrustedProxies back the sign-in security tools: the same
	// handler the panel uses, over the same running limiter.
	LoginGuard        *loginguard.Guard
	EnvTrustedProxies string
	// MultiTenant mirrors FILEX_MULTI_TENANT for the tenant tools: the list
	// says whether tenants can sign in at all (maintenance mode when off).
	MultiTenant bool
	// ArchiveEngine backs the archive settings tools: the same engine the
	// panel's Archives page reads and probes. Nil = those tools answer 503.
	ArchiveEngine *archivecli.Service
}

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 AIDoors added in v0.50.0

type AIDoors struct {
	Ops        *Ops
	Apps       *AppPlugins
	Trash      *Trash
	Versions   *Versions
	Archive    *Archive
	SharesMine *SharesMine
	Share      *Share
	// The bell, stars, comments and item permissions (ai_doors_people.go).
	Notifications *Notifications
	Meta          *Meta
	Comments      *Comments
	Grants        *Grants
	// ACL answers the per-route permission the explorer's route asks before
	// its handler (plugins.run on an app's action).
	ACL *acl.Resolver
}

AIDoors are the explorer's own handlers, exactly as routes.go builds them for /api/files and /api/shares. The AI surface runs them in process. A nil field leaves its tools answering 503.

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) *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.

func (*AIMCP) AttachACL

func (h *AIMCP) AttachACL(r *acl.Resolver)

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

func (h *AIMCP) AttachBody(b *filebody.Resolver)

AttachBody wires the byte-source resolver so MCP reads serve a file that is still being transferred out of staging.

func (*AIMCP) AttachDoors added in v0.50.0

func (h *AIMCP) AttachDoors(d *AIDoors)

AttachDoors wires the explorer's handlers the door tools run in process.

func (*AIMCP) AttachE2EPolicy added in v0.51.0

func (h *AIMCP) AttachE2EPolicy(s *e2epolicy.Service)

AttachE2EPolicy wires the rule into every ops core the MCP surface builds per call (getServer).

func (*AIMCP) AttachSearchIndex

func (h *AIMCP) AttachSearchIndex(idx *search.Index)

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

func (h *AIMCP) AttachTenants(rv tenanturl.Resolver)

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

func (h *AIMCP) AttachThumbs(p *thumb.Pipeline)

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.

func (*AIMCP) ServeHTTP

func (h *AIMCP) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP delegates to the SDK's streamable handler. The client's address is resolved here, once, and carried on the context: a tool call has no *http.Request of its own, and the audit rows its writes leave name the address like every other door's (mcpAuditWrite, AIAdmin.auditInvoke).

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.

func (*AITokens) List

func (h *AITokens) List(w http.ResponseWriter, r *http.Request)

List returns every token row WITHOUT the secret (only the hash is stored and even that is omitted via the model's json:"-" tag).

func (*AITokens) Update

func (h *AITokens) Update(w http.ResponseWriter, r *http.Request)

Update edits a token's label / username allow-list / kind and the levels of its permissions (`permissions`, package tokenperm).

PATCH /api/admin/ai-tokens/{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
	// Quota is the per-person ceiling an interface's save is held to, like
	// every other write (security review UI-7). Nil = no ceiling.
	Quota *quota.Service
	// E2EPolicy is who may encrypt (e2e_policy_gate.go): CommitSibling — an
	// interface's "save as" and a job's output — asks it before it creates a
	// key file or a `.fxe`. nil = not wired.
	E2EPolicy *e2epolicy.Service
	// Assoc keeps which handler opens which kind of file (internal/assoc):
	// the listing carries its rules, and an interface switched off for a
	// kind is refused on it (openAllowed). Nil: every viewer opens its kinds.
	Assoc *assoc.Service
}

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) UICall added in v0.48.0

func (h *AppPlugins) UICall(w http.ResponseWriter, r *http.Request)

UICall is the app's own interface asking its module (`ui_call`). The files the interface was opened with are judged for the person asking exactly like a view's — confinement, ACL viewer, not in an encrypted folder — and handed to the module as refs.

func (*AppPlugins) UISave added in v0.48.0

func (h *AppPlugins) UISave(w http.ResponseWriter, r *http.Request)

UISave writes what an app's interface saved.

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
	// Assoc keeps which app opens and draws which kind (internal/assoc): the
	// install review's File types group, the choices made there, and the
	// rules an app's removal takes it out of. Nil: none of that.
	Assoc *assoc.Service
	// OnRemoved is told an app was removed (internal/appstore forgets its
	// license and lifts its hold). Nil: nobody is.
	OnRemoved func(ctx context.Context, app string)
}

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) CheckUpdates added in v0.47.0

func (h *AppPluginsAdmin) CheckUpdates(w http.ResponseWriter, r *http.Request)

CheckUpdates asks every app's source for a newer version now, applies what may be applied, and answers what happened with the list redrawn: POST /api/admin/app-plugins/updates/check. A check already running (the daily one) is waited for rather than doubled.

⚠ Detached from the request: a check installs apps, and an administrator who closes the tab must not cut one in half (Upgrade holds its own swap either way).

func (*AppPluginsAdmin) Delete

func (h *AppPluginsAdmin) Delete(w http.ResponseWriter, r *http.Request)

func (*AppPluginsAdmin) Get

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)

Install installs an app — or, with ?dry_run=1, answers its review and installs nothing. The review is open to an admin-scoped API key (it is how an agent sees what it would be asking for); the install itself needs a signed-in administrator (requireSession).

func (*AppPluginsAdmin) List

func (*AppPluginsAdmin) Locks

Locks lists the live app-plugin file locks: GET /api/admin/app-plugins/locks[?storage_id=].

func (*AppPluginsAdmin) Logs

func (*AppPluginsAdmin) Patch

func (*AppPluginsAdmin) PutOverrides

func (h *AppPluginsAdmin) PutOverrides(w http.ResponseWriter, r *http.Request)

PutOverrides changes who may use an app's actions and where (admin_only, enabled, applies): a permission change, so a signed-in administrator's.

func (*AppPluginsAdmin) PutSettings

func (h *AppPluginsAdmin) PutSettings(w http.ResponseWriter, r *http.Request)

func (*AppPluginsAdmin) PutThumbLimits added in v0.50.0

func (h *AppPluginsAdmin) PutThumbLimits(w http.ResponseWriter, r *http.Request)

PutThumbLimits answers PUT /api/admin/app-plugins/{id}/thumbnails: a signed-in administrator's, like every change to what an app is handed.

func (*AppPluginsAdmin) Rollback added in v0.48.0

func (h *AppPluginsAdmin) Rollback(w http.ResponseWriter, r *http.Request)

Rollback puts the version an upgrade replaced back: POST /api/admin/app-plugins/{id}/rollback. No new approval — that version's grant was approved when it was installed (wasmplugin/versions.go).

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) ThumbLimits added in v0.50.0

func (h *AppPluginsAdmin) ThumbLimits(w http.ResponseWriter, r *http.Request)

ThumbLimits answers GET /api/admin/app-plugins/{id}/thumbnails: the app's thumbnail limits in force, as stored, the defaults, the bounds, the kinds.

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 AppStore added in v0.52.0

type AppStore struct {
	Svc   *appstore.Service
	Admin *AppPluginsAdmin
	// Demo: a public demo trusts no store (its admin account is public).
	Demo bool
	// PublicURL is this filex's configured address (FILEX_PUBLIC_URL, when
	// set): a link's filex_origin must be its origin. Empty: the origin the
	// request arrived at.
	PublicURL string
	// Requests is the Install requests service: a link an approval asked for
	// (the embedded store, #162) closes its request when it is installed.
	// Groups answers a person's groups for the store screen
	// (app_store_view.go). Either nil: that part is off.
	Requests *pluginreq.Service
	Groups   StoreGroups
}

AppStore is the handler set. Svc nil answers 503 (the runtime is off).

func NewAppStore added in v0.52.0

func NewAppStore(svc *appstore.Service, admin *AppPluginsAdmin, demo bool) *AppStore

NewAppStore builds the handler set over the Apps admin handlers it shares the review and the install with.

func (*AppStore) AppLicense added in v0.52.0

func (h *AppStore) AppLicense(w http.ResponseWriter, r *http.Request)

AppLicense answers what an app reads about its own license: the status, and for a valid one its dates - never the key, the store or the holder (every user who may run apps, and any token with that right, reads this). A free app answers {"status": "free"}.

func (*AppStore) Cancel added in v0.52.0

func (h *AppStore) Cancel(w http.ResponseWriter, r *http.Request)

Cancel ends a reviewed link without installing: the store is told `cancelled`, and the link is used up here.

func (*AppStore) Connect added in v0.53.0

func (h *AppStore) Connect(w http.ResponseWriter, r *http.Request)

Connect connects this filex to a trusted store with the one-time code the store's "My instances" page made.

func (*AppStore) Disconnect added in v0.53.0

func (h *AppStore) Disconnect(w http.ResponseWriter, r *http.Request)

Disconnect forgets this filex's key for a store (and tells the store).

func (*AppStore) GetConnection added in v0.53.0

func (h *AppStore) GetConnection(w http.ResponseWriter, r *http.Request)

GetConnection answers whether this filex is connected to a store.

func (*AppStore) GetView added in v0.53.0

func (h *AppStore) GetView(w http.ResponseWriter, r *http.Request)

GetView answers a scope's store screen settings.

func (*AppStore) Install added in v0.52.0

func (h *AppStore) Install(w http.ResponseWriter, r *http.Request)

Install installs (or upgrades) what a reviewed link names: the repository is read AGAIN and held to the link's pins again - what the administrator approved is what lands, not what the source serves by now - then the same install a GitHub install is. A paid app is held from before it is installed until the store confirms its license.

func (*AppStore) Intent added in v0.52.0

func (h *AppStore) Intent(w http.ResponseWriter, r *http.Request)

Intent reads an install link and answers its review: the store must be trusted (otherwise the answer is the trust question - the store and its key fingerprints - and nothing was fetched from the link), the link signed, current and unused, and what the repository serves must be what the store pinned. Then the same dry run a GitHub install's review is.

func (*AppStore) LeaveRequest added in v0.53.0

func (h *AppStore) LeaveRequest(w http.ResponseWriter, r *http.Request)

LeaveRequest records a person's request for an app of a store's catalog: the same Install requests list an API key leaves requests on, decided the same way by an administrator signed in to the panel.

func (*AppStore) License added in v0.52.0

func (h *AppStore) License(w http.ResponseWriter, r *http.Request)

func (*AppStore) Licenses added in v0.52.0

func (h *AppStore) Licenses(w http.ResponseWriter, r *http.Request)

func (*AppStore) ListStores added in v0.52.0

func (h *AppStore) ListStores(w http.ResponseWriter, r *http.Request)

func (*AppStore) MountAdmin added in v0.52.0

func (h *AppStore) MountAdmin(r chi.Router)

MountAdmin registers the admin routes inside /api/admin/app-plugins. The static paths are registered before the caller's /{id} routes match them.

func (*AppStore) MountUser added in v0.53.0

func (h *AppStore) MountUser(r chi.Router)

MountUser registers a person's store routes (at /api/app-store).

func (*AppStore) MyRequests added in v0.53.0

func (h *AppStore) MyRequests(w http.ResponseWriter, r *http.Request)

MyRequests answers the requests the caller left from the store screen.

func (*AppStore) PutLicense added in v0.52.0

func (h *AppStore) PutLicense(w http.ResponseWriter, r *http.Request)

func (*AppStore) PutView added in v0.53.0

func (h *AppStore) PutView(w http.ResponseWriter, r *http.Request)

PutView saves a scope's store screen settings.

func (*AppStore) Status added in v0.53.0

func (h *AppStore) Status(w http.ResponseWriter, r *http.Request)

Status answers whether the caller sees the store screen, and its stores. An API key, or a filex with the store off, is answered "not visible".

func (*AppStore) TrustStore added in v0.52.0

func (h *AppStore) TrustStore(w http.ResponseWriter, r *http.Request)

TrustStore trusts a store with the keys it publishes, provided they are the ones the administrator was shown (`fingerprints`, as the trust question listed them).

func (*AppStore) UntrustStore added in v0.52.0

func (h *AppStore) UntrustStore(w http.ResponseWriter, r *http.Request)

func (*AppStore) UserCatalog added in v0.53.0

func (h *AppStore) UserCatalog(w http.ResponseWriter, r *http.Request)

UserCatalog answers a store's catalog, as filex verified it.

func (*AppStore) UserMedia added in v0.53.0

func (h *AppStore) UserMedia(w http.ResponseWriter, r *http.Request)

UserMedia answers an icon of a store's catalog, through filex.

func (*AppStore) VerifyLicense added in v0.52.0

func (h *AppStore) VerifyLicense(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

type AppearanceSource struct {
	Store db.Store
	// contains filtered or unexported fields
}

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

func (a *AppearanceSource) DefaultTokens(ctx context.Context, dark bool) map[string]string

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

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
	// Files streams the one file a `"mode":"file"` link names (the browser's
	// drag-out link, download_link.go). Nil answers that mode with 503.
	Files FileLinkStreamer
	// MultiTenant decides whether a link's redeem is held to the tenant host
	// it was minted on and to its owner's tenant scope.
	MultiTenant bool
	// E2EPolicy is who may encrypt (e2e_policy_gate.go): extraction skips a
	// member that would create a key file or a `.fxe`, and add and create
	// refuse a new archive under such a name. nil = not wired.
	E2EPolicy *e2epolicy.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

func NewArchive(store db.Store, resolver func(int64) (storage.Driver, error)) *Archive

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

func (a *Archive) AttachACL(r *acl.Resolver)

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

func (a *Archive) AttachBody(b *filebody.Resolver)

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 (a *Archive) AttachFileLinks(s FileLinkStreamer, multiTenant bool)

AttachFileLinks wires one-file links. Without it `"mode":"file"` answers 503.

func (*Archive) AttachOps added in v0.44.2

func (a *Archive) AttachOps(service *ops.Service)

AttachOps wires the shared long-running file-operation queue.

func (*Archive) AttachSearchIndex

func (a *Archive) AttachSearchIndex(i *search.Index)

AttachSearchIndex / AttachThumbs wire the two optional halves of the bookkeeper.

func (*Archive) AttachThumbs

func (a *Archive) AttachThumbs(p *thumb.Pipeline)

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.

func (*Archive) Extract

func (a *Archive) Extract(w http.ResponseWriter, r *http.Request)

Extract pulls members out of an archive into the destination directory.

DestDir is interpreted on the SAME storage as the source. Members defaults to "all" when empty.

func (*Archive) List

func (a *Archive) List(w http.ResponseWriter, r *http.Request)

List enumerates archive members.

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 (*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
	// (and the names typed at the sign-in form) in it belong to the other
	// visitors. See demoMask.
	DemoMode bool
}

Audit handles /api/admin/audit.

func NewAudit

func NewAudit(store db.Store) *Audit

NewAudit constructs the handler.

func (*Audit) List

func (h *Audit) List(w http.ResponseWriter, r *http.Request)

List returns paginated audit entries with optional filters.

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
	// Guard limits wrong sign-in attempts per account identifier and per
	// address (internal/loginguard). Nil = no limit, which is what a handler
	// built without one (a test, an embedder) had before.
	Guard *loginguard.Guard
	// EmailToken is the installation's e-mail token (FILEX_OS_LOGIN_EMAIL_TOKEN,
	// identity.EmailToken): a realm's derived addresses end in it
	// (`alex@acme.local`). "" = the default.
	EmailToken string
	// Handoffs holds the one-use tickets that carry a sign-in from the
	// platform's address to a tenant's own (see handOff; auth.HandoffStore).
	// Nil = no handoff: the session is opened where the person signed in, as
	// before.
	Handoffs *auth.HandoffStore
	// Live is the running set of sign-in providers: the sign-in page asks it
	// which methods a realm has (Methods). Nil on a harness that has none;
	// Methods then answers from LocalAuth / OIDCAuth alone.
	Live *authsetup.Live
}

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) Handoff added in v0.50.0

func (h *Auth) Handoff(w http.ResponseWriter, r *http.Request)

Handoff completes a sign-in handed over by handOff: POST /api/auth/handoff {"code"}, on the tenant's own address. Answers like Login — the session cookie, and {user, token} — or 401 for a ticket that is unknown, spent, expired, issued for another purpose, presented on another host (it is spent all the same), or whose account may no longer sign in here (auth.CheckHandoffTarget). Every refusal of a real ticket is audited.

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) Methods added in v0.50.0

func (h *Auth) Methods(w http.ResponseWriter, r *http.Request)

Methods answers GET /api/auth/methods?realm=: how a sign-in for the tenant the address and the realm name may go (docs/TENANT-ADMIN.md):

{"password": bool, "recovery": bool, "sso": [{"id", "label"}]}

The sign-in page asks before it draws its buttons, and again when the Realm field changes.

⚠ No realm oracle. For a realm that names a tenant, `password` is whether the instance answers a password form at all (never whether THAT tenant has a directory), and a realm nobody has answers exactly like a tenant with no SSO. A realm with SSO shows its buttons: starting its sign-in would send the browser to its identity provider anyway. `recovery` is the platform's own tenant's only.

func (*Auth) OIDCCallback

func (h *Auth) OIDCCallback(w http.ResponseWriter, r *http.Request)

OIDCCallback completes the OIDC flow.

func (*Auth) OIDCStart

func (h *Auth) OIDCStart(w http.ResponseWriter, r *http.Request)

OIDCStart redirects to the IdP: `?instance=<slug>&realm=<realm>` start one OIDC of the tenant the address or the realm names (docs/TENANT-ADMIN.md).

A start the tenant has no such OIDC for, or a realm nobody has, goes back to the sign-in page (`?error=oidc`), like a failed callback: the start is a browser navigation, and a JSON body would dead-end the person. No reason code for those two: "this realm has no such SSO" against "no such realm" would tell a stranger which realms exist. Any other failure to start goes back the same way, with a code only when the person can act on it (too many sign-ins in flight: try again shortly); its words stay in the log.

func (*Auth) SSOFor added in v0.50.0

func (h *Auth) SSOFor(r *http.Request) []ssoMethod

SSOFor is the SSO buttons of the sign-in page as it first loads: the tenant the address names, else the platform's own (/api/capabilities `auth_sso`). The page asks Methods only when a realm is typed.

func (*Auth) WhoAmI

func (h *Auth) WhoAmI(w http.ResponseWriter, r *http.Request)

WhoAmI returns the current user (or null).

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) Create added in v0.50.0

func (h *AuthProviders) Create(w http.ResponseWriter, r *http.Request)

Create makes another instance of a managed driver (a second LDAP, an OIDC for some tenants only): POST /api/admin/auth-providers {driver, slug?, label?, enabled?, config?, tenants?, confirm_failed_test?, test_account?}. It is saved like Update saves (the real test first), and serves the platform's own tenant unless `tenants` names others (a multi-tenant install).

func (*AuthProviders) Delete added in v0.50.0

func (h *AuthProviders) Delete(w http.ResponseWriter, r *http.Request)

Delete removes an instance made with Create (DELETE /api/admin/auth-providers/{name}). A driver's first instance is switched off, not deleted (its settings rows are the environment-independent record of it); an environment provider is the environment's.

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) SetTenants added in v0.50.0

func (h *AuthProviders) SetTenants(w http.ResponseWriter, r *http.Request)

SetTenants says which tenants sign in through an instance (PUT /api/admin/auth-providers/{name}/tenants {tenants: [ids], confirm_tenant_lockout?}): bindings added and removed so the list is exactly this one. A multi-tenant install's; a tenant's own instance serves its tenant only, ever. Removing the last way in of a tenant is the lockout rule's.

func (*AuthProviders) SyncStart added in v0.52.0

func (h *AuthProviders) SyncStart(w http.ResponseWriter, r *http.Request)

SyncStart starts an LDAP instance's directory sync and answers 202 at once — a directory of thousands takes longer than a request should; the page polls SyncStatus. 404 when the provider cannot sync, 409 while a run is going.

POST /api/admin/auth-providers/{name}/sync

func (*AuthProviders) SyncStatus added in v0.52.0

func (h *AuthProviders) SyncStatus(w http.ResponseWriter, r *http.Request)

SyncStatus answers an LDAP instance's directory sync: whether it can run (a running LDAP provider), whether it is running now, its interval and the last run's report (docs/LDAP.md → Directory sync).

GET /api/admin/auth-providers/{name}/sync

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

type AuthSelf struct {
	Store db.Store
	// ACL resolves the caller's per-user permissions for Me (package perm).
	// Nil (tests) leaves them out of the answer.
	ACL *acl.Resolver
}

AuthSelf wraps the self-service profile/password/TOTP routes.

func NewAuthSelf

func NewAuthSelf(store db.Store) *AuthSelf

NewAuthSelf constructs the handler.

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.

func (*Branding) Get

func (h *Branding) Get(w http.ResponseWriter, r *http.Request)

Get returns the effective branding for the request host. Public (no auth): the admin SPA login page fetches it before any session exists.

type BrandingConfig

type BrandingConfig struct {
	Name          string `json:"name"`
	LogoURL       string `json:"logo_url"`
	Accent        string `json:"accent"`
	FooterText    string `json:"footer_text"`
	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

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
	// AppDocs lists the rows the running apps add to the "New" menu
	// (`new_documents`). Told to a signed-in person only: which apps an
	// instance runs is not a property of the build. Nil = none.
	AppDocs func() []newdoc.Type
	/* 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
	// E2EPolicy answers `e2e_policy`: whether the caller's tenant may use
	// encryption at all, and its policy (internal/e2epolicy). Nil publishes
	// nothing, which is what a server from before the policy said.
	E2EPolicy *e2epolicy.Service
	// 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
	// SSO lists the sign-in page's SSO buttons for the address asked on (the
	// tenant it names, else the platform's own): `auth_sso` [{id, label}].
	// Nil = the field is absent and the page keeps its one button from
	// `auth_drivers` (docs/TENANT-ADMIN.md).
	SSO func(r *http.Request) []ssoMethod
	// Queued names the changes this server runs as jobs of its operations queue
	// when asked with `queued=1`: "rename" on POST /api/files/manager?action=rename,
	// "restore" on POST /api/files/manager/restore, "purge" on
	// DELETE /api/admin/trash/{id}. Published as `queued`;
	// empty publishes nothing, and the explorer changes inside the request as
	// it always did.
	Queued []string
}

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

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 / 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.

⚠ 0.50: no `libreoffice` any more. LibreOffice left the images and the office engine is the connected OnlyOffice Document Server, so a flag named after a program filex never runs could only ever be false - a reader would take it for "office documents cannot be previewed or converted here".

type CapabilitiesShort

type CapabilitiesShort struct {
	FFmpeg      bool `json:"ffmpeg"`
	Ghostscript bool `json:"ghostscript"`
	// No `libreoffice` since 0.50: office documents are OnlyOffice's
	// (onlyoffice_reachable), and filex runs no LibreOffice anywhere.
	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 NewCloud

func NewCloud(svc *cloud.Service, stripe *cloud.StripeClient, multiTenant bool) *Cloud

NewCloud constructs the handler.

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) Register

func (h *Cloud) Register(r chi.Router)

Register mounts the cloud routes (called only when FILEX_CLOUD=1).

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").

func (*Cloud) Verify

func (h *Cloud) Verify(w http.ResponseWriter, r *http.Request)

Verify consumes a verification token ({"token": "…"}).

func (*Cloud) Webhook

func (h *Cloud) Webhook(w http.ResponseWriter, r *http.Request)

Webhook drafts the Stripe webhook receiver. Without STRIPE_SECRET → 503; with it, the (skeleton) signature check rejects everything until a launch implements it — the endpoint can never be spoofed into acting.

type Comments

type Comments struct {
	Store   db.Store
	Service *comments.Service
	ACL     *acl.Resolver
}

Comments wraps the node-comment HTTP routes.

func NewComments

func NewComments(store db.Store) *Comments

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

func (h *Comments) AttachACL(r *acl.Resolver)

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: {…}}

func (*Comments) Delete

func (h *Comments) Delete(w http.ResponseWriter, r *http.Request)

Delete soft-deletes a comment (author or admin only).

DELETE /api/files/comments/{id} → {ok: true}

func (*Comments) List

func (h *Comments) List(w http.ResponseWriter, r *http.Request)

List returns a node's live comments, oldest first, with author names and a per-row `can_delete` for the caller.

GET /api/files/comments?node_id=N → {comments: […], node_id: N}

type CustomCSS

type CustomCSS struct {
	Store db.Store
}

CustomCSS serves the operator stylesheet to signed-in browsers.

func NewCustomCSS

func NewCustomCSS(store db.Store) *CustomCSS

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 demoMask.recent.
	DemoMode bool
}

Dashboard handles /api/admin/dashboard.

func NewDashboard

func NewDashboard(store db.Store, caps *capability.Service, q queue.Driver) *Dashboard

NewDashboard constructs the handler. q is the op queue ("Queue depth"); nil reads as an empty queue.

func (*Dashboard) Get

func (h *Dashboard) Get(w http.ResponseWriter, r *http.Request)

Get renders the dashboard payload.

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

type DiscoveredFolder struct {
	Name string `json:"name"`
	Root string `json:"root"`
}

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 {
	// ACL, when wired, closes a link whose creator may no longer make it
	// (link_creator.go). Nil in tests built by hand: every link answers.
	ACL       *acl.Resolver
	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) AttachACL added in v0.49.0

func (h *Drop) AttachACL(r *acl.Resolver)

AttachACL wires the permission resolver: a file request closes when its creator may no longer make one, and the creator's blocked file types apply to what is dropped (link_creator.go).

func (*Drop) AttachBranding

func (h *Drop) AttachBranding(b *BrandingSource)

AttachBranding wires the shared branding source (wiring:e1).

func (*Drop) AttachLocale

func (h *Drop) AttachLocale(def string)

AttachLocale sets the fallback language for the drop pages.

func (*Drop) AttachShell

func (h *Drop) AttachShell(shell http.Handler)

AttachShell wires the SPA document served to JavaScript browsers.

func (*Drop) AttachTenants

func (h *Drop) AttachTenants(rv tenanturl.Resolver)

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.

func (*Drop) Upload

func (h *Drop) Upload(w http.ResponseWriter, r *http.Request)

Upload handles two POST shapes on the same URL:

  • multipart/form-data (files present) → the actual drop.
  • urlencoded PIN form submit → validate the PIN and render the uploader.

type Duplicates

type Duplicates struct {
	Store db.Store
}

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 NewE2E

func NewE2E(store db.Store, escrow *e2e.EscrowKey) *E2E

NewE2E constructs the handler. escrow may be nil.

func (*E2E) AttachACL

func (h *E2E) AttachACL(r *acl.Resolver)

AttachACL wires the RBAC resolver.

func (*E2E) AttachCleanup added in v0.48.0

AttachCleanup wires what POST /api/files/e2e/cleanup removes from. Each may be nil; that part is then skipped (and reported as zero).

func (*E2E) Cleanup added in v0.48.0

func (h *E2E) Cleanup(w http.ResponseWriter, r *http.Request)

Cleanup removes what filex holds from before a folder was encrypted.

POST /api/files/e2e/cleanup {path, versions, trash}
  → {versions_deleted, trash_purged, thumbnails_dropped, index_cleared}

Thumbnails and extracted search content are always dropped: they are caches of plaintext the folder no longer has. Versions and trash entries are someone's safety net and are removed only when asked, and only by the folder's owner or an administrator.

func (*E2E) EscrowChallenge

func (h *E2E) EscrowChallenge(w http.ResponseWriter, r *http.Request)

EscrowChallenge mints a proof-of-possession challenge for an encrypted folder, or for a single encrypted file (`.fxe`, its own root).

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 owner of the folder (or of the single encrypted file) that was opened.

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.

func (*E2E) PasswordChanged added in v0.48.0

func (h *E2E) PasswordChanged(w http.ResponseWriter, r *http.Request)

PasswordChanged records a folder's (or a single encrypted file's) password change and tells the owner.

POST /api/files/e2e/password-changed {path, via, rekey} → {ok, notified}

type E2ECleanupIndex added in v0.48.0

type E2ECleanupIndex interface {
	IndexNodeContent(ctx context.Context, n *model.Node, content string) error
}

E2ECleanupIndex blanks a node's extracted content in the search index.

type E2ECleanupThumbs added in v0.48.0

type E2ECleanupThumbs interface {
	Forget(ctx context.Context, nodeID int64)
}

E2ECleanupThumbs drops a node's thumbnail (the JPEG and its row).

type E2ECleanupTrash added in v0.48.0

type E2ECleanupTrash interface {
	List(ctx context.Context, storageID *int64, limit, offset int) ([]trash.TrashEntry, int, error)
	PurgeOne(ctx context.Context, nodeID int64) error
}

E2ECleanupTrash is the trash surface the cleanup needs.

type E2ECleanupVersions added in v0.48.0

type E2ECleanupVersions interface {
	List(ctx context.Context, nodeID int64) ([]*model.NodeVersion, error)
	HardDeleteVersion(ctx context.Context, versionID int64) error
}

E2ECleanupVersions is the versioning surface the cleanup needs.

type E2EPolicyAdmin added in v0.51.0

type E2EPolicyAdmin struct {
	Store       db.Store
	Policy      *e2epolicy.Service
	MultiTenant bool
	// Requests decides the approval policy's requests (e2e_policy_files.go
	// leaves them).
	Requests *e2epolicy.Requests
}

E2EPolicyAdmin is the handler set.

func NewE2EPolicyAdmin added in v0.51.0

func NewE2EPolicyAdmin(store db.Store, policy *e2epolicy.Service, multiTenant bool) *E2EPolicyAdmin

NewE2EPolicyAdmin constructs the handler.

func (*E2EPolicyAdmin) ApproveRequest added in v0.51.0

func (h *E2EPolicyAdmin) ApproveRequest(w http.ResponseWriter, r *http.Request)

ApproveRequest opens one encryption for the requester at the place asked: POST /api/admin/e2e/requests/{id}/approve. Session only.

func (*E2EPolicyAdmin) Get added in v0.51.0

Get answers the policy this administrator holds: GET /api/admin/e2e.

func (*E2EPolicyAdmin) ListRequests added in v0.51.0

func (h *E2EPolicyAdmin) ListRequests(w http.ResponseWriter, r *http.Request)

ListRequests answers the requests this administrator decides: GET /api/admin/e2e/requests[?status=…]. Pending by default.

func (*E2EPolicyAdmin) Patch added in v0.51.0

func (h *E2EPolicyAdmin) Patch(w http.ResponseWriter, r *http.Request)

Patch sets the policy this administrator holds: PATCH /api/admin/e2e {"policy": "…"}. Session only. A tenant whose ceiling is off may still choose one: it applies the day the operator switches the ceiling on.

func (*E2EPolicyAdmin) PatchTenant added in v0.51.0

func (h *E2EPolicyAdmin) PatchTenant(w http.ResponseWriter, r *http.Request)

PatchTenant switches a tenant's ceiling: PATCH /api/admin/e2e/tenants/{id} {"e2e_allowed": false}. Supertenant only, session only, multi-tenant only. Off, nobody in that tenant may start encrypting — its administrators included; what is already encrypted keeps working.

func (*E2EPolicyAdmin) RejectRequest added in v0.51.0

func (h *E2EPolicyAdmin) RejectRequest(w http.ResponseWriter, r *http.Request)

RejectRequest closes a request: POST /api/admin/e2e/requests/{id}/reject. Session only.

func (*E2EPolicyAdmin) Tenants added in v0.51.0

func (h *E2EPolicyAdmin) Tenants(w http.ResponseWriter, r *http.Request)

Tenants answers every tenant's ceiling and policy: GET /api/admin/e2e/tenants. Supertenant only.

type E2EPolicyFiles added in v0.51.0

type E2EPolicyFiles struct {
	Store    db.Store
	Policy   *e2epolicy.Service
	Requests *e2epolicy.Requests
}

E2EPolicyFiles is the handler set.

func NewE2EPolicyFiles added in v0.51.0

func NewE2EPolicyFiles(store db.Store, policy *e2epolicy.Service, requests *e2epolicy.Requests) *E2EPolicyFiles

NewE2EPolicyFiles constructs the handler.

func (*E2EPolicyFiles) Allowed added in v0.51.0

func (h *E2EPolicyFiles) Allowed(w http.ResponseWriter, r *http.Request)

Allowed answers, for each path, what the explorer may offer there: POST /api/files/e2e/allowed {"items":[{"path":"Depo://a","kind":"new_folder"}]} → {"encrypt":["allowed"|"request"|"denied",…], "reasons":["",…]}, in the order of the items. A reason names the layer that said no, as a 403 e2e_not_allowed would (e2epolicy.Reason; approval_required with "request", "" with "allowed"): the CLI says it before asking for a password.

An item's kind says which encryption is asked about: `folder` (the path is the folder to encrypt where it is), `new_folder` (the folder to make a new encrypted folder in), `file` (the file to encrypt), or none for the one the catalogue says. The answer is e2epolicy.Service.AnswersForKinds there - the rule every create door asks (CheckCreate), with the approvals that door would spend looked for and never spent, so the menu offers exactly what the server then accepts. A kind it does not know answers "denied". It changes nothing, so it needs `read`, like POST ?action=allowed beside it.

⚠ Asked for administrators too, unlike the permission question: policy `off` stops them, and so does their tenant's ceiling.

A path it cannot place — a storage outside the caller's tenant (the list is the tenant's, tenantstore), "..", a storage nobody has — answers "denied". So does a path outside the caller's root, when a token's `root:` scope or X-Filex-Root confines them to a folder: nothing of the rule is heard there.

⚠ confine.Middleware refuses such a path in a JSON body, but it reads no other body, and this handler decodes JSON whatever the Content-Type says — so the root is read here too, before any rule is asked, as place() reads it for a request.

The answer is a list and not keyed by path: a folder-confined token's paths are rewritten on the way in (confine.Middleware), so the caller would not recognise them.

func (*E2EPolicyFiles) CreateRequest added in v0.51.0

func (h *E2EPolicyFiles) CreateRequest(w http.ResponseWriter, r *http.Request)

CreateRequest leaves a request: POST /api/files/e2e/requests. 201 with the new request, 200 with the one already waiting for this person and place. 404 path_missing when nothing is at the path, 429 too_many_pending when the person has e2epolicy.MaxPendingPerPerson waiting already.

func (*E2EPolicyFiles) MyRequests added in v0.51.0

func (h *E2EPolicyFiles) MyRequests(w http.ResponseWriter, r *http.Request)

MyRequests answers the caller's own requests, newest first: GET /api/files/e2e/requests. A token confined to a folder (`root:`) sees the ones inside it, as it sees nothing else outside it.

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
	// AppUIOrigin is FILEX_APP_UI_ORIGIN and OfficeFrameOrigin
	// FILEX_ONLYOFFICE_FRAME_ORIGIN. With either the editor's api.js runs in a
	// frame on another origin; with neither, in filex's own page - which the
	// ONLYOFFICE card says (external.CodeEditorSameOrigin, task #92).
	AppUIOrigin       string
	OfficeFrameOrigin string
	// ReversePath measures the third leg — the document server's route BACK to
	// filex — by asking it to download a one-shot URL of ours, through the same
	// signed fetch endpoint a document uses, and watching for the request. It
	// also asks whether the document server enforces JWT. A nil target means
	// the configuration in force. Nil when OnlyOffice is not wired, and only
	// meaningful for that service: nothing else calls filex back.
	ReversePath func(ctx context.Context, t *onlyoffice.Target) 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) AttachAppUIOrigin added in v0.53.0

func (h *ExternalAdmin) AttachAppUIOrigin(origin string)

AttachAppUIOrigin wires FILEX_APP_UI_ORIGIN into the advisories, beside the public URL and for the same two call sites.

func (*ExternalAdmin) AttachOfficeFrameOrigin added in v0.53.0

func (h *ExternalAdmin) AttachOfficeFrameOrigin(origin string)

AttachOfficeFrameOrigin wires FILEX_ONLYOFFICE_FRAME_ORIGIN into the same advisories.

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 FileLinkStreamer added in v0.45.0

type FileLinkStreamer interface {
	ServeLinkedFile(w http.ResponseWriter, r *http.Request, s *model.Storage, rel string)
}

FileLinkStreamer serves the bytes of one file for a link. *Manager is the implementation: the link goes through the same download path as the explorer's own Download (Range, staging, MIME, disposition), not a copy of it.

type FileTypesAdmin added in v0.50.0

type FileTypesAdmin struct {
	Assoc *assoc.Service
	// Demo refuses every change (the admin account of a demo is public).
	Demo bool
}

FileTypesAdmin serves the Default apps screen.

func NewFileTypesAdmin added in v0.50.0

func NewFileTypesAdmin(svc *assoc.Service, demo bool) *FileTypesAdmin

NewFileTypesAdmin constructs the handler. A nil service (app plugins off) still lists filex's own kinds - none - and refuses changes with 503.

func (*FileTypesAdmin) List added in v0.50.0

List answers GET /api/admin/file-types.

func (*FileTypesAdmin) Put added in v0.50.0

Put answers PUT /api/admin/file-types/{ext}.

func (*FileTypesAdmin) Reset added in v0.50.0

func (h *FileTypesAdmin) Reset(w http.ResponseWriter, r *http.Request)

Reset answers DELETE /api/admin/file-types/{ext}: both capabilities back to the default order.

type FolderPreviewSwitch added in v0.50.0

type FolderPreviewSwitch interface{ FolderPreviews() bool }

FolderPreviewSwitch reports whether folders show their pictures (*thumb.Pipeline: thumb.FolderPreviewsSetting, cached).

type Grants

type Grants struct {
	Store     db.Store
	ACL       *acl.Resolver
	Share     *share.Service // optional — nil disables the share fallback in Invite
	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 NewGrants

func NewGrants(store db.Store, resolver *acl.Resolver) *Grants

NewGrants constructs the permissions handler.

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) AdminDeleteGroup added in v0.50.0

func (h *Grants) AdminDeleteGroup(w http.ResponseWriter, r *http.Request)

AdminDeleteGroup revokes any group's grant (admin override).

DELETE /api/admin/grants/groups/{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

func (h *Grants) AttachInvite(sh *share.Service, m *mailer.Service, publicURL string)

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

func (h *Grants) AttachTenants(rv tenanturl.Resolver)

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 — or a group — on a path.

POST /api/files/permissions {path, user_id | group_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) DeleteGroup added in v0.50.0

func (h *Grants) DeleteGroup(w http.ResponseWriter, r *http.Request)

DeleteGroup revokes a group grant.

DELETE /api/files/permissions/groups/{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) SearchGroups added in v0.50.0

func (h *Grants) SearchGroups(w http.ResponseWriter, r *http.Request)

SearchGroups returns the groups matching q (by name) that the caller could grant to, so the permissions panel can offer them beside people. Like SearchUsers, any authenticated user may call it; it carries names only.

GET /api/files/permissions/groups?q=<substr>

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}

func (*Grants) Update

func (h *Grants) Update(w http.ResponseWriter, r *http.Request)

Update changes a grant's level.

PATCH /api/files/permissions/{id} {level}

func (*Grants) UpdateGroup added in v0.50.0

func (h *Grants) UpdateGroup(w http.ResponseWriter, r *http.Request)

UpdateGroup changes a group grant's level.

PATCH /api/files/permissions/groups/{id} {level}

type GroupsAdmin added in v0.50.0

type GroupsAdmin struct {
	Store db.Store
	ACL   *acl.Resolver
}

GroupsAdmin serves /api/admin/groups* and /api/admin/users/{id}/groups.

func NewGroupsAdmin added in v0.50.0

func NewGroupsAdmin(store db.Store, resolver *acl.Resolver) *GroupsAdmin

NewGroupsAdmin constructs the handler.

func (*GroupsAdmin) AddMembers added in v0.50.0

func (h *GroupsAdmin) AddMembers(w http.ResponseWriter, r *http.Request)

AddMembers puts people in a group by hand. A person already in it through a link becomes a manual member — they stay whatever the directory says.

POST /api/admin/groups/{id}/members {"user_ids":[3,4]}

func (*GroupsAdmin) Create added in v0.50.0

func (h *GroupsAdmin) Create(w http.ResponseWriter, r *http.Request)

Create adds a group.

POST /api/admin/groups {name, description, role_id, links, provider_id?}

func (*GroupsAdmin) Delete added in v0.50.0

func (h *GroupsAdmin) Delete(w http.ResponseWriter, r *http.Request)

Delete removes a group, its memberships and its folder grants. Its members keep their level underneath; one with another group holding a role gets that group's role.

DELETE /api/admin/groups/{id}

func (*GroupsAdmin) Detach added in v0.52.0

func (h *GroupsAdmin) Detach(w http.ResponseWriter, r *http.Request)

Detach keeps a group whose directory group is gone as an ordinary filex group: it is no longer the directory's, its dead LDAP link goes, and its folders, role and hand-added members stay. Only for a group flagged removed — one the directory still has is the directory's, and which directory groups exist is managed on the directory. An administrator's: links are.

POST /api/admin/groups/{id}/detach

func (*GroupsAdmin) Get added in v0.50.0

func (h *GroupsAdmin) Get(w http.ResponseWriter, r *http.Request)

Get returns one group with its members and its folder grants.

GET /api/admin/groups/{id}

func (*GroupsAdmin) List added in v0.50.0

func (h *GroupsAdmin) List(w http.ResponseWriter, r *http.Request)

List returns the groups the caller's tenant may see, in id order, each with how many members and folder grants it has.

GET /api/admin/groups

func (*GroupsAdmin) Memberships added in v0.52.0

func (h *GroupsAdmin) Memberships(w http.ResponseWriter, r *http.Request)

Memberships answers every person's groups the caller may see, keyed by user id — the Users list's Groups column in one call instead of one per row. Each carries the membership's source (added, SSO, LDAP).

func (*GroupsAdmin) RemoveMember added in v0.50.0

func (h *GroupsAdmin) RemoveMember(w http.ResponseWriter, r *http.Request)

RemoveMember takes a person out of a group, however they came to be in it. One who is in it through a link joins again at their next sign-in if the directory still says so.

DELETE /api/admin/groups/{id}/members/{user_id}

func (*GroupsAdmin) Update added in v0.50.0

func (h *GroupsAdmin) Update(w http.ResponseWriter, r *http.Request)

Update replaces a group's name, description, role and links. Its tenant never changes.

PUT /api/admin/groups/{id}

func (*GroupsAdmin) UserGroups added in v0.50.0

func (h *GroupsAdmin) UserGroups(w http.ResponseWriter, r *http.Request)

UserGroups returns the groups one account is in, and how.

GET /api/admin/users/{id}/groups → {"groups":[{id, name, role_id, source}]}

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 LoginSecurity added in v0.50.0

type LoginSecurity struct {
	Store db.Store
	// Guard is the running limiter; its settings cache is dropped after a
	// write so the next attempt sees the new numbers.
	Guard *loginguard.Guard
	// EnvTrustedProxies is FILEX_TRUSTED_PROXIES, shown as the source of the
	// trusted-proxy list when the setting is empty.
	EnvTrustedProxies string
	// DemoMode marks a public playground, where these pages are readable by
	// whoever read the published credentials: the visitors' addresses (the
	// locks, the trail) and the operator's (the allow-list, the proxies) are
	// masked - demo_redact.go. On a normal install nothing is.
	DemoMode bool
}

LoginSecurity handles /api/admin/login-security.

func NewLoginSecurity added in v0.50.0

func NewLoginSecurity(store db.Store, guard *loginguard.Guard, envTrustedProxies string) *LoginSecurity

NewLoginSecurity constructs the handler.

func (*LoginSecurity) Attempts added in v0.50.0

func (h *LoginSecurity) Attempts(w http.ResponseWriter, r *http.Request)

Attempts lists the sign-in trail: every wrong attempt, lock, release, allow-list pass and settings change, newest first. It is the audit log filtered to `login.` and `login_security.` - one name for each event whichever door it came through (auth.DoorAction), the door in `via`; ?action= narrows to one of them.

func (*LoginSecurity) Get added in v0.50.0

Get returns the settings, the bounds the form should enforce, and the trusted-proxy list in force.

func (*LoginSecurity) Locks added in v0.50.0

func (h *LoginSecurity) Locks(w http.ResponseWriter, r *http.Request)

Locks lists the counters: who is being counted, who is locked, until when. ?locked=1 keeps only the locks in force; ?scope=account|ip narrows.

func (*LoginSecurity) Patch added in v0.50.0

func (h *LoginSecurity) Patch(w http.ResponseWriter, r *http.Request)

Patch validates every field it was given and, only when all are valid, writes them. It answers what Get does.

func (*LoginSecurity) Unlock added in v0.50.0

func (h *LoginSecurity) Unlock(w http.ResponseWriter, r *http.Request)

Unlock lifts one lock — and clears its counter and its escalation — or, with {"all":true}, every lock in force. An address that is being blocked is unlocked by the address (scope "ip"), an account by the identifier as typed (scope "account"; it is normalized the way the limiter does).

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
	// ThumbRefresh draws in the background what a listing found missing or
	// stale (hydrateThumbs). Nil: nothing is drawn from a listing.
	ThumbRefresh ThumbRefresher
	// FolderPreviews says whether folders show their pictures in a listing
	// (thumb.FolderPreviewsSetting, cached by the pipeline). Nil: on.
	FolderPreviews FolderPreviewSwitch
	// 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
	// AppDocs makes the rows apps add to the "New" menu (new_documents).
	// Nil = only the built-in kinds.
	AppDocs *wasmplugin.Registry
	// 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
	// Ops runs a folder rename asked with `queued=1` as a job of the queue
	// (vfRename). nil renames inside the request, as it always did.
	Ops *ops.Service
	// E2EPolicy is who may encrypt (e2e_policy_gate.go). The doors on this
	// handler that CREATE a file — upload, New document, a draft's save, and
	// through it the resumable upload and the public file request — ask it
	// before an encrypted folder's key file or a `.fxe` comes into being.
	// nil = the rule is not wired (a handler built by hand in a test).
	E2EPolicy *e2epolicy.Service
}

Manager handles read-only browsing endpoints under /api/files/manager.

func NewManager

func NewManager(store db.Store, resolver func(int64) (storage.Driver, error)) *Manager

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

func (h *Manager) AttachACL(r *acl.Resolver)

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

func (h *Manager) AttachBody(b *filebody.Resolver)

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

func (h *Manager) AttachChangeLog(l *realtime.ChangeLog)

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) AttachOps added in v0.47.0

func (h *Manager) AttachOps(o *ops.Service)

AttachOps wires the queue a rename asked with `queued=1` runs on.

func (*Manager) AttachSearchIndex

func (h *Manager) AttachSearchIndex(idx *search.Index)

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) CountDrafts added in v0.48.0

func (h *Manager) CountDrafts(w http.ResponseWriter, r *http.Request)

CountDrafts answers GET /api/files/drafts/count — the navigation panel's badge, asked after every change the explorer makes to a draft.

func (*Manager) CreateDraft added in v0.48.0

func (h *Manager) CreateDraft(w http.ResponseWriter, r *http.Request)

CreateDraft answers POST /api/files/drafts: New document, as a draft.

The request is action=newfile's (vfNewFileBody) and is judged by the same half (planNewDoc): the type, the name, the folder it is meant for — the caller must be able to write THERE, because that is where it will go — the storage's read-only switch and the caller's quota. What differs is where the bytes land: the caller's drafts area, and nothing at the destination. A name already taken there is not refused: whether the draft goes in beside it as `name (2).ext` is asked when it is saved (SaveDraft), which is when it matters.

func (*Manager) DiscardDraft added in v0.48.0

func (h *Manager) DiscardDraft(w http.ResponseWriter, r *http.Request)

DiscardDraft answers DELETE /api/files/drafts/{key}: the draft goes to the TRASH, like any deleted file, and the trash's retention deletes it for good (the owner's design, issue #71). Its row is kept: a restore from the trash puts the file back in its drafts folder, and it is a draft again. The trash shows it to its owner alone (Trash.trashListKeep).

func (*Manager) EnsureDir

func (h *Manager) EnsureDir(ctx context.Context, st *model.Storage, rel string) (*int64, error)

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) GetDraft added in v0.48.0

func (h *Manager) GetDraft(w http.ResponseWriter, r *http.Request)

GetDraft answers GET /api/files/drafts/{key}.

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:

  1. Native (admin SPA, trash, etc.): ?storage=<id>&parent=<id> Returns {nodes:[…model.Node]} from the DB cache.

  2. 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) ListDrafts added in v0.48.0

func (h *Manager) ListDrafts(w http.ResponseWriter, r *http.Request)

ListDrafts answers GET /api/files/drafts.

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) NameTaken added in v0.47.0

func (h *Manager) NameTaken(ctx context.Context, storageID int64, src, dst string) (bool, error)

NameTaken implements ops.RenameSync: the rule the explorer's rename is refused on (destinationTaken), asked again by the worker for a queued one.

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) SaveDraft added in v0.48.0

func (h *Manager) SaveDraft(w http.ResponseWriter, r *http.Request)

SaveDraft answers POST /api/files/drafts/{key}/save: the draft goes where it is meant to go, and stops being a draft.

Nothing is ever replaced. When the name is free the file lands there; when something already has it the answer is 409 TARGET_TAKEN with `suggested`, the `name (2).ext` beside it (ops.UniqueDestNumbered — the numbering the New document dialog suggests), and nothing moves until the person agrees and the client asks again with `as`.

The file is MOVED, not copied: its catalogue row keeps its id, so an editor still open on it — the document server's session above all, which posts its final save by node id seconds after the editor closes — writes into the saved document rather than into a draft that is gone.

func (*Manager) ServeLinkedFile added in v0.45.0

func (h *Manager) ServeLinkedFile(w http.ResponseWriter, r *http.Request, s *model.Storage, rel string)

ServeLinkedFile streams one file for a credential-free download link (the one-file tickets of archive_download.go / download_link.go).

⚠ The link's OWNER is already on the request context. The ≥viewer check in streamBody is therefore the owner's reach NOW — at the drop — and not the reach they had when the link was minted; a grant revoked in between refuses the download. That is the property that lets the redeem carry no credential.

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) SyncCopy

func (h *Manager) SyncCopy(ctx context.Context, storageID int64, src, dst string)

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

func (h *Manager) SyncHardDelete(ctx context.Context, storageID int64, src string)

SyncHardDelete drops the rows of an item whose bytes are gone for good: the driver could not trash them and deleted them outright, they were already missing, or a move across storages took them away. Mirrors vfDelete's branches for the same three cases (dropGoneRows).

⚠⚠ Issue #104: it used to soft-delete the one row WHERE IT STOOD. That row had no bytes in `.filex-trash/`, so it was a trash entry nothing could restore (issue #74's shape, written by filex itself), and for a folder only the folder's row was touched: its contents stayed live under a deleted parent, still counted, still found by search, until a scan noticed.

func (*Manager) SyncMove

func (h *Manager) SyncMove(ctx context.Context, storageID int64, src, dst string)

SyncMove updates the moved node's path/parent. Delegates to the same helper vfMove uses.

func (*Manager) SyncRename added in v0.47.0

func (h *Manager) SyncRename(ctx context.Context, storageID int64, src, dst string)

SyncRename implements ops.RenameSync: vfRename's catalogue side for a queued rename (finishRename).

func (*Manager) SyncSoftDelete

func (h *Manager) SyncSoftDelete(ctx context.Context, storageID int64, src, trashRel string)

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
	// ThumbRefresh draws in the background what these rows found missing or
	// stale (hydrateThumbs).
	ThumbRefresh ThumbRefresher
}

Meta hosts the tags / star / recent endpoints.

func NewMeta

func NewMeta(store db.Store) *Meta

NewMeta constructs the handler.

func (*Meta) AttachACL

func (h *Meta) AttachACL(r *acl.Resolver)

AttachACL wires the RBAC/ACL resolver so every starred / recent / tag row carries the caller's effective level on it.

func (*Meta) AttachThumbSigner

func (h *Meta) AttachThumbSigner(s *thumb.Signer)

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
	// UserScope puts a person's tenant on a context that has none — the
	// digest's background pass judging their bell without a request
	// (DigestViewer). Nil on a single-tenant install, where there is no tenant
	// to put.
	UserScope func(ctx context.Context, u *model.User) context.Context
}

Notifications wraps the notify.Service for the HTTP layer.

func NewNotifications

func NewNotifications(svc notify.Service, store db.Store, r *acl.Resolver) *Notifications

NewNotifications constructs the handler.

func (*Notifications) AdminDigest added in v0.53.0

func (h *Notifications) AdminDigest(w http.ResponseWriter, r *http.Request)

AdminDigest answers the digest defaults the caller administers.

GET /api/admin/notifications/digest

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) AdminUpdateDigest added in v0.53.0

func (h *Notifications) AdminUpdateDigest(w http.ResponseWriter, r *http.Request)

AdminUpdateDigest changes the digest defaults the caller administers. A field left out keeps its value; `urgent_events: null` restores the built-in list. The window must be within 1-15 minutes (400 invalid_window); unknown event ids are dropped.

PATCH /api/admin/notifications/digest
body: {window_minutes?: 1-15, urgent_events?: ["file.infected", …] | null}

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) DigestViewer added in v0.53.0

func (h *Notifications) DigestViewer(ctx context.Context, userID int64) (notify.View, error)

DigestViewer is the notify.Viewer for the digest's background pass: the person's account, their tenant on the context (UserScope, multi-tenant), and the bell this handler would give them.

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
	// FrameURL is the editor's frame on another origin (task #92):
	// FILEX_ONLYOFFICE_FRAME_ORIGIN + onlyoffice.FrameHostPath, else
	// FILEX_APP_UI_ORIGIN + base path + onlyoffice.FramePath. Handed out with
	// every editor config so the explorer runs api.js there. Empty: neither
	// is set, and api.js runs in filex's own page.
	FrameURL string
}

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) Diagnose added in v0.50.0

func (h *OnlyOffice) Diagnose(w http.ResponseWriter, r *http.Request)

Diagnose answers the editor's question after "Download failed": did the document server ask filex for this document since it was opened, and what did filex answer? (onlyoffice.Service.Diagnose, issue #80.)

GET /api/files/onlyoffice/diagnose?path=<adapter://rel>
GET /api/files/onlyoffice/diagnose?id=<node-id>

⚠ Only for a person who may open the document: the same tenant, root and viewer checks the editor configuration asks, with the same 404 for a document the caller cannot see. The answer names no path and no storage, only when, what status, and why.

It is answered from this process's memory; with several replicas each one knows only the requests it served (docs/ONLYOFFICE.md).

No MCP tool mirrors it on purpose: it explains a failure of the in-browser editor, which an agent never opens. An agent that needs the same facts reads the server log, where every refusal is written with its reason.

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) Frame added in v0.53.0

func (h *OnlyOffice) Frame(w http.ResponseWriter, r *http.Request)

Frame serves the editor's frame (task #92): the page ONLYOFFICE's api.js runs in, on another origin than filex's own.

GET|HEAD /filex-frame/editor                on FILEX_ONLYOFFICE_FRAME_ORIGIN
GET|HEAD <base>/_appui/_onlyoffice/editor   on FILEX_APP_UI_ORIGIN

Reached only on those hosts (api.officeFrameHost, api.appUIHostSplit). Credential-free and cookieless: the page is the same for everybody - a session cookie the browser sends along (the frame origin is usually the same site as filex) is not read - and what it opens is the signed config the explorer hands it. A 404 while no document server is configured, or while its address cannot be named in a policy.

⚠ Never cached: the policy names the document server in force, which the administrator can change without a restart.

func (*OnlyOffice) Session added in v0.53.0

func (h *OnlyOffice) Session(w http.ResponseWriter, r *http.Request)

Session answers the office editor about the editing session it has open, when the document changed outside it (#184, onlyoffice/session_base.go).

POST /api/files/onlyoffice/session
{ "path": "adapter://rel", "key": "<document.key>", "action": "state"|"mine"|"theirs" }
→ { "stale": bool, "known": bool }

`state` (the default) asks whether the session is still on the document's current version: stale means a save of it would be written beside the document, not over it. `mine` is the person's answer "write my version over the outside one" (the session's base moves to the version now), `theirs` "keep the outside version" (the session's save, when it comes, is not written). `known` is false when this process had no record of the session.

⚠ Asking needs what opening the document needs (the same tenant, root and viewer checks, the same 404). Answering decides what becomes of a save, so it needs what an editing session needs: files.modify and a token that may write.

type OpenWith added in v0.50.0

type OpenWith struct {
	Store db.Store
}

OpenWith serves the account's "open with" choices.

func NewOpenWith added in v0.50.0

func NewOpenWith(store db.Store) *OpenWith

NewOpenWith constructs the handler.

func (*OpenWith) Clear added in v0.50.0

func (h *OpenWith) Clear(w http.ResponseWriter, r *http.Request)

Clear drops every choice. The account row keeps an empty set, so nothing is adopted again from a surface document that still carries an old copy.

func (*OpenWith) Forget added in v0.50.0

func (h *OpenWith) Forget(w http.ResponseWriter, r *http.Request)

Forget drops one choice: the kind opens with what the administrator put first again.

func (*OpenWith) Get added in v0.50.0

func (h *OpenWith) Get(w http.ResponseWriter, r *http.Request)

Get answers the caller's choices.

func (*OpenWith) Put added in v0.50.0

func (h *OpenWith) Put(w http.ResponseWriter, r *http.Request)

Put keeps one choice: the handler that opens the kind for this person.

⚠ Not checked against what is installed or on now: a choice is kept when its app is switched off and used again when it is back (lib/appViewer decides what opens a file, from what is on for the surface it runs on).

type Ops

type Ops struct {
	Service *ops.Service
	Store   db.Store // for path → storage_id resolution in the per-verb endpoints
	ACL     *acl.Resolver
	// E2EPolicy is who may encrypt (e2e_policy_gate.go): a move under a name
	// of the caller's choosing that lands on a key file's or a `.fxe`'s name
	// asks it (refuseE2E), unless it is free: a folder, a `.fxe` that stays a
	// `.fxe`, a key file that stays its own folder's. A COPY that makes a new
	// encrypted item where it lands asks it too, whatever its name: a `.fxe`,
	// a key file, a folder that holds either (refuseE2ECopy). nil: not wired,
	// allowed.
	E2EPolicy *e2epolicy.Service
	// StorageResolver is how refuseE2E tells a file from a folder at a
	// source. nil: every source counts as a file.
	StorageResolver func(int64) (storage.Driver, error)
}

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 NewOps

func NewOps(svc *ops.Service, store db.Store) *Ops

NewOps constructs an Ops handler.

func (*Ops) AttachACL

func (o *Ops) AttachACL(r *acl.Resolver)

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: their own, or any in reach for an administrator (opsViewer). A row already finished answers 409 FINISHED, and a running one that finishes what it starts (a rename, a restore, a purge) 409 NOT_CANCELLABLE; an unknown id, another tenant's or another person's, 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 PanelSearch added in v0.53.0

type PanelSearch struct {
	Store db.Store
	ACL   *acl.Resolver
	// contains filtered or unexported fields
}

PanelSearch is the admin panel's search. Sources are added by routes.go, each next to the route it reads; the zero set answers no rows.

func NewPanelSearch added in v0.53.0

func NewPanelSearch(store db.Store, resolver *acl.Resolver) *PanelSearch

NewPanelSearch builds the handler. Sources are added with the Add* methods.

func (*PanelSearch) AddApps added in v0.53.0

func (h *PanelSearch) AddApps(list, actions http.Handler)

AddApps reads the installed apps (GET /api/admin/app-plugins) and, when that list answered, their actions (GET /api/files/plugins/actions).

func (*PanelSearch) AddGroups added in v0.53.0

func (h *PanelSearch) AddGroups(door http.Handler)

AddGroups reads the Groups page's list (GET /api/admin/groups).

func (*PanelSearch) AddKeys added in v0.53.0

func (h *PanelSearch) AddKeys(door http.Handler)

AddKeys reads the API / MCP page's list (GET /api/admin/ai-tokens).

func (*PanelSearch) AddShares added in v0.53.0

func (h *PanelSearch) AddShares(door http.Handler)

AddShares reads the Shares page's list (GET /api/admin/shares), the newest panelShareScan links.

func (*PanelSearch) AddStorages added in v0.53.0

func (h *PanelSearch) AddStorages(door http.Handler)

AddStorages reads the Storages page's list (GET /api/admin/storages) without its per-storage counts (`stats=none`).

func (*PanelSearch) AddUsers added in v0.53.0

func (h *PanelSearch) AddUsers(door http.Handler)

AddUsers reads the Users page's list (GET /api/admin/users).

func (*PanelSearch) Clear added in v0.53.0

func (h *PanelSearch) Clear(w http.ResponseWriter, r *http.Request)

Clear empties the caller's recent searches.

func (*PanelSearch) Forget added in v0.53.0

func (h *PanelSearch) Forget(w http.ResponseWriter, r *http.Request)

Forget removes one of the caller's own recent searches; 404 for an id that is not theirs.

func (*PanelSearch) Recent added in v0.53.0

func (h *PanelSearch) Recent(w http.ResponseWriter, r *http.Request)

Recent answers {"searches": [...]}, newest first.

func (*PanelSearch) Remember added in v0.53.0

func (h *PanelSearch) Remember(w http.ResponseWriter, r *http.Request)

Remember records a search ({"query": "…"}): it moves to the top, and the list keeps the newest model.RecentSearchKeep. Answers 201 with the row.

func (*PanelSearch) RequirePanel added in v0.53.0

func (h *PanelSearch) RequirePanel(next http.Handler) http.Handler

RequirePanel admits whoever the admin panel opens for: an administrator (a session, or an API key that may administer - auth.CallerMayAdminister), or a signed-in session holding at least one delegated admin.* permission. Everybody else is refused, a plain account and a delegated administrator's API key alike (the delegated area is a session's, RequireAdminPermission).

func (*PanelSearch) Search added in v0.53.0

func (h *PanelSearch) Search(w http.ResponseWriter, r *http.Request)

Search answers {"results": [...], "searched": [...]}: the rows that match every word of q, at most `limit` per kind, and the kinds that were read - a kind the reader may not open is not in `searched`.

type PermissionsAdmin added in v0.49.0

type PermissionsAdmin struct {
	Store db.Store
	ACL   *acl.Resolver
	// AppPermissions lists the installed apps' user permissions
	// (wasmplugin.Registry.UserPermissions); nil when apps are off.
	AppPermissions func() []wasmplugin.UserPermRow
	// Notify tells administrators about a role a save left without a
	// permission carved out of one it allows (permission_gaps.go); nil when
	// notifications are off.
	Notify notify.Service
}

PermissionsAdmin serves /api/admin/roles* and /api/admin/users/{id}/{exceptions,roles}.

func NewPermissionsAdmin added in v0.49.0

func NewPermissionsAdmin(store db.Store, resolver *acl.Resolver) *PermissionsAdmin

NewPermissionsAdmin constructs the handler.

func (*PermissionsAdmin) Catalogue added in v0.49.0

func (h *PermissionsAdmin) Catalogue(w http.ResponseWriter, _ *http.Request)

Catalogue lists every permission and preset.

GET /api/admin/roles/catalogue

func (*PermissionsAdmin) CreateRule added in v0.49.0

func (h *PermissionsAdmin) CreateRule(w http.ResponseWriter, r *http.Request)

CreateRule adds a rule. names / descriptions are the role in other interface languages ({"tr": "…"}); name stays required and is the fallback.

POST /api/admin/roles {name, description, names, descriptions, enabled, permissions, targets, effects, settings, conditions}

func (*PermissionsAdmin) DeleteRule added in v0.49.0

func (h *PermissionsAdmin) DeleteRule(w http.ResponseWriter, r *http.Request)

DeleteRule removes a role. A role people hold is deleted only when the caller says what those people become — ?to=user, ?to=viewer or ?to=<role id> — so deleting a role that takes things away never quietly hands its people the whole built-in User role. Without it: 409 with the count.

DELETE /api/admin/roles/{id}[?to=user|viewer|<id>]

func (*PermissionsAdmin) DismissGap added in v0.52.0

func (h *PermissionsAdmin) DismissGap(w http.ResponseWriter, r *http.Request)

DismissGap records that a role lacks the permission on purpose: it is not shown again while it stays that way.

POST /api/admin/roles/gaps/dismiss {"id": "role:12:files.encrypt"} → {"gaps": […]}

func (*PermissionsAdmin) GetDefaults added in v0.49.0

func (h *PermissionsAdmin) GetDefaults(w http.ResponseWriter, r *http.Request)

GetDefaults returns what a built-in role's accounts start from — the User role's, or with ?role=viewer the Viewer role's.

GET /api/admin/roles/builtin[?role=viewer]

func (*PermissionsAdmin) GetUserPermissions added in v0.49.0

func (h *PermissionsAdmin) GetUserPermissions(w http.ResponseWriter, r *http.Request)

GetUserPermissions returns an account's overrides and effective permissions.

GET /api/admin/users/{id}/exceptions

func (*PermissionsAdmin) GetUserRoles added in v0.49.0

func (h *PermissionsAdmin) GetUserRoles(w http.ResponseWriter, r *http.Request)

GetUserRoles returns the one custom role the account holds, or null.

GET /api/admin/users/{id}/roles → {"role_id": 3} | {"role_id": null}

func (*PermissionsAdmin) ListGaps added in v0.52.0

func (h *PermissionsAdmin) ListGaps(w http.ResponseWriter, r *http.Request)

ListGaps returns the roles the caller may edit that allow adding files but not encrypting, and nobody said were on purpose.

GET /api/admin/roles/gaps → {"gaps": [{id, key, from, role | rule_id, rule_name}]}

func (*PermissionsAdmin) ListOverrides added in v0.49.0

func (h *PermissionsAdmin) ListOverrides(w http.ResponseWriter, r *http.Request)

ListOverrides returns every account that has overrides, keyed by user id, so the Users list can mark "Custom" rows without a request per row. A tenant administrator sees its own tenant's accounts only.

GET /api/admin/roles/exceptions → {"overrides":{"12":{"files.delete":"deny"}}}

func (*PermissionsAdmin) ListRules added in v0.49.0

func (h *PermissionsAdmin) ListRules(w http.ResponseWriter, r *http.Request)

ListRules returns the rules the caller's tenant may see, in id order.

GET /api/admin/roles

func (*PermissionsAdmin) MyPermissions added in v0.49.0

func (h *PermissionsAdmin) MyPermissions(w http.ResponseWriter, r *http.Request)

MyPermissions returns the caller's own effective permissions — what the file manager reads to hide the buttons that would only answer 403.

GET /api/auth/me/permissions

func (*PermissionsAdmin) PreviewRole added in v0.50.0

func (h *PermissionsAdmin) PreviewRole(w http.ResponseWriter, r *http.Request)

PreviewRole answers what a custom role being edited comes to before it is saved: the built-in role its people would be on, "user" or "viewer" (perm.HolderRole). The role editor asks it for each app permission's "Default", so the rule lives here alone. The body is a role body as the editor has it — permissions, effects, conditions; the rest is ignored — possibly unfinished: nothing is checked or stored.

POST /api/admin/roles/preview {permissions, effects, conditions} → {holder_role}

func (*PermissionsAdmin) PutDefaults added in v0.49.0

func (h *PermissionsAdmin) PutDefaults(w http.ResponseWriter, r *http.Request)

PutDefaults replaces the defaults.

PUT /api/admin/roles/builtin {"permissions":["files.download",…]}

⚠ Supertenant-only in multi-tenant mode. Each built-in role is ONE instance-wide row (perm.SaveRoleBase), held by every account of every tenant that has no custom role — so a tenant admin writing it would narrow or widen every other tenant's people, up to making them all delegated administrators (admin.* with a session). A tenant's own roles are custom roles, which carry its provider_id (CreateRule). Reading stays open.

func (*PermissionsAdmin) PutUserPermissions added in v0.49.0

func (h *PermissionsAdmin) PutUserPermissions(w http.ResponseWriter, r *http.Request)

PutUserPermissions replaces an account's overrides ({} clears them).

PUT /api/admin/users/{id}/exceptions {"overrides":{"files.delete":"deny"}}

func (*PermissionsAdmin) PutUserRoles added in v0.49.0

func (h *PermissionsAdmin) PutUserRoles(w http.ResponseWriter, r *http.Request)

PutUserRoles sets the account's ONE role, in one call: a custom role (role_id), no custom role (role_id null), or a built-in role (role). A custom role also sets the built-in level it implies (perm.HolderRole: User when it can change or share anything, Viewer otherwise); a built-in role ends the custom one. An administrator given a custom role stops being one — never the last administrator. A delegated administrator never touches an administrator, never lifts an account above their own role, and gives only roles that allow nothing they do not hold (refuseAdminTarget and below).

PUT /api/admin/users/{id}/roles {"role_id": 3} | {"role_id": null} | {"role": "viewer"}

func (*PermissionsAdmin) RestoreGap added in v0.52.0

func (h *PermissionsAdmin) RestoreGap(w http.ResponseWriter, r *http.Request)

RestoreGap gives a role the permission it lacks: the built-in role's list the key, a custom role's folder part an Allow of it. Nothing else changes.

POST /api/admin/roles/gaps/restore {"id": "builtin:user:files.encrypt"} → {"gaps": […]}

func (*PermissionsAdmin) UpdateRule added in v0.49.0

func (h *PermissionsAdmin) UpdateRule(w http.ResponseWriter, r *http.Request)

UpdateRule replaces a rule's fields.

PUT /api/admin/roles/{id}

type PluginRequests added in v0.49.0

type PluginRequests struct {
	Svc *pluginreq.Service
	// Assoc and Apps draw an app request's File types group and write the
	// approving administrator's choices (0.50, docs/APP-PLUGINS.md → Default
	// apps): the same rows the install wizard shows, and for an upgrade only
	// the kinds it adds. Nil (app plugins off): no group, nothing placed.
	Assoc *assoc.Service
	Apps  *wasmplugin.Registry
	// Store answers an approval of a request from the store screen (source
	// "store", #162): a fresh install link from the connected store, which
	// the administrator installs through the store review. Nil: such a
	// request cannot be approved here.
	Store *AppStore
}

PluginRequests is the handler set.

func NewPluginRequests added in v0.49.0

func NewPluginRequests(svc *pluginreq.Service) *PluginRequests

NewPluginRequests constructs the handler.

func (*PluginRequests) Approve added in v0.49.0

func (h *PluginRequests) Approve(w http.ResponseWriter, r *http.Request)

Approve installs what the request froze: session only.

func (*PluginRequests) Create added in v0.49.0

func (h *PluginRequests) Create(w http.ResponseWriter, r *http.Request)

Create leaves a request: POST /api/admin/plugin-requests. 201 with the new request, or 200 with the pending one for the same source.

func (*PluginRequests) Get added in v0.49.0

Get answers one request with its frozen manifest and review.

func (*PluginRequests) List added in v0.49.0

List answers the requests: GET /api/admin/plugin-requests[?status=pending| approved|rejected|expired|superseded|all]. Pending by default.

func (*PluginRequests) Reject added in v0.49.0

func (h *PluginRequests) Reject(w http.ResponseWriter, r *http.Request)

Reject closes a request without installing anything: session only.

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

func NewPlugins(m *plugin.Manager, multiTenant bool) *Plugins

NewPlugins constructs the handler.

func (*Plugins) CheckUpdates added in v0.48.0

func (h *Plugins) CheckUpdates(w http.ResponseWriter, r *http.Request)

CheckUpdates reads every binary plugin's source now and answers the list redrawn with the report. It installs nothing.

func (*Plugins) Delete

func (h *Plugins) Delete(w http.ResponseWriter, r *http.Request)

func (*Plugins) Get

func (h *Plugins) Get(w http.ResponseWriter, r *http.Request)

func (*Plugins) Install

func (h *Plugins) Install(w http.ResponseWriter, r *http.Request)

Install accepts three shapes; the Content-Type decides which.

func (*Plugins) List

func (h *Plugins) List(w http.ResponseWriter, r *http.Request)

func (*Plugins) Logs added in v0.50.0

func (h *Plugins) Logs(w http.ResponseWriter, r *http.Request)

Logs answers a storage plugin's log - its starts and failures, and the storage sync's answers it could not make sense of (an entry it could not say exists or not, issue #104) - the same shape as an app plugin's (`?after=<seq>` → `{lines, next}`, internal/pluginlog). A line repeated comes back with the same `id` and a higher `count`.

func (*Plugins) Patch

func (h *Plugins) Patch(w http.ResponseWriter, r *http.Request)

Patch switches a plugin on or off and names its source. Both are the administrator's: switching one on runs its binary, and the source is where its next approved version comes from.

func (*Plugins) Restart

func (h *Plugins) Restart(w http.ResponseWriter, r *http.Request)

func (*Plugins) Upgrade

func (h *Plugins) Upgrade(w http.ResponseWriter, r *http.Request)

Upgrade replaces a binary plugin's file, keeping its registration and every storage built on it. A failed upgrade rolls back to the previous binary.

type Protection

type Protection struct {
	Store db.Store
}

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

type Providers struct {
	Store       db.Store
	MultiTenant bool
	// DemoMode marks a public playground: every write is refused here too.
	// api.DemoGuard already refuses writes under /api/admin and /api/ai/admin,
	// but the admin MCP tools call this handler in-process and never pass that
	// middleware, so the handler holds its own lock.
	DemoMode bool
}

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;
  • a tenant's REALM is given when it is created (the slug by default) and never changes: an update that names another realm is refused (realm_immutable), and the store never writes the column again.

func NewProviders

func NewProviders(store db.Store, multiTenant bool) *Providers

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) Get added in v0.50.0

func (h *Providers) Get(w http.ResponseWriter, r *http.Request)

Get returns one provider (tenant) with its storage links and user count.

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) RealmSuggestion added in v0.50.0

func (h *Providers) RealmSuggestion(w http.ResponseWriter, r *http.Request)

RealmSuggestion answers GET /api/admin/providers/realm-suggestion?slug= with the realm the tenant screen offers for a tenant being created (tenant.SuggestRealm), the first variant of it that is valid, not reserved and not taken: {realm, base, available}. `realm` is "" when the slug holds nothing realm-shaped. Nothing is reserved by asking: the realm is settled by POST, and a second tenant created in between is refused there (`realm_taken`).

⚠ One rule for every client: the screen and the MCP tool ask here rather than each carrying its own transliteration.

func (*Providers) UnlinkStorage

func (h *Providers) UnlinkStorage(w http.ResponseWriter, r *http.Request)

UnlinkStorage removes a storage link (never the storage itself).

func (*Providers) Update

func (h *Providers) Update(w http.ResponseWriter, r *http.Request)

Update edits a tenant; flag changes go through the transfer guard.

type PublicAPI

type PublicAPI struct {
	// ACL, when wired, closes a link whose creator may no longer make it
	// (link_creator.go). Nil in tests built by hand: every link answers.
	ACL     *acl.Resolver
	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

func NewPublicAPI(store db.Store, svc *share.Service) *PublicAPI

NewPublicAPI constructs the handler.

func (*PublicAPI) AttachACL added in v0.49.0

func (h *PublicAPI) AttachACL(r *acl.Resolver)

AttachACL wires the permission resolver for linkCreatorAllows.

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

func (h *PublicAPI) AttachDrop(d *Drop)

AttachDrop wires the file-request ingest.

func (*PublicAPI) AttachLocale

func (h *PublicAPI) AttachLocale(def string)

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"`
	FooterText    string `json:"footer_text"`
	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 {
	Kind     string `json:"kind"` // "file" | "folder" | "app"
	NeedsPIN bool   `json:"needs_pin"`
	Unlocked bool   `json:"unlocked"`
	Expired  bool   `json:"expired"`
	Revoked  bool   `json:"revoked"`
	// Unavailable: the item behind the link (or a folder above it) is an
	// entry the storage could not answer for (#104). The link is alive and
	// may answer again; until then the page says so instead of offering a
	// download that would fail. Told once the PIN gate is open, like the node.
	Unavailable bool `json:"unavailable,omitempty"`
	// Locked reports the PIN gate being shut after five wrong answers. It is
	// NOT a reason the link is dead — it lifts by itself.
	Locked     bool       `json:"locked"`
	ExpiresAt  *time.Time `json:"expires_at,omitempty"`
	VisitsLeft *int       `json:"visits_left"`
	Subject    string     `json:"subject,omitempty"`
	// 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.
	Node *PublicNode `json:"node,omitempty"`
	App  *PublicApp  `json:"app,omitempty"`
}

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

type PublicUILocale struct {
	Code    string            `json:"code"`
	Strings map[string]string `json:"strings"`
}

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

func NewQueue(driver queue.Driver) *Queue

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

func (h *Queue) AttachStore(s db.Store)

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

func (*Queue) Retry

func (h *Queue) Retry(w http.ResponseWriter, r *http.Request)

Retry rolls a failed op back into pending.

POST /api/admin/queue/{id}/retry

func (*Queue) Stats

func (h *Queue) Stats(w http.ResponseWriter, r *http.Request)

Stats returns the dashboard counters.

GET /api/admin/queue/stats

type QueueStats

type QueueStats interface {
	Stats(ctx context.Context) (queue.Stats, error)
}

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 NewQuota

func NewQuota(svc *quota.Service, store db.Store) *Quota

NewQuota constructs the handler.

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

func (h *Quota) AttachACL(r *acl.Resolver)

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
	// Links applies a folder change to the running process (the storage's
	// driver rebuilt, its initial copy begun again). Nil: nothing replicates.
	Links ReplicaLinks
}

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: {storage_id: 7, path: "...", op: "write|delete|move|copy"}

storage_id may be left out (a caller from before 0.53) when exactly one storage has an unresolved failure at (path, op).

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) InitialCopies added in v0.53.0

func (h *Replica) InitialCopies(w http.ResponseWriter, r *http.Request)

InitialCopies lists every replicating storage's initial copy - the copy of what it held when it was linked - with its progress.

GET /admin/api/replica/initial-copies → {items: [...]}

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 (h *Replica) ListLinks(w http.ResponseWriter, r *http.Request)

ListLinks lists the folder each linked storage writes into on its target.

GET /admin/api/replica/links → {items: [{storage_id, storage_name, target_id, target_name, folder, created_unix}]}

func (*Replica) ListRules

func (h *Replica) ListRules(w http.ResponseWriter, r *http.Request)

ListRules paginates rules.

GET /admin/api/replica/rules

func (*Replica) RestartInitialCopy added in v0.53.0

func (h *Replica) RestartInitialCopy(w http.ResponseWriter, r *http.Request)

RestartInitialCopy begins a storage's initial copy again: every file is looked at anew, the ones the target already holds are left alone.

POST /admin/api/replica/initial-copies/{storage_id}/restart

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) SetLinkFolder added in v0.53.0

func (h *Replica) SetLinkFolder(w http.ResponseWriter, r *http.Request)

SetLinkFolder changes the folder a storage writes into on its target. The name is made safe the way a new folder's is; the storage's initial copy begins again in the new folder, and nothing is moved or deleted in the old one.

PUT /admin/api/replica/links/{storage_id}  {folder: "..."} → the link
409 when another storage on the target uses that folder, when the storage
is not linked

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 ReplicaLinks interface {
	// StorageLinkChanged: the storage was linked, relinked, unlinked,
	// created linked or deleted.
	StorageLinkChanged(ctx context.Context, storageID int64)
	// TargetChanged: the target was edited or deleted. storageIDs are the
	// storages linked to it (read before a delete clears the links);
	// restartCopies begins their initial copies again - the target's driver
	// or configuration changed, or it was switched back on.
	TargetChanged(ctx context.Context, targetID int64, storageIDs []int64, restartCopies bool)
	// FolderChanged: the storage's folder on its target was changed; its
	// driver is rebuilt and its initial copy begins again.
	FolderChanged(ctx context.Context, storageID int64)
}

ReplicaLinks is what a storage or replication-target edit tells the running process (internal/server storage_cache.go): the drivers of the storages concerned are rebuilt around the new pairing, and their initial copies start, restart or stop. Without it a link saved on the Replication page changed a row and nothing else (#186).

type ReplicationTargets

type ReplicationTargets struct {
	Store db.Store
	// Links applies an edit to the running process; nil: nothing replicates
	// (tests, embedders).
	Links ReplicaLinks
}

func NewReplicationTargets

func NewReplicationTargets(store db.Store) *ReplicationTargets

func (*ReplicationTargets) Create

func (*ReplicationTargets) Delete

Delete removes a target; the store unlinks every storage that pointed at it, and those storages go back to their bare drivers at once (their writes stop fanning out to a sink that no longer exists).

func (*ReplicationTargets) Get

func (*ReplicationTargets) List

func (*ReplicationTargets) Update

Update changes a target. The body is merged onto the stored row - a field the body leaves out keeps its value, so `{"enabled": false}` switches a target off without wiping its name, driver and configuration (it used to replace the whole row with the body).

The storages linked to it get their drivers rebuilt, so the change reaches the fan-out at once; a new driver or configuration, or switching the target back on, also begins their initial copies again: the new place is empty, or the changes made while it was off went nowhere.

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.

func (*S3Keys) List

func (h *S3Keys) List(w http.ResponseWriter, r *http.Request)

List returns the caller's own keys. The secret is not among them and cannot be: only its sealed form is stored, and `json:"-"` keeps even that off the wire.

func (*S3Keys) SetState

func (h *S3Keys) SetState(w http.ResponseWriter, r *http.Request)

SetState disables or re-enables a key without deleting it, so a leaked credential can be stopped while still being visible in the audit trail.

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.

func (*SSHKeys) List

func (h *SSHKeys) List(w http.ResponseWriter, r *http.Request)

List returns the caller's own keys.

func (*SSHKeys) SetState

func (h *SSHKeys) SetState(w http.ResponseWriter, r *http.Request)

SetState disables or re-enables a key without deleting it.

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
	// E2EPolicy is who may encrypt (e2e_policy_gate.go): a save that creates
	// an encrypted folder's key file asks it. nil = not wired.
	E2EPolicy *e2epolicy.Service
	// Thumbs draws the saved file again. ⚠ Before 0.50 nothing did: an SVG
	// edited in the built-in editor kept the picture of its first version.
	Thumbs ThumbPipeline
}

SaveText handles plain-text edits from the SFC's code/markdown viewer.

func NewSaveText

func NewSaveText(store db.Store, resolver func(int64) (storage.Driver, error)) *SaveText

NewSaveText constructs the handler.

func (*SaveText) AttachACL

func (h *SaveText) AttachACL(r *acl.Resolver)

AttachACL wires the RBAC resolver so save-text requires ≥editor on the file.

func (*SaveText) AttachSearchIndex

func (h *SaveText) AttachSearchIndex(i *search.Index)

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) AttachThumbs added in v0.50.0

func (h *SaveText) AttachThumbs(p ThumbPipeline)

AttachThumbs wires the thumbnail pipeline (see Thumbs).

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 struct {
	Index *search.Index
	Store db.Store
	ACL   *acl.Resolver
}

Search handles /api/files/search.

func NewSearch

func NewSearch(idx *search.Index, store db.Store) *Search

NewSearch constructs a Search handler.

func (*Search) AttachACL

func (h *Search) AttachACL(r *acl.Resolver)

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

type SearchAdmin struct {
	Index *search.Index
	Store db.Store
}

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, and the levels of its permissions - `permissions`, package tokenperm; ownership enforced — the credential, its verbs and its `root:` 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
	// LoginGuard holds the sign-in security settings (`login.*`) in memory; a
	// write of one of them here makes it read them again, so the generic
	// settings API cannot leave the limiter deciding with an old value.
	// Nil-safe.
	LoginGuard *loginguard.Guard
	// DemoMode marks a public playground: the addresses in the sign-in
	// security settings (the allow-list, the trusted proxies) are masked in
	// what this page reads back (demo_redact.go maskSettingsForDemo).
	DemoMode bool
}

Settings handles /api/admin/settings.

func NewSettings

func NewSettings(store db.Store) *Settings

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

func (h *Settings) AttachMailer(m *mailer.Service)

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 {
	Service         *share.Service
	Store           db.Store
	StorageResolver func(int64) (storage.Driver, error)
	PublicURL       string
	ACL             *acl.Resolver
	// Zip caches generated folder-share ZIPs (keyed by node id + content
	// 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
	// Branding resolves the public pages' identity (logo/name/accent/footer)
	// from settings (wiring:e1). Nil-safe: unwired = stock filex chrome.
	Branding *BrandingSource
	// Thumbs renders the gallery tiles on the public folder page. Nil-safe:
	// 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
	// DefaultLocale is the public pages' fallback language (the server's own
	// `default_locale`). The visitor's request wins over it — see publicLocale.
	DefaultLocale string
	// Body resolves where a shared file's bytes are: the driver, or filex's
	// staging area while a staged upload is still transferring. Nil-safe.
	Body *filebody.Resolver
	// Tenants resolves which origin a minted /s/ or /d/ link is built on.
	Tenants tenanturl.Resolver
	// Shell is the SPA document a JavaScript browser gets at /s/{token}; the
	// 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
	// Apps lets the no-JS page list the copies an app link exposed. nil means
	// an app link falls back to "this needs a browser with JavaScript".
	Apps *AppPlugins
	// Index keeps the search index in step when sharing records a file the
	// 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

func (h *Share) AttachACL(r *acl.Resolver)

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

func (h *Share) AttachBody(b *filebody.Resolver)

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

func (h *Share) AttachIndex(idx *search.Index)

AttachIndex wires the search index for on-demand cataloguing.

func (*Share) AttachLocale

func (h *Share) AttachLocale(def string)

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

func (h *Share) AttachShell(shell http.Handler)

AttachShell wires the SPA document served to JavaScript browsers.

func (*Share) AttachTenants

func (h *Share) AttachTenants(rv tenanturl.Resolver)

AttachTenants wires the shared per-request origin resolver (internal/tenanturl).

func (*Share) AttachThumbs

func (h *Share) AttachThumbs(p *thumb.Pipeline)

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)

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

func (h *ShareX) AttachACL(r *acl.Resolver)

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

func (h *ShareX) AttachBody(b *filebody.Resolver)

AttachBody wires the byte-source resolver (staging-aware reads).

func (*ShareX) AttachE2EPolicy added in v0.51.0

func (h *ShareX) AttachE2EPolicy(s *e2epolicy.Service)

AttachE2EPolicy wires the rule into ShareX captures.

func (*ShareX) AttachSearchIndex

func (h *ShareX) AttachSearchIndex(idx *search.Index)

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

func (h *ShareX) AttachTenants(rv tenanturl.Resolver)

AttachTenants wires the shared origin resolver (internal/tenanturl).

func (*ShareX) AttachThumbs

func (h *ShareX) AttachThumbs(p *thumb.Pipeline)

AttachThumbs wires the thumbnail pipeline so captures get grid thumbnails like manager uploads (nil = thumbnails skipped).

func (*ShareX) Upload

func (h *ShareX) Upload(w http.ResponseWriter, r *http.Request)

Upload accepts a ShareX multipart capture (`file`), stores + indexes it, mints a public inline-viewable share, and returns {"url": …}.

type Shared

type Shared struct {
	Store db.Store
	// ThumbSigner stamps the `thumb_url` this listing hands out. Nil emits an
	// unsigned URL, which authenticated clients still fetch fine.
	ThumbSigner *thumb.Signer
	// ThumbRefresh draws in the background what a projected row found missing
	// or stale (hydrateThumbs).
	ThumbRefresh ThumbRefresher
}

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 NewShared

func NewShared(store db.Store) *Shared

NewShared constructs the handler.

func (*Shared) AttachThumbSigner

func (h *Shared) AttachThumbSigner(s *thumb.Signer)

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:

  1. 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.

  2. "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.

  3. 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.

  4. 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 {
	Store db.Store
	// Tenants resolves the origin every listed link is built on — the same
	// 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
	// Apps is told when a link an app opened is revoked or deleted here, so
	// 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 {
	Store db.Store
	// Service opens the sealed PIN (share.RevealPIN). Nil-safe: without it the
	// PIN endpoint answers "cannot be shown" rather than panicking, which is
	// also what an instance with no secret key gets.
	Service *share.Service
	// Tenants resolves the origin the listed links are built on — the same
	// resolver the share dialog and the admin listing use, never
	// window.location (issue #32).
	Tenants tenanturl.Resolver
	// Apps describes an app's link for what it is (db.AppLink). Nil when
	// 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

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

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)
	// ReplicaLinks is told when a storage's replication link changes (it was
	// linked, relinked, unlinked, or the storage was created linked or
	// deleted), so its initial copy starts or stops. Nil: nothing replicates.
	ReplicaLinks ReplicaLinks
	// 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

func NewStorages(store db.Store, worker *syncpkg.Worker) *Storages

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.

`?stats=none` answers the rows alone - no file counts, last sync run or catalogue coverage, each a query per storage. The admin panel's search (panel_search.go, task #168) reads this list on every few keystrokes and draws none of them.

func (*Storages) SetOrder added in v0.46.0

func (h *Storages) SetOrder(w http.ResponseWriter, r *http.Request)

SetOrder writes the order storages are listed in (issue #57):

PUT /api/admin/storages/order  {"ids": [3, 1, 7]}

The list is the WHOLE order as the admin sees it, first = top: the storages it names get positions 1..n and every other storage the caller may administer is un-placed (NULL: after the placed ones, by id). An empty list is "back to the default order". Answers {"ok": true, "ids": [...]}.

"May administer" is what h.Store.ListStorages returns for this request. h.Store is the tenant-scoped store, so for a tenant admin another tenant's storage is not in it: naming one is refused with exactly the words an id that never existed gets, and the other tenant's positions are never written (tenantown.go on why the two must be indistinguishable).

Nothing here reaches outward, so a public demo lets it through.

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).

func (*Storages) Update

func (h *Storages) Update(w http.ResponseWriter, r *http.Request)

Update modifies a storage row.

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 StoreGroups added in v0.53.0

type StoreGroups interface {
	ListUserGroupMemberships(ctx context.Context, userID int64) ([]*model.GroupMember, error)
	GetGroup(ctx context.Context, id int64) (*model.Group, error)
}

StoreGroups is the part of db.Store the store screen asks: a person's groups, and the groups a setting may name.

type SyncAdmin

type SyncAdmin struct {
	Store db.Store
}

SyncAdmin handles /api/admin/sync-runs.

func NewSyncAdmin

func NewSyncAdmin(store db.Store) *SyncAdmin

NewSyncAdmin constructs the handler.

func (*SyncAdmin) Detail

func (h *SyncAdmin) Detail(w http.ResponseWriter, r *http.Request)

Detail returns a single sync run with conflict info.

func (*SyncAdmin) List

func (h *SyncAdmin) List(w http.ResponseWriter, r *http.Request)

List returns recent sync runs across all storages with optional filters.

type TLSHooks added in v0.50.0

type TLSHooks struct {
	Store db.Store
	Box   *secretbox.Box
	// PublicURL is the platform's own address (FILEX_PUBLIC_URL), always
	// certified.
	PublicURL string
}

TLSHooks answers the reverse proxy's two questions about certificates (FILEX_TLS_MODE=proxy, docs/TENANT-ADMIN.md, "TLS"):

GET /api/tls/ask?domain=<name>                 200: certify it; 404: do not
GET /api/tls/certificate?server_name=<name>    the tenant's own certificate (PEM), or 204

The first is Caddy's on-demand TLS `ask`: a stranger pointing a name at the platform gets no certificate, because the platform serves only its own address, enabled tenants' addresses, their platform subdomains and their ACTIVE own domains. The second is Caddy's `get_certificate http`: a tenant that brought a certificate for its domain has it served.

⚠ Both answer the proxy ITSELF only (clientip.ProxyItself: a trusted peer, no forwarded-for header): the same proxy forwards every stranger's request to filex, and those must not learn the platform's customers' domains, let alone read a private key. Anyone else gets 404, the answer an unknown name gets.

func (*TLSHooks) Ask added in v0.50.0

func (h *TLSHooks) Ask(w http.ResponseWriter, r *http.Request)

Ask answers whether the proxy may certify a name.

func (*TLSHooks) Certificate added in v0.50.0

func (h *TLSHooks) Certificate(w http.ResponseWriter, r *http.Request)

Certificate hands the proxy a tenant's own certificate for a name (the chain, then the key, PEM), or 204 when the tenant brought none: the proxy then issues its own.

type TenancyAdmin added in v0.53.0

type TenancyAdmin struct {
	Store db.Store
	// Cfg is the configuration this server started with, after
	// tenancy.Resolve: MultiTenant is the mode in force, MultiTenantFrom what
	// pinned it.
	Cfg config.Config
}

TenancyAdmin is Admin → Multi-tenant mode (internal/tenancy):

GET /api/admin/tenancy   the mode in force, the one the next start will
                         run with, what pinned it, how many tenants
PUT /api/admin/tenancy   {"enabled": bool, "confirm": "<n>"}

Who

The platform operator alone: the mode is the whole instance's. On a multi-tenant install a tenant's administrator is refused both (403 supertenant_only, requireSupertenant); on a single-tenant install every administrator is the platform's. A change is a person's, signed in to the panel (sessionOnly): an API key reads the switch and cannot flip it.

What a change does

It saves the `tenancy.multi_tenant` setting and nothing else. The mode in force changes at the next start (the reasons are in package tenancy), and until then both answers say `restart_required`. A mode the environment or the config file pins is refused with 409 `tenancy_locked`, naming which.

Turning the mode OFF while the install has tenants besides the platform's own is maintenance mode: nothing is deleted, the tenants cannot sign in until the mode is back on. It is refused with 409 `confirm_required` and the number of tenants unless `confirm` is that number, so it is never one stray click. Both directions are recorded in the audit log (tenancy.enable / tenancy.disable, with the tenant count).

func NewTenancyAdmin added in v0.53.0

func NewTenancyAdmin(store db.Store, cfg config.Config) *TenancyAdmin

NewTenancyAdmin constructs the handler.

func (*TenancyAdmin) Get added in v0.53.0

Get answers the switch.

func (*TenancyAdmin) Put added in v0.53.0

Put saves the switch for the next start.

type TenantSelf added in v0.50.0

type TenantSelf struct {
	Store       db.Store
	Live        *authsetup.Live
	Domains     *tenantdomain.Service
	MultiTenant bool
	DemoMode    bool
	// TLSMode is FILEX_TLS_MODE, told to the screen (who issues the
	// certificate of an own domain that brought none).
	TLSMode string
}

TenantSelf handles /api/admin/tenant.

func NewTenantSelf added in v0.50.0

func NewTenantSelf(store db.Store, live *authsetup.Live, domains *tenantdomain.Service, multiTenant bool) *TenantSelf

NewTenantSelf constructs the handler.

func (*TenantSelf) AddDomain added in v0.50.0

func (h *TenantSelf) AddDomain(w http.ResponseWriter, r *http.Request)

AddDomain records an own domain for the tenant ({domain}), pending until its CNAME is seen; it is looked at once at once.

func (*TenantSelf) CheckDomain added in v0.50.0

func (h *TenantSelf) CheckDomain(w http.ResponseWriter, r *http.Request)

CheckDomain looks at a domain's CNAME now.

func (*TenantSelf) CreateProvider added in v0.50.0

func (h *TenantSelf) CreateProvider(w http.ResponseWriter, r *http.Request)

CreateProvider adds the tenant's own OIDC or LDAP: {driver, label?, slug?, enabled?, config?, confirm_failed_test?}.

func (*TenantSelf) DeleteCertificate added in v0.50.0

func (h *TenantSelf) DeleteCertificate(w http.ResponseWriter, r *http.Request)

DeleteCertificate removes it: the installation's way issues one again.

func (*TenantSelf) DeleteDomain added in v0.50.0

func (h *TenantSelf) DeleteDomain(w http.ResponseWriter, r *http.Request)

DeleteDomain removes an own domain from the tenant.

func (*TenantSelf) DeleteProvider added in v0.50.0

func (h *TenantSelf) DeleteProvider(w http.ResponseWriter, r *http.Request)

DeleteProvider removes one of the tenant's own providers.

func (*TenantSelf) Get added in v0.50.0

func (h *TenantSelf) Get(w http.ResponseWriter, r *http.Request)

Get answers the tenant's overview.

func (*TenantSelf) SetCertificate added in v0.50.0

func (h *TenantSelf) SetCertificate(w http.ResponseWriter, r *http.Request)

SetCertificate stores the tenant's own certificate for a domain: {cert_pem, key_pem}. The key is sealed and never sent back.

func (*TenantSelf) SetInsecure added in v0.50.0

func (h *TenantSelf) SetInsecure(w http.ResponseWriter, r *http.Request)

SetInsecure is the platform operator's per-tenant switch: {allow: bool}. A tenant's administrator is refused (403).

func (*TenantSelf) TestProvider added in v0.50.0

func (h *TenantSelf) TestProvider(w http.ResponseWriter, r *http.Request)

TestProvider tests one of the tenant's own providers on the form as it stands, through the same guard as its sign-ins.

func (*TenantSelf) UpdateProvider added in v0.50.0

func (h *TenantSelf) UpdateProvider(w http.ResponseWriter, r *http.Request)

UpdateProvider changes one of the tenant's own providers.

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.

func (*Themes) List

func (h *Themes) List(w http.ResponseWriter, r *http.Request)

List returns every stored theme, for the editor.

func (*Themes) Put

func (h *Themes) Put(w http.ResponseWriter, r *http.Request)

Put creates or replaces one theme. This is ALSO the import path: the body is the exported document, so "import" on the admin screen is a file read plus this call — one shape, one validator, no second way in.

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 NewThumb

func NewThumb(store db.Store, p *thumb.Pipeline) *Thumb

NewThumb constructs a Thumb handler.

func (*Thumb) AttachACL

func (h *Thumb) AttachACL(r *acl.Resolver)

AttachACL wires the RBAC resolver used by the authenticated path.

func (*Thumb) AttachSigner

func (h *Thumb) AttachSigner(s *thumb.Signer)

AttachSigner wires the URL signature verifier.

func (*Thumb) Serve

func (h *Thumb) Serve(w http.ResponseWriter, r *http.Request)

Serve writes the JPEG bytes from the cache.

type ThumbPipeline

type ThumbPipeline interface {
	GenerateThumb(ctx context.Context, node *model.Node) error
}

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 ThumbRefresher added in v0.50.0

type ThumbRefresher interface {
	Consider(n *model.Node, t *model.Thumbnail) thumb.Verdict
}

ThumbRefresher is what a listing hands a missing or stale thumbnail to (*thumb.Refresher). Consider must never block: it runs inside the listing.

type ThumbRepair added in v0.50.0

type ThumbRepair struct {
	Store db.Store
	Ops   *ops.Service
	// Wait is how long POST watches a fresh run before answering; 0 = 3 s.
	// A small folder is done by then and the answer is its result.
	Wait time.Duration
	// Limits drops the pipeline's cached settings after a change. Nil: the
	// new ones apply within the cache's few seconds.
	Limits ThumbSettingsForgetter
}

ThumbRepair serves Admin → Tools → Thumbnail repair:

POST /api/admin/tools/thumbnails/repair  {path, mode}
GET  /api/admin/tools/thumbnails/repair  the tenant's latest run

`path` is a qualified path: "photos://2024/summer" (a folder and everything in it), "photos://2024/cover.jpg" (one file), "photos://" (the storage), or "" (every storage the caller reaches). `mode` is "fix" (missing, failed, skipped, stale) or "rebuild" (everything).

The run is an ops job (ops/thumb_repair.go): in the operations tray, cancellable through POST /api/files/ops/{id}/cancel, its tenant's alone, resumed after a restart. The walk it runs is the CLI's (`filex thumb backfill`, server.BackfillThumbs).

⚠ Administrators only, and a tenant administrator only over their tenant's storages: a named storage outside the scope is 404, and "every storage" is the tenant's reach (trash.Reach), never the instance.

func NewThumbRepair added in v0.50.0

func NewThumbRepair(store db.Store, o *ops.Service) *ThumbRepair

NewThumbRepair constructs the handler.

func (*ThumbRepair) Generators added in v0.50.0

func (h *ThumbRepair) Generators(w http.ResponseWriter, r *http.Request)

Generators answers GET .../thumbnails/generators: the ready thumbnails in the caller's reach, counted by who drew them, the most first. A caller held to a folder is counted the root's own storage only (problemReach): the other storages' counts are not its (filex #185).

func (*ThumbRepair) Problems added in v0.50.0

func (h *ThumbRepair) Problems(w http.ResponseWriter, r *http.Request)

Problems lists the files whose thumbnail failed or was skipped, with the reason (GET .../thumbnails/problems), inside the caller's reach - and inside the folder of a caller held to one (a `root:` token, a session narrowed by X-Filex-Root; confine.Middleware is mounted on /tools).

func (*ThumbRepair) Settings added in v0.50.0

func (h *ThumbRepair) Settings(w http.ResponseWriter, r *http.Request)

Settings answers the thumbnail settings in force (GET .../thumbnails/settings).

func (*ThumbRepair) Start added in v0.50.0

func (h *ThumbRepair) Start(w http.ResponseWriter, r *http.Request)

Start queues a repair (see the type comment) and watches it for a moment: 200 with the result when it ended within Wait, 202 with its progress when it goes on. 409 BUSY while this tenant already has one going (with that run as `job`); 400 for an unreadable request; 404 for a storage or path the caller cannot see.

func (*ThumbRepair) Status added in v0.50.0

func (h *ThumbRepair) Status(w http.ResponseWriter, r *http.Request)

Status answers the tenant's latest repair: queued, running, or how it ended. `{"running": false}` alone when it has asked for none.

func (*ThumbRepair) UpdateSettings added in v0.50.0

func (h *ThumbRepair) UpdateSettings(w http.ResponseWriter, r *http.Request)

UpdateSettings changes them (PATCH .../thumbnails/settings). The instance's setting: a tenant administrator is refused. A value out of range is 400, never clamped (dbsetting.IntSpec.Validate).

type ThumbSettingsForgetter added in v0.50.0

type ThumbSettingsForgetter interface{ ForgetSettings() }

ThumbSettingsForgetter drops the pipeline's cached settings after a change (*thumb.Pipeline).

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) AttachE2EPolicy added in v0.51.0

func (h *TicketUpload) AttachE2EPolicy(s *e2epolicy.Service)

AttachE2EPolicy wires the rule into the ticket redeem (/u/{ticket}): the write runs as the minter, and is judged as theirs.

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 NewTrash

func NewTrash(svc *trash.Service, store db.Store) *Trash

NewTrash constructs the handler.

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

func (h *Trash) AttachACL(r *acl.Resolver)

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) AttachOps

func (h *Trash) AttachOps(o *ops.Service)

AttachOps wires the queue "empty the trash now" runs on.

func (*Trash) AttachSearchIndex

func (h *Trash) AttachSearchIndex(i *search.Index)

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=…

⚠⚠ The rows a caller may see are judged one by one after the store has read them (trashListKeep), so the store's pagination and its count describe rows the caller may not see. `total` used to be the page's own length after the filters (and the offset stayed in the store's coordinates): a member shown 12 of the first 25 rows was told there were 12 in all, the client drew no pager, and everything after the first page was unreachable (lesson #413). The walk below reads the caller's trash in store batches, counts every entry the caller may see, and cuts the page at offset/limit in the caller's own coordinates.

func (*Trash) Purge

func (h *Trash) Purge(w http.ResponseWriter, r *http.Request)

Purge hard-deletes a single trashed node by id.

DELETE /api/admin/trash/{id}

func (*Trash) PurgeNode added in v0.47.0

func (h *Trash) PurgeNode(ctx context.Context, storageID, nodeID int64) error

PurgeNode implements ops.Purger: Purge's work for a queued purge. The entry was judged when it was queued (Purge: ownsNode) on storageID, and one of any other storage is refused (queuedEntry).

An entry that is no longer there at all is DONE, not failed: what a purge is for has happened. That is the shape of a purge requeued at boot after the process died past HardDeleteNode, and of two administrators purging the same entry — both of which ended `failed` with a bare "sql: no rows in result set" on the second run.

func (*Trash) Restore

func (h *Trash) Restore(w http.ResponseWriter, r *http.Request)

Restore lifts the deleted_at flag on a soft-deleted node.

Asked with `queued=1` it takes a batch (`node_ids`), judges every entry the way it judges one, and queues them as a job of the operations queue instead: 202 `{ops}`, one job per storage (restoreQueued).

func (*Trash) RestoreNode added in v0.47.0

func (h *Trash) RestoreNode(ctx context.Context, storageID, nodeID int64) error

RestoreNode implements ops.Restorer: Restore's work for a queued restore. The bytes and the row come back, and the node is re-indexed, scanned and announced (announceRestore). The entry was judged when it was queued (RestoreQueued) on storageID, and one of any other storage is refused (queuedEntry). A place that is taken is an error in the words Restore answers with, and nothing of that entry moves.

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

func NewUpdate(svc *update.Service) *Update

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.

func (*Update) Status

func (h *Update) Status(w http.ResponseWriter, r *http.Request)

Status returns the cached view. Cheap by design — the admin shell polls it.

type Upload

type Upload struct {
	Store           db.Store
	StorageResolver func(int64) (storage.Driver, error)
	Thumbs          *thumb.Pipeline
	ACL             *acl.Resolver
	// E2EPolicy is who may encrypt (e2e_policy_gate.go): Init asks it before
	// a multipart upload creates a key file or a `.fxe`. nil = not wired.
	E2EPolicy *e2epolicy.Service
}

Upload handles browser to S3 multipart uploads.

Flow:

  1. 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.
  2. Browser uploads each part directly to S3 via the presigned URLs and captures each part's ETag from the response header.
  3. 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.
  4. 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

func (u *Upload) AttachACL(r *acl.Resolver)

AttachACL wires the RBAC resolver so chunked uploads require ≥editor on the destination at init time (finalize/abort continue an already-authorized id).

func (*Upload) Finalize

func (u *Upload) Finalize(w http.ResponseWriter, r *http.Request)

Finalize tells S3 to assemble the parts.

func (*Upload) Init

func (u *Upload) Init(w http.ResponseWriter, r *http.Request)

Init kicks off a multipart upload — returns presigned PUT URLs for each part.

type Usage

type Usage struct {
	Svc *usage.Service
}

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

type UserPrefs struct {
	Store db.Store
}

UserPrefs wires the per-user, per-surface preference routes.

func NewUserPrefs

func NewUserPrefs(store db.Store) *UserPrefs

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.

func (*UserPrefs) Put

func (h *UserPrefs) Put(w http.ResponseWriter, r *http.Request)

Put replaces the caller's document for one surface.

type Users

type Users struct {
	Store db.Store
	// ACL resolves per-user permissions, for the delegated administrator's
	// lines (refuseGain, refuseTakeover). Unset, a delegated administrator is
	// refused those changes; a full administrator never needs it.
	ACL *acl.Resolver
	// Mailer and Tenants send a new account its invitation (send_invite):
	// the address to sign in at and a first password. No mailer, or a send
	// that fails: the password comes back once for the administrator.
	Mailer  *mailer.Service
	Tenants tenanturl.Resolver
	// DirectoryFor names the LDAP directory that owns an address among the
	// ones that sign people in to a tenant (0: the platform's own;
	// authsetup.Live.DirectoryFor); nil on a harness with none.
	DirectoryFor func(email string, tenant int64) (name, label string, ok bool)
}

Users handles /api/admin/users.

func NewUsers

func NewUsers(store db.Store) *Users

NewUsers constructs a Users handler.

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).

func (*Users) List

func (h *Users) List(w http.ResponseWriter, r *http.Request)

List returns all users.

func (*Users) Update

func (h *Users) Update(w http.ResponseWriter, r *http.Request)

Update modifies a user. Only fields present in the body are touched.

type UsersAdmin

type UsersAdmin struct {
	Store db.Store
	// ACL resolves per-user permissions for the delegated administrator's
	// takeover line (refuseTakeover). Unset, a delegated administrator is
	// refused; a full administrator never needs it.
	ACL *acl.Resolver
}

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
	// Thumbs draws the restored content. ⚠ Before 0.50 a restore kept the
	// picture of the version it replaced.
	Thumbs ThumbPipeline
	// 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

func (h *Versions) AttachACL(r *acl.Resolver)

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

func (h *Versions) AttachSearchIndex(i *search.Index)

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.

func (*Versions) Restore

func (h *Versions) Restore(w http.ResponseWriter, r *http.Request)

Restore replaces the live content with a recorded version.

func (*Versions) Snapshot

func (h *Versions) Snapshot(w http.ResponseWriter, r *http.Request)

Snapshot records the node's current content as a new version on demand (the inspector's "take a version now" button; writes normally snapshot implicitly, this is the explicit user-triggered path).

type ViewPrefs

type ViewPrefs struct {
	Store db.Store
}

ViewPrefs wires the per-user view-preference routes.

func NewViewPrefs

func NewViewPrefs(store db.Store) *ViewPrefs

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.

func (*ViewPrefs) Put

func (h *ViewPrefs) Put(w http.ResponseWriter, r *http.Request)

Put replaces the caller's document.

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

func (h *WS) AttachMultiTenant(on bool)

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

func (h *WS) AttachPublicURLConfigured(set bool)

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

func (h *WS) AttachTenants(rv tenanturl.Resolver)

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

type WebhooksAdmin struct {
	Store  db.Store
	Notify notify.Service
}

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

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL