Flask S3 Viewer is a powerful extension that makes it easy to browse S3 in any Flask application.
Project description
Flask S3 Viewer
Browse, upload, and manage Amazon S3 buckets from any Flask application.
v1.0 is a major rewrite. Modern UI (Tailwind + HTMX, dark mode), Flask 3 support, hardened path-traversal defenses, Flask extension pattern, type hints, pytest + moto test suite, GitHub Actions CI. See migration guide if upgrading from
0.x.
Highlights
- Modern UI — Tailwind CSS, HTMX-driven partial updates, light/dark mode, inline heroicons. Parent-folder (
..) row and logged-in user widget in the header. No build pipeline required for end users (CSS ships pre-built). - Optional auth — Hook framework (
auth_callback+permission_callbackwith per-action constants) plus built-in Google OAuth via the[auth]extra. Auth routes (/auth/{login,callback,logout}) live outside the namespace prefix so one redirect URI covers every viewer on the app. - Smart search — Case-insensitive substring match, Unicode-safe (Korean / Japanese / accented Latin), NFC-normalised so macOS uploads match the browser IME, scoped to the current folder, and surfaces matching child folder rows.
- Secure by default — Rejects path-traversal tokens (
..,.,//,\) at every prefix boundary. Cache directory escape blocked byrealpathcontainment, and cache files are stored as JSON with restrictive file permissions. - Flask extension pattern —
FlaskS3Viewer(app, namespace=...)auto-registers. Supports multiple buckets per app viaadd_new_one(...). Works withinit_app(app)for deferred binding. - Multi-bucket — Independent namespaces, optional per-bucket CloudFront / external
object_hostname. - Presigned uploads — Multi-file presigned POST flow for large files; default form upload also supported.
- Caching — File-system JSON cache with TTL; automatically invalidated on writes (search bypasses the cache, and authenticated listings are user-isolated).
- Tested — 203 pytest cases, ruff + mypy clean, moto-based S3 mock.
Installation
pip install flask_s3_viewer
Requires Python 3.10+, Flask 3.0+, boto3 1.34+.
Quick start
from flask import Flask
from flask_s3_viewer import FlaskS3Viewer
from flask_s3_viewer.aws.ref import Region
app = Flask(__name__)
# Auto-register. No `register()` call needed.
FlaskS3Viewer(
app,
namespace="my-bucket",
object_hostname="https://cdn.example.com", # optional CloudFront host
config={
"profile_name": "default",
"region_name": Region.SEOUL.value,
"bucket_name": "my-bucket",
"cache_dir": "/tmp/flask_s3_viewer",
"use_cache": True,
"ttl": 86400,
},
)
@app.route("/")
def index():
return "App index"
if __name__ == "__main__":
app.run(debug=True, port=3000)
Visit http://localhost:3000/my-bucket/files to browse the bucket.
Branding (title + logo)
FlaskS3Viewer(
app,
namespace="my-bucket",
title="ACME File Vault",
logo_path="/opt/acme/assets/logo.svg", # local file, auto inlined as a data: URI
# or: logo_url="https://cdn.acme.io/logo.svg",
config={...},
)
logo_path reads the file once at construction time and embeds it as a data: URI so you don't need a separate static route. logo_url accepts any browser-resolvable URL (CDN, url_for("static", filename=...), etc.). logo_path takes precedence.
Customizing templates (template_folder)
Scaffold a writable copy of the bundled templates with the CLI, edit, then point the viewer at that folder:
# Templates only (default — covers most theming needs)
flask_s3_viewer -p ./fsv-templates
# Or, fork the entire UI bundle (templates + static/css/app.css + htmx + core.js)
flask_s3_viewer -p ./fsv-templates --with-static
FlaskS3Viewer(
app,
namespace="my-bucket",
template_folder="./fsv-templates", # files here win over bundled defaults
config={...},
)
Behind the scenes the extension prepends a FileSystemLoader(template_folder) to the app's Jinja loader via ChoiceLoader, so any not-overridden template (e.g. error.html when you only edited files.html) still resolves against the bundle. Other blueprints' template resolution is untouched.
Multiple buckets
viewer = FlaskS3Viewer(app, namespace="primary", config={...})
viewer.add_new_one(namespace="backups", config={...})
Each namespace gets its own URL prefix and its own configuration.
Deferred initialization
viewer = FlaskS3Viewer(namespace="my-bucket", config={...})
def create_app():
app = Flask(__name__)
viewer.init_app(app)
return app
Accessing the underlying boto3 client
from flask import current_app
from flask_s3_viewer import FlaskS3Viewer
# Inside a request:
client = current_app.extensions["flask_s3_viewer"]["my-bucket"]._s3
# Or via the helper:
client = FlaskS3Viewer.get_boto_client(app, "my-bucket")
session = FlaskS3Viewer.get_boto_session(app, "my-bucket")
Configuration
All config keys are forwarded to the underlying S3 client:
| Key | Type | Default | Notes |
|---|---|---|---|
bucket_name |
str | — | Required. |
profile_name |
str | None | None | Uses boto3 default credential chain if None. |
region_name |
str | None | None | e.g. ap-northeast-2. |
endpoint_url |
str | None | None | Custom S3 endpoint (MinIO, etc.). |
access_key |
str | None | None | Prefer profiles / IAM roles. |
secret_key |
str | None | None | |
session_token |
str | None | None | |
verify |
bool | str | False | TLS verify (or path to CA bundle). |
base_path |
str | "" |
Object key prefix scope for this viewer. |
use_cache |
bool | False | File-system JSON cache. |
cache_dir |
str | None | None | Required when use_cache=True. |
ttl |
int (seconds) | 300 | Cache time-to-live. |
timezone |
str | None | None | IANA timezone for Modified display, e.g. Asia/Seoul. If None, boto3's original timestamp string is shown. |
role_arn |
str | None | None | If set, the wrapper runs STS AssumeRole on top of the base credentials and uses the returned temporary keys (cross-account, multi-tenant). |
role_session_name |
str | None | "flask-s3-viewer" |
Identifier for the assumed session. |
external_id |
str | None | None | Forwarded to STS for cross-account roles that require it. |
duration_seconds |
int | None | None | Lifetime of the assumed credentials in seconds (15 min – 12 h). |
mfa_serial |
str | None | None | MFA device ARN/serial for STS AssumeRole. |
token_code |
str | None | None | One-time MFA code (paired with mfa_serial). |
token_code_callback |
callable | None | Alternative to token_code — called once to prompt the user. |
Constructor options:
| Option | Notes |
|---|---|
app |
Flask app (optional; pass later via init_app(app)). |
namespace |
Unique per app. Becomes the URL prefix. |
object_hostname |
External link prefix (e.g. CloudFront). |
allowed_extensions |
set[str] | None — only allow these uploads. |
upload_type |
"default" (multipart form post) or "presign". |
title |
Heading + browser tab title text. Default "Flask S3 Viewer". |
logo_url |
URL of a custom logo image (absolute, url_for(...), or /static/...). |
logo_path |
Local filesystem path to a logo image — auto-inlined as a data: URI. Takes precedence over logo_url. |
template_folder |
Directory whose Jinja files override the bundled templates (Flask ChoiceLoader pattern). Seed it via the CLI scaffold. |
AWS authentication
flask-s3-viewer defers to boto3's default credential chain, so these all work out of the box:
- Static keys (
access_key/secret_key/session_token) - Named profile (
profile_name='my-profile') — including profiles withrole_arn+source_profilein~/.aws/config(boto3 handles AssumeRole automatically) AWS_*environment variables- EC2 IMDS / ECS task role / AWS SSO cache / EKS IRSA (Web Identity OIDC) — picked up automatically when nothing else is set.
For workflows that need explicit STS AssumeRole (cross-account, multi-tenant, ad-hoc role delegation from a base credential), pass role_arn in the config:
FlaskS3Viewer(
app,
namespace="cross-account",
config={
"bucket_name": "target-bucket",
"region_name": "us-east-1",
# Base credentials come from the default chain (profile/env/IRSA).
"role_arn": "arn:aws:iam::123456789012:role/AppRole",
"external_id": "shared-secret", # optional
"role_session_name": "my-app", # default: "flask-s3-viewer"
"duration_seconds": 3600, # 15 min – 12 h
},
)
For MFA-protected roles, supply a token (or a callback for interactive prompting):
FlaskS3Viewer(
app,
namespace="mfa-account",
config={
"bucket_name": "secure-bucket",
"region_name": "us-east-1",
"role_arn": "arn:aws:iam::123456789012:role/AdminRole",
"mfa_serial": "arn:aws:iam::123456789012:mfa/alice",
"token_code_callback": lambda: input("MFA code: ").strip(),
},
)
Authentication & permissions
flask-s3-viewer ships with two opt-in layers. The package works exactly as before with no auth wiring — both default to "allow everyone".
Layer 1: hook framework (no extra dependency)
Plug in your existing login system with two callables:
from flask_s3_viewer.auth import ACTION_LIST, ACTION_UPLOAD, ACTION_DELETE
def who_is_asking(request):
"""Return the user's email (or any opaque id) — None means anonymous."""
return request.headers.get("X-Forwarded-Email")
def can_they(email, action, namespace, key):
"""Authorize a single action. action is one of the ACTION_* constants."""
if action == ACTION_DELETE:
return email.endswith("@admin.example.com")
return True
FlaskS3Viewer(
app, namespace="bucket",
auth_callback=who_is_asking,
permission_callback=can_they,
config={...},
)
The five action constants are ACTION_LIST, ACTION_DOWNLOAD, ACTION_UPLOAD, ACTION_DELETE, ACTION_PRESIGN.
RBAC bucket switcher
For multi-bucket apps, keep hard authorization in permission_callback and use
visible_namespaces_callback(email, registry) to control which buckets appear
in the header switcher:
RBAC = {
"alice@example.com": {"assets", "private"},
"bob@example.com": {"assets"},
}
def visible_buckets(email, registry):
return RBAC.get(email, set())
def can_they(email, action, namespace, key):
return namespace in RBAC.get(email, set())
viewer = FlaskS3Viewer(
app,
namespace="assets",
title="Assets",
auth_callback=who_is_asking,
permission_callback=can_they,
visible_namespaces_callback=visible_buckets,
config={...},
)
viewer.add_new_one(
namespace="private",
title="Private",
config={...},
)
The switcher only hides inaccessible namespaces from the UI. Direct URL access
is still checked by permission_callback, so RBAC remains server-side.
Layer 2: built-in Google OAuth (optional [auth] extra)
pip install "flask_s3_viewer[auth]"
app.secret_key = "..." # required — signs the session cookie
FlaskS3Viewer(
app, namespace="bucket",
google_client_id="...apps.googleusercontent.com",
google_client_secret="...",
allowed_emails=["alice@example.com"],
allowed_domains=["example.com"],
config={...},
)
Installs /auth/login, /auth/callback, /auth/logout as app-level routes (outside the FlaskS3Viewer namespace prefix). Configure the redirect URI as https://<host>/auth/callback in Google Cloud Console — one URI per app even when you mount multiple namespaces. Anonymous browser visits to a protected page are redirected through Google sign-in automatically.
Mix and match: pass your own auth_callback / permission_callback even when Google is enabled, or use email_allowlist() as a permission builder for non-Google deployments.
Security
- Path traversal hardening — Every user-supplied
prefixis validated. Tokens..,., empty segments, and\are rejected with HTTP 400. - Defense in depth — The cache layer additionally enforces
realpathcontainment, preventing any path that would resolve outsidecache_dir. - Subresource Integrity — Bundled
htmx.min.jsreferences itssha384hash; the Tailwind output is shipped pre-built and signed by the package. - Credentials — Never log credentials. Prefer named profiles or instance roles over hard-coded keys.
Development
The frontend assets are pre-built and committed to the repo. To rebuild after editing templates:
cd frontend
npm install
npm run build # writes flask_s3_viewer/blueprints/static/css/app.css
CI verifies the CSS is up to date (git diff --exit-code).
Tests:
pip install -e ".[dev]"
ruff check flask_s3_viewer/ tests/
mypy flask_s3_viewer/
pytest tests/ --cov=flask_s3_viewer
Migrating from 0.x
See MIGRATION.md for the full guide. Highlights:
- Drop
s3viewer.register()— the constructor now auto-registers. FlaskS3Viewer.get_instance(ns)→FlaskS3Viewer.get_instance(app, ns)(same forget_boto_client,get_boto_session).- Duplicate namespace registration now raises
ValueErrorinstead of silently reusing. - Unknown namespaces return HTTP 404 instead of 500.
- Single template namespace —
template_namespace="base"|"mdl"is ignored with a deprecation warning. - CLI
--templateoption removed. - Path-traversal tokens in
prefixnow return HTTP 400. - Requires Flask 3.0+ and boto3 1.34+.
License
MIT © Hoiwoong Jung
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file flask_s3_viewer-1.0.1.tar.gz.
File metadata
- Download URL: flask_s3_viewer-1.0.1.tar.gz
- Upload date:
- Size: 117.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.10.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5715d98c06d7e7181567a9bbd10e81af610ff897695142006d8ecac554a4f234
|
|
| MD5 |
25b4541506edccf7787cb98cab7d7940
|
|
| BLAKE2b-256 |
6fa1b3f7e7068591e3bbd224a3b9bcb4427b297af271c835d4bf8976a385f57f
|
File details
Details for the file flask_s3_viewer-1.0.1-py3-none-any.whl.
File metadata
- Download URL: flask_s3_viewer-1.0.1-py3-none-any.whl
- Upload date:
- Size: 91.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.10.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
85a37eea6556aed51a1e5f9f7e6daf2cb25fedd9a8460dd776a63cde18c44e23
|
|
| MD5 |
db09e5166cdced2f8e237055f271ccc9
|
|
| BLAKE2b-256 |
db3e12181cf6ecd5054c0acdb88e43563b7b7c29a8c9c5621777235f4236d3ea
|