-
-
Notifications
You must be signed in to change notification settings - Fork 36
Optimize page loads and stabilize diagnostics charts #191
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
4ff89c6
Optimize initial page load and static delivery
bifrost0x 20e62ff
Bind static cache keys to asset content
bifrost0x 859cfa9
Index trusted static asset paths
bifrost0x 951951a
Keep diagnostics charts stable across navigation
bifrost0x 127d212
Keep static HEAD metadata representation-safe
bifrost0x 0a740ec
Negotiate static encoding before validators
bifrost0x 16641ab
Classify static cache policy after preconditions
bifrost0x File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,241 @@ | ||
| """Safe delivery policy for public, application-owned static assets.""" | ||
|
|
||
| import gzip | ||
| import hashlib | ||
| import re | ||
| from functools import lru_cache, wraps | ||
| from pathlib import Path | ||
|
|
||
| from flask import request, url_for | ||
| from flask.sessions import SecureCookieSessionInterface | ||
|
|
||
|
|
||
| _VERSION_PATTERN = re.compile(r"[0-9a-f]{16}") | ||
| _COMPRESSIBLE_MIMETYPES = ( | ||
| "application/javascript", | ||
| "application/x-javascript", | ||
| "image/svg+xml", | ||
| "text/css", | ||
| "text/javascript", | ||
| ) | ||
| _ASSET_INDEX_EXTENSION = "static_delivery_asset_paths" | ||
| _DEFERRED_CONDITIONAL_ENV = "webssh.static_delivery.deferred_conditional" | ||
| _CONDITIONAL_ENV_KEYS = ( | ||
| "HTTP_IF_MATCH", | ||
| "HTTP_IF_NONE_MATCH", | ||
| "HTTP_IF_MODIFIED_SINCE", | ||
| "HTTP_IF_UNMODIFIED_SINCE", | ||
| ) | ||
|
|
||
|
|
||
| def _remove_cookie_variance(response) -> None: | ||
| response.vary = [ | ||
| value for value in response.vary | ||
| if value.casefold() != "cookie" | ||
| ] | ||
|
|
||
|
|
||
| def _add_accept_encoding_variance(response) -> None: | ||
| if all(value.casefold() != "accept-encoding" for value in response.vary): | ||
| response.vary = [*response.vary, "Accept-Encoding"] | ||
|
|
||
|
|
||
| def _build_static_asset_index(app) -> dict[str, Path]: | ||
| """Index trusted files without joining request-controlled path segments.""" | ||
| if not app.static_folder: | ||
| raise RuntimeError("static delivery requires an application static folder") | ||
| try: | ||
| static_root = Path(app.static_folder).resolve(strict=True) | ||
| except OSError as exc: | ||
| raise RuntimeError("application static folder does not exist") from exc | ||
|
|
||
| asset_paths = {} | ||
| for candidate in static_root.rglob("*"): | ||
| try: | ||
| resolved = candidate.resolve(strict=True) | ||
| resolved.relative_to(static_root) | ||
| except (OSError, ValueError): | ||
| continue | ||
| if resolved.is_file(): | ||
| asset_paths[candidate.relative_to(static_root).as_posix()] = resolved | ||
| return asset_paths | ||
|
|
||
|
|
||
| @lru_cache(maxsize=512) | ||
| def _content_version( | ||
| path: str, | ||
| modified_ns: int, | ||
| changed_ns: int, | ||
| size: int, | ||
| ) -> str: | ||
| del modified_ns, changed_ns, size | ||
| digest = hashlib.sha256() | ||
| with open(path, "rb") as asset: | ||
| for chunk in iter(lambda: asset.read(1024 * 1024), b""): | ||
| digest.update(chunk) | ||
| return digest.hexdigest()[:16] | ||
|
|
||
|
|
||
| def static_asset_version(app, filename: str) -> str | None: | ||
| """Return a content-derived cache key for one local static asset.""" | ||
| asset_paths = app.extensions.get(_ASSET_INDEX_EXTENSION, {}) | ||
| asset_path = asset_paths.get(filename) | ||
| if asset_path is None: | ||
| return None | ||
| try: | ||
| stat = asset_path.stat() | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| return _content_version( | ||
| str(asset_path), | ||
| stat.st_mtime_ns, | ||
| stat.st_ctime_ns, | ||
| stat.st_size, | ||
| ) | ||
| except OSError: | ||
| return None | ||
|
|
||
|
|
||
| def _compress_static_response(response): | ||
| """Apply deterministic gzip with an encoding-specific validator.""" | ||
| _add_accept_encoding_variance(response) | ||
| if ( | ||
| request.method not in {"GET", "HEAD"} | ||
| or response.status_code != 200 | ||
|
bifrost0x marked this conversation as resolved.
|
||
| or "Content-Range" in response.headers | ||
| or "Content-Encoding" in response.headers | ||
| or request.accept_encodings.best_match(("gzip", "identity")) != "gzip" | ||
| or ( | ||
| response.content_length is not None | ||
| and response.content_length < 1024 | ||
| ) | ||
| ): | ||
| return response | ||
|
|
||
| response.direct_passthrough = False | ||
| plain = response.get_data() | ||
| compressed = gzip.compress(plain, compresslevel=6, mtime=0) | ||
| if len(compressed) >= len(plain): | ||
| return response | ||
|
|
||
| etag, weak = response.get_etag() | ||
| if etag: | ||
| response.set_etag(f"{etag}:gzip", weak=weak) | ||
| response.set_data(compressed) | ||
| response.headers["Content-Encoding"] = "gzip" | ||
| response.headers["Content-Length"] = str(len(compressed)) | ||
| return response.make_conditional(request) | ||
|
|
||
|
|
||
| def _apply_deferred_static_condition(response): | ||
| deferred = request.environ.pop(_DEFERRED_CONDITIONAL_ENV, False) | ||
| if not deferred or response.status_code != 200: | ||
| return response | ||
| return response.make_conditional(request) | ||
|
|
||
|
|
||
| class _StaticAwareSessionInterface(SecureCookieSessionInterface): | ||
| """Remove artificial cookie variance after Flask saves the session.""" | ||
|
|
||
| def save_session(self, app, session, response) -> None: | ||
| super().save_session(app, session, response) | ||
| if request.endpoint != "static": | ||
| return | ||
| if response.headers.getlist("Set-Cookie"): | ||
| response.headers["Cache-Control"] = "private, no-store" | ||
| else: | ||
| _remove_cookie_variance(response) | ||
|
|
||
|
|
||
| def _has_current_asset_version(app) -> bool: | ||
| """Return whether the only query key matches the current asset content.""" | ||
| versions = request.args.getlist("v") | ||
| if ( | ||
| len(request.args) != 1 | ||
| or len(versions) != 1 | ||
| or _VERSION_PATTERN.fullmatch(versions[0]) is None | ||
| ): | ||
| return False | ||
| filename = (request.view_args or {}).get("filename") | ||
| if not isinstance(filename, str): | ||
| return False | ||
| return versions[0] == static_asset_version(app, filename) | ||
|
|
||
|
|
||
| def init_static_delivery(app) -> None: | ||
| """Enable compression and explicit caching only for Flask's static route. | ||
|
|
||
| Dynamic HTML and API responses are deliberately excluded so secrets, CSRF | ||
| tokens, and user-specific data never share a compression context. | ||
| """ | ||
| if not isinstance(app.session_interface, SecureCookieSessionInterface): | ||
| raise RuntimeError( | ||
| "static delivery requires Flask's secure-cookie session interface" | ||
| ) | ||
| app.session_interface = _StaticAwareSessionInterface() | ||
| app.extensions[_ASSET_INDEX_EXTENSION] = _build_static_asset_index(app) | ||
|
|
||
| static_view = app.view_functions.get("static") | ||
| if static_view is None: | ||
| raise RuntimeError("static delivery requires Flask's static endpoint") | ||
|
|
||
| @wraps(static_view) | ||
| def serve_static_with_negotiated_conditionals(**values): | ||
| deferred_headers = {} | ||
| if ( | ||
| request.method in {"GET", "HEAD"} | ||
| and "Range" not in request.headers | ||
| and request.accept_encodings.best_match(("gzip", "identity")) == "gzip" | ||
| ): | ||
| for key in _CONDITIONAL_ENV_KEYS: | ||
| value = request.environ.pop(key, None) | ||
| if value is not None: | ||
| deferred_headers[key] = value | ||
| if deferred_headers: | ||
| request.environ[_DEFERRED_CONDITIONAL_ENV] = True | ||
| try: | ||
| return static_view(**values) | ||
| finally: | ||
| request.environ.update(deferred_headers) | ||
|
|
||
| app.view_functions["static"] = serve_static_with_negotiated_conditionals | ||
|
|
||
| def static_asset_url(filename: str) -> str: | ||
| version = static_asset_version(app, filename) | ||
| if version is None: | ||
| raise FileNotFoundError(f"static asset does not exist: {filename}") | ||
| return url_for("static", filename=filename, v=version) | ||
|
|
||
| app.jinja_env.globals["static_asset_url"] = static_asset_url | ||
|
|
||
| @app.after_request | ||
| def optimize_static_delivery(response): | ||
| if request.endpoint != "static": | ||
| return response | ||
|
|
||
| # Flask-Login checks its remember-cookie marker after every response, | ||
| # which marks the session as accessed even though static content does | ||
| # not depend on it. Remove that artificial variance for shared caches. | ||
| _remove_cookie_variance(response) | ||
|
|
||
| if response.mimetype in _COMPRESSIBLE_MIMETYPES: | ||
| response = _compress_static_response(response) | ||
| response = _apply_deferred_static_condition(response) | ||
|
|
||
| if response.headers.getlist("Set-Cookie"): | ||
| response.headers["Cache-Control"] = "private, no-store" | ||
| elif ( | ||
| request.method in {"GET", "HEAD"} | ||
| and response.status_code in {200, 206, 304} | ||
| and _has_current_asset_version(app) | ||
| ): | ||
| response.headers["Cache-Control"] = ( | ||
| "public, max-age=31536000, immutable" | ||
| ) | ||
| elif request.method not in {"GET", "HEAD"} or request.args: | ||
| response.headers["Cache-Control"] = "no-store" | ||
| else: | ||
| response.headers["Cache-Control"] = ( | ||
| "public, max-age=0, must-revalidate" | ||
| ) | ||
| response.headers.pop("Pragma", None) | ||
| response.headers.pop("Expires", None) | ||
| return response | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| #!/usr/bin/env node | ||
|
|
||
| const fs = require('node:fs'); | ||
| const path = require('node:path'); | ||
| const vm = require('node:vm'); | ||
|
|
||
| const projectRoot = path.resolve(__dirname, '..'); | ||
| const sourcePath = path.join(projectRoot, 'static/js/i18n.js'); | ||
| const outputPath = path.join(projectRoot, 'static/js/i18n-auth.js'); | ||
| const surfacePaths = [ | ||
| 'templates/login.html', | ||
| 'templates/register.html', | ||
| 'templates/change_password.html', | ||
| 'static/js/auth.js', | ||
| 'static/js/webauthn.js', | ||
| 'static/js/security-ui.js', | ||
| ]; | ||
|
|
||
| function read(relativePath) { | ||
| return fs.readFileSync(path.join(projectRoot, relativePath), 'utf8'); | ||
| } | ||
|
|
||
| function parseTranslations(source) { | ||
| const declaration = 'const translations = '; | ||
| const runtimeMarker = 'const BrowserPreferences = '; | ||
| const objectStart = source.indexOf(declaration); | ||
| const runtimeStart = source.indexOf(runtimeMarker); | ||
| if (objectStart < 0 || runtimeStart < 0 || runtimeStart <= objectStart) { | ||
| throw new Error('Unable to locate the translation table and runtime.'); | ||
| } | ||
| const objectLiteral = source | ||
| .slice(objectStart + declaration.length, runtimeStart) | ||
| .trim() | ||
| .replace(/;$/, ''); | ||
| const translations = vm.runInNewContext(`(${objectLiteral})`, Object.create(null)); | ||
| return { | ||
| translations, | ||
| runtime: source.slice(runtimeStart).trimStart(), | ||
| }; | ||
| } | ||
|
|
||
| function referencedTranslationKeys(translations) { | ||
| const availableKeys = new Set(Object.keys(translations.en || {})); | ||
| const keys = new Set(); | ||
| const keyPattern = /["']([a-z][a-zA-Z0-9_-]*\.[a-zA-Z0-9_.-]+)["']/g; | ||
|
|
||
| for (const relativePath of surfacePaths) { | ||
| const source = read(relativePath); | ||
| let match; | ||
| while ((match = keyPattern.exec(source)) !== null) { | ||
| if (availableKeys.has(match[1])) { | ||
| keys.add(match[1]); | ||
| } | ||
| } | ||
| } | ||
| return [...keys].sort(); | ||
| } | ||
|
|
||
| function build() { | ||
| const fullSource = fs.readFileSync(sourcePath, 'utf8'); | ||
| const { translations, runtime } = parseTranslations(fullSource); | ||
| const locales = Object.keys(translations); | ||
| const keys = referencedTranslationKeys(translations); | ||
| if (locales.length === 0 || keys.length === 0) { | ||
| throw new Error('Refusing to generate an empty authentication bundle.'); | ||
| } | ||
|
|
||
| const selected = Object.fromEntries(locales.map(locale => { | ||
| const localeTranslations = translations[locale]; | ||
| const missing = keys.filter(key => !(key in localeTranslations)); | ||
| if (missing.length > 0) { | ||
| throw new Error(`${locale} is missing authentication keys: ${missing.join(', ')}`); | ||
| } | ||
| return [locale, Object.fromEntries(keys.map(key => [key, localeTranslations[key]]))]; | ||
| })); | ||
|
|
||
| return [ | ||
| '// Generated by scripts/build-auth-i18n.js. Do not edit directly.', | ||
| `const translations = ${JSON.stringify(selected, null, 4)};`, | ||
| '', | ||
| runtime.trimEnd(), | ||
| '', | ||
| ].join('\n'); | ||
| } | ||
|
|
||
| const generated = build(); | ||
| if (process.argv.includes('--check')) { | ||
| const committed = fs.existsSync(outputPath) | ||
| ? fs.readFileSync(outputPath, 'utf8') | ||
| : ''; | ||
| if (committed !== generated) { | ||
| console.error('static/js/i18n-auth.js is stale; run npm run i18n:auth.'); | ||
| process.exitCode = 1; | ||
| } | ||
| } else { | ||
| fs.writeFileSync(outputPath, generated, 'utf8'); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.