Changelog

All notable changes to this project will be documented in this file. Dates are displayed in UTC.

Generated by ``auto-changelog` <https://github.com/CookPete/auto-changelog>`_.

1.3.0

19 May 2026 — Header logo / title anchor click target is now configurable. Public API gains one keyword argument (logo_link_url) on both FlaskS3Viewer.__init__ and add_new_one; existing callers that omit the kwarg see no behaviour change — the v1.2 HTMX swap remains the default.

Added

  • logo_link_url constructor argument (and matching add_new_one kwarg). When set, the header logo + title anchor renders as a plain <a href="..."> pointing at the configured URL instead of the default HTMX listing reset, so the brand mark can return users to an external dashboard / home page. The HTMX swap attributes (hx-get / hx-target / hx-push-url) are dropped in this mode to ensure a standard full-page navigation; other listing HTMX flows (file rows, pagination, search, bucket switcher) are unaffected. Multi-namespace semantics follow the established _INHERIT sentinel pattern: omit on add_new_one to inherit the parent viewer’s value, pass None explicitly to drop the override on a child namespace, or pass a different string to override per namespace. The blueprint context processor exposes the resolved value as FS3V_LOGO_LINK_URL for templates.

Documented

  • New logo_link_url note in the Branding (title + logo) section of docs/source/usage/configuration.rst covering the HTMX swap trade-off, multi-namespace inheritance semantics, and backwards compatibility with v1.2 deployments.

1.2.0

19 May 2026 — STS AssumeRole automatic temporary credential refresh. Public API (FlaskS3Viewer constructor, add_new_one, init_app, get_instance, get_boto_client, get_boto_session, AWSSession.__init__) is unchanged — long-running viewers that use role_arn simply stop hitting ExpiredToken after DurationSeconds elapses.

Added

  • AWSSession now wires the working boto3 session against 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 defaults: 15-minute advisory window for background refresh, 10-minute mandatory window for synchronous refresh). With a token_code_callback the callback fires on every refresh so each AssumeRole call carries a fresh OTP. The legacy single-shot path is preserved for the mfa_serial + literal token_code combination (the consumed OTP cannot be replayed for refresh) so existing short-lived MFA workflows behave identically to v1.1.x.

Changed

  • AWSSession._assume_role internally branches into _assume_role_refreshable (default for role_arn users) and _assume_role_static (legacy single-shot path). External signature, kwargs, and return type are unchanged; the working session’s credentials object may now be a RefreshableCredentials instance (a subclass of botocore.credentials.Credentials) on the refresh-eligible path, so isinstance(creds, Credentials) host checks continue to pass.

Documented

  • New Automatic credential refresh subsection under STS AssumeRole / MFA in docs/source/usage/configuration.rst covering refresh eligibility, the 15-minute advisory / 10-minute mandatory refresh windows, and token_code_callback invocation semantics.

  • New Presigned URL TTL with temporary credentials subsection documenting that presigned URL lifetime is capped to min(Expires, STS session expiry) and recommending either long-lived IAM credentials or a duration_seconds ≥ the requested presign expiry.

  • Choosing duration_seconds guidance (≥ 3600 recommended; STS AssumeRole rate limits and the 15/10-minute advisory/mandatory refresh windows).

  • Using with EKS IRSA explainer for the two-stage credential chain (IRSA AssumeRoleWithWebIdentity → flask-s3-viewer AssumeRole).

  • STS endpoint selection note recommending regional STS endpoints in non-us-east-1 regions (e.g. ap-northeast-2) for latency and availability.

  • Mapping the web user to a CloudTrail identity note covering the separation between the audit user field and the RoleSessionName recorded by CloudTrail, with a PII-avoidance warning for RoleSessionName choices.

1.1.1

19 May 2026 — Packaging follow-up to 1.1.0.

Fixed

  • Pin requests>=2.31 inside the [auth] extra. Authlib’s Flask client routes Google OAuth token exchange through authlib.integrations.requests_client, but Authlib itself only declares cryptography and joserfc as install requirements, so pip install "flask-s3-viewer[auth]" had been leaving the requests import unresolved at runtime.

Changed

  • Normalize the PyPI distribution name to the canonical hyphenated form (flask-s3-viewer) in line with PEP 503. The Python module remains flask_s3_viewer and pip install flask_s3_viewer keeps working — PyPI normalizes both spellings to the same project.

1.1.0

18 May 2026 — Audit logging & logging surface clean-up. The public API (FlaskS3Viewer constructor, add_new_one, init_app, get_instance, get_boto_client, get_boto_session) is unchanged — the changes below affect log output only.

Added

  • New audit logger flask_s3_viewer.audit. Every S3 CRUD action that flows through the blueprint (list / download / upload / delete / presign) emits exactly one structured record carrying action, namespace, key, user, result, status_code, client_ip, user_agent, and (on failure) error. Level mapping: ok → INFO, denied → WARNING, error → ERROR. Activation is pure-logging: attach a handler to flask_s3_viewer.audit, set its level, optionally pin propagate = False. No constructor flag. flask_s3_viewer.audit.emit(...) is a public helper host code may call to record extra non-CRUD operations on the same logger. Log injection is defended at this layer — ASCII control bytes in any user-controllable field (key / email / User-Agent / exception text) are escaped to \xNN before the record is built. UA is capped at 256 bytes; free-form fields at 1024 bytes. See Audit logging in docs/source/usage/configuration.rst for ProxyFix guidance, JSON handler setup, and PII / ARN / bucket-name redaction filter examples.

  • Each audit record now carries a request_id (8 hex chars) that groups all rows from a single Flask request. Multi-file uploads / presigns and the auth/error/success rows of one request share one id, so per-file rows are joinable in JSON pipelines via record.request_id and grep-able in plaintext logs via the req=<id> token appended to the human-readable message body. Outside a request context every emit() gets a fresh id. The emit() public signature is unchanged.

  • Audit records now include a bucket field (record extra + bucket=<name> message token) reflecting the viewer’s S3 bucket name. The blueprint’s url_value_preprocessor resolves the bucket once per request so every audit row carries it without any per-view bookkeeping; emits from outside a request context yield an empty bucket unless the caller pre-sets g.FSV_AUDIT_BUCKET. The emit() public signature is unchanged.

Changed

  • Internal logging.info(...) / logging.error(...) / logging.debug(...) calls in flask_s3_viewer/__init__.py, aws/s3.py, aws/session.py, and aws/cache.py have been routed through per-module logging.getLogger(__name__) instances. Record name now reads flask_s3_viewer.<module> instead of root. Host pipelines that filter by record name (rather than by handler attachment) need to update their allow-lists; host pipelines that attach handlers to the root logger (or to flask_s3_viewer) are unaffected — the module loggers default to propagate=True.

  • app.url_map registration dump was demoted from INFO to DEBUG so registering many namespaces (or large blueprints) no longer floods INFO logs at app startup.

  • Audit logger now emits one row per uploaded / presigned file instead of one aggregate row per request. Each row’s key is the canonical per-object S3 target (<prefix><safe_name>); duplicate-basename and overwrite-conflict rejections emit one error row per violating file. The “no files iterated” cases (mkdir-only upload, empty file_list presign, invalid prefix, denied auth) still emit a single prefix-keyed row. The emit() public signature is unchanged; only the per-request emit count grew (1 → N).

  • upload_type='presign' mode no longer pre-issues conflict-check presign URLs at file-selection time. The previous flow walked every selected file through a serial post_presign round-trip before any chip rendered, so picking N files incurred N sequential S3 / signer hops before the user could even see what they had staged. Chips and the Upload button now appear immediately after the file picker resolves; presign URL issuance is deferred to the Upload click. Conflict (HTTP 409) and permission-denied (HTTP 403) dialogs therefore surface after Upload instead of after file selection. default upload mode is unaffected. core.js public surface (readyFileHandling, uploadFiles, preventDefaults) is unchanged — only _upload_form.html was touched.

  • add_new_one() now accepts explicit auth-related kwargs (auth_callback, permission_callback, visible_namespaces_callback, google_client_id, google_client_secret, allowed_emails, allowed_domains). Omitted kwargs inherit the parent viewer’s value; explicit values (including None to mean disabled) override. Previously the or self.x fallback made auth_callback=None indistinguishable from “not passed”, so a child namespace could not opt out of a parent’s auth configuration. Backward-compatible: existing callers that omit the kwarg see no behavior change.

  • AWSS3Client.bucket_name is now a read-only public property. The legacy private _bucket_name attribute is retained for any host code reading it directly; new integrations should prefer the property. The blueprint’s pull_division now uses the public property when populating g.FSV_AUDIT_BUCKET.

  • Presign upload UI now disables the <input type="file"> while a presign request is in flight (fsvPresignSetUploadBusy). Previously only the Upload button was disabled, leaving a race where a second file-picker selection could overwrite the pending slot set between the click and the response from /files/presign. The label (<label for="fs3viewer_files">) is suppressed automatically by the browser when the input itself is disabled.

  • Audit logging redaction guide now documents that the bucket record extra is also a candidate for masking, with a one-line addition to the KeyErrorRedactFilter example.

Fixed

  • aws/s3.py‘s PURGE: / MKDIR: / UP_OBJECT: DEBUG lines used the broken logging.debug('PURGE:', name) form (the second positional argument was treated as a logging-args tuple, against an unparameterised message string, which the stdlib silently drops). They now use logger.debug('PURGE: %s', name) etc., so a host that pins the logger at DEBUG actually sees the keys involved in each cache invalidation / placeholder create / upload event.

1.0.1

15 May 2026

Fixed

  • Polished upload selection spacing and rebuilt the generated Tailwind CSS.

  • Added a dismiss control to permission error toasts and shortened their auto-dismiss timing.

1.0.0

15 May 2026 — first stable 1.0 release.

Added

  • Authentication & permission framework. Two opt-in layers, both off by default to preserve the legacy anonymous experience: (a) hook framework — auth_callback(request) -> email | None and permission_callback(email, action, namespace, key) -> bool, where action is one of ACTION_LIST / ACTION_DOWNLOAD / ACTION_UPLOAD / ACTION_DELETE / ACTION_PRESIGN; (b) built-in Google OAuth via Authlib (optional [auth] extra), wired by passing google_client_id / google_client_secret. Convenience builder email_allowlist(emails=..., domains=...) and constructor kwargs allowed_emails / allowed_domains short-circuit the common allow-list case. Routes /auth/login, /auth/callback, /auth/logout are installed as app-level routes outside the namespace prefix — one OAuth redirect URI per Flask app even when multiple FlaskS3Viewer namespaces are mounted, and namespace renames don’t require Google Console updates. Anonymous browser GETs are redirected through Google sign-in when configured; non-browser callers still get bare 401/403.

  • Logged-in user widget in the file listing header: when auth is wired and the request has an identity, the user’s email + a logout button render next to the theme toggle. Identity is resolved via viewer.auth_callback(request) so custom integrations show the right user, not just Google sessions.

  • Parent-folder (..) row at the top of every sub-prefix listing. Familiar file-browser UX; uses the same HTMX partial swap as folder rows so there is no full page reload. Omitted at root.

Changed

  • Listing search rewritten from JMESPath f-string interpolation to a Python case-insensitive in filter with NFC normalisation. Concrete wins:

    • Unicode-safe — Korean, Japanese, accented Latin, and emoji queries all match (was ASCII-only).

    • Case-insensitiveREADME.MD matches readme.

    • Current-folder scoped — search stays on the current listing level, filtering only direct child files and folders under the active prefix.

    • NFC normalisation on both query and key — macOS Finder uploads land in S3 as NFD-decomposed Hangul (ㅅ+ㅡ+...) but the browser IME emits NFC (스+...); without this, partial queries silently found nothing.

    • ``base_path`` is excluded from the comparison so the mount point doesn’t leak into every match (e.g. base_path='test/' no longer made test match everything).

    • Disk cache bypassed during search to avoid stale results across deployments.

    • No JMESPath injection seam — special characters (backtick, quote, backslash) in the query are inert.

Fixed

  • title / logo_url / logo_path / object_hostname / upload_type now reach every render path. The full-page GET listing handler had been silently dropping the branding kwargs, which made FlaskS3Viewer(title='...') look like a no-op on the main /files page. Centralised in the blueprint context processor so future templates inherit the same data without per-handler bookkeeping.

  • Search box keeps focus when Enter is pressed (Enter no longer triggers the form’s native submit — the 300 ms-debounced keyup trigger already runs the query).

1.0.0a1

13 May 2026 — major rewrite, pre-release. See MIGRATION.md.

Breaking changes

  • Drop Flask 2.x and boto3 1.28.x support. Requires Flask 3.0+, boto3 1.34+.

  • Remove s3viewer.register(). The constructor (FlaskS3Viewer(app, ...)) now auto-registers, or use init_app(app) for deferred binding (Flask extension pattern).

  • FlaskS3Viewer.get_instance(namespace)get_instance(app, namespace) (staticmethod). Same change for get_boto_client and get_boto_session.

  • Duplicate namespace registration now raises ValueError (was silent reuse).

  • Unknown namespace via URL returns HTTP 404 (was 500 from a KeyError).

  • Single unified template namespace. template_namespace="base"|"mdl" is accepted with a DeprecationWarning and ignored. The bundled base/ and mdl/ template directories are removed.

  • CLI: --template/-t option removed (the CLI now copies the single templates/ directory).

  • FlaskS3Viewer.SUPPORT_TEMPLATES constant removed.

  • Path-traversal tokens in user-supplied prefix are rejected with HTTP 400. Tokens: .., ., empty segment (//), backslash (\).

  • base_path with leading / is normalized at construction (fixes a runtime cache crash).

  • HTMX DELETE /<ns>/files/<key> returns 200 instead of 204 (HX-Request flow); legacy non-HTMX clients still receive 204.

  • Singleton metaclass removed. type(FlaskS3Viewer) is type, no _instances class variable.

Added

  • Constructor branding options: title (heading + browser tab), logo_url (any browser-resolvable URL), and logo_path (local filesystem path, automatically inlined as a data: URI).

  • Bulk multi-select with select-all and a single confirmed batch delete; confirm message distinguishes folder (recursive) vs file deletions.

  • “New Folder” button (header action area, prompts for a name and POSTs prefix only) and per-folder trash button.

  • Refresh button in the header.

  • Drag-and-drop file picker: dropping files stages them on the input, the user clicks Upload to confirm (no auto-submit).

  • Per-file folder breadcrumb on every listing partial; navigations preserve the prefix on search via dynamic oninput synchronization.

  • Inline SVG favicon (no more /favicon.ico 404).

  • Upload progress bar with htmx:xhr:progress events; conflict (HTTP 409) triggers a confirm dialog and one-shot overwrite resubmission via fetch (full multipart re-send, not htmx trigger).

  • {% block extra_head %} in layout.html so downstream apps can inject custom <link>/<script> while keeping the rest of the layout.

  • Presign upload mode (upload_type="presign") ships with a dedicated template branch and lazy-loads flask.s3viewer.core.js, preserving the legacy DOM IDs (fs3viewer_files, fs3viewer_prefix, fs3viewer_progress, file_chip, floading, upload_form, fs3viewer_refresh).

  • Folder deletion now also removes the empty placeholder object created by mkdir() (previously the placeholder survived remove_all).

  • Modern UI: single Tailwind 3.4 design system, dark mode toggle (with prefers-color-scheme and localStorage persist), inline heroicons, dynamic breadcrumb.

  • HTMX 2.0.3 integration: search, navigation, pagination, upload, and delete now use partial responses (HX-Request header) without full page reloads. htmx.min.js is bundled with sha384 SRI.

  • Per-app extension registry: app.extensions["flask_s3_viewer"][namespace].

  • InvalidPrefix exception (subclass of FlaskS3ViewerError) for path-traversal violations.

  • realpath containment guard in the cache layer (defense in depth).

  • pytest + moto test suite (74 cases, 83% coverage). pip install -e ".[dev]" provides the dev tooling.

  • GitHub Actions CI: Python 3.10/3.11/3.12 matrix, ruff + mypy + pytest + build. Tailwind build drift gate.

  • pyproject.toml (PEP 621 metadata + mypy + ruff config). setup.cfg removed. setup.py slimmed to a one-line shim.

  • Type hints throughout. mypy disallow_untyped_defs, check_untyped_defs, no_implicit_optional, etc. (0 issues).

  • frontend/ directory with Tailwind build pipeline (devDependency only — excluded from sdist via prune frontend).

  • MIGRATION.md upgrade guide.

Fixed

  • Cache directory creation no longer crashes when base_path starts with /.

  • error.html now uses the correct FS3V_CODE / FS3V_MESSAGE variables (previously bound to {{ message }} / {{ code }} and silently empty).

  • __init__.py defaults profile_name so the bucket config namedtuple cannot raise on missing key.

  • files_delete now maps InvalidPrefix to HTTP 400 instead of 500 (consistent with the other three entry points).

Deferred to v1.1

  • mypy strict = true upgrade.

Authentication — STS AssumeRole + MFA

  • AWSSession now supports explicit role_arn based STS AssumeRole on top of the base credentials. Combined options: role_arn, role_session_name (default "flask-s3-viewer"), external_id (cross-account), duration_seconds, mfa_serial, token_code (or token_code_callback for interactive prompts). All options live inside the config dict so the constructor surface is unchanged.

  • Without role_arn the legacy direct-credential path is preserved — no STS call is made. boto3’s default credential chain (env vars, named profile, IMDS, ECS task role, IRSA, SSO cache, profile-based role_arn in ~/.aws/config) keeps working as before.

Customization — template overrides

  • New constructor argument template_folder lets the deployer point the viewer at a directory of Jinja overrides. The extension prepends a FileSystemLoader(template_folder) to the app’s Jinja loader using Flask’s ChoiceLoader pattern, so any user-edited template wins while not-overridden ones still resolve against the bundle. Other blueprints’ template resolution is untouched.

  • CLI: flask_s3_viewer -p ./out already copied the bundled templates/ directory; the new --with-static flag additionally copies static/ (css/app.css, vendor/htmx.min.js, js/flask.s3viewer.core.js) so designers can fork the whole UI bundle in one step.

HTTP — Range requests

  • GET /<ns>/files/<key> now honors the Range header (RFC 7233). Well-formed ranges return 206 Partial Content with Content-Range and Content-Length populated from the boto3 response; every download response advertises Accept-Ranges: bytes so resume-aware clients (video/audio players, curl -C -, mobile downloaders) can use it. Malformed / unsatisfiable ranges map to 416 Range Not Satisfiable via a new InvalidRangeError. Backwards-compatible: legacy full-object downloads (no Range header) still return 200 OK.

Tests

  • tests/test_error_paths.py (7 cases) — boto3 ClientError recovery contract: find_one / is_exists / mkdir degrade gracefully; add_one / remove_one / post_presign re-raise. Plus an AWSSession construction-failure path where a non-ClientError Exception leaves runnable=False.

  • tests/test_download.py (6 cases) — Content-Disposition encoding branches: ASCII filename, RFC 5987 UTF-8 fallback for non-latin1 filenames (e.g. 한글.txt), MIME passthrough from boto3, no-cache headers, and 1 MiB streaming round-trip through Response(direct_passthrough=True).

  • Total: 99 tests, 84% line coverage (aws/s3.py 68 → 73 %, aws/session.py 73 → 86 %, view.py 83 % stable).

  • tests/test_extras.py (31 cases) — coverage-gap fillers: constructor guards (unknown upload_type, missing cache_dir, template_namespace deprecation warning, object_hostname trailing slash); deferred init_app(app) flow; add_new_one multi-bucket; get_boto_client/get_boto_session staticmethods; _resolve_logo branches; errors.py raisers; S3 wrapper utilities (find_all, remove polymorphism, root-delete guard, download_one); view branches (unknown ns 404, mkdir create + 409 conflict, upload success + 409 conflict + overwrite flow, idempotent delete, delete traversal 400). Final: 130 tests, 91% line coverage.

Lint

  • Enable ruff UP rule (PEP 585 / 604). Typing imports modernized across the codebase: Optional[X]X | None, List[X]list[X], Set[X]set[X], Tuple[A, B]tuple[A, B], Union[A, B]A | B. Runtime behavior unchanged (Python 3.10 floor enforces PEP 585/604 support). One legacy %-format logging.error call was rewritten to use the canonical logging.error(fmt, *args) form.

Frontend

  • flask.s3viewer.core.js slimmed from 372 → 159 lines. All legacy URL-param, browser-detection, search, refresh-badge, and clipboard helpers were removed (HTMX owns those flows now); the file is now a focused presigned-POST → S3 fan-out client. Public surface preserved: readyFileHandling, uploadFiles, preventDefaults. Required DOM contract (fs3viewer_files, fs3viewer_prefix, fs3viewer_progress, file_chip, file_count, floading, upload_form, fs3viewer_refresh) is pinned by tests/test_presign.py.

Packaging

  • Adopted PEP 639 license expression (license = "MIT" + license-files = ["LICENSE"]) and dropped the deprecated {file = "LICENSE"} table form. Built artifacts now declare License-Expression: MIT in their METADATA.


0.3.1

7 November 2024

0.2.6

19 July 2024

0.2.5

19 July 2024

0.2.4

22 May 2024

0.2.2

7 September 2021

0.2.1

27 November 2020

0.2.0

31 October 2020

0.1.2

31 October 2020

0.1.1

30 April 2020

0.1.0

30 April 2020

0.0.16

26 April 2020

0.0.15

16 April 2020

0.0.14

14 April 2020

0.0.13

14 April 2020

0.0.12

10 April 2020

0.0.11

7 April 2020

0.0.10

7 April 2020

0.0.9

7 April 2020

0.0.8

6 April 2020

0.0.7

6 April 2020