# Sharing & file requests filex has two kinds of public link, both token-based and account-free for the recipient: - **Share links** (`/s/{token}`) - let someone **download** a file or folder. - **File requests** (`/d/{token}`) - let someone **upload** files *into* a folder without ever seeing its contents ("file-drop" / "Request files"). Both are created from the explorer's **Share** dialog on any item (a share link needs ≥editor on the item and the `share.links` permission; a file request, `share.upload_links` - see [PERMISSIONS.md](PERMISSIONS.md)); a file request can also be started for the folder you are in from the navigation panel's **+ New → Request files**. The agent API and the MCP `file_share` tool ask exactly the same, and an API token needs `write`. A link keeps needing that right after it is made ([A link follows its creator](#a-link-follows-its-creator)). The same dialog carries **People with access** - the per-item grants to people and groups ([GROUPS.md](GROUPS.md)) - for the item's **owner** only: an editor cannot read the grant list, so the section is not offered to them (and the details panel's **Manage permissions** is an owner's button). On a storage with RBAC switched off a grant changes nothing; an administrator then sees the section greyed with where to switch RBAC on (Admin → **Storages** → the storage → *Per-item access control*), and nobody else is offered it. An [app plugin](APP-PLUGINS.md) opens the third kind on the same machinery: the link it sends an outside signer is an ordinary share carrying that app's screen, so it appears in **Shares** with everything else and you revoke it the same way. **What the visitor sees is one shell**, whichever of the three they were sent: your instance's name, logo, colours and footer (Admin → **Branding**, and the default theme picked under **Appearance**), the PIN gate, the expiry, the visit counter, the language picker and the wording for a link that is over. A signature request from a renamed instance does not say "filex". Behind it the plain server-rendered pages are kept for a browser with no JavaScript - a share link is opened by strangers on whatever browser they have - and anything that is not a browser navigating (`curl -O`, wget, a backup script) still gets the **bytes**, not a page. **Every one of these pages, and every mail a share sends, is a template** the administrator can rewrite (*Admin → Settings → Templates*, [TEMPLATES.md](TEMPLATES.md)): the PIN gate, "this link is not available", the waits, the pages without JavaScript, the frame of the public link page and a title, an introduction and a footer line on it. On a multi-tenant install a link's pages wear the templates and the branding of the tenant the link was made in, at whatever address it is opened; a "not available" page wears the address's, so it tells nobody whose link a token was. ⚠ **Not a share: download tickets.** Two other kinds of credential-free URL exist, and neither is a share: the one-off archive of a selection (`/z/…`, from **Download** on several rows) and the one-file link the web app puts on a file you drag out onto your desktop (the same `/z/…`). They are not listed under **Shares** and cannot be revoked there, because there is nothing left to revoke by the time you could: they work once, for minutes (a drag link for one minute at most), only for what you selected, and a drag link is checked again against *your* permissions at the moment it is used. Each drag-out download is in the Audit log. To give someone a file, make a share link - a ticket you copy out of your browser is already spent or about to be. - [Share links (download)](#share-links-download) - [A link follows its creator](#a-link-follows-its-creator) - [File requests (upload / file-drop)](#file-requests-upload--file-drop) - [Emailing a link](#emailing-a-link) - [Failure modes & troubleshooting](#failure-modes--troubleshooting) --- ## Share links (download) **Create.** Explorer → **Share**: the **Link sharing** switch at the top makes the link, with the settings under **Link options** (PIN, expiry, download limit). ⚠ **One link per item from this dialog.** With a link already on, the button under the options reads **Replace the link with these settings**: it revokes the link and makes a new one - a new address, the old one stops working - rather than leaving a second live link beside the first. (Until v0.43.0 it quietly made a second one, and the header's link was also listed again underneath.) Or `POST /api/files/share`: ```jsonc { "path": "s3://reports/q3.pdf", "password": true, // generate an 8-digit PIN (returned in the response) "expires_at": "2026-08-01T00:00:00Z", "max_downloads": 50 } ``` The response includes the public URL (`https://files.example.com/s/`) and, if requested, the generated PIN. With [bot protection](BOT-PROTECTION.md) on for `share_password`, the PIN screen carries the bot check too, and the PIN is answered on the page only: the no-JavaScript form and a PIN in the address (`?pin=`, `X-Filex-Pin`) are refused with *JavaScript needed* while it is on. The PIN is not lost after that: the link's creator and an administrator can read it back - see [Your own links, and their PINs](#your-own-links-and-their-pins). **Open** `/s/{token}`: - **A file** streams as a download through filex. (An S3 storage with `disable_presign: false` answers with a redirect to a presigned bucket URL instead - off by default since v0.42.2, see [STORAGE.md](STORAGE.md#s3--s3-compatible).) `?inline=1` renders inline. A picture, a PDF, sound, video and plain text render as themselves; HTML, SVG, XML and any unrecognised kind render sandboxed, with no scripts (`Content-Security-Policy: sandbox`) - the same rule as the explorer's preview and a shared folder's entries. - **A folder** streams **every file under it as a ZIP** (internal folders like `.filex-trash` are skipped). - **PIN-protected** links show a PIN form first; a correct PIN unlocks the download for twelve hours (an HttpOnly cookie that carries no PIN and opens only that one link). The PIN can also be passed as `?pin=` or the `X-Filex-Pin` header. ⚠ **Five wrong answers shut the gate for ten minutes.** The count lives on the link itself, so it survives a restart and holds across two instances behind one address, and the *correct* PIN is refused while the lock is on - a lock the right answer lifts is no lock at all. A shut gate is not a dead link: it opens by itself. (Until this release the lock only ever guarded an app plugin's page; a PIN on a `/s/` link could be walked through at the speed of HTTP.) **Options.** | Option | Meaning | |---|---| | `password` | Generate a random 8-digit PIN, returned in the response and readable again later ([below](#your-own-links-and-their-pins)). | | `pin` | Set the PIN yourself. **4 to 12 characters** - the one PIN rule every door that makes a link is held to (the dialog, the agent API, MCP, a permission rule, an app's page). Anything else is refused with `400 {"error":"pin_length","pin_min":4,"pin_max":12,"message":…}`; the visitor's PIN box stops at the same `pin_max`, which the public page is told (`GET /api/public/s/{token}` → `pin_max`). | | `expires_in` | The link's life in **seconds**, counted on the server's clock. What the share dialog sends: a moment worked out on the visitor's own clock drifted with it. | | `expires_at` | Absolute expiry (RFC3339); wins over `expires_in`. Either is capped by the **maximum link life** (below). | | `max_downloads` | Auto-expire after N downloads. | **Every new link has a maximum life.** The admin sets it under **Protection → Share links** (`share.max_ttl_days`, default **7 days**, `0` = no ceiling; seeded once from `FILEX_SHARE_MAX_TTL`). A link created without `expires_at` gets `now + max`; one asking for more is shortened to it. The response says so - `expires_at` is the date actually stored and `expiry_clamped: true` marks a request the server changed - and the dialogs only offer choices the server will keep (a 7-day server shows *1 day / 7 days*, not *30 days* or *Never*), with the real expiry printed under the fresh link. The ceiling the dialog offers is **the person's own**: `GET /api/capabilities` answers `share_link_max_days`, the install's ceiling or the **Maximum share-link lifetime** of the permission rules binding the caller ([PERMISSIONS.md → Custom roles](PERMISSIONS.md#custom-roles)), whichever is shorter. (`share_max_ttl_days` is still there and is the install's setting alone.) Someone held to 3 days is offered 3 days, not offered 7 and then told the link was cut. Apps obey the same ceiling, and are told it: every call an app gets carries `share_max_ttl_days`, read from the same setting the clamp reads, so an app's screen can offer only what its links will keep (the e-Signature app's Time step says "at most 7" on a 7-day server instead of offering 14 and quietly getting 7). ⚠ **Links that already exist are never touched.** Lowering the ceiling changes what new links get, not what old ones have: a customer's link minted last month keeps its own expiry (or none). What the server does instead is *count* them - `GET /api/admin/protection` returns `shares_over_max_ttl`, the Protection page shows the number, and the boot log prints it - so whoever lowered the limit can revoke any of them by hand under **Shares**, or leave them alone. **The download cap is exact.** A download is claimed against the cap *before* the bytes are served, so "3 downloads" hands out three files even when several people click at once, or when a large transfer is still running as the next one starts. (It used to be counted afterwards, so every request that began inside that window read the same pre-download count and was waved through: measured on a live instance, a link capped at ONE download served three complete files to three overlapping clients.) A serve that fails before a single byte leaves gives its slot back; a transfer the visitor abandons half-way has spent one. Each real byte-serve counts once: the file itself, a folder's "download all" ZIP, and a single file fetched from a shared folder's browse page. The gallery thumbnails on that page, the ZIP progress poll and the "preparing…" page do not. **Command line.** A share is often made *for a server*, and that reader has no browser - so the answer that creates a download link carries the line that fetches it, **written by the server**: `download_command` with `curl` (a POSIX shell) and `powershell` (Windows PowerShell 5.1 and 7). The dialog shows both; `POST /api/ai/share` and the MCP `file_share` tool return the same field, so an agent passes it on instead of assembling its own. ```bash curl -fSL -o 'q3.pdf' 'https://files.example.com/s/?pin=12345678' ``` ```powershell Invoke-WebRequest -UseBasicParsing -Uri 'https://files.example.com/s/?pin=12345678' -OutFile 'q3.pdf' ``` The rules are the server's: `-L` matters (an S3 storage with presigned URLs turned on answers with a redirect to the bucket, and without it curl saves the redirect instead of the file); a folder link targets `?zip=wait`, which blocks until the archive is built and then streams it, saved as `.zip`; the PIN rides as `?pin=` and is in the command only on the creator's own answer, the one time it is said. A file request has nothing to fetch and gets no command. **Folder ZIPs are cached, and the cache is disposable.** A shared folder's archive is built once and kept at `/sharezips/-.zip`, so the second visitor does not pay for the walk again. A background warmer pre-builds it when the link is created and re-checks every active folder share every 5 minutes; the signature covers the file set, sizes and mtimes, so editing the folder invalidates the archive and the next pass rebuilds it. While a build is running, `/s/{token}` shows a "preparing… %" page (`?zip=status` polls, `?zip=wait` blocks); nothing about that is counted as a download. Four rules keep that cache from becoming a disk problem: - **Nothing outlives its share.** Each warmer pass deletes every archive whose node no longer has an active folder share - expired, revoked, or out of downloads - plus the leftovers of builds that died with a restart. An archive is regenerable, so a link that cannot be used has no archive. - **A build stops when its share does.** A build that is still running when its share expires abandons itself within about a minute and deletes its partial file. (A 16.7 GB folder shared for eleven minutes once kept reading from S3 for three hours after the link had died, then left a 15 GB archive nobody ever downloaded.) - **The warmer has a ceiling; the download button does not.** Folders whose files add up to more than `FILEX_SHAREZIP_WARM_MAX_BYTES` (default **2 GiB**, `0` = no ceiling) are *not* pre-built - not when the link is created, not on the five-minute pass. They are zipped the moment a visitor clicks download, with the same "preparing… %" page as before, and cached from then on like any other. Nothing is refused for being large; the server just does not spend hours of object-storage reads on a link nobody may ever open. The warmer logs each such folder once. - **No archive older than a week.** Whatever its share's state, a cached ZIP older than `FILEX_SHAREZIP_MAX_AGE` (default **7d**, `0` = keep for the share's life) is swept and rebuilt on demand - or by the next warm pass, if the folder is under the ceiling. Together with the 7-day default link life this bounds the cache to what is actually being shared this week. ⚠ **Operators: exclude the cache directory from backups.** `/cache` (prepared copies *and* folder-share ZIPs) is regenerable by definition; backing it up puts throwaway gigabytes into your snapshots, your off-site copy and every restore. Older installs kept the ZIPs in `/sharezips` - filex moves that directory into the cache directory on first start, so exclude `/cache` and, for a while, `**/sharezips/**` too. **Metadata** (no PIN needed): `GET /api/files/share/{token}` → `requires_pin, expires_at, download_count, max_downloads, downloads_remaining, filename, size, mime, is_directory`. **Revoke.** From **My shares** (your own links) or, for an administrator, **Shares** (everybody's): the link's **Actions** menu → **Revoke**. Underneath, `DELETE /api/files/share/{id}` (owner or admin; what **My shares** calls) and `POST /api/admin/shares/{id}/revoke` (the admin page) soft-revoke the link: its expiry is set to now **and the revoke is recorded** (`shares.revoked_at`, migration 00053), so both **My shares** and **Shares** say *Revoked* rather than *Expired*. ⚠ Links revoked **before v0.43.0** carry no such record and still read as expired. Either way the link shows a styled 404 page - and to a visitor `expired`, `revoked` and a shut PIN gate are three different answers ([BACKEND.md](BACKEND.md)). Revoking (or deleting) a link an **app** opened - a signing link, say - also wakes that app within seconds, and the app finds the link gone and acts on it: the e-Signature app closes the signature request the link belonged to, tells the requester whose link it was, and releases the document. Nothing is sent to the app; it is woken and asks (see APP-PLUGINS-API.md → `share_state`). ### Your own links, and their PINs **My shares** lists the links you created, for everybody - not only for administrators. In the web app it sits in the explorer's navigation panel directly under **Shared with me** (`/drive/my-shares`), and each row's **Actions** menu offers **Copy link**, **Copy PIN** and **Revoke**. Administrators keep **Shares** for everybody's links, with the same **Copy PIN** entry. The listing is `GET /api/shares`. **Where a link stands is the server's answer.** Every listed link carries `share.state`: `active`, `expired` (its time is up), `exhausted` (its download - or an app page's visit, or a file request's upload - cap is used up) or `revoked`. **My shares** shows that word and offers **Revoke** only on an active link. It used to compare the expiry with the browser's clock, so a link whose downloads were used up still read as active while every visitor was turned away. **A PIN can be read back.** A PIN is stored twice: as a bcrypt hash, which is still the only thing the PIN gate checks, and sealed with AES-256-GCM under `FILEX_SECRET_KEY`, so the link's creator - or an administrator - can copy it again later (`GET /api/shares/{id}/pin`). Every read writes an audit row (`share.pin_revealed`), the PIN goes to the clipboard and nowhere else, and an `app` token cannot read one: a PIN is a credential, and an app token has no person behind it. **Copy PIN** is not offered when there is nothing to show: - the link has no PIN; - the link was created before v0.43.0, or while the server had no key - only the hash was kept, so make a new link if you need a PIN you can see again; - the server has no `FILEX_SECRET_KEY`, so there is nothing to seal a PIN with. The link works exactly the same; only reading its PIN back does not. ### A link follows its creator A public link is only ever worth what its creator may still do. It answers while the person who made it **still** holds the right to make it on the item - `share.links` for a share link, `share.upload_links` for a file request, at the editor level - and answers like a link that never existed (`404`) from the moment they do not: their role or an exception took the permission away, they were made a viewer, their grant on the folder was revoked, or they left the group whose grant or role gave it. It pauses the same way while the creator's **account is switched off** (or their tenant suspended): the link answers `404` and a file request takes nothing, and switching the account back on brings the same address and PIN back. Nothing is deleted and nothing is revoked: give the right back and the link answers again, with the same address and PIN. Its creator - and an administrator - still sees it under **My shares** / **Shares** meanwhile, and can revoke it. **A link stays with its tenant.** On a multi-tenant install a link is made in the tenant its storage belongs to, and answers only while that tenant still has the storage. When the platform operator moves the storage to another tenant, the link - a download link, a file request, an app's page, with or without a recorded creator - answers `404` like one that does not exist, and moving the storage back brings it back. A link made on a storage that was in no tenant, or in the platform's own, keeps answering when the storage is given to a tenant ([MULTI-TENANCY.md](MULTI-TENANCY.md#168-a-storage-moved-to-another-tenant-what-was-made-on-it-before---closed-059)). The download page, the link's metadata, a shared folder's browsing, the file request page and its uploads, and the public shell's `/api/public/*` all ask the same question, on every visit. **Deleting the creator's account deletes their links** - every download link and file request they opened goes with the account, whichever door deleted it (an administrator, a tenant deletion), and the deletion's audit entry counts them (`links_closed`) ([BACKEND.md → Admin: users](BACKEND.md#admin-users)). Two kinds of link are left alone: - an **app's own public page** - a signing link: the app's own permission allowed the action that opened it, and an outside signer's page does not close because the requester's sharing rights changed, nor when the requester's account is deleted (the app checks the requester's access to the document itself, below); - a link with **no recorded creator** - made before links recorded one, or whose creator was deleted before 0.49.0: there is nobody left to ask, so it keeps answering until an administrator revokes it (or, on a multi-tenant install, its storage leaves the tenant it was made in, above). A custom role can also shape the links its people make - a maximum lifetime and a required PIN ([PERMISSIONS.md → Custom roles](PERMISSIONS.md#custom-roles)). An [uploader](UPLOADERS.md) set to public links makes one of these for every upload, as its owner: the same ceiling, the same rules, listed in their **My shares** and revoked there or with the upload's deletion link. --- ## File requests (upload / file-drop) The inverse of a share link: a public page where anyone can **drop files into a folder** - collecting documents, photos, submissions - without an account and **without seeing what's already in the folder** ("blind drop"). The target folder is resolved server-side from the token; the uploader can never influence the destination. With [bot protection](BOT-PROTECTION.md) on for `file_request`, every submission passes the bot check first and carries its [upload pass](BOT-PROTECTION.md#file-requests-the-upload-pass). **Create.** On a **folder**, Explorer → **Share**, section **Request files** - or **+ New → Request files** for the folder you are in - or `POST /api/files/share` with `kind: "drop"`: ```jsonc { "path": "s3://inbox", "kind": "drop", "password": true, // optional PIN "expires_at": "2026-08-01T00:00:00Z", "drop_settings": { "max_files": 10, // per submission (default 20) "max_file_size_mb": 200, // per file (default 500) "allowed_ext": ["pdf", "jpg"], // empty = all types "ask_name": true // optional uploader name field }, "max_uploads": 100 } // lifetime cap on total files received ``` You get a `https://files.example.com/d/` link. **How a drop works.** The visitor opens `/d/{token}`, optionally enters a PIN, an optional name + note, and drops files. Each submission lands in its **own subfolder** named `YYYY-MM-DD_HHMMSS_` (so submissions never collide and you can see who sent what); an optional note is saved as `NOT.txt` beside the files. The owner is notified (in-app + email, best-effort). On the page a JavaScript browser gets: - the limits (size, types, how many files are left) are stated **before** anything is picked, and the **name** field - when the link asks for one - sits above the drop area, because dropping sends; - a file the link does not take (its type, its size, one file too many) is **not sent**: its row says why, and the rest of the drop still goes; - everything one drop carries goes up in **one** request - one drop, one submission folder (it used to be one request, and one folder, per file); - a refusal the server makes is shown in the server's own words (below); - once every byte is sent, the rows say **"Saving…"** while the server writes the files to the storage, one after another. The bar no longer sits at 100%; - the **name** box stops at the server's limit, **40 characters** (`GET /api/public/d/{token}` → `limits.name_max`, the no-JavaScript form says the same). A longer name sent some other way is refused (`400 name_too_long`, with `name_max` and the sentence) - it used to be cut to 40 without a word, so the owner read a different name from the one that was typed. **The page's words are the server's.** Every sentence the JavaScript share and file-request pages say - the limits, "Sent", "Saving…", a refused file, the footer - comes from the server catalogue's `server.public.*`, the table its no-JavaScript pages render, fetched for the visitor's language from `GET /api/public/strings?lang=` ([BACKEND.md](BACKEND.md)). A file refused before it is sent reads exactly as the server's refusal of it would ("a.pdf is too big (max 5 MB)."), and a language pack translates each sentence once. **Limits & safety** (enforced server-side): per-submission file count and per-file size, an optional extension allowlist, an optional PIN, an expiry, a lifetime `max_uploads` cap, and **per-IP rate limiting** on the anonymous upload endpoint: 40 submissions from one address in any hour, counted over the clock's hour and the one before it, so the turn of an hour is no fresh start (`429 rate_limited`; an address that reached it waits until the next hour ends). IPv6 addresses are counted per /64, the block one subscriber holds. The count costs the same one step under a flood of addresses. Read-only storages reject drops. **The upload cap is exact.** A submission's files are claimed against `max_uploads` *before* the first one is written, in one database statement, so "accept 5 files" takes five even when several people send at once. (Until 0.57.2 the count moved only after the files had landed, so every upload that began while another was still writing read the same count and was let through: a link capped at ONE file took fifteen concurrent uploads - GHSA-4pc6-66f4-q2x4.) An upload refused by the cap writes nothing - `410 expired` when no place is left, `422 exceeds_remaining` with the `remaining` count when fewer places are left than files sent - and a file the storage did not take gives its place back. **The creator's own limits apply too.** What is dropped lands in the creator's storage as the creator's file, so their role's limits hold on top of the link's: a file type their role blocks is refused (`415 ext_not_allowed`, with the `extension`), and a file over their largest-file limit answers `413 file_too_large`, even where the link's own `max_file_size_mb` is larger ([PERMISSIONS.md → Custom roles](PERMISSIONS.md#custom-roles)). **Language.** Every public page - the PIN gate, the uploader, the error pages and the download-share pages - renders in ONE language per visitor. In the shell that a JavaScript browser gets, that is the browser's own language, and a **picker** in the header changes it (remembered in that browser, and nowhere else: there is no account behind a share link). The plain server-rendered pages resolve `?lang=` (`tr` / `en`), then `Accept-Language`, then the server's `default_locale`; add `?lang=en` to a link you are sending to somebody whose browser is set to neither. **Options.** | `drop_settings` key | Default | Meaning | |---|---|---| | `max_files` | `20` | Max files per submission. | | `max_file_size_mb` | `500` | Max size per file. | | `allowed_ext` | all | Allowlist of extensions (e.g. `["pdf","png"]`). | | `ask_name` | `true` | Show an optional "your name" field. | | (share) `max_uploads` | - | Cap on total files the link may ever receive. | | (share) `password` / `expires_at` | - | PIN / expiry, as for download links. | --- ## Emailing a link After creating a link you can email it to **one or many** recipients: ``` POST /api/files/permissions/share-mail {"share": "", "emails": ["ada@example.com"]} ``` - **The request names the link, not what is in the mail.** `share` is the link's token (`token` in the answer of `POST /api/files/share`); a numeric `share_id` (the `uuid` that `GET /api/files/share` lists) works too. `email` and/or `emails: [...]` are the addresses (comma, semicolon, space and newline separate them). - **Each recipient reads the mail in their own language** (0.54, #191): an address that is an account here reads its owner's language; any other address reads the language picked for the recipients - the dialog's optional *Recipient's language*, sent as `locale` only when somebody picked one - else the server's (`FILEX_DEFAULT_LOCALE`, else English). The sender's own screen language is never the recipient's. An account opened by an invitation (`POST /api/files/permissions/invite` with `create_user`) starts in that same picked language, else the server's. - **The server writes the mail from the link itself**: its address on the tenant's own host, the days it has left, the item's name, whether it is a file or a folder, a file's size, and - for a file request (a `/d/` link) - the upload invitation with the link's own limits (files, size per file, types). Fields an older client still sends (`url`, `pin`, `expires_days`, `is_dir`, `size`, `mode`, `path`) are **not read**; a request that names no link is refused with `400`. - **Only a link you manage**: the one you made, or any link in your tenant if you are an administrator - the same rule as revoking it - and only while you still hold what making it took (editor on the item and `share.links`, or `share.upload_links` for a file request). Somebody else's link answers `403`; one outside your tenant or your token's folder, `404`; an expired, used-up or revoked link, `410`. - **No PIN in the mail.** A link with a PIN says so, and that the sender gives the PIN separately: the PIN and the link do not travel in the same message. The answer carries `pin_withheld: true`, and the dialog keeps showing the PIN to copy. (An invitation to an address with no account, `POST /api/files/permissions/invite`, is the exception: the server makes that link and its PIN a moment before, nobody else has the PIN, so that mail carries it.) - **Limits:** at most **20** addresses per send (`400 too_many_recipients`, nothing is sent) and **100** addresses an hour per account (`429 rate_limited`). - **Answer:** `{emailed, sent[], failed[], pin_withheld, message}` - `message` is the outcome in the sender's language, and the refusals carry one too. When mail is not set up or not verified the answer is `503 {"error":"not_configured"}` and the dialog keeps showing the link so you can pass it on yourself. (SMTP is configured in the admin settings.) The dialog's **Share** button (the operating system's share sheet - WhatsApp, Mail, …) hands over the very same words: `GET /api/files/permissions/share-message?share=&lang=` answers `{subject, body, pin_withheld}` without sending anything, under the same rule. `lang` is the language picked for the recipient; without it the words are in the server's language - whoever the sheet reaches is nobody the server knows. --- ## Short links When an app holding the link-shortener role is installed (and you hold its permission), the share dialog offers **Get short link** beside a link and beside a file request, and **My shares** offers it on each live link. The short address becomes the one to copy, shown readable (an emoji code shows as emoji) and copied in the form every chat and mail program keeps whole. The link itself stays a line below with its own Copy. - **A mail carries the short address.** Once a link has one, *Send* and the device's share sheet put the short address in the message instead of the long one; nothing else about the mail changes. - **A short address ends with its link.** Revoked, expired or out of downloads, the link's short address answers the same "not available" page as an address that never existed. - What the app keeps, how codes are made and where the addresses live are the app's setup: [APP-PLUGINS.md → Short links](APP-PLUGINS.md#short-links). ## Failure modes & troubleshooting - **Link shows a 404 page** - expired, past its download/upload cap, or revoked - or its creator no longer holds the right to make it, their account is switched off, or its storage was moved to another tenant ([A link follows its creator](#a-link-follows-its-creator)); it answers again when the right, the account or the storage is back. - **An app's link says it is gone, although nobody revoked it** - the account that created it was switched off or deleted (re-enabling the account brings every one of its links back), or the app that answers it was stopped or removed. A link whose creator has since lost access to the document still opens, but the step that would start the app's work is refused and the visitor is asked to get a new link from the person who sent it. - **"Request files" not offered** - you're on a file, not a folder (drop links are folder-only), you lack ≥editor on it, or your role does not allow `share.upload_links` here. The same goes for the dialog's link switch and the details panel's **Create link** (`share.links`) and for **People with access** (`share.users`): every explorer leaves out what the server would refuse. - **Making a link is refused** - `403 permission_denied` naming `share.links` (or `share.upload_links` for a file request): your role or an exception does not allow that kind of link, here or in this folder. The answer names the role that decided; an administrator changes it ([PERMISSIONS.md](PERMISSIONS.md)). - **Drop rejected** - hit `max_files`, `max_file_size_mb`, a disallowed extension, the per-IP rate limit, a read-only storage, or a type or size the **creator's** role does not allow (`415 ext_not_allowed`, `413 file_too_large`). The page shows which: every refusal of `POST /d/{token}` and `POST /api/public/d/{token}/upload` carries the code a script branches on (`error`, e.g. `ext_not_allowed`) **and** the sentence a person reads (`message`, in the visitor's language). - **"The file storage is unreachable right now"** - the link, the PIN and the files are all fine; the storage behind the folder refused the write. The endpoint answers **`503` `{"error":"storage_unavailable"}`** (not a 500) and logs the failure with the storage name and driver, so an outage is visible where outages are looked for. The link stays valid - the same upload works once the backend is back. Out of space is the neighbouring case: **`507` `{"error":"quota_exceeded"}`**, which is the *owner's* problem, not the uploader's. - **"Sent, but the server did not confirm it in time"** - every byte of the drop arrived, but the answer did not: a proxy in front of filex stopped waiting (Cloudflare gives up at 100 s, nginx by default at 60 s), or the connection dropped. The server finishes writing a drop that has fully arrived, whoever stopped waiting, so the files are normally in the submission folder; the owner is notified as usual. The visitor is asked to check before sending again, because a second send is a second submission. A proxy timeout long enough for your largest drops avoids the question. Only a proxy's *timeout* (504 from nginx, 524 from Cloudflare) is read this way: a 502 / 520 means filex itself did not answer - nginx buffers the body, so that comes after the last byte too, but the drop never reached filex - and the row says it could not be sent. - **Uploader sees folder contents?** - they don't; the drop page never lists the folder. If you want them to *see* files, use a download share instead. - **Share link opens the wrong URL / host** - `FILEX_PUBLIC_URL` is wrong or unset. It's baked into every generated link; unset, every link says `http://localhost:5212`, and the admin panel shows a banner until it is set. The variable is spelled exactly `FILEX_PUBLIC_URL` - nothing else is read (see [Public URL](CONFIGURATION.md#public-url)). - **Email not sent** - SMTP not configured/verified; the response is `{emailed:false}` and the UI still shows the link to share manually. --- ## See also - [RBAC.md](RBAC.md) - who can create shares / access items - [GROUPS.md](GROUPS.md) - sharing a folder with a group - [UPLOADERS.md](UPLOADERS.md) - a link for every file a screenshot tool or a script uploads - [CONFIGURATION.md](CONFIGURATION.md) · [STORAGE.md](STORAGE.md)