Jodit File Browser and Uploader connector for Python (FastAPI)
1.2K
Python/FastAPI implementation of the Jodit File Browser and Uploader connector.
--strict)pip install "jodit-python[all]"
# or run the Docker image
docker run --rm -p 8081:8081 -v $(pwd)/files:/app/files w2fb/jodit-python
Python 3.14+. Optional features are extras:
[pdf] (generatePdf, needs the Pango system library: brew install pango on macOS, libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0 on Debian/Ubuntu)[docx] (generateDocx)[s3] (S3 storage)[azure] (Azure Blob storage)[gcs] (Google Cloud Storage)[sftp] (SFTP storage; FTP needs no extra)[all] installs them all. Without an extra the connector still works and the action that needs it answers 501 naming what to install.For development: uv (make sync installs everything).
make sync # install dependencies
make run # http://localhost:8081/ping
make menu # interactive list of commands
make demo # opens http://localhost:8080/demo/ (NO_BROWSER=1 to skip)
demo/index.html is the Jodit PRO file browser (from a CDN) talking to the connector configured by demo/config.json (CORS on, files in ./files, served by the same static server as the page). Jodit PRO needs no license key on localhost; on other hosts it shows a "Trial version" notice.
# main.py
from starlette.requests import Request
from jcpy import create_app
async def check_authentication(request: Request) -> str:
token = request.headers.get("authorization")
return "admin" if token == "Bearer secret" else "guest"
app = create_app("config.json", check_authentication=check_authentication)
uv run uvicorn main:app --port 8081
config.json overrides only what differs from the built-in defaults (camelCase keys):
{
"onlyPOST": true,
"sources": {
"uploads": {
"title": "Uploads",
"root": "/var/www/uploads",
"baseurl": "https://example.com/uploads/"
}
}
}
Jodit only reads answers sent with 200, 201 or 202, so for the Jodit FileBrowser and Uploader set "errorHttpStatus": false: errors are then sent with 200, as by the PHP connector, and Jodit shows their message (data.code keeps the status). See errorHttpStatus.
Without an explicit path the configuration is read from the CONFIG environment variable (JSON text) or the file named by CONFIG_FILE.
Access rules live in accessControl (the last matching rule wins, unlisted actions are allowed):
{
"defaultRole": "guest",
"accessControl": [
{ "role": "guest", "FILE_UPLOAD": false, "FILE_REMOVE": false },
{ "role": "admin", "path": "/private", "FILES": true }
]
}
They can also be loaded per check from code, e.g. from a database:
from jcpy import AccessControlRule, create_app
async def load_rules() -> list[AccessControlRule]:
rows = await db.fetch_rules()
return [AccessControlRule.model_validate(row) for row in rows]
app = create_app("config.json", access_control=load_rules)
{
"sources": {
"media": {
"title": "Media",
"baseurl": "https://my-bucket.s3.eu-central-1.amazonaws.com/media/",
"storageAdapter": "s3",
"s3": {"bucket": "my-bucket", "region": "eu-central-1", "prefix": "media"}
}
}
}
Without credentials the AWS default chain is used (environment, profile, instance role). MinIO, Cloudflare R2, Yandex Object Storage and others work through endpoint (plus forcePathStyle: true where needed). Other backends implement jcpy.StorageAdapter and are registered with register_storage_adapter("name", factory).
{
"sources": {
"site": {
"title": "Website files",
"baseurl": "https://www.example.com/uploads/",
"storageAdapter": "sftp",
"sftp": {
"host": "files.example.com",
"username": "editor",
"privateKeyFile": "/run/secrets/editor_ed25519",
"hostKey": "ssh-ed25519 AAAA...",
"directory": "/var/www/uploads"
}
}
}
}
storageAdapter: "ftp" with an ftp block works the same way (tls: true for FTPS), and so does "webdav" with a webdav block (url, username, password) for Apache, nginx, Nextcloud and other WebDAV servers. The SFTP host key is always checked (hostKey, knownHostsFile or the system known_hosts); get it with ssh-keyscan. Writes are atomic (a temporary file renamed over the target), and connections are pooled and reopened when they drop.
from starlette.requests import Request
from jcpy import ResolvedSources, create_app
async def resolve_sources(request: Request) -> ResolvedSources | None:
tenant = await find_tenant(request.headers.get("x-tenant-id"))
if tenant is None:
return None # static "sources" apply
return ResolvedSources(
id=f"{tenant.id}:{tenant.updated_at}",
sources={"files": tenant.source_settings},
)
app = create_app("config.json", resolve_sources=resolve_sources)
The resolver runs on every request, before authentication; the built sources are cached by id (dynamicSourcesCache: 200 tenants, 60 s by default). Use "sources": {} for an instance that only serves tenants.
Several independent instances can live in one application:
from fastapi import FastAPI
from jcpy import create_router
app = FastAPI()
app.include_router(create_router("public.json"), prefix="/public")
app.include_router(
create_router("admin.json", check_authentication=admin_auth),
prefix="/admin",
)
Complete Documentation - Full documentation with guides and API reference
Quick Links:
OpenAPI Specification:
The site is built from docs/ (make docs serves it locally, make docs-build builds it). The OpenAPI 3.1 document is generated from Pydantic models (make openapi); CI fails when it is stale. The connector itself does not serve /docs or /openapi.json: every path is an action name.
--strict, ships py.typedlinux/amd64 + linux/arm64 imageRunnable programs in examples/ (start them from the repository root, e.g. uv run python examples/basic.py):
| Example | Shows |
|---|---|
basic.py | Standalone connector from a JSON config |
cookie_auth.py | Role from a cookie |
jwt_auth.py | Role from a signed JWT (PyJWT) |
session_auth.py | Role in a server-signed session with login routes |
custom_svg.py | Custom thumbnail icons |
multi_instance.py | Two isolated connectors in one application |
s3.py | Files in an S3 bucket |
multi_tenant.py | Per-request (tenant) sources |
custom_storage.py | Custom storage adapter registered by name |
make check # lint, format, mypy --strict, OpenAPI and docs checks, tests
All caches (uv, ruff, mypy, pytest, coverage, bytecode) live in .cache/. The Makefile and the dev container set PYTHONPYCACHEPREFIX=.cache/pycache; export it yourself when running uv run ... directly.
make dev-up # dev container with hot reload on http://localhost:8081
make dev-shell # shell inside it (make check works there too)
make dev-down
make prod-up # production image (non-root, tini, healthcheck)
make prod-down
PORT overrides the published host port, e.g. PORT=9000 make prod-up. Files served by the production container live in ./files.
Open the folder in VS Code (or any IDE supporting Dev Containers) and choose Reopen in Container. The container is built from the dev stage of the Dockerfile; the virtualenv lives in a named volume, so it does not clash with a local .venv. Start the server with make dev.
Content type
Image
Digest
sha256:c3c7ca1d7…
Size
120.5 MB
Last updated
11 days ago
docker pull w2fb/jodit-python