AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
PHP 앱용 임베드 파일 관리자, 테넌트별 S3·R2·SFTP·로컬 저장, JWT 인증, 중앙 DB 없음, 무료 MIT 라이선스
Embeddable file manager for PHP apps — each tenant's files stay in their own S3, R2, SFTP, or local storage. JWT auth, no central database. Free, MIT licensed.
개발 도구PHP · TypeScriptGitHub @thai-pc온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
Standalone, embeddable file manager built with PHP 8.1+. Multi-storage support (Local, AWS S3, Cloudflare R2), JWT authentication, and a zero-build-step frontend powered by Alpine.js.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Standalone, embeddable file manager built with PHP 8.1+. Multi-storage support (Local, AWS S3, Cloudflare R2), JWT authentication, and a zero-build-step frontend powered by Alpine.js.
Drop it into any web app via iframe + SDK, or use the provided adapters for Laravel, WordPress, React, Vue / Nuxt, CKEditor 4, TinyMCE, and Summernote.README 전문 보기 ↗Standalone, embeddable file manager built with PHP 8.1+. Multi-storage support (Local, AWS S3, Cloudflare R2, SFTP), JWT authentication, and a zero-build-step frontend powered by Alpine.js.
Drop it into any web app via iframe + SDK, or use the provided adapters for Laravel, WordPress, React, Vue / Nuxt, CKEditor 4, TinyMCE, and Summernote.
docs/guides/DEPLOYMENT.mdsrcset · Watermark · Usage dashboard — internals: docs/guides/FEATURES.mdEverything here is in the open-source (MIT) core unless marked 🔒 Paid. The
Best for column tells you when to reach for each one. Paid features are
separate, gitignored modules gated by license — see docs/guides/ACTIVATE.md.
| Area | Feature | Best for |
|---|---|---|
| Storage | Local · AWS S3 · Cloudflare R2 · any S3-compatible · SFTP | Local for one VPS/demo; S3/R2 for SaaS + CDN (R2 = no egress fee); SFTP to manage a VPS/host like cPanel |
| BYOB — each tenant's own bucket (S3/R2/SFTP), creds encrypted in the JWT | Multi-tenant SaaS where the user pays for their own storage | |
| Cross-disk copy/move (streamed) | Backups, storage migration, "process locally → push to cloud" | |
| 🔒 Paid: Backup (on-demand one-way sync of a folder to another disk; schedule it with your own cron) | Disaster recovery for storage-resident data | |
| Auth | JWT HS256 + granular claims (perms, disk, path prefix, quota, ext allowlist, owner-only) | Any multi-tenant app — stateless, no session DB; your backend mints the token |
role claim preset (viewer/editor/admin/superadmin) — see docs/design/ACL-ROLE-PRESETS-DESIGN.md | Quick permission bundles without hand-picking claims | |
| 🔒 Paid: SSO bridge (OIDC) | Enterprise login instead of per-tenant token minting | |
| Files | Upload / download / move / copy / rename / delete / mkdir / bulk multi-select | Every app that manages files |
| Chunk upload (S3 multipart) · resumable tus upload (local/SFTP, standalone core) · presigned PUT/POST | Reliable large files (video, design archives) that survive a dropped connection and PHP's upload limit | |
| Import from URL (SSRF-guarded, opt-in) | CMS "import by link", stock imagery | |
| Zip download + Extract (slip/bomb-guarded) | Deploys, backups, "send me all these files" | |
| Trash — soft-delete files + folders, restore | Undo accidental deletes (Drive-style) | |
gd, curl, json, openssl, mbstring, fileinfoThe standalone app (nginx + php-fpm) is published to GHCR — no PHP/Composer on your host. Pull and run:
docker run -p 8080:80 \
-e FLUXFILES_SECRET="$(openssl rand -hex 32)" \
ghcr.io/thai-pc/fluxfiles:latest
Open http://localhost:8080/public/index.html. On its first boot the container
materializes FLUXFILES_*, AWS_*, R2_*, and SFTP_* variables into
/app/packages/core/.env; that file remains authoritative on later boots. Persist
uploads + runtime state with a volume, and add cloud creds as needed:
docker run -p 8080:80 \
-e FLUXFILES_SECRET="$(openssl rand -hex 32)" \
-e AWS_BUCKET=my-bucket -e AWS_ACCESS_KEY_ID=... -e AWS_SECRET_ACCESS_KEY=... \
-v fluxfiles-data:/app/packages/core/storage \
ghcr.io/thai-pc/fluxfiles:latest
When that volume is retained, changing docker run -e … does not rotate an
existing setting: update the persisted .env deliberately (or replace it from
your secret manager), then restart the container. Back up the entire storage
volume, including hidden _fluxfiles/ bookkeeping. The production image includes
the extensions needed by the JSON and SQLite/MySQL/PostgreSQL DB backends; use
docs/design/DB-STORAGE-MIGRATION-DESIGN.md for
the migration procedure.
Every tagged core release publishes three Docker tags: latest, the exact
release version (e.g. 0.2.100), and its minor line (e.g. 0.2), for
linux/amd64 and linux/arm64 — pin to the minor or exact tag in production
instead of latest if you want reproducible deploys; check
the package page
for the current version. To build from the monorepo instead: docker compose up
(app + LocalStack S3) or make up.
A non-PHP backend? Run this image as your file service and mint tokens with
@fluxfiles/node(JS/TS) orfluxfiles-token(Python) — no PHP in your own codebase.
Into an existing PHP project (recommended):
composer require fluxfiles/fluxfiles
Or clone the monorepo (for contributing / running the full dev environment):
git clone https://github.com/thai-pc/fluxfiles.git
cd fluxfiles
composer install -d packages/core
Both give you the same code — only the folder layout and run command differ. See
packages/core/README.md
for the full breakdown.
cp .env.example .env
Edit .env — at minimum, set these two:
FLUXFILES_SECRET=your-random-secret-key-min-32-chars
FLUXFILES_ALLOWED_ORIGINS=http://localhost:3000,https://yourapp.com
# composer require fluxfiles/fluxfiles
php vendor/bin/fluxfiles serve --port=8080
# git clone (monorepo)
cd packages/core && php -S localhost:8080 router.php
Open in browser:
When opening FluxFiles directly via /public/index.html, configure it with URL parameters:
/public/index.html?token=JWT&disk=local&path=photos/&locale=vi&theme=dark
| Parameter | Required | Default | Description |
|---|---|---|---|
token | Yes | — | JWT authentication token |
disk | No | local | Active disk |
disks | No | local | Comma-separated available disks (e.g. local,s3,r2) |
path | No | `` (root) | Initial directory path |
locale | No | en | UI language (en, vi, zh, ja, ko, fr, de, es, ar, pt, it, ru, th, hi, tr, nl) |
lang | No | en | Alias for locale |
theme | No | auto | light, dark, or auto-detect |
multiple | No | false | 1 or true to enable multi-select |
Security —
?token=is for dev/standalone only. A full-privilege JWT in the URL can leak via browser history, server/proxy access logs, the copy buffer, and theRefererheader. FluxFiles hardens this case: the page sendsReferrer-Policy: no-referrer(+ a<meta referrer>backup) and the app stripstokenfrom the URL on boot viahistory.replaceState(it lives in memory after). Still, for production embed via the SDK / React / Vue — the token goes overpostMessage, never in the URL — and keepttlshort. See Token handling.
// Composer: vendor/fluxfiles/fluxfiles/embed.php
// Monorepo clone: packages/core/embed.php
require_once 'path/to/fluxfiles/embed.php';
$token = fluxfiles_token(
userId: 'user-123',
perms: ['read', 'write', 'delete'],
disks: ['local', 's3', 'r2'],
prefix: 'user-123/', // scope user to their own directory
maxUploadMb: 10, // MB — max size PER uploaded file
allowedExt: ['jpg','png','pdf'], // extensions (lowercase, no dot); null = all safe types
ttl: 3600, // SECONDS — token lifetime (3600 = 1 hour)
ownerOnly: false, // true = users only manage files they uploaded
maxStorageMb: 1000, // MB — total quota for the prefix; 0 = unlimited
maxFiles: 0 // max number of files under the prefix; 0 = unlimited
);
Units at a glance:
maxUploadMbandmaxStorageMbare megabytes (MB),ttlis seconds,allowedExtis a list of extensions (lowercase, no leading dot). See the configuration reference for parameter types and units — and for the recommended one-options-array form (fluxfiles_token(['user' => …, 'claims' => […]])).
Or generate via CLI for testing:
php packages/core/tests/generate-token.php
Deploying behind nginx or Apache needs three things: routing /public/ through
PHP (for server-side locale injection — a static try_files would skip it),
blocking a handful of sensitive paths (.env, _fluxfiles/, storage/ runtime
state such as rate_limit.json and tus/), and neutralizing uploaded HTML/SVG so
they can't run as same-origin XSS.
Full nginx server block, Apache .htaccess, and directory-permissions
commands: docs/guides/DEPLOYMENT.md.
A large upload must pass three independent limits — the smallest one wins.
The JWT max_upload is the only one FluxFiles controls; the other two are your
server/PHP config and must be ≥ your largest max_upload, or files are
rejected before reaching the app (often as a confusing 413 or
400 No file uploaded instead of FluxFiles' own 413 upload_too_large).
| Layer | Setting | Where | Note |
|---|---|---|---|
| Web server | client_max_body_size (nginx) / LimitRequestBody (Apache) | nginx server/http block | Rejects the request body if too big. |
| PHP | upload_max_filesize and post_max_size | php.ini (or php-fpm pool) | post_max_size must be ≥ upload_max_filesize; both ≥ your max_upload. |
| FluxFiles | max_upload (JWT claim, MB, per file) | issued in your token | Returns 413 upload_too_large when exceeded. |
; php.ini — example for a 100 MB per-file limit
upload_max_filesize = 100M
post_max_size = 110M ; a little above upload_max_filesize
Files larger than 10 MB on S3/R2 disks use chunked (multipart) upload, which sends 5 MB parts directly to the bucket — so those bypass
post_max_size. On local/SFTP disks, the standalone core uploads files larger than 8 MB with resumable tus chunks, so only each chunk has to fit the PHP/nginx limits. Behind the Laravel/WordPress proxies (no/tusroute) a local upload is a single request and the limits above apply in full.
Include fluxfiles.js on your page — zero dependencies, works with any framework
(use the minified fluxfiles.min.js, ~5 KB, in production):
<script src="https://fm.yourdomain.com/fluxfiles.js"></script>
<!-- production: <script src="https://fm.yourdomain.com/fluxfiles.min.js"></script> -->
<button onclick="openFilePicker()">Choose File</button>
<script>
function openFilePicker() {
FluxFiles.open({
endpoint: 'https://fm.yourdomain.com',
token: 'eyJhbGci...', // JWT token from your backend
disk: 'local', // default disk
disks: ['local', 'r2'], // available disks in sidebar
mode: 'picker', // 'picker' = select & close, 'browser' = stay open
multiple: false, // true = multi-select returns array
locale: 'en', // default 'en' if omitted
theme: 'auto', // 'light', 'dark', or 'auto'
allowedTypes: ['image/*', '.pdf'],
maxUploadMb: 10, // MB — max size per file (deprecated alias: `maxSize` in bytes)
maxFiles: 20, // max files per upload batch (0/omit = unlimited)
container: '#my-div', // CSS selector — omit for modal overlay
onSelect(file) {
// file = { url, permanent_url, key, name, path, size, disk,
// mime, width, height, created, modified, meta, variants }
// created = stable upload time (folders carry it too); modified = live mtime.
// For embedding in SAVED content, prefer permanent_url — `url` is a
// short-lived presigned URL on private disks and will expire.
const src = file.permanent_url || file.url;
const img = document.getElementById('image');
img.src = src;
img.alt = (file.meta && file.meta.alt_text) || file.name;
if (file.width) img.width = file.width;
if (file.height) img.height = file.height;
},
onClose() {
console.log('File picker closed');
},
// Token refresh — called automatically on 401
async onTokenRefresh({ reason, disk, path }) {
const res = await fetch('/api/auth/refresh-fluxfiles-token');
const { token } = await res.json();
return token; // return new JWT string, or null to fail
}
});
}
</script>
permanent_url vs url)The select payload carries two URLs:
url — ready to display immediately. On a private disk it's a
short-lived presigned URL — it expires after url_ttl seconds (default
1 hour, configurable per disk via AWS_URL_TTL / R2_URL_TTL, max 24h).
Media files use the longer preview_url_ttl claim instead (default 2h). Fine
for previewing right after selection; do not save it into content.permanent_url — a stable, non-expiring URL for embedding in saved
content (CMS pages, editor HTML, DB records). Present for local disks, public
disks, and any disk with a public_url (CDN / custom domain). It is null
for a private bucket with no public domain — such a disk has no permanent URL.// Embedding into content you persist:
const embedSrc = file.permanent_url || file.url; // prefer the stable one
To make
permanent_urlavailable for a private S3/R2 bucket, serve it behind a CDN / custom domain and setpublic_urlon the disk config. The CKEditor 4, TinyMCE, and Summernote plugins already preferpermanent_urlautomatically and warn when they have to fall back to a presigned URL.
FluxFiles generates three fixed WebP variants on upload (thumb 150 / medium
768 / large 1920 px) — use file.variants.<size>.url and you're already serving
WebP. For arbitrary sizes on demand (responsive images, exact widths), each
image entry also carries an img_base URL:
// From a selected/listed file, build a modern image at any size:
const src = FluxFiles.imgUrl(file, { width: 800, quality: 80 });
// → /api/fm/img?token=…&width=800&quality=80 (resized + cached in _variants/)
AVIF/WebP are content-negotiated for free via Accept (format=auto, default),
converted + cached on first request into _variants/, and width/quality are
snapped to a bounded set of sizes so cache growth can't run away. Full mechanics
— negotiation, caching, box sizing (fit/dpr), SVG/GIF exclusions:
docs/guides/FEATURES.md#on-demand-webp--avif.
img_basecarries a short-lived per-file token in the query string (an<img>can't send anAuthorizationheader) — the same tradeoff as the media stream token: single-file scope + short TTL. It mints only when the disk config wires a stream secret; the standalone/Docker app always does this, and the Laravel and WordPress proxy adapters now do too, so/api/fm/imgis fully proxied (Laravel needs core ≥ 0.2.77, WordPress needs core ≥ 0.2.79 — see thewebp_enabledentry indocs/reference/CONFIG.md).
srcsetOn top of img_base, every image entry also gets a ready-to-use img_srcset
string, so the host can drop a responsive image straight from list():
<img :src="file.url" :srcset="file.img_srcset" :sizes="file.img_sizes || '100vw'" :alt="…">
<!-- img_srcset = "/api/fm/img?token=…&width=400 400w, …&width=800 800w, …&width=1200 1200w" -->
Candidate widths come from the token's srcset_widths ladder, capped at the
image's natural width. Details: docs/guides/FEATURES.md#responsive-srcset.
FluxFiles watermarks images the way the rest of the industry does — two modes for
two jobs. For almost everyone it's the first one (burn-in). The second is an
advanced mode only for selling images. Full walkthrough of both, including the
overlay claims and adapter support matrix: docs/guides/FEATURES.md#watermark.
Open an image → the Watermark tab in the detail panel (free) → drag a logo or
text to any position, resize the logo by its handle, set opacity → Apply
(replace the file) or Save as copy. It burns the watermark into the image
(POST /api/fm/watermark), exactly like a Photoshop export or a WordPress
watermark plugin. The file now is a watermarked image, so every consumer
carries it — the picker, a download, an <img> you insert into TinyMCE /
CKEditor / Summernote. Non-destructive — the true original is snapshotted to
_fluxfiles/originals/ and a Remove watermark button restores it byte-for-byte.
✅ Want a watermarked image in your content / blog / CMS? This is it. Pick it in the editor and the inserted
<img>shows the watermark. Nothing else to set.
Only if you're building a stock-photo / photographer store (Shutterstock,
Getty, Pixieset…): show watermarked previews but never hand out the clean file
until purchase — applied on the fly when serving (/api/fm/img), source never
modified. Enable with token claims (off by default; forces the token preview-only):
$token = fluxfiles_token([
'user' => 'user-42', 'perms' => ['read'], 'disks' => ['local'], 'prefix' => 'users/42',
'claims' => [
'webp_enabled' => true, 'watermark_enabled' => true,
'watermark_type' => 'text', 'watermark_text' => '© Acme Corp',
'watermark_position' => 'bottom-right', 'watermark_opacity' => 0.6,
],
]);
| Burn-in editor (normal) | Overlay (advanced, selling) | |
|---|---|---|
| Where | Written into the file bytes | Applied at serve time via /api/fm/img |
| Clean original | Replaced (or saved as a copy) | Kept, but never served (preview-only) |
| Insert into an editor / download | Yes | No — show it live in your gallery instead |
| Best for | Branding, putting marked images in content | A stock-photo / photo-seller store |
The two are mutually exclusive per token — see
docs/guides/FEATURES.md#watermarkfor why, plus the adapter support matrix and how to render live previews on a gallery page.
GET /api/fm/usage returns a storage breakdown for the token's prefix — quota
status, size/count by type, and the largest folders — computed on the fly
(no DB, no history). The UI ships a dashboard panel (a toolbar button) with a
quota meter, a by-type chart, a clickable top-folders list, and a duplicate
finder (GET /api/fm/duplicates — byte-identical files and the space they waste,
with a one-click "move extra copies to trash").
// GET /api/fm/usage?disk=local
{
"computed_at": "2026-06-20T10:00:00Z",
"cache_age_seconds": 0,
"quota": { "used_bytes": 4500000000, "limit_bytes": 5000000000, "percent": 90, "status": "critical" },
"file_count": 142,
"by_type": [{ "type": "image", "size_bytes": 3000000000, "count": 120, "percent": 66.7 }],
"top_folders": [{ "path": "/products", "size_bytes": 3200000000, "count": 100 }]
}
_fluxfiles/ and
_variants/ are excluded. status is ok / warning (≥70%) / critical (≥90%),
configurable via the usage_* claims._fluxfiles/usage.json for
usage_cache_ttl seconds (default 15 min; 0 disables it). ?refresh=true
recomputes (rate-limited to 2/min, shared with the duplicate finder) — the UI's
Refresh button is debounced 60s.quota_limit lives in the JWT ("token is the config"), so changing a
tenant's quota only takes effect on their next issued token — by design.Always on — drag-drop several files, or pick multiple in the Upload dialog. They
upload sequentially (one request each; big S3/R2 files use chunk upload, big
local/SFTP files use resumable tus upload on the standalone core) with a
combined progress bar; each is checked independently (size/ext/quota/dup) so one
rejection doesn't abort the rest. Caps: max_upload (MB per file),
max_files (total under the prefix, server-side 413), and the SDK
maxFiles (per-batch, client-side).
Not the same as the SDK's
multiple: true— that's picker selection (returning an array viaFM_SELECT), independent of uploading.
Control the file manager programmatically after opening:
FluxFiles.navigate('/photos/2024'); // Navigate to path
FluxFiles.setDisk('s3'); // Switch disk
FluxFiles.refresh(); // Reload current directory
FluxFiles.search('invoice'); // Trigger search
FluxFiles.crossCopy('s3', 'backups/'); // Copy selected file to another disk
FluxFiles.crossMove('r2', 'archive/'); // Move selected file to another disk
FluxFiles.aiTag(); // AI-tag selected image
FluxFiles.setLocale('vi'); // Change language
FluxFiles.close(); // Close file manager
FluxFiles.updateToken('eyJ...'); // Push new token (e.g. background refresh)
FluxFiles.on('FM_READY', (payload) => {
console.log('Version:', payload.version);
console.log('Capabilities:', payload.capabilities);
});
FluxFiles.on('FM_SELECT', (file) => {
// Single file: { url, key, name, path, size, disk, meta, variants }
// Multiple: [{ url, key, ... }, ...]
});
FluxFiles.on('FM_EVENT', (event) => {
// event.event: 'upload:done', 'trash:done', 'rename:done',
// 'move:done', 'cross-copy:done', 'cross-move:done',
// 'folder:created', 'crop:done', 'ai_tag:done',
// 'watermark:done', 'import:done', 'restore:done', …
console.log(event.event, event.key);
});
FluxFiles.on('FM_CLOSE', () => {
console.log('Closed');
});
// Token refresh events
FluxFiles.on('FM_TOKEN_REFRESH', (ctx) => {
console.log('Token refresh requested:', ctx.reason);
});
// Unsubscribe
const unsub = FluxFiles.on('FM_SELECT', handler);
unsub(); // remove listener
On a 401 the iframe asks the host for a fresh token (FM_TOKEN_REFRESH → your
onTokenRefresh callback returns a new JWT → the failed request retries). Concurrent
401s are coalesced; after 2 failed refreshes (or a 10s timeout) the auth-expired
screen shows; auth:refreshed/auth:expired fire via FM_EVENT. To refresh
proactively, call FluxFiles.updateToken(newJwt) (e.g. on a timer).
For server-rendered pages, use the PHP helper to generate the iframe HTML:
// Composer: vendor/fluxfiles/fluxfiles/embed.php
// Monorepo clone: packages/core/embed.php
require_once 'path/to/fluxfiles/embed.php';
// Generate token
$token = fluxfiles_token(
userId: (string) $currentUser->id,
perms: ['read', 'write', 'delete'],
disks: ['local', 'r2'],
prefix: 'users/' . $currentUser->id . '/'
);
// Render inline embed
echo fluxfiles_embed(
endpoint: 'https://fm.yourdomain.com',
token: $token,
disk: 'local',
mode: 'browser',
width: '100%',
height: '600px'
);
TypeScript declarations are included in fluxfiles.d.ts:
import type { FluxFilesInstance, FluxFilesOpenOptions, FluxFile } from './fluxfiles';
Disks are defined in config/disks.php:
return [
// Local filesystem
'local' => [
'driver' => 'local',
'root' => __DIR__ . '/../storage/uploads',
'url' => '/storage/uploads', // public URL prefix
],
// AWS S3 (or any S3-compatible — set AWS_ENDPOINT for MinIO / DO Spaces)
's3' => [
'driver' => 's3',
'region' => $_ENV['AWS_DEFAULT_REGION'],
'bucket' => $_ENV['AWS_BUCKET'],
'key' => $_ENV['AWS_ACCESS_KEY_ID'],
'secret' => $_ENV['AWS_SECRET_ACCESS_KEY'],
'endpoint' => $_ENV['AWS_ENDPOINT'] ?? '', // empty = native AWS S3
],
// Cloudflare R2 (S3-compatible)
'r2' => [
'driver' => 's3',
'endpoint' => 'https://' . $_ENV['R2_ACCOUNT_ID'] . '.r2.cloudflarestorage.com',
'region' => 'auto',
'bucket' => $_ENV['R2_BUCKET'],
'key' => $_ENV['R2_ACCESS_KEY_ID'],
'secret' => $_ENV['R2_SECRET_ACCESS_KEY'],
],
];
Note: R2 uses the S3-compatible API. ACL operations are not supported — FluxFiles automatically disables
retain_visibilityfor endpoint-based disks.
A 3rd disk driver (after local and S3/R2) that turns FluxFiles into a file
manager for an SFTP server — a VPS, shared host, or anything behind SSH. Set the
SFTP_* env vars (see Environment Variables) and an
sftp disk is registered automatically; the tree UI is disk-agnostic, so listing,
upload, rename, move and delete just work.
SFTP_HOST=vps.example.com
SFTP_USERNAME=deploy
SFTP_PASSWORD=… # password auth, OR key auth below
SFTP_PRIVATE_KEY=<PEM/OpenSSH key> # RSA or ED25519 (key wins when both are set)
SFTP_PRIVATE_KEY_PASSPHRASE=… # set if the key is passphrase-protected
SFTP_ROOT=/var/www
Key auth: password OR private key (key wins when both are set). The key may be RSA or ED25519, in PEM or OpenSSH format, and may be passphrase-protected — set
SFTP_PRIVATE_KEY_PASSPHRASE(phpseclib decrypts it at connect time). The same works for BYOB tokens: passprivate_key+private_key_passphrasein the disk config (they're AES-256-GCM-encrypted into the JWT, never stored).
How it differs from S3 (by design):
/api/fm/stream link (the same mechanism gated local
media uses). So uploads/downloads use app-server bandwidth; keep per-file size
modest with the token's max_upload claim (e.g. 100–200 MB). HTTP Range isn't
advertised for SFTP (browse/edit, not media seeking).GET /api/fm/chmod?disk=&path= reads the octal mode; POST with
{disk, path, mode} sets it (gated by the allow_chmod claim, default true).FLUXFILES_SSRF_ALLOW_HOSTS (legit when the box is on your own
private network / a VPN'd VPS).SFTP_HOST_FINGERPRINT (and SFTP_REQUIRE_HOST_KEY=true
to fail closed without it); SFTP_STRICT_ALGORITHMS=true refuses legacy ciphers —
see docs/reference/CONFIG.md §2.14.fluxfiles_byob_token($u, ['my-vps' => ['driver' => 'sftp', 'host' => …, 'username' => …, 'password' => …]]), or use 'private_key' => … (RSA/ED25519)
with an optional 'private_key_passphrase' => …. Credentials are
AES-256-GCM-encrypted into the JWT and SSRF-checked on decode, never stored./api/fm/stream route that gated
local media uses (it streams bytes through the app rather than a static/presigned
URL) — and, like /api/fm/stream generally, it is proxied by both the Laravel
and WordPress adapters, not just the core-standalone/Docker app.When the disk is SFTP, FluxFiles can open a built-in terminal (xterm.js) so
you can run shell commands on the box — git pull, composer install,
php artisan migrate, chmod -R, tar, … — without leaving the file manager.
It runs over the same SSH connection the SFTP disk already uses.
// opt-in: a trusted admin token for one tenant's site, with the terminal on
$token = fluxfiles_token([
'user' => 'admin-7',
'perms' => ['read', 'write'],
'disks' => ['sftp'],
'prefix' => 'sites/acme/',
'ttl' => 1800,
'claims' => ['allow_terminal' => true],
]);
POST /api/fm/terminal {disk, cmd, cwd?, confirm?} runs one command per
request over phpseclib's SSH exec, merges stdout+stderr, and threads the
resulting working directory back so cd/pwd persist across commands. It's a
stateless command-runner, not a PTY — perfect for build/deploy/maintenance
commands, but interactive TUIs (vim, top, nano) and mid-command stdin
don't work (run nano-free edits via the code editor).
With SFTP_MULTIPLEX=true (key auth) consecutive commands reuse one OpenSSH
ControlMaster session instead of a fresh handshake each.assets/vendor/xterm/ (no CDN) so it loads offline / when a CDN is blocked.terminal_pty_url (free, opt-in)The command-runner can't do interactive TUIs. If you want a true PTY, run any
PTY-over-WebSocket server on your own box — ttyd,
gotty, or wetty,
all free/OSS — and point FluxFiles at it with the terminal_pty_url claim:
$token = fluxfiles_token([
'user' => 'admin-7',
'perms' => ['read', 'write'],
'disks' => ['sftp'],
'prefix' => 'sites/acme/',
'claims' => [
'allow_terminal' => true,
'terminal_pty_url' => 'https://term.acme.com/', // your self-hosted ttyd/gotty/wetty
],
]);
When set (and allow_terminal is on), the terminal panel embeds that server for a
full interactive shell instead of the command-runner. Empty (default) → the
command-runner. This is free/core — a config toggle, never a paid module. The PTY
server runs on the customer's own VPS (the box they manage), so FluxFiles' stateless
core hosts nothing; that server owns its own auth. Only http(s) URLs are accepted.
🔒 This grants shell access as the SSH user — it is NOT confined to the disk's
root. Use a least-privilege SSH account; a shared orroot-level one gives anyallow_terminaltoken the whole server. Guardrails: opt-in claim (default off),writeperm required, per-command audit, a server kill-switch (FLUXFILES_TERMINAL_DISABLED), and an accident guard (not a security boundary) against catastrophic commands. Full threat model, why the dangerous-command list is deliberately small, and the shared-hosting/no-shell fallback:docs/guides/FEATURES.md#ssh-terminal--security-model.
Adapter support (terminal): /api/fm/terminal is proxied by both the
Laravel and WordPress adapters (FluxFilesController::terminal() /
FluxFilesApi::handleTerminal(), requiring core ≥ 0.2.46) as well as by
embed.php, @fluxfiles/node, and the standalone Laravel mode — so the
allow_terminal claim works the same way regardless of which adapter mints the
token. React/Vue inherit whatever core their host points at. Runs against the
standalone PHP server or the Docker image, and against either proxy adapter.
A narrower, safer sibling of the SSH terminal above: instead of an arbitrary
command, POST /api/fm/git-deploy runs a fixed-shape deploy against a repo
you (the operator) configure ahead of time — never from the request body.
// opt-in: lets this token redeploy one pre-configured repo on the SFTP box
$token = fluxfiles_token([
'user' => 'admin-7',
'perms' => ['read', 'write'],
'disks' => ['sftp'],
'prefix' => 'sites/acme/',
'ttl' => 1800,
'claims' => [
'allow_git_deploy' => true,
'git_deploy_path' => '/var/www/acme', // operator-set — never from the request
'git_deploy_branch' => 'main', // optional — omit to `pull --ff-only` the current branch
'git_deploy_hooks' => false, // default false — hooks run with core.hooksPath=/dev/null
],
]);
POST /api/fm/git-deploy {disk} deploys git_deploy_path on the SFTP
box: with git_deploy_branch set it runs a destructive
fetch --prune + reset --hard origin/<branch> (matches the pinned branch
exactly, discarding local changes); without it, a safer
pull --ff-only that refuses on a diverged history instead of merging.core.hooksPath=/dev/null) — set
git_deploy_hooks => true only if you trust the repo's hook scripts to run on
your server..git/config carrying any key outside
a known-safe allowlist (a planted filter/credential/core.sshCommand/include,
…) is refused with 409 git_deploy_unsafe_repo before anything runs, and FluxFiles
never lets a token write into a .git/ directory on an SFTP disk.allow_git_deploy claim (default off) — never bundled with
allow_sftp/allow_terminal, so granting SFTP or the command-runner doesn't
silently grant deploy too. A mkdir-based lock on the repo serializes
concurrent deploys, and requests are rate-limited on their own bucket
(FLUXFILES_GIT_DEPLOY_RATE_LIMIT, default 5/min).🔒 Git deploy is deliberately a subset of the SSH terminal, not a second arbitrary-shell door — the repo path/branch/hooks-enabled are all operator-controlled claims. It still lands a non-atomic deploy onto a (possibly live) webroot — see
docs/security/GIT-DEPLOY-SECURITY-REVIEW.mdfor the full threat model, including what's documented-but-not-solved.
Adapter support: proxied by both Laravel (FluxFilesController::gitDeploy())
and WordPress (FluxFilesApi::handleGitDeploy()), requiring core ≥ 0.2.81.
Edit a file's text content in place — the cPanel "Edit" use case for
wp-config.php, .env, nginx.conf, deploy.sh, etc. Works on any disk
(local / S3 / R2 / SFTP) and is proxied by Laravel/WordPress.
GET /api/fm/content?disk=&path= → { path, content, size } (read perm;
binary file → 415, file > 5 MB → 413).PUT /api/fm/content {disk, path, content} overwrites an existing file
(write perm; missing file → 404, oversize → 413).Ctrl/⌘+S saves)..env, .htaccess) are refused by name unless the token also has
show_hidden.⚠️
allow_code_editdefaults tofalse. Editing config/executable files is powerful — a token that can rewritewp-config.php,.htaccess, or a deploy script is effectively code execution on that storage. Only mintallow_code_edit => truefor trusted admin tokens, ideally narrowed with aprefixand/or anextallowlist (the editor still honoursext—allowed_ext). The always-on dangerous-extension upload block is deliberately not applied to editing (that's the whole point), so the claim is the lock.
// trusted admin token — can edit configs under this tenant's prefix only
$token = fluxfiles_token([
'user' => 'admin-7',
'perms' => ['read', 'write'],
'disks' => ['sftp'],
'prefix' => 'sites/acme/',
'maxUploadMb' => 50,
'claims' => ['allow_code_edit' => true, 'show_hidden' => true],
]);
Download a multi-file/-folder selection as a zip, or extract a zip in place — both synchronous (no queue/worker — FluxFiles keeps no server-side job state).
POST /api/fm/zip {disk, paths[], name?} streams a .zip of the selected
files and folders (recursive), constant-memory via ZipStream (each entry
piped through Flysystem, so it works on local/S3/R2/SFTP). Needs read perm +
allow_download; a pre-flight rejects (413) anything over zip_max_mb /
zip_max_files before a byte is streamed. Proxied by the Laravel and
WordPress adapters too.POST /api/fm/extract {disk, path, dest?} extracts a .zip in place
(default dest: a folder named after the archive). Returns JSON and is proxied by
Laravel/WordPress. Two passes: every entry is validated before the first write;
then entries are scanned and written one by one — so a virus/DLP hit or storage
error mid-way can leave the earlier entries written (it is not a transaction).🔒 Extract is hardened against the classic archive attacks: zip-slip (absolute /
../ drive-letter entries rejected), zip-bomb (uncompressed size + entry-count caps — and the bytes actually inflated are counted, so a lying header can't sneak past — plus the per-token storage quota on the total), and the always-on dangerous-extension block (a zip can't smuggle a.php/.sh, or a.git/directory onto an SFTP disk) plus the token'sextallowlist. A structural violation in any entry aborts before anything is written.
In the UI, a Download ZIP button appears in the selection toolbar (and on a
folder), and an Extract action on .zip files. Claims: allow_zip /
allow_extract (default true), zip_max_mb (1024), zip_max_files (10000).
Add a new entry to config/disks.php:
'minio' => [
'driver' => 's3',
'endpoint' => 'http://minio.local:9000',
'region' => 'us-east-1',
'bucket' => 'my-bucket',
'key' => 'minioadmin',
'secret' => 'minioadmin',
],
Then include 'minio' in the JWT disks claim.
Any S3-compatible object store works today through the s3 driver — just set
endpoint (and region). No new dependency or driver is needed; the same
chunked-upload / presign / visibility logic applies.
| Provider | endpoint example | Notes |
|---|---|---|
| DigitalOcean Spaces | https://nyc3.digitaloceanspaces.com | region = the Space's region (e.g. nyc3) |
| Backblaze B2 | https://s3.us-west-004.backblazeb2.com | Use the S3-compatible endpoint from the bucket page |
| Wasabi | https://s3.us-east-1.wasabisys.com | Region-specific endpoint |
| MinIO (self-hosted) | http://minio.local:9000 | region can be any value (e.g. us-east-1) |
| Cloudflare R2 | https://<account>.r2.cloudflarestorage.com | First-class — see above (no presigned POST: use PUT or chunked upload) |
'spaces' => [
'driver' => 's3',
'endpoint' => 'https://nyc3.digitaloceanspaces.com',
'region' => 'nyc3',
'bucket' => $_ENV['DO_SPACES_BUCKET'],
'key' => $_ENV['DO_SPACES_KEY'],
'secret' => $_ENV['DO_SPACES_SECRET'],
],
Like R2, endpoint-based disks have ACL operations disabled automatically (
retain_visibilityoff). For native Google Cloud Storage or Azure Blob (non-S3 APIs), a dedicated Flysystem adapter +DiskManagerdriver would be needed — not currently bundled.
Users can connect their own S3/R2 buckets (or SFTP servers — see SFTP disk). Credentials are AES-256-GCM encrypted inside the JWT (derived key via HKDF, separate from signing key):
$token = fluxfiles_byob_token(
userId: 'user-123',
byobDisks: [
'my-bucket' => [
'driver' => 's3',
'region' => 'us-west-2',
'bucket' => 'user-personal-bucket',
'key' => 'AKIAIOSFODNN7EXAMPLE',
'secret' => 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
],
],
perms: ['read', 'write', 'delete'],
ttl: 1800 // SECONDS — shorter lifetime recommended for BYOB tokens (1800 = 30 min)
);
Security: BYOB allows the s3 and sftp drivers only — the local driver is blocked to prevent path traversal. Custom endpoints and SFTP hosts are SSRF-checked on every decode.
Copy or move files between any two disks (e.g., local to R2):
# API
POST /api/fm/cross-copy
{"src_disk":"local","src_path":"photo.jpg","dst_disk":"r2","dst_path":"backups/photo.jpg"}
# SDK
FluxFiles.crossCopy('r2', 'backups/');
FluxFiles.crossMove('s3', 'archive/');
Metadata and image variants are transferred together. Quota is checked on the destination disk.
📖 Full config reference:
docs/reference/CONFIG.mdis the single source of truth for all JWT claims (with types/defaults) and server env vars. Bought a paid edition?docs/guides/ACTIVATE.mdcovers installing the module, where the licence key goes on each platform, and which of the three gates a given error is telling you about. Self-hosting? Seedocs/guides/DEPLOYMENT.mdfor web-server config. Selling FluxFiles rather than using it?docs/guides/OPERATIONS.mdis the "going live" runbook (Polar, licence server, module hosting). The tables below cover the common ones; mint everything in one options object —fluxfiles_token(['user' => …, 'claims' => […]])— whereclaimsis the escape hatch for any claim by its raw name. Want a ready-made config for a specific vertical (agency client sites, photo studio delivery, SaaS multi-tenant, ...) instead of assembling claims from scratch? Seedocs/guides/INDUSTRY-PRESETS.md. Want a quick permission bundle instead of picking claims one by one? The mint-timeroleclaim (viewer/editor/admin/superadmin) expands to a preset set of claims — seedocs/design/ACL-ROLE-PRESETS-DESIGN.md.
{
"sub": "user-123",
"iat": 1710500000,
"exp": 1710503600, // Unix seconds
"jti": "a1b2c3d4e5f6",
"perms": ["read", "write", "delete"],
"disks": ["local", "s3", "r2"],
"prefix": "user-123/",
"max_upload": 10, // MB per file
"allowed_ext": ["jpg", "png", "pdf"],
"max_storage": 1000, // MB total (0 = unlimited)
"byob_disks": {}
}
The full claim list (100+, with types/defaults/units) and every fluxfiles_token()
parameter live in docs/reference/CONFIG.md — kept as the single copy so it
can't drift from what the code actually reads. The handful used in every app:
sub, perms, disks, prefix, max_upload, allowed_ext, ttl (a
fluxfiles_token() parameter, not a claim — it sets exp = iat + ttl, in seconds).
POST /api/fm/import-url fetches a public URL server-side and saves it like a
normal upload (sharing the quota / dedup / variants / AI-tag pipeline). It is
off by default — a token must carry allow_url_import to use it, so the
server can never be abused as an open HTTP proxy. There is nothing to install
or configure server-side to enable it per tenant: like every other limit, it
lives in the JWT. Enabling it is the only step — just set the import claims
when you mint the token.
// Core (standalone) — options-array form; `import` is the URL-import claim group
$token = fluxfiles_token([
'user' => 'user-42',
'perms' => ['read', 'write'],
'disks' => ['local'],
'prefix' => 'users/42',
'import' => [
'allow_url_import' => true, // required — enables the feature
'max_import_mb' => 20, // optional — cap per import (MB)
'import_url_allowlist' => ['*.unsplash.com', 'cdn.example.com'], // optional
'import_path' => 'imports', // optional — force a destination
'import_rate_limit' => 10, // optional — imports/min
'import_concurrency' => 3, // optional — max concurrent
],
]);
// Node — @fluxfiles/node (camelCase options)
const token = createToken({
userId: 'user-42', perms: ['read', 'write'],
allowUrlImport: true, maxImportMb: 20, importUrlAllowlist: ['*.unsplash.com'],
});
Same claims in the adapters — see the Enable Import from URL section in each (Laravel · WordPress · Node · Python).
Once the token allows it, call the route (the React/Vue/iframe SDKs proxy it for you):
curl -X POST "$ENDPOINT/api/fm/import-url" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"url":"https://images.unsplash.com/photo-123.jpg","path":"imports"}'
Server-side defaults (apply when a token omits the matching claim) are
optional env vars — see .env.example:
| Env var | Default | Purpose |
|---|---|---|
FLUXFILES_IMPORT_MAX_MB | 50 | Max MB per import when the token omits max_import_mb |
FLUXFILES_IMPORT_RATE_LIMIT | 10 | Imports/min when the token omits import_rate_limit |
FLUXFILES_IMPORT_TIMEOUT | 30 | Seconds per import fetch |
FLUXFILES_IMPORT_ALLOW_SVG | false | SVG import is off by default (XML can carry script) |
Security: every redirect hop is SSRF-guarded (private/loopback/link-local/ CGNAT/metadata/IPv6 targets are rejected, including decimal/hex-obfuscated IPs), the response is MIME-checked by magic bytes against a deny-list, and the body is streamed with a hard size cap.
import_url_allowlistfurther restricts which hosts may be fetched.
| Permission | Allows |
|---|---|
read | List files, view metadata, search, download (presign), get quota |
write | Upload, rename, copy, move, mkdir, save metadata, crop, AI-tag |
delete | Delete files and directories (incl. trash, purge, restore) |
audit | Activity log, compliance scorecard, and — with the matching paid module — audit export/verify, integrity check, legal hold, retention runs |
FluxFiles provides two layers of user isolation that can be used independently or combined:
Layer 1: Path prefix (recommended) — Each user gets a unique prefix so they physically cannot see or touch other users' files:
$token = fluxfiles_token(
userId: $user->id,
perms: ['read', 'write', 'delete'],
prefix: 'users/' . $user->id . '/', // user 42 → users/42/*
);
Layer 2: Owner-only mode — When multiple users share the same prefix (e.g., a shared team folder), owner_only restricts destructive operations (delete, rename, move, crop) to the user who uploaded the file:
$token = fluxfiles_token(
userId: $user->id,
perms: ['read', 'write', 'delete'],
prefix: 'team/shared/',
ownerOnly: true, // can only delete/rename own files
);
| Scenario | Use |
|---|---|
| Each user has their own space | prefix: 'users/{id}/' |
| Shared folder, users can only manage own files | prefix: 'shared/' + owner_only: true |
| Admin with full access | prefix: '' (no prefix, no owner_only) |
| Shared folder, everyone can manage all files | prefix: 'shared/' (no owner_only) |
Warning:
owner_onlyis a safety layer, NOT a replacement forprefixisolation. Always useprefixto scope users to their own directory.owner_onlyonly protects against delete/rename/move — it does NOT prevent users from reading or downloading each other's files.
Note: Files uploaded before
owner_onlywas enabled lack ownership metadata and will be accessible to all users unless you import them with owner assignment or readonly mode.
If a disk or bucket already contains files before FluxFiles is installed, run the indexer so existing content becomes searchable and folder search works. The default mode is index-only: it writes FluxFiles index files under _fluxfiles/ but does not modify source files/objects.
cd packages/core
bin/fluxfiles index --disk=local --path=users/42 --dry-run
bin/fluxfiles index --disk=local --path=users/42
Useful options:
| Option | Effect |
|---|---|
--owner=user-123 | Persist ownership metadata so owner_only can protect imported files |
--readonly | Persist an internal owner that no normal user matches |
--hash | Compute SHA-256 hashes for duplicate detection |
--variants | Generate WebP image variants during indexing |
--overwrite | Re-index files that already have index entries |
--persist-metadata | Write local sidecars / S3 object metadata instead of index-only |
Laravel proxy mode also exposes the same workflow:
php artisan fluxfiles:seed --disk=local --path=users/42 --dry-run
php artisan fluxfiles:seed --disk=local --path=users/42 --owner=user-123
FluxFiles is stateless — there is no per-tenant configuration stored on the server. Instead, each token is the configuration for that tenant. Your backend mints a short-lived JWT per customer, and the claims are enforced server-side on every request. So each tenant gets its own storage location, limits and permissions with no config files and no restarts — you just issue a different token.
| What you set per tenant | Claim |
|---|---|
| Where their files live (their own sub-folder) | prefix |
| Which storage they use — or their own bucket | disks / byob_disks |
| Max size of each uploaded file | max_upload |
| Total storage quota | max_storage |
| Max number of files | max_files |
| Allowed file types | allowed_ext |
| What they're allowed to do | perms, owner_only |
| Whether AI auto-tags their uploads | ai_auto_tag |
| Their API rate limits | rate_read / rate_write |
| Their image variant sizes | variants |
| Whether photo metadata (GPS) is stripped | strip_exif |
Drive the numbers from your plans / database — e.g. a free tier vs. a pro tier:
// Free tier — 5 MB/file, images only, 500 MB quota, 200 files
$token = fluxfiles_token(
userId: "tenant_{$tenant->id}",
perms: ['read', 'write', 'delete'],
disks: ['local'],
prefix: "tenant_{$tenant->id}/",
maxUploadMb: 5,
allowedExt: ['jpg', 'jpeg', 'png', 'webp'],
ttl: 3600,
maxStorageMb: 500,
maxFiles: 200,
);
// Pro tier — 100 MB/file, any safe type, 50 GB quota, unlimited files
$token = fluxfiles_token(
userId: "tenant_{$tenant->id}",
perms: ['read', 'write', 'delete', 'audit'],
disks: ['s3'],
prefix: "tenant_{$tenant->id}/",
maxUploadMb: 100,
allowedExt: null, // all non-dangerous types
ttl: 3600,
maxStorageMb: 51200, // 50 GB
maxFiles: 0, // unlimited
);
Issuing tokens from a JS backend instead?
@fluxfiles/node'screateToken()takes the same claims (and so does Python'sfluxfiles-token). The Laravel adapter wraps this asFluxFiles::tokenForUser([...])— see its README's Per-tenant configuration.
Bring Your Own Bucket (BYOB) is the strongest isolation: give each tenant
their own S3/R2 bucket. The credentials are AES-256-GCM encrypted inside the
token (byob_disks) and decrypted only at runtime, so you never store the
tenant's data or their keys. See Storage Disks.
Still global (not per-token): the AI provider + API key (security — keys stay server-side; only the on/off
ai_auto_tagis per tenant) and the always-blocked dangerous-extension list. Everything else above — including image variant sizes, AI auto-tag and rate limits — is now per tenant.
Base path: /api/fm/. All responses follow { "data": {...}, "error": null },
or { "data": null, "error": "..." } on error with the matching HTTP status.
| Method | Path | Description |
|---|---|---|
GET | /list?disk=&path= | List directory contents |
POST | /upload | Upload file (multipart) |
POST | /tus (+ HEAD/PATCH/DELETE /tus/<id>) | Resumable upload (tus 1.0, standalone core) |
POST | /trash / DELETE /delete | Soft-delete (restorable) / permanent delete |
POST | /rename, /move, /copy, /mkdir | Standard file ops |
POST | /cross-copy, /cross-move | Copy/move between disks |
POST | /presign | Presigned GET/PUT/POST URL |
GET | /duplicates?disk= | Duplicate finder |
Full reference — every route (trash, zip/extract, code editor, chmod,
metadata, search, quota, audit, Bucket Doctor, chunk upload, tus, paid modules),
request/response bodies, and the upload/duplicate-detection JSON shapes:
docs/guides/API.md.
Tokened media endpoints (query-string token, for
<img>/<video>):GET /img?token=&width=&quality=&format=(on-demand WebP/AVIF, see On-demand WebP) andGET /stream?token=(gated private media, including SFTP-disk serving). Both mint only when a stream secret is wired — true for core-standalone/Docker always, and now for both the Laravel and WordPress proxy adapters too, so both routes are fully proxied, not core-only.
FluxFiles ships an official adapter for every major stack. Each has its own README with full examples, props/options, and build steps — this section is the install + the gist; follow the package link for details.
| Adapter | Install | What it does | Docs |
|---|---|---|---|
| Node (token SDK) | npm i @fluxfiles/node | Mint JWTs (+ encrypted BYOB) from any Node backend — byte-compatible with the PHP core, zero deps | packages/node |
| Python (token SDK) | pip install fluxfiles-token | Mint JWTs (+ encrypted BYOB) from any Python backend (Django, FastAPI, Flask) — byte-compatible with the PHP core | packages/python |
| MCP server | npx -y @fluxfiles/mcp | Let an AI agent (Claude Desktop/Code, any MCP client) list, search and read files through a scoped JWT; write/trash tools only behind env flags | packages/mcp |
| Laravel | composer require fluxfiles/laravel | <x-fluxfiles> Blade component + FluxFiles::token() facade + publishable config + route proxy | packages/laravel |
| WordPress | release ZIP (bundles vendor/) | Plugin: Settings page, [fluxfiles] shortcode, Classic-editor media button, REST API at /wp-json/fluxfiles/v1/ | packages/wordpress |
| React | npm i @fluxfiles/react | <FluxFiles> / <FluxFilesModal> + useFluxFiles() hook (TypeScript) |
The token-minting adapters (Node, Python, Laravel, WordPress) emit the same JWT as
the PHP fluxfiles_token() helper — identical claims + BYOB encryption. The
browser adapters (React, Vue, the editor plugins) embed the core UI over the
same iframe + postMessage SDK, so anything the standalone UI does, they do too.
Minimal example — mint a scoped token server-side, then embed/pick:
// Laravel controller
$token = FluxFiles\Laravel\FluxFilesFacade::token((string) auth()->id(), [
'perms' => ['read', 'write'],
'disks' => ['local', 's3'],
'prefix' => 'users/' . auth()->id() . '/',
]);
<x-fluxfiles disk="s3" mode="browser" height="600px" /> {{-- Blade --}}
// React (same token)
<FluxFiles endpoint="https://fm.example.com" token={token} disk="s3"
onSelect={(file) => console.log(file.url)} style={{ height: 600 }} />
16 languages built in. Translation files in lang/*.json.
| Code | Language | Dir | Code | Language | Dir | |
|---|---|---|---|---|---|---|
en | English | LTR | pt | Portugues | LTR | |
vi | Tieng Viet | LTR | it | Italiano | LTR | |
zh | Chinese | LTR | ru | Русский | LTR | |
ja | Japanese | LTR | th | ไทย | LTR | |
ko | Korean | LTR | hi | हिन्दी | LTR | |
fr | Francais | LTR | tr | Turkce | LTR | |
de | Deutsch | LTR | nl | Nederlands | LTR | |
es | Espanol | LTR | ar | Arabic | RTL |
Locale priority: SDK locale option > URL param (?locale= or ?lang=) > FLUXFILES_LOCALE env > en
Default is English. No auto-detection from browser. To use a different language, set it explicitly.
Set locale via URL (standalone mode):
/public/index.html?token=...&locale=vi
/public/index.html?token=...&lang=ja
Set locale via SDK:
FluxFiles.open({ locale: 'vi', ... });
// or change at runtime:
FluxFiles.setLocale('ja');
Set locale server-wide (env):
FLUXFILES_LOCALE=vi
Add a new language: See packages/core/lang/CONTRIBUTING.md — copy packages/core/lang/en.json, translate, submit PR.
| Protection | How |
|---|---|
| JWT HS256 | Algorithm pinned — prevents algorithm confusion attacks; typed share/intake/stream/img tokens are refused as a Bearer token |
| CORS whitelist | Only configured origins receive Access-Control-Allow-Origin |
| Origin validation | POST/PUT/DELETE requests are rejected if Origin header doesn't match whitelist |
| postMessage origin | SDK and iframe validate e.origin and the source window to prevent cross-origin message injection |
| Path traversal | .. and . segments stripped, null bytes removed, paths normalized before use |
| Extension blocking | Dangerous extensions (php and its handler-mapped spellings, exe, sh, bat, SSI, etc.) and server-config names (.user.ini, web.config) blocked, even in double-extension filenames (e.g. shell.php.jpg) |
| Extension immutability | A file's extension is fixed at upload. Rename/move/copy/cross-disk cannot change it (→ 400 ext_changed), and move/copy re-check the allowedExt policy (→ 403 ext_not_allowed) — so a scoped token can't relocate a file out of its allowed types. Folders are unaffected. |
| Path scoping | Users confined to their prefix directory — cannot access files outside scope |
| Owner-only mode | owner_only JWT claim restricts delete/rename/move to files the user uploaded |
| System path protection | _fluxfiles/ and _variants/ directories (matched case-insensitively) blocked from list/delete/rename/move, hidden in file listing, and excluded from file/folder search results; writes into .git/ are refused on SFTP disks |
| Hidden files | Without show_hidden, dotfiles are hidden from listings and refused by name on content/meta/presign/zip/chmod |
| Disk whitelist | Per-token disk access — users can only access disks listed in JWT |
The JWT is a bearer credential — anyone holding it has the access it grants. How you deliver it to the file manager matters:
postMessage (FM_CONFIG), so it never appears in a URL. Use this for production./public/index.html?token=<JWT>. A token in the URL
can leak through browser history, server access logs, and the Referer
header of any outbound request the page makes. Treat standalone URLs as
sensitive and prefer it for local dev / short-lived links only.Mitigations regardless of delivery:
ttl is in seconds; issue the smallest lifetime that
fits the session (e.g. 900–3600). Expired tokens return 401 and the SDK
triggers your onTokenRefresh to mint a fresh one.prefix, the minimum perms, and disks so a leaked
token grants as little as possible.?token=) at your proxy.Gated media stream tokens (
FLUXFILES_LOCAL_PRIVATE=true): an<video>/<audio>element can't send anAuthorizationheader, so the per-file/api/fm/streamtoken rides the query string — and like any URL token, it can leak via access logs /Referer. It is deliberately scoped to a single file with a short TTL (stream_token_ttl, default 1h), so a leak exposes only that one file, briefly. Lowerstream_token_ttlfor a tighter window, serve over HTTPS, and avoid logging full URLs. Its rate bucket (FLUXFILES_STREAM_RATE_LIMIT, 300/min per token) is deliberately generous, because HTTP Range seeking fires many requests per playback — the single-file scope + short TTL is the real control.
FLUXFILES_SECRET to a cryptographically random string (min 32 chars)FLUXFILES_ALLOWED_ORIGINS to your production domain(s)postMessage (not ?token= URLs) in production; keep ttl short.env, vendor/, storage/ runtime state (rate_limit.json, tus/, ssh-sockets/) and _fluxfiles/storage/ directory permissions to 755, .env to 600.env with real credentials to gitFirst:
composer install -d packages/core— the PHP tests loadpackages/core/vendor/autoload.php.
# Core PHP — unit + integration suite
for f in packages/core/tests/unit/*.php packages/core/tests/integration/*.php; do php "$f"; done
# API e2e (boots a dev server) + env-gated live S3/R2 (LocalStack/AWS/R2)
cd packages/core && php -S 127.0.0.1:8080 router.php &
bash packages/core/tests/e2e/test-api.sh
# Self-booting HTTP e2e (each starts its own php -S)
for f in packages/core/tests/e2e/*-http.php; do php "$f"; done
# Browser e2e (Playwright drives the real UI in chromium)
cd packages/core/tests/browser && npm install && npx playwright install chromium && npm test
# Framework wrappers — vitest for JS adapters, stubbed-PHP smokes for PHP adapters
cd packages/react && npm install && npm test # (and sdk / vue / ckeditor4 / tinymce / summernote / node / mcp)
php packages/laravel/tests/test-laravel-smoke.php # (and wordpress)
bash scripts/pack-smoke.sh all # verifies the published dist/types
# Docker — clean-container runs across a PHP matrix
make test PHP=8.5 # one version · make test-all # 8.1–8.5 · make up # app:8080 + S3:4566
.github/workflows/test.yml runs all of this (16 jobs, including dedicated
db-mysql/db-postgres jobs for the opt-in FLUXFILES_STORAGE_BACKEND=db mode and a
lock-file consistency check).
For the CI map, the adapter↔core floor guard, and the tag→registry release flow, see
docs/reference/ARCHITECTURE.md.
Only two are required — everything else has a working default:
| Variable | Required | Default | Description |
|---|---|---|---|
FLUXFILES_SECRET | Yes | — | JWT signing secret (min 32 chars) |
FLUXFILES_ALLOWED_ORIGINS | Yes | — | Comma-separated CORS origins |
Storage-disk credentials (AWS_*, R2_*, SFTP_*) are covered in
Storage Disks above, next to the config that reads them. Everything
else — rate limits, local-media privacy, URL import, terminal, AI tagging, and more —
is documented with its default in docs/reference/CONFIG.md.
Contributing? See
docs/reference/ARCHITECTURE.mdfor how the engine and adapters relate, the three integration patterns, and the publish flow.
FluxFiles/
├── packages/
│ ├── core/ # Composer package: fluxfiles/fluxfiles
│ │ ├── api/ # PHP backend (API router + core classes)
│ │ ├── assets/ # UI JS/CSS served by core
│ │ ├── config/ # disk definitions
│ │ ├── db/ # migrations for the opt-in DB backend
│ │ ├── lang/ # 16 translations
│ │ ├── public/ # iframe entrypoint + share/intake pages
│ │ ├── storage/ # local uploads + runtime files (gitignored)
│ │ └── tests/ # test suite
│ ├── laravel/ # Composer package: fluxfiles/laravel
│ ├── wordpress/ # WP plugin
│ ├── react/ # npm: @fluxfiles/react (TypeScript)
│ ├── vue/ # npm: @fluxfiles/vue (TypeScript)
│ ├── ckeditor4/ # CKEditor 4 plugin
│ ├── tinymce/ # TinyMCE 4/5 plugin
│ ├── summernote/ # Summernote plugin
│ ├── node/ # npm: @fluxfiles/node (JWT minting from Node)
│ ├── mcp/ # npm: @fluxfiles/mcp (MCP server for AI agents)
│ ├── python/ # PyPI: fluxfiles-token (JWT minting from Python)
│ └── sdk/ # npm: fluxfiles (SDK)
├── docs/ # guides, reference, design + security records (see docs/README.md)
├── .env.example # Environment template
├── CHANGELOG.md
└── LICENSE # MIT
_fluxfiles/, rate_limit.json)FluxFiles creates a few internal runtime files under your storage root (for local disk: packages/core/storage/uploads/).
These files are not user content and are not meant to be edited by hand.
_fluxfiles/ directoryHidden internal folder used for indexes and logs (one per disk root, or per tenant prefix for prefix-scoped bookkeeping). The API/UI blocks system paths (_fluxfiles/, _variants/) from normal list/rename/move/delete operations.
_fluxfiles/index.json: Metadata index for files (title/alt/caption/tags) stored alongside objects. Used by GET /api/fm/search to perform fast search across the disk._fluxfiles/dirs.json: Directory index (list of folder paths). Used by GET /api/fm/search-folders so searches like test2 can match folder names without scanning the whole storage._fluxfiles/meta/ (local/SFTP disks): per-file metadata sidecars. S3/R2 keep this in object metadata instead._fluxfiles/audit.jsonl: Append-only, HMAC-chained audit log (JSON Lines). When it grows too large the oldest lines are moved to _fluxfiles/audit/archive/ (never silently dropped)._fluxfiles/trash.json + _fluxfiles/trash/: soft-deleted files and their manifest._fluxfiles/originals/: pre-watermark originals (so a burn-in can be undone)._fluxfiles/usage.json: cached usage-dashboard breakdown.shares.json, intakes.json, versions/, holds.json, fixity.json._fluxfiles/index.lock (local disks only): Lock file for safely updating indexes on local filesystem. Not used for S3/R2.If you delete _fluxfiles/, FluxFiles will generally recreate it as needed, but you may temporarily lose search results / audit history until indexes are rebuilt by new operations — and you lose trash, version history and legal holds outright.
storage/rate_limit.json (and other server-local state)Local file-backed counter used by the rate limiter (per-user read/write quotas controlled by .env: FLUXFILES_RATE_LIMIT_READ, FLUXFILES_RATE_LIMIT_WRITE).
storage/tus/ holds partial resumable uploads (swept after 24 h). The terminal's
ControlMaster sockets live in ssh-sockets/ under FLUXFILES_STORAGE_PATH when set,
otherwise the system temp dir — both server-local runtime state that must never be served.
Everything above describes the default
FLUXFILES_STORAGE_BACKEND=jsonmode. SetFLUXFILES_STORAGE_BACKEND=dbto move metadata, search/folder index, audit log, trash manifest, legal holds, quota, and the rate limiter into your own self-hosted MySQL/PostgreSQL/SQLite instead — none of those_fluxfiles/*.jsonfiles orrate_limit.jsonare created or used in that mode. File bytes always stay on your configured disk either way. Seedocs/design/DB-STORAGE-MIGRATION-DESIGN.md.
| What | Where | Notes |
|---|---|---|
| Secrets & CORS | .env | FLUXFILES_SECRET, FLUXFILES_ALLOWED_ORIGINS |
| Storage disks | packages/core/config/disks.php | Add/remove disk definitions |
| Cloud credentials | .env | AWS_*, R2_*, SFTP_* variables |
| AI tagging | .env | Provider, API key, model, auto-tag on upload |
| Branding / colors | packages/core/assets/fm.css | CSS custom properties (--ff-primary, --ff-bg, etc.) |
| UI behavior | packages/core/assets/fm.js | Alpine.js component — modify any behavior |
| SDK protocol | packages/sdk/fluxfiles.js | Event names, iframe communication |
| Token defaults | packages/core/embed.php | Default TTL, claims, signing |
| Image variants | packages/core/api/ImageOptimizer.php (or the per-token variants claim) | Change sizes (thumb/medium/large) and quality |
| Rate limits | .env | FLUXFILES_RATE_LIMIT_READ, FLUXFILES_RATE_LIMIT_WRITE |
| Translations | packages/core/lang/*.json | Edit existing or add new locale |
| Dangerous extensions | packages/core/api/FileManager.php | DANGEROUS_EXTENSIONS constant |
| Packages | packages/*/ | Core, adapters, SDK |
| Problem | Likely cause / fix |
|---|---|
403 on an otherwise-valid request | A JWT claim is scoping you out — check perms, disk, prefix, owner_only, show_hidden (dotfiles), or an allow_* feature claim. See docs/reference/CONFIG.md. |
403 legal_hold_active | The file (or a folder above/below it) is under legal hold — release the hold first. Enforcement works even without the paid module installed. |
501 calling a feature endpoint (share, versioning, webhooks, AI vision, OCR, virus scan, backup, SSO, audit export, DLP, legal hold, C2PA) | That's a paid module and isn't installed — see docs/guides/ACTIVATE.md. Everything in the Features table above not marked Paid is free/core; these are separate add-ons. |
402 on a paid-module endpoint | Module is installed but the license didn't validate — check docs/guides/ACTIVATE.md and your license server config. |
Every upload fails after enabling allow_virus_scan / allow_dlp_scan | Both are fail-closed: a missing module, licence or unreachable scanner refuses the write. Configure the engine or drop the claim. |
| Uploaded/renamed files "disappear" or don't show up where expected | Check prefix/disks claims scoping the token, and whether you're looking at the composer-install vs monorepo storage root — see packages/core/README.md. |
| Search or folder search returns stale/missing results after manually touching files on disk |
Still stuck? Check docs/reference/ARCHITECTURE.md for the runtime flow, docs/guides/API.md for route details, or open an issue.
Created and maintained by thai-pc. If you fork or redistribute, please retain the copyright notice:
Based on FluxFiles by thai-pc — https://github.com/thai-pc/fluxfiles
docs/guides/API.md| 🔒 Paid: Versioning |
| File history / rollback |
| 🔒 Paid: Share links, Intake (upload portals) | Client delivery, "send us your files" forms |
| Images | Auto WebP variants + on-demand WebP/AVIF at any size + responsive srcset | Fast, lightweight, SEO images for CMS/blog/e-commerce |
| Crop (aspect presets) | Avatars, thumbnails, hero images |
| Watermark — burn-in editor (and an opt-in serve-time overlay for selling images) | Branding assets; or protecting previews of images you sell |
| Optimize — recompress to WebP at rest + PDF compression (free/core) | Shrink storage/bandwidth without a paid module |
strip_exif (opt-in) — lossless removal of GPS/camera metadata on upload | Privacy for user-uploaded phone photos |
| Media | Inline image/video/audio/PDF preview + gated private local streaming · opt-in PDF/video poster thumbnails | Course platforms, private media, document portals |
| Admin / hosting | Code/config editor (CodeMirror), SFTP chmod, SSH terminal (opt-in), one-click Git deploy (opt-in) | cPanel-style tooling: edit .env/nginx.conf, set permissions, run git/composer, redeploy a repo |
| Metadata | Title/alt/caption/tags + full-text file & folder search · AI auto-tag (free, bring your own provider key) | Digital Asset Management, SEO, large media libraries |
| 🔒 Paid: AI vision (background removal/upscale/smart-crop, BYO key), OCR | Image clean-up, text extraction |
| Insights | Storage usage dashboard (quota + by type/folder) + duplicate finder | Show tenants their consumption, reclaim wasted space |
| Safety | Dedup (SHA-256), rate limiting, tamper-evident (HMAC-chained) audit log, quota, origin/CSRF, dangerous-ext block, SSRF + zip guards | Production hardening, baked in |
| 🔒 Paid: Audit export/verify/purge + integrity (fixity) check, DLP, Legal hold + retention rules, Virus scan, C2PA | Compliance and content-authenticity requirements |
| Storage backend | Bookkeeping (metadata/index/audit/trash/quota/rate-limit) in JSON files (default) or your own MySQL/PostgreSQL/SQLite | See docs/design/DB-STORAGE-MIGRATION-DESIGN.md |
| UI | Dark mode (auto/manual), 16 languages + RTL, responsive | Global, white-label, mobile |
| Adapters | Laravel · WordPress · React · Vue/Nuxt · CKEditor 4 · TinyMCE · Summernote · @fluxfiles/node · fluxfiles-token (Python) · @fluxfiles/mcp (AI agents) | Drop into your stack, skip the plumbing |
| 🔒 Paid: Webhooks | Event-driven integrations (at-most-once delivery) |
packages/react| Vue 3 / Nuxt 3 | npm i @fluxfiles/vue | <FluxFiles> / <FluxFilesModal> + composable; Nuxt auto-import plugin | packages/vue |
| CKEditor 4 | npm i @fluxfiles/ckeditor4 | Toolbar button + native Image-Properties "Browse" | packages/ckeditor4 |
| TinyMCE 4/5 | npm i @fluxfiles/tinymce | Toolbar button + native image-dialog file picker | packages/tinymce |
| Summernote | npm i @fluxfiles/summernote | Toolbar button (inserts <img>/<a> at the cursor) | packages/summernote |
| Permission model | Granular read, write, delete, audit checked on every operation |
| BYOB encryption | AES-256-GCM with HKDF-derived key (separate from signing key) |
| BYOB local blocked | BYOB tokens cannot use the local driver — only S3-compatible storage or SFTP |
| BYOB SSRF guard | BYOB endpoints must be http(s) and are rejected if they resolve to loopback, link-local, private/reserved ranges, or the cloud metadata IP (169.254.169.254); the validated IP is pinned to the connection |
| External tools | Ghostscript, ffmpeg, ssh, tesseract, c2patool and ClamAV run with a minimal environment — they never inherit FLUXFILES_SECRET or cloud keys; Ghostscript < 10.05.0 is refused |
| Rate limiting | Token bucket per user, file-locked (or DB-backed), configurable (default: 60 read, 10 write/min) with dedicated buckets for import, tus, terminal, git deploy, /img, /stream and public share/intake links |
| Quota enforcement | Per-user storage limits checked before upload and cross-disk copy |
| Duplicate detection | SHA-256 hash; dedupe_uploads refuses byte-identical re-uploads |
| Audit trail | Every write (and every authenticated 403) logged with user ID, action, IP, user agent; entries are HMAC-chained so edits/deletions are detectable; rotated entries are archived, not dropped |
| Presign validation | Method restricted to GET/PUT/POST; GET TTL capped at 86400 seconds, PUT/POST at 900; POST policies enforce size/key/type at the bucket |
| Gated media stream | /api/fm/stream reads disk/path from the signed token (never the query), realpath-contains the file in the disk root (symlink/traversal guard), serves only local private and SFTP disks, and forces non-inline types to download with nosniff |
| Error handling | Generic errors to client, detailed errors to server log only |
| Search XSS | HTML entities escaped before highlight <mark> tags applied |
You edited storage outside the API. Delete _fluxfiles/index.json / dirs.json to force a rebuild — see Storage Internals. |
| Rate-limit errors under normal use | Tune FLUXFILES_RATE_LIMIT_READ / FLUXFILES_RATE_LIMIT_WRITE in .env, or switch to FLUXFILES_STORAGE_BACKEND=db for DB-backed limits at scale. |
Large uploads stall or 404 on /api/fm/tus behind a load balancer | Partial tus uploads live on one node's disk — share storage/tus/ between nodes or use sticky sessions. |
| Token works standalone but not embedded via iframe/SDK | Never pass the JWT in a URL query string — adapters send it over postMessage; check FLUXFILES_ALLOWED_ORIGINS and browser console for postMessage origin mismatches. See Security. |
| SFTP disk connection fails after enabling hardening | require_host_key/strict_algorithms fail closed on legacy servers — see the SFTP disk section. |
PDF optimize returns 501 pdf_unavailable | Server is missing Ghostscript (gs), or has a version older than 10.05.0 (treated as missing) — install a current one or skip PDF optimization. |
| No PDF/video thumbnails | Poster generation is opt-in: set the media_posters claim, and install Ghostscript ≥ 10.05.0 / ffmpeg. |