Configuration
Before you can begin using Flask S3Viewer, you should set up authentication credentials. Credentials for your AWS account can be found in the IAM Console. You can create or use an existing user. Go to manage access keys and generate a new set of keys.
Configure credentials
Install AWS CLI.
pip install awscli
If you have the AWS CLI installed, then you can use it to configure your credentials file:
aws configure
Alternatively, you can create the credential file yourself. By default, its location is at ~/.aws/credentials. and Flask S3Viewer is going to use the credential file.
Minimum settings
This is a minimal setup for using flask s3viewer. First install the dependency packages.
pip install flask flask_s3_viewer
Import flask and flask_s3_viewer
1from flask import Flask
2
3from flask_s3_viewer import FlaskS3Viewer
4from flask_s3_viewer.aws.ref import Region
Initiailize Flask application and FlaskS3Viewer.
1# Init Flask
2app = Flask(__name__)
3
4# Init Flask S3Viewer (auto-registers in v1.0+)
5FlaskS3Viewer(
6 # Flask App
7 app,
8 # Namespace must be unique
9 namespace='flask-s3-viewer',
10 # Hostname, e.g. Cloudfront endpoint
11 object_hostname='http://flask-s3-viewer.com',
12 # Put your AWS's profile name and Bucket name
13 config={
14 'profile_name': 'PROFILE_NAME',
15 'bucket_name': 'S3_BUCKET_NAME'
16 }
17)
18
19if __name__ == '__main__':
20 app.run(debug=True, port=3000)
Note
In v1.0+, the constructor auto-registers the blueprint via Flask extension pattern.
The legacy s3viewer.register() call has been removed. For deferred registration,
pass app=None and call viewer.init_app(app) later.
The values in the code above are mandatory. If the setting is finished, run your Flask application and visit http://localhost/{namespace}/files, e.g. http://localhost:3000/flask-s3-viewer/files.
You can get example codes over here.
User Guides
It is about various advanced settings.
Multiple bucket settings
You can also initiailize multiple bucket.
1...
2
3s3viewer = FlaskS3Viewer(
4 ...
5)
6
7# Init another bucket
8s3viewer.add_new_one(
9 namespace='another_namespace',
10 object_hostname='http://anotherbucket.com',
11 config={
12 'profile_name': 'PROFILE_NAME',
13 'bucket_name': 'S3_BUCKET_NAME'
14 }
15)
Mount a specific path in a bucket for browsing
You can mount a specific path in the bucket to the browser. ( Be careful not to end the path with / )
1...
2
3s3viewer = FlaskS3Viewer(
4 ...
5)
6
7# Init another bucket
8s3viewer.add_new_one(
9 namespace='another_namespace',
10 object_hostname='http://anotherbucket.com',
11 config={
12 'profile_name': 'PROFILE_NAME',
13 'bucket_name': 'S3_BUCKET_NAME',
14 'base_path': 'path/to/your/folder',
15 }
16)
Limit the file extensions
You can limit the file extensions that are uploaded, if you want.
1s3viewer = FlaskS3Viewer(
2 ...
3
4 # allowed extension
5 allowed_extensions={'jpg', 'jpeg'},
6 config={
7 ...
8 }
9)
Design template
Since v1.0, Flask S3 Viewer ships a single unified design built with Tailwind CSS + HTMX, with light/dark mode and inline heroicons.
Note
The template_namespace='base'|'mdl' argument is deprecated. Passing it
emits a DeprecationWarning and is otherwise ignored. The previous
base/ and mdl/ template directories have been removed.
Branding (title + logo)
Four constructor options let you brand the UI without overriding templates:
1FlaskS3Viewer(
2 app,
3 namespace='my-bucket',
4 title='ACME File Vault',
5 logo_path='/opt/acme/assets/logo.svg', # local file, auto-inlined
6 # logo_url='https://cdn.acme.io/logo.svg', # alternatively, any URL
7 logo_link_url='https://intranet.acme.io/dashboard', # optional
8 config={...},
9)
logo_path reads the file once at construction time and embeds it as a
data: URI so you don’t need to expose it via a separate static route.
logo_url accepts any browser-resolvable URL (CDN, url_for("static",
filename=...) result, or absolute URL). logo_path takes precedence
over logo_url when both are provided.
logo_link_url (since v1.3) overrides the click target of the header
logo + title anchor. When set, the anchor renders as a plain
<a href="..."> pointing at the configured URL — useful when the
deployer wants the brand mark to return users to an external dashboard
/ home page rather than the namespace’s own root listing. The HTMX swap
attributes (hx-get / hx-target / hx-push-url) are intentionally
omitted in this mode so the browser performs a standard full-page
navigation; the listing’s HTMX flows (file rows, pagination, search,
bucket switcher) are unaffected. Omit logo_link_url (or leave it as
None) to keep the v1.2 behaviour where the anchor performs an
in-place HTMX listing reset back to the namespace root.
For multi-namespace deployments using add_new_one(), omit the
kwarg on the child to inherit the parent’s value; pass None
explicitly to drop the parent’s override on this child namespace; pass
a different string to override the parent value only on the child.
Template overrides
The recommended path is the CLI scaffold plus the template_folder=
constructor argument:
# Copy just the Jinja templates (most common)
flask_s3_viewer -p ./fsv-templates
# Fork the whole UI bundle (templates + static/css/app.css + htmx + core.js)
flask_s3_viewer -p ./fsv-templates --with-static
Edit any of layout.html / files.html / _file_list.html /
_pagination.html / _upload_form.html / error.html in the
scaffolded directory, then point the viewer at it:
1FlaskS3Viewer(
2 app,
3 namespace='my-bucket',
4 template_folder='./fsv-templates',
5 config={...},
6)
Behind the scenes the extension prepends a FileSystemLoader to the
Flask app’s Jinja loader via ChoiceLoader, so any not-overridden
template still resolves against the bundle and other blueprints’
templates are unaffected.
layout.html also exposes a {% block extra_head %} hook for the
common case where you only need to inject CSS / JS / <meta> tags:
{% extends "flask_s3_viewer/layout.html" %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='custom.css') }}">
{% endblock %}
Controll large files
If you want to controll large files (maybe larger than 5MB ~ maximum 5TB), I recommand to set like below. Flask S3Viewer is going to use S3’s presigned URL. It’s nice to controll large files.
1s3viewer = FlaskS3Viewer(
2 ...
3 # Change upload type to 'presign'
4 upload_type='presign',
5 config={
6 ...
7 }
8)
but you must do S3’s CORS settings before like set above.
STS AssumeRole / MFA
For cross-account or multi-tenant deployments, the viewer can run
sts:AssumeRole on top of the base credentials (profile / env /
IRSA / IMDS — whatever boto3 resolves by default). Pass the role
config inside the config dict:
1FlaskS3Viewer(
2 app,
3 namespace='cross-account',
4 config={
5 'bucket_name': 'target-bucket',
6 'region_name': 'us-east-1',
7 # Base credentials still come from boto3's default chain.
8 'role_arn': 'arn:aws:iam::123456789012:role/AppRole',
9 'external_id': 'shared-secret', # optional
10 'role_session_name': 'my-app', # default: flask-s3-viewer
11 'duration_seconds': 3600, # 15 min ~ 12 h
12 },
13)
For MFA-protected roles, supply either token_code directly or a
token_code_callback callable that returns the current code on demand
(useful for interactive prompts that mustn’t expire):
1FlaskS3Viewer(
2 app,
3 namespace='mfa-account',
4 config={
5 'bucket_name': 'secure-bucket',
6 'region_name': 'us-east-1',
7 'role_arn': 'arn:aws:iam::123456789012:role/AdminRole',
8 'mfa_serial': 'arn:aws:iam::123456789012:mfa/alice',
9 'token_code_callback': lambda: input('MFA code: ').strip(),
10 },
11)
If role_arn is omitted, no STS call happens — the direct credential
path is used. That covers static keys, named profiles (including
profiles that themselves declare role_arn``+``source_profile in
~/.aws/config — boto3 handles AssumeRole automatically), env vars,
EC2 IMDS, ECS task role, AWS SSO, and EKS IRSA.
Automatic credential refresh
Since v1.2, AssumeRole temporary credentials are wrapped in
botocore’s RefreshableCredentials whenever role_arn is set
and either MFA is not used or a token_code_callback is supplied.
boto3 re-invokes sts:AssumeRole automatically when the cached
credentials approach expiry. botocore’s defaults are a 15-minute
advisory window (best-effort background refresh) and a 10-minute
mandatory window (synchronous refresh — the next S3 call blocks
until new credentials are in place). A long-running viewer no longer
hits ExpiredToken once DurationSeconds elapses.
The legacy single-shot path is preserved for the mfa_serial +
literal token_code combination — once the OTP is consumed there
is no way to obtain the next one without prompting the user, so the
session keeps the v1.1.x behaviour and surfaces ExpiredToken
after the session expires. Use this only for short-lived workflows.
For headless deployments that still need MFA, supply a
token_code_callback that fetches the current OTP from your
secret store. The callback is invoked on every refresh, so each
AssumeRole call carries a fresh code:
1def fetch_otp() -> str:
2 # Pull the current TOTP from your secret manager / hardware HSM /
3 # short-lived broker — anything except interactive stdin in a
4 # daemon context.
5 return secrets_client.get_current_totp('flask-s3-viewer')
6
7FlaskS3Viewer(
8 app,
9 namespace='mfa-account',
10 config={
11 'bucket_name': 'secure-bucket',
12 'role_arn': 'arn:aws:iam::123456789012:role/AdminRole',
13 'mfa_serial': 'arn:aws:iam::123456789012:mfa/headless',
14 'token_code_callback': fetch_otp,
15 },
16)
Thread-safety is delegated to botocore’s standard
RefreshableCredentials locking. Each Flask process/worker gets
its own FlaskS3Viewer instance and its own refresh schedule —
the library does not share credentials across workers.
Presigned URL TTL with temporary credentials
A presigned URL signed with STS-issued credentials is bounded by
``min(Expires, STS session expiry)``. boto3 writes the
requested X-Amz-Expires into the URL query verbatim, but S3
rejects the request at access time once the underlying STS session
expires. Concrete consequence: a viewer started with
duration_seconds=3600 (1 hour) that issues a presigned URL with
Expires=86400 (24 hours) still produces a URL the client can
only use for ~1 hour. Automatic refresh (above) does not extend
URLs that were already signed — refreshed credentials only affect
new signatures.
If you need long-lived presigned URLs:
Sign with a long-lived IAM user (skip
role_arnfor that namespace), orSet
duration_seconds≥ the longestExpiresvalue your application requests (within the STS maximum of 12 h for chained AssumeRole, or 43200 s when explicitly allowed by the role).
Choosing duration_seconds
STS AssumeRole quotas are per-account (default ~30 TPS) and
botocore’s 15-minute advisory / 10-minute mandatory refresh
windows pull each renewal that much earlier than Expiration. The
practical guidance:
≥ 3600 (1 h) recommended. With a 1-hour session, the advisory window kicks in ~45 min after issuance and renews once per hour.
900 s (the STS minimum) is risky. Because the advisory window is also 900 s, every S3 call after issuance falls inside the advisory band and triggers a background refresh — effectively rate-limited by botocore’s per-credential lock, but still hard on STS quota under high worker counts.
Typical sweet spot: 3600 – 43200 (1 h – 12 h).
For viewers behind
gunicorn --workers N, each worker maintains its own refresh schedule. Multiply your expected refresh frequency byNwhen sizing against the STS account quota; a largerduration_seconds(e.g. 12 h) keeps the per-second refresh rate well under quota even at high worker counts.
Using with EKS IRSA
In EKS, IAM Roles for Service Accounts (IRSA) issues web-identity
tokens via the projected ServiceAccount token. When you combine
IRSA with an explicit role_arn in the viewer config, two
credential layers stack:
Base — boto3 calls
sts:AssumeRoleWithWebIdentityagainst the IRSA-projected token and gets a refresh-capableCredentialsobject out of the box (boto3 manages this layer; flask-s3-viewer is not involved).Working — flask-s3-viewer then calls
sts:AssumeRoleagainst that base and produces the working session. The v1.2 refresh wiring renews this second layer transparently.
Both layers refresh independently, so a viewer running on EKS for days keeps working without manual intervention.
STS endpoint selection
For non-us-east-1 deployments, prefer the regional STS
endpoint to reduce latency and improve availability. boto3 1.30+
uses regional STS by default; older configurations may need
AWS_STS_REGIONAL_ENDPOINTS=regional in the environment.
Concretely, calls to sts.amazonaws.com (global,
us-east-1) from Seoul measure 150 – 200 ms RTT, while
sts.ap-northeast-2.amazonaws.com is in the 5 – 10 ms range.
Mapping the web user to a CloudTrail identity
The audit log records the web user (Flask session / header)
under the user field; CloudTrail records the
``RoleSessionName`` passed to sts:AssumeRole. Bridge the
two by embedding a stable per-user identifier into
role_session_name:
1FlaskS3Viewer(
2 app,
3 namespace='cross-account',
4 config={
5 'bucket_name': 'target-bucket',
6 'role_arn': 'arn:aws:iam::123456789012:role/AppRole',
7 # CloudTrail surfaces this string in every API call. Keep it
8 # opaque but trace-able.
9 'role_session_name': f'fs3v-{user_id_hash}',
10 },
11)
Warning
RoleSessionName is recorded in cleartext in CloudTrail
and surfaces in many AWS Console screens. Do not embed PII such
as full email addresses, Korean RRN (주민등록번호), phone
numbers, or any other regulated identifier. Use a short hash
(e.g. hashlib.sha256(email).hexdigest()[:16]) or an opaque
numeric user id instead. The audit user field can keep the
email for the operator’s own log pipeline.
Note
Per-namespace role assumption is already supported — each
add_new_one(config={...}) call builds an independent
AWSSession, so namespace A can assume role X while
namespace B assumes role Y. The v1.2 refresh wiring applies
independently per namespace.
Range requests / partial downloads
Since v1.0, GET /<namespace>/files/<key> honors the HTTP Range
header (RFC 7233). A well-formed range returns 206 Partial Content
with Content-Range and Content-Length populated; every download
response advertises Accept-Ranges: bytes. Malformed or unsatisfiable
ranges return 416 Range Not Satisfiable.
This is what lets curl -C -, video/audio <video>/<audio>
players, and chunked mobile downloaders resume or seek without
re-fetching the whole object.
1# Resume a partially downloaded file
2curl -C - -O http://localhost:3000/flask-s3-viewer/files/big.bin
3
4# Or request a specific byte range explicitly
5curl -H "Range: bytes=0-1023" -O \
6 http://localhost:3000/flask-s3-viewer/files/big.bin
1[
2 {
3 "AllowedHeaders": [
4 "*"
5 ],
6 "AllowedMethods": [
7 "POST",
8 "PUT",
9 "GET",
10 "HEAD",
11 "DELETE"
12 ],
13 "AllowedOrigins": [
14 "http://localhost:3000"
15 ],
16 }
17]
Authentication & permissions
Two opt-in layers, both off by default — the package keeps the legacy anonymous experience verbatim until you wire something up.
Hook framework — bring your own login. Two callables:
1from flask_s3_viewer.auth import ACTION_DELETE
2
3def auth_callback(request):
4 # Return the user's email/id, or None for anonymous.
5 return request.headers.get("X-Forwarded-Email")
6
7def permission_callback(email, action, namespace, key):
8 # ``action`` is one of ACTION_LIST / ACTION_DOWNLOAD /
9 # ACTION_UPLOAD / ACTION_DELETE / ACTION_PRESIGN.
10 if action == ACTION_DELETE:
11 return email.endswith("@admin.example.com")
12 return True
13
14FlaskS3Viewer(
15 app, namespace="bucket",
16 auth_callback=auth_callback,
17 permission_callback=permission_callback,
18 config={...},
19)
Returning None from auth_callback triggers a 401 (or a Google
login redirect — see below). permission_callback returning False
triggers a 403.
Built-in Google OAuth — requires the optional [auth] extra:
pip install "flask_s3_viewer[auth]"
1app.secret_key = "..." # required — signs the session cookie
2
3FlaskS3Viewer(
4 app, namespace="bucket",
5 google_client_id="...apps.googleusercontent.com",
6 google_client_secret="...",
7 allowed_emails=["alice@example.com"],
8 allowed_domains=["example.com"],
9 config={...},
10)
Routes /auth/login, /auth/callback, /auth/logout are
registered as app-level routes — they live 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 via add_new_one();
renaming a namespace does not require updating Google Console.
Anonymous browser GETs are redirected through Google sign-in;
non-browser clients still get a bare 401.
allowed_emails / allowed_domains are a shortcut for the common
allow-list case — internally they wire up the
email_allowlist(emails=..., domains=...) builder as the
permission_callback. Pass your own permission_callback for
fine-grained per-action policy.
Audit logging
Every S3 CRUD action that flows through the blueprint emits a single
structured record on the flask_s3_viewer.audit logger. The logger
is always present — host applications opt in by attaching a handler
and/or adjusting its level via the standard logging API. No
constructor flag toggles audit on or off; the v1.0 public API is
unchanged.
Logger name: flask_s3_viewer.audit
Default level: unset (records propagate to root and are filtered
by the host’s effective level). Successful actions emit at
INFO; permission denials emit at WARNING; unexpected
exceptions emit at ERROR.
Record fields (attached as LogRecord attributes via extra=):
action— one oflist,download,upload,delete,presign
namespace— viewer namespace the request landed on
bucket— S3 bucket name resolved from the viewer config for the current request. Populated automatically by the blueprint’surl_value_preprocessor; the empty string whenemit()is called outside a Flask request context unless the caller pre-setsg.FSV_AUDIT_BUCKETthemselves.
key— canonical S3 key / prefix (post-base_path)
user— authenticated email or the literal stringanonymous
result—ok/denied/error
status_code— HTTP status emitted to the client. Reflects the planned response code at emit time. If a render-phase exception fires after a successful upload row was emitted (e.g. the listing template rendering hits an I/O error), the audit row may carry201while the client receives a500from Flask’s error handler.
client_ip—request.remote_addr
user_agent— capped at 256 bytes; sanitised
request_id— 8 hex chars; rows emitted within the same Flask request share one id. Records emitted from host code outside a request context get a fresh id each call.
error— present only when an exception was attached
The human-readable message is a single space-separated key=value line:
action=download namespace=fsv-test bucket=fsv-bucket
key=docs/report.pdf user=alice@example.com result=ok status=200
req=a1b2c3d4
Newlines, carriage returns, and other ASCII control bytes inside
attacker-controllable fields (key, email, User-Agent, exception
message) are escaped as \\xNN before the record is built, so a
crafted request cannot smuggle a fake row into the log stream.
Multi-file requests emit one row per file. An upload of three files
produces three action=upload records — each key is the
per-object S3 target the request would write (<prefix><safe_name>).
presign follows the same rule: one row per file_list entry,
each with its own status_code (200 ok / 409 conflict / 403
disallowed / 500 error). The “no files iterated” cases (mkdir-only
upload, empty file_list presign, invalid prefix, denied auth) still
emit a single aggregate row keyed by the prefix. Note: when a
multi-file upload aborts with 403 (disallowed extension) or returns
409 (duplicate / overwrite conflict) the HTTP response is a single
status but the audit stream carries one row per violating file —
response status != row count by design. Plan for the row volume:
uploading 100 files in one request produces 100 audit records.
If the same target key appears N times in a single upload request, the
audit stream records one 409 row per unique conflicting key —
not N. The duplicate-detection step dedupes targets before emit (see
view.py duplicate_targets set), so two uploads of a.txt in
one request produce one row, not two.
Plain file handler example:
1import logging
2
3handler = logging.FileHandler('/var/log/flask_s3_viewer/audit.log')
4handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(message)s'))
5logging.getLogger('flask_s3_viewer.audit').addHandler(handler)
6logging.getLogger('flask_s3_viewer.audit').setLevel(logging.INFO)
Structured JSON handler example (uses python-json-logger):
1import logging
2from pythonjsonlogger import jsonlogger
3
4audit_handler = logging.FileHandler('/var/log/flask_s3_viewer/audit.jsonl')
5audit_handler.setFormatter(jsonlogger.JsonFormatter(
6 '%(asctime)s %(levelname)s %(action)s %(namespace)s '
7 '%(bucket)s %(key)s %(user)s %(result)s %(status_code)s '
8 '%(client_ip)s %(user_agent)s'
9))
10audit = logging.getLogger('flask_s3_viewer.audit')
11audit.addHandler(audit_handler)
12audit.setLevel(logging.INFO)
13# The library leaves propagate=True by default — disable it here
14# if you do NOT also want these records flowing to root handlers.
15audit.propagate = False
PII / secret redaction. Emails and S3 keys are written verbatim,
which may be sensitive depending on deployment policy. Attach a
logging.Filter if you need to mask, hash, or drop fields before
they hit disk — for example to GDPR-truncate the user field, or to
strip ARNs/bucket names from error messages produced by boto3
ClientError stringification.
1class RedactFilter(logging.Filter):
2 def filter(self, record):
3 if getattr(record, 'user', None):
4 user = record.user
5 record.user = user.split('@', 1)[0][:2] + '***@' + user.split('@', 1)[-1]
6 return True
7
8audit.addFilter(RedactFilter())
Multi-file uploads / presigns emit one record per file (see Multi-file requests emit one row per file above), so the filter above runs N times per request — keep it side-effect free and allocation-light.
For key and error — which can carry full S3 paths and boto3
ClientError text containing bucket names / ARNs / request IDs —
attach a second filter that keeps just enough breadcrumb to trace the
incident without leaking the rest of the path or the AWS account
topology:
1import re
2
3_ARN_RE = re.compile(r'arn:aws:[^\s"\']+')
4_BUCKET_RE = re.compile(r'(?i)\bbucket[\s:=]+[^\s"\',]+')
5
6class KeyErrorRedactFilter(logging.Filter):
7 """Redact prefix tails on ``key`` and AWS identifiers on ``error``."""
8 def filter(self, record):
9 key = getattr(record, 'key', None)
10 if key:
11 # Keep only the first path segment ("docs/...") so the
12 # audit trail still distinguishes top-level folders but
13 # the leaf filename / nested path is masked.
14 head, sep, _tail = key.partition('/')
15 record.key = f'{head}{sep}***' if sep else '***'
16 err = getattr(record, 'error', None)
17 if err:
18 err = _ARN_RE.sub('arn:aws:***', err)
19 err = _BUCKET_RE.sub('bucket=***', err)
20 record.error = err
21 # ``bucket`` is exposed as a record extra (and a
22 # ``bucket=<name>`` token in the message body) on every emit.
23 # Mask it the same way if your retention policy treats live
24 # S3 bucket names as sensitive metadata.
25 if getattr(record, 'bucket', None):
26 record.bucket = '***'
27 return True
28
29audit.addFilter(KeyErrorRedactFilter())
The two filters compose — install both if you want the user, key, and
error fields all masked. Tune the regex set to your environment;
ClientError text varies by API call. The bucket record extra
is populated for every blueprint emit, so masking it here keeps the
audit pipeline from accidentally fanning bucket names out to
secondary handlers (Splunk indexers, SIEM forwards, etc.) when the
deployer wants to keep that detail bounded to a single trusted sink.
Capturing the real client IP behind a reverse proxy. client_ip
is sourced from request.remote_addr, which Werkzeug fills from the
last hop on the TCP connection. When the app sits behind a load
balancer, ALB / ELB / nginx / Cloudflare, that hop is the proxy and
every audit row records the proxy IP — not the originating client.
For the audit trail to actually identify clients you must install
Werkzeug’s ProxyFix middleware (or an equivalent) so
X-Forwarded-For / Forwarded headers are honored:
1from werkzeug.middleware.proxy_fix import ProxyFix
2
3# ``x_for=1`` trusts exactly one X-Forwarded-For hop (your edge LB).
4# If the request transits N reverse proxies you control end-to-end,
5# raise this to N. Trusting too many hops lets clients spoof the IP.
6app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
Without ProxyFix (or a host-supplied equivalent), every client_ip
field in the audit stream is the LB’s address and the audit trail
loses most of its forensic value. The value of x_for is
deployment-specific — adjust it for nested LB / CDN topologies, and
only trust hops you operate.
Calling :func:`emit` from host code. The emit function is part
of the public surface: host integrations may import it and emit
extra audit lines for non-CRUD operations they layer on top of the
viewer (e.g. a custom admin route that bulk-tags objects). Usage:
1from flask_s3_viewer.audit import emit as audit_emit
2from flask_s3_viewer.auth import ACTION_LIST # or ACTION_DOWNLOAD/...
3
4# Call from inside a Flask request context so client_ip / user_agent
5# are populated automatically; outside a request both fields emit as
6# empty strings.
7audit_emit(
8 action=ACTION_LIST,
9 namespace='my-bucket',
10 key='reports/2026/', # caller pre-normalises (post-base_path)
11 user=current_user_email,
12 result='ok',
13 status_code=200,
14)
Prefer the flask_s3_viewer.auth.ACTION_* constants over raw
strings; action and result are sanitised but the level mapping
(ok``→INFO, ``denied``→WARNING, ``error``→ERROR) depends on
``result. The signature is part of v1.x stability — additions will
be backwards-compatible.
Use Caching
S3 is charged per call. Therefore, Flask S3Viewer supports caching (currently only supports file caching, in-memory database will be supported later).
1s3viewer = FlaskS3Viewer(
2 ...
3 config={
4 ...
5 # Flask S3Viewer will cache the list of s3 objects, if you set True
6 'use_cache': True,
7 # Where cached files will be written
8 'cache_dir': '/tmp/flask_s3_viewer',
9 # Time To Live
10 'ttl': 86400
11 }
12)
Full example
1...
2
3 FlaskS3Viewer(
4 # Flask app
5 app,
6 # Namespace must be unique
7 namespace='flask-s3-viewer',
8 # File's hostname
9 object_hostname='http://flask-s3-viewer.com',
10 # Allowed extension
11 allowed_extensions={},
12 # Bucket configs and else
13 config={
14 # Required
15 'profile_name': 'PROFILE_NAME',
16 # Required
17 'bucket_name': 'S3_BUCKET_NAME',
18 'region_name': Region.SEOUL.value,
19 # Not necessary, if you configure aws settings, e.g. ~/.aws
20 'access_key': 'AWS_IAM_ACCESS_KEY',
21 'secret_key': 'AWS_IAM_SECRET_KEY',
22 # For S3 compatible
23 'endpoint_url': None,
24 # Flask S3Viewer will cache the list of s3 objects, if you set True
25 'use_cache': True,
26 # Where cached files will be written
27 'cache_dir': '/tmp/flask_s3_viewer',
28 # Time To Live
29 'ttl': 86400,
30 }
31 )
Things to know
Searching
Case-insensitive substring match applied in Python. Notable properties:
Unicode-safe. Korean, Japanese, accented Latin, and emoji all match — the EN-only JMESPath limitation in earlier versions is gone.
NFC-normalised. macOS Finder uploads land in S3 as NFD-decomposed Hangul while the browser IME emits NFC; both sides are normalised before comparison so the bytes line up.
Recursive. When the search box is non-empty the listing switches to a flat (
delimiter='') S3 call scoped to the current prefix, so a matching filename three folders deep is visible without manual drill-in.Folder rows are synthesized. Any sub-prefix whose own segment contains the query appears as a clickable folder, alongside the matching files.
``base_path`` is excluded from the comparison. The namespace mount point itself doesn’t bleed into every match.
Cache-bypassed. Search results are not persisted to the disk cache, so a code-level change in matching semantics never gets served stale from an old deployment.
Matching is bounded by max_items × max_pages per request (default
1000 keys). For very large prefixes a query may need to be combined
with a narrower prefix to surface deeper matches.