painapple-code 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- painapple_code/__init__.py +30 -0
- painapple_code/__main__.py +18 -0
- painapple_code/_version.py +24 -0
- painapple_code/auth_middleware.py +930 -0
- painapple_code/cli/__init__.py +204 -0
- painapple_code/cli/deploy/__init__.py +15 -0
- painapple_code/cli/deploy/claude_seed.py +41 -0
- painapple_code/cli/deploy/config.py +260 -0
- painapple_code/cli/deploy/container.py +715 -0
- painapple_code/cli/deploy/launch.py +163 -0
- painapple_code/cli/deploy/runtime.py +223 -0
- painapple_code/cli/lifecycle_cmd.py +443 -0
- painapple_code/cli/list_cmd.py +572 -0
- painapple_code/cli/manage_cmd.py +670 -0
- painapple_code/cli/netinfo.py +125 -0
- painapple_code/cli/profiles.py +390 -0
- painapple_code/cli/serve_args.py +163 -0
- painapple_code/cli/serve_config.py +256 -0
- painapple_code/cli/setup_cmd.py +664 -0
- painapple_code/cli/ui.py +438 -0
- painapple_code/cost_analytics.py +879 -0
- painapple_code/data/models.defaults.yaml +18 -0
- painapple_code/data/models.yaml +18 -0
- painapple_code/data/presets.defaults.json +32 -0
- painapple_code/data/strings.yaml +2269 -0
- painapple_code/helpers.py +406 -0
- painapple_code/paths.py +1514 -0
- painapple_code/prompt_explorer.py +984 -0
- painapple_code/providers/__init__.py +224 -0
- painapple_code/providers/base.py +710 -0
- painapple_code/providers/claude/__init__.py +74 -0
- painapple_code/providers/claude/capabilities.py +218 -0
- painapple_code/providers/claude/errors.py +79 -0
- painapple_code/providers/claude/launch.py +50 -0
- painapple_code/providers/claude/summary.py +89 -0
- painapple_code/providers/claude/translate.py +74 -0
- painapple_code/providers/claude_sdk/__init__.py +139 -0
- painapple_code/providers/claude_sdk/driver.py +412 -0
- painapple_code/providers/codex/__init__.py +87 -0
- painapple_code/providers/codex/capabilities.py +321 -0
- painapple_code/providers/codex/errors.py +70 -0
- painapple_code/providers/codex/launch.py +142 -0
- painapple_code/providers/codex/summary.py +196 -0
- painapple_code/providers/codex/translate.py +204 -0
- painapple_code/providers/codex_app_server/__init__.py +100 -0
- painapple_code/providers/codex_app_server/capabilities.py +41 -0
- painapple_code/providers/codex_app_server/launch.py +202 -0
- painapple_code/providers/codex_app_server/summary.py +291 -0
- painapple_code/providers/codex_app_server/translate.py +263 -0
- painapple_code/providers/codex_app_server/transport.py +238 -0
- painapple_code/routes/__init__.py +0 -0
- painapple_code/routes/api_agents.py +441 -0
- painapple_code/routes/api_app.py +368 -0
- painapple_code/routes/api_app_commit_sections.py +127 -0
- painapple_code/routes/api_app_config.py +997 -0
- painapple_code/routes/api_app_session_prefs.py +217 -0
- painapple_code/routes/api_browser.py +690 -0
- painapple_code/routes/api_commands.py +374 -0
- painapple_code/routes/api_costs.py +128 -0
- painapple_code/routes/api_drafts.py +139 -0
- painapple_code/routes/api_exec.py +149 -0
- painapple_code/routes/api_files.py +542 -0
- painapple_code/routes/api_git.py +657 -0
- painapple_code/routes/api_logs.py +649 -0
- painapple_code/routes/api_plugins.py +275 -0
- painapple_code/routes/api_project_config.py +418 -0
- painapple_code/routes/api_prompts.py +231 -0
- painapple_code/routes/api_providers.py +53 -0
- painapple_code/routes/api_search.py +411 -0
- painapple_code/routes/api_session_stash.py +234 -0
- painapple_code/routes/api_session_welcome.py +151 -0
- painapple_code/routes/api_sessions.py +742 -0
- painapple_code/routes/api_shadow.py +323 -0
- painapple_code/routes/api_shadow_db.py +512 -0
- painapple_code/routes/api_shadow_files.py +332 -0
- painapple_code/routes/api_shadow_search.py +476 -0
- painapple_code/routes/api_skills.py +610 -0
- painapple_code/routes/api_tasks.py +147 -0
- painapple_code/routes/api_terminal.py +540 -0
- painapple_code/routes/api_upload.py +293 -0
- painapple_code/routes/api_viewer.py +229 -0
- painapple_code/routes/dependencies.py +109 -0
- painapple_code/routes/ws_chat.py +837 -0
- painapple_code/server.py +2208 -0
- painapple_code/server_logging.py +263 -0
- painapple_code/services/__init__.py +0 -0
- painapple_code/services/agent_session.py +2738 -0
- painapple_code/session_store.py +253 -0
- painapple_code/session_store_core.py +879 -0
- painapple_code/shadow_db.py +830 -0
- painapple_code/shadow_db_plans.py +369 -0
- painapple_code/shadow_db_queries.py +595 -0
- painapple_code/shadow_db_schema.py +676 -0
- painapple_code/shadow_git.py +1296 -0
- painapple_code/shadow_git_frontmatter.py +314 -0
- painapple_code/shadow_git_sections.py +520 -0
- painapple_code/shadow_git_summary.py +462 -0
- painapple_code/shadow_parser.py +560 -0
- painapple_code/static/css/00-variables.css +276 -0
- painapple_code/static/css/01-base.css +245 -0
- painapple_code/static/css/02-tooltips.css +36 -0
- painapple_code/static/css/09-left-rail.css +448 -0
- painapple_code/static/css/10-header.css +777 -0
- painapple_code/static/css/11-connection-bar.css +120 -0
- painapple_code/static/css/20-messages.css +750 -0
- painapple_code/static/css/21-tool-blocks.css +2340 -0
- painapple_code/static/css/22-edit-diff.css +619 -0
- painapple_code/static/css/23-thinking.css +1584 -0
- painapple_code/static/css/24-background-tasks.css +359 -0
- painapple_code/static/css/25-context-block.css +252 -0
- painapple_code/static/css/26-session-setup.css +144 -0
- painapple_code/static/css/30-markdown.css +854 -0
- painapple_code/static/css/40-input.css +2418 -0
- painapple_code/static/css/41-autocomplete.css +127 -0
- painapple_code/static/css/42-activity-strip.css +97 -0
- painapple_code/static/css/43-file-autocomplete.css +121 -0
- painapple_code/static/css/44-snippets-autocomplete.css +224 -0
- painapple_code/static/css/45-selection-bar.css +433 -0
- painapple_code/static/css/46-keyboard-bar.css +119 -0
- painapple_code/static/css/47-keyboard-longpress.css +116 -0
- painapple_code/static/css/47-token-profile.css +106 -0
- painapple_code/static/css/50-welcome.css +3095 -0
- painapple_code/static/css/51-modals.css +301 -0
- painapple_code/static/css/52-utilities.css +77 -0
- painapple_code/static/css/53-image-upload.css +456 -0
- painapple_code/static/css/54-todo-list.css +102 -0
- painapple_code/static/css/55-file-upload.css +113 -0
- painapple_code/static/css/56-chat-navigator.css +218 -0
- painapple_code/static/css/56-permission-card.css +332 -0
- painapple_code/static/css/56-plan-approval.css +204 -0
- painapple_code/static/css/56-question-form.css +602 -0
- painapple_code/static/css/57-swipe-indicator.css +95 -0
- painapple_code/static/css/58-session-families.css +627 -0
- painapple_code/static/css/59-about.css +196 -0
- painapple_code/static/css/61-log-explorer.css +723 -0
- painapple_code/static/css/62-json-tree.css +489 -0
- painapple_code/static/css/62-skills-widget.css +650 -0
- painapple_code/static/css/63-commands-widget.css +30 -0
- painapple_code/static/css/63-plugins-widget.css +174 -0
- painapple_code/static/css/63-terminal.css +516 -0
- painapple_code/static/css/64-search-files-widget.css +448 -0
- painapple_code/static/css/65-snippets-widget.css +426 -0
- painapple_code/static/css/66-active-sessions.css +979 -0
- painapple_code/static/css/66-agents-widget.css +265 -0
- painapple_code/static/css/66-file-explorer-widget.css +855 -0
- painapple_code/static/css/66-git-panel.css +791 -0
- painapple_code/static/css/66-history-explorer.css +1496 -0
- painapple_code/static/css/67-diff-viewer.css +602 -0
- painapple_code/static/css/67-editor-view.css +748 -0
- painapple_code/static/css/67-file-preview-widget.css +1873 -0
- painapple_code/static/css/68-browser-widget.css +337 -0
- painapple_code/static/css/68-compare-wizard.css +355 -0
- painapple_code/static/css/68-csv-preview.css +106 -0
- painapple_code/static/css/68-diff-preview.css +103 -0
- painapple_code/static/css/68-helpers-install-widget.css +491 -0
- painapple_code/static/css/68-image-annotate.css +346 -0
- painapple_code/static/css/68-terminal-view.css +98 -0
- painapple_code/static/css/68-uploads-widget.css +229 -0
- painapple_code/static/css/69-cost-analytics.css +537 -0
- painapple_code/static/css/70-config-panel.css +3736 -0
- painapple_code/static/css/70-excalidraw.css +136 -0
- painapple_code/static/css/70-prompt-explorer.css +890 -0
- painapple_code/static/css/71-chart.css +111 -0
- painapple_code/static/css/72-discussion-sidebar.css +678 -0
- painapple_code/static/css/74-debug-logs.css +656 -0
- painapple_code/static/css/80-open-dialog.css +146 -0
- painapple_code/static/css/80-quick-switcher.css +383 -0
- painapple_code/static/css/80-widget-system.css +1763 -0
- painapple_code/static/css/81-quick-actions.css +1020 -0
- painapple_code/static/css/82-context-popover.css +1233 -0
- painapple_code/static/css/83-context-menu.css +339 -0
- painapple_code/static/css/85-lazy-loading.css +102 -0
- painapple_code/static/css/86-zen-mode.css +708 -0
- painapple_code/static/css/87-grid-switcher.css +346 -0
- painapple_code/static/css/login.css +198 -0
- painapple_code/static/dist/app.bundle.js +7177 -0
- painapple_code/static/feature-triage.html +446 -0
- painapple_code/static/icons/apple-touch-icon.png +0 -0
- painapple_code/static/icons/favicon-32.png +0 -0
- painapple_code/static/icons/favicon.ico +0 -0
- painapple_code/static/icons/icon-128.png +0 -0
- painapple_code/static/icons/icon-144.png +0 -0
- painapple_code/static/icons/icon-152.png +0 -0
- painapple_code/static/icons/icon-180.png +0 -0
- painapple_code/static/icons/icon-192.png +0 -0
- painapple_code/static/icons/icon-384.png +0 -0
- painapple_code/static/icons/icon-512.png +0 -0
- painapple_code/static/icons/icon-72.png +0 -0
- painapple_code/static/icons/icon-96.png +0 -0
- painapple_code/static/js/action-delegate.js +187 -0
- painapple_code/static/js/activity-strip.js +193 -0
- painapple_code/static/js/app/context-menus.js +619 -0
- painapple_code/static/js/app/file-preview.js +163 -0
- painapple_code/static/js/app/init-ui.js +574 -0
- painapple_code/static/js/app/input-events.js +561 -0
- painapple_code/static/js/app/input-resize.js +313 -0
- painapple_code/static/js/app/message-actions.js +419 -0
- painapple_code/static/js/app/panels.js +210 -0
- painapple_code/static/js/app/rendering-delegators.js +246 -0
- painapple_code/static/js/app/server-loading.js +365 -0
- painapple_code/static/js/app/session-ops.js +287 -0
- painapple_code/static/js/app/session-switch.js +443 -0
- painapple_code/static/js/app/settings-helpers.js +280 -0
- painapple_code/static/js/app/startup-welcome.js +290 -0
- painapple_code/static/js/app-context.js +157 -0
- painapple_code/static/js/app.js +2134 -0
- painapple_code/static/js/auth-fetch.js +46 -0
- painapple_code/static/js/background-tasks.js +274 -0
- painapple_code/static/js/boot.js +73 -0
- painapple_code/static/js/caret-position.js +171 -0
- painapple_code/static/js/chat-navigator.js +710 -0
- painapple_code/static/js/chat-search.js +323 -0
- painapple_code/static/js/command-executor.js +176 -0
- painapple_code/static/js/command-store.js +497 -0
- painapple_code/static/js/components.js +1414 -0
- painapple_code/static/js/config.js +218 -0
- painapple_code/static/js/context-menu.js +740 -0
- painapple_code/static/js/controllers/chat-controller.js +4252 -0
- painapple_code/static/js/controllers/dialog-controller.js +217 -0
- painapple_code/static/js/controllers/tab-controller.js +1828 -0
- painapple_code/static/js/controllers/thinking-controller.js +915 -0
- painapple_code/static/js/diff-utils.js +486 -0
- painapple_code/static/js/drafts-pill.js +95 -0
- painapple_code/static/js/editor-view.js +815 -0
- painapple_code/static/js/effort-settings.js +430 -0
- painapple_code/static/js/feature-triage.js +811 -0
- painapple_code/static/js/file-autocomplete.js +516 -0
- painapple_code/static/js/file-tabs.js +123 -0
- painapple_code/static/js/gestures.js +448 -0
- painapple_code/static/js/grid-switcher.js +334 -0
- painapple_code/static/js/input-handler.js +1472 -0
- painapple_code/static/js/keyboard-bar.js +495 -0
- painapple_code/static/js/layout-switcher.js +109 -0
- painapple_code/static/js/linkify-utils.js +200 -0
- painapple_code/static/js/login.js +141 -0
- painapple_code/static/js/open-dialog.js +879 -0
- painapple_code/static/js/orphan-terminals.js +373 -0
- painapple_code/static/js/path-utils.js +179 -0
- painapple_code/static/js/perf-marks.js +102 -0
- painapple_code/static/js/permission-settings.js +475 -0
- painapple_code/static/js/pointer-input.js +59 -0
- painapple_code/static/js/preview/json-tree-menu.js +226 -0
- painapple_code/static/js/preview/json-tree-toolbar.js +291 -0
- painapple_code/static/js/preview/json-tree.js +457 -0
- painapple_code/static/js/preview/preview-breadcrumb.js +387 -0
- painapple_code/static/js/preview/preview-edit.js +367 -0
- painapple_code/static/js/preview/preview-events.js +361 -0
- painapple_code/static/js/preview/preview-history.js +567 -0
- painapple_code/static/js/preview/preview-inline-edit.js +869 -0
- painapple_code/static/js/preview/preview-poll.js +108 -0
- painapple_code/static/js/preview/preview-render.js +572 -0
- painapple_code/static/js/preview/preview-search.js +421 -0
- painapple_code/static/js/preview/preview-state.js +256 -0
- painapple_code/static/js/preview/preview-utils.js +251 -0
- painapple_code/static/js/preview-plugins/chart-plugin.js +22 -0
- painapple_code/static/js/preview-plugins/csv-plugin.js +188 -0
- painapple_code/static/js/preview-plugins/diff-plugin.js +258 -0
- painapple_code/static/js/preview-plugins/excalidraw-plugin.js +25 -0
- painapple_code/static/js/preview-plugins/html-plugin.js +104 -0
- painapple_code/static/js/preview-plugins/image-plugin.js +20 -0
- painapple_code/static/js/preview-plugins/index.js +34 -0
- painapple_code/static/js/preview-plugins/json-plugin.js +99 -0
- painapple_code/static/js/preview-plugins/jsonl-plugin.js +152 -0
- painapple_code/static/js/preview-plugins/markdown-plugin.js +266 -0
- painapple_code/static/js/preview-plugins/panzoom-plugin.js +63 -0
- painapple_code/static/js/preview-plugins/plugin-helpers.js +282 -0
- painapple_code/static/js/project-color-picker.js +110 -0
- painapple_code/static/js/project-colors.js +163 -0
- painapple_code/static/js/prompt-favorites.js +187 -0
- painapple_code/static/js/quick-actions-menu.js +1736 -0
- painapple_code/static/js/quick-actions-registry.js +1239 -0
- painapple_code/static/js/quick-switcher/controller.js +294 -0
- painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
- painapple_code/static/js/quick-switcher/index.js +30 -0
- painapple_code/static/js/quick-switcher/providers/base-provider.js +51 -0
- painapple_code/static/js/quick-switcher/providers/command-provider.js +56 -0
- painapple_code/static/js/quick-switcher/providers/file-provider.js +291 -0
- painapple_code/static/js/quick-switcher/providers/panel-provider.js +132 -0
- painapple_code/static/js/quick-switcher/providers/project-provider.js +361 -0
- painapple_code/static/js/quick-switcher/providers/read-files-provider.js +104 -0
- painapple_code/static/js/quick-switcher/providers/skills-provider.js +129 -0
- painapple_code/static/js/quick-switcher/registry.js +69 -0
- painapple_code/static/js/quick-switcher/ui/item.js +54 -0
- painapple_code/static/js/quick-switcher/ui/picker.js +464 -0
- painapple_code/static/js/recent-opens.js +40 -0
- painapple_code/static/js/scroll-manager.js +530 -0
- painapple_code/static/js/scroll-state-machine.js +359 -0
- painapple_code/static/js/selection/action-bar.js +793 -0
- painapple_code/static/js/selection/index.js +15 -0
- painapple_code/static/js/selection/selection-handler.js +877 -0
- painapple_code/static/js/selection/state.js +181 -0
- painapple_code/static/js/session/agent-progress.js +202 -0
- painapple_code/static/js/session/handle-agent-message.js +481 -0
- painapple_code/static/js/session/handle-message.js +550 -0
- painapple_code/static/js/session/interactive.js +587 -0
- painapple_code/static/js/session/message-store.js +271 -0
- painapple_code/static/js/session/messages.js +253 -0
- painapple_code/static/js/session/persistence.js +179 -0
- painapple_code/static/js/session/restore.js +136 -0
- painapple_code/static/js/session/sync.js +333 -0
- painapple_code/static/js/session-container-pool.js +371 -0
- painapple_code/static/js/session-setup-panel.js +274 -0
- painapple_code/static/js/session.js +1047 -0
- painapple_code/static/js/shortcut-hints.js +272 -0
- painapple_code/static/js/shortcuts.js +1146 -0
- painapple_code/static/js/skills-autocomplete.js +262 -0
- painapple_code/static/js/snippets-autocomplete.js +738 -0
- painapple_code/static/js/stash-refs-view.js +83 -0
- painapple_code/static/js/stash-ui.js +768 -0
- painapple_code/static/js/stash.js +785 -0
- painapple_code/static/js/status-bar.js +1184 -0
- painapple_code/static/js/token-profile.js +274 -0
- painapple_code/static/js/tool-renderer-blocks.js +1626 -0
- painapple_code/static/js/tool-renderer-thinking.js +813 -0
- painapple_code/static/js/tool-renderer.js +1500 -0
- painapple_code/static/js/tooltips.js +197 -0
- painapple_code/static/js/upload-manager.js +1019 -0
- painapple_code/static/js/utils.js +521 -0
- painapple_code/static/js/welcome/api.js +363 -0
- painapple_code/static/js/welcome/cards.js +626 -0
- painapple_code/static/js/welcome/context-menu.js +585 -0
- painapple_code/static/js/welcome/families.js +801 -0
- painapple_code/static/js/welcome/preview.js +234 -0
- painapple_code/static/js/welcome/state.js +129 -0
- painapple_code/static/js/welcome.js +1874 -0
- painapple_code/static/js/widget-system/base-widget.js +717 -0
- painapple_code/static/js/widget-system/device-manager.js +178 -0
- painapple_code/static/js/widget-system/event-bus.js +152 -0
- painapple_code/static/js/widget-system/icons.js +120 -0
- painapple_code/static/js/widget-system/index.js +49 -0
- painapple_code/static/js/widget-system/init.js +54 -0
- painapple_code/static/js/widget-system/types/bottom-sheet.js +307 -0
- painapple_code/static/js/widget-system/types/floating.js +915 -0
- painapple_code/static/js/widget-system/types/index.js +40 -0
- painapple_code/static/js/widget-system/types/modal.js +147 -0
- painapple_code/static/js/widget-system/types/sidebar.js +158 -0
- painapple_code/static/js/widget-system/types/tab.js +146 -0
- painapple_code/static/js/widget-system/types/top-sheet.js +203 -0
- painapple_code/static/js/widget-system/widget-manager.js +1187 -0
- painapple_code/static/js/widgets/about-widget.js +260 -0
- painapple_code/static/js/widgets/active-sessions-widget.js +760 -0
- painapple_code/static/js/widgets/agents-widget.js +897 -0
- painapple_code/static/js/widgets/browser-widget.js +579 -0
- painapple_code/static/js/widgets/commands-widget.js +694 -0
- painapple_code/static/js/widgets/config/commit-sections.js +449 -0
- painapple_code/static/js/widgets/config/dir-autocomplete.js +224 -0
- painapple_code/static/js/widgets/config/gestures.js +161 -0
- painapple_code/static/js/widgets/config/models-tab.js +988 -0
- painapple_code/static/js/widgets/config/quick-actions-tab.js +882 -0
- painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
- painapple_code/static/js/widgets/config/state.js +517 -0
- painapple_code/static/js/widgets/config/system-controls.js +90 -0
- painapple_code/static/js/widgets/config-widget.js +1535 -0
- painapple_code/static/js/widgets/cost-analytics-widget.js +621 -0
- painapple_code/static/js/widgets/debug-widget.js +693 -0
- painapple_code/static/js/widgets/diff-viewer-widget.js +1420 -0
- painapple_code/static/js/widgets/discussion-widget.js +1107 -0
- painapple_code/static/js/widgets/file-explorer-widget.js +2166 -0
- painapple_code/static/js/widgets/file-preview-widget.js +861 -0
- painapple_code/static/js/widgets/git-widget.js +794 -0
- painapple_code/static/js/widgets/helpers-install-widget.js +720 -0
- painapple_code/static/js/widgets/history-explorer-widget.js +1804 -0
- painapple_code/static/js/widgets/image-annotate-widget.js +951 -0
- painapple_code/static/js/widgets/image-preview-widget.js +692 -0
- painapple_code/static/js/widgets/index.js +176 -0
- painapple_code/static/js/widgets/log-explorer-widget.js +1057 -0
- painapple_code/static/js/widgets/plugins-widget.js +305 -0
- painapple_code/static/js/widgets/prompt-explorer-widget.js +1119 -0
- painapple_code/static/js/widgets/search-files-widget.js +529 -0
- painapple_code/static/js/widgets/skills-widget.js +843 -0
- painapple_code/static/js/widgets/snippets-widget.js +479 -0
- painapple_code/static/js/widgets/sub-agents-widget.js +333 -0
- painapple_code/static/js/widgets/tasks-widget.js +379 -0
- painapple_code/static/js/widgets/terminal/connection.js +283 -0
- painapple_code/static/js/widgets/terminal/gestures.js +357 -0
- painapple_code/static/js/widgets/terminal/init.js +642 -0
- painapple_code/static/js/widgets/terminal/link-providers.js +532 -0
- painapple_code/static/js/widgets/terminal/osc52.js +213 -0
- painapple_code/static/js/widgets/terminal/render.js +159 -0
- painapple_code/static/js/widgets/terminal/size.js +54 -0
- painapple_code/static/js/widgets/terminal/state.js +211 -0
- painapple_code/static/js/widgets/terminal/wrap-utils.js +101 -0
- painapple_code/static/js/widgets/terminal-widget.js +793 -0
- painapple_code/static/js/widgets/uploads-widget.js +461 -0
- painapple_code/static/js/widgets/zen-widget.js +1001 -0
- painapple_code/static/login.html +51 -0
- painapple_code/static/manifest.json +73 -0
- painapple_code/static/sw.js +293 -0
- painapple_code/static/vendor/LICENSES.txt +769 -0
- painapple_code/static/vendor/README.md +103 -0
- painapple_code/static/vendor/codemirror.js +35 -0
- painapple_code/static/vendor/codemirror.js.LEGAL.txt +304 -0
- painapple_code/static/vendor/github-markdown-dark.min.css +1124 -0
- painapple_code/static/vendor/highlight-github-dark.min.css +10 -0
- painapple_code/static/vendor/highlight-lang-dockerfile.min.js +8 -0
- painapple_code/static/vendor/highlight-lang-nginx.min.js +21 -0
- painapple_code/static/vendor/highlight-lang-properties.min.js +10 -0
- painapple_code/static/vendor/highlight-lang-scala.min.js +28 -0
- painapple_code/static/vendor/highlight.min.js +1213 -0
- painapple_code/static/vendor/marked.min.js +6 -0
- painapple_code/static/vendor/purify.min.js +3 -0
- painapple_code/static/vendor/xterm-addon-fit.js +2 -0
- painapple_code/static/vendor/xterm.css +209 -0
- painapple_code/static/vendor/xterm.js +2 -0
- painapple_code/static/web-client.html +635 -0
- painapple_code/subprocess_registry.py +535 -0
- painapple_code/tls_cert.py +79 -0
- painapple_code/tools/agents/shadow-git-helper.md +400 -0
- painapple_code/tools/excalidraw-to-svg.js +143 -0
- painapple_code/tools/install-helpers.sh +148 -0
- painapple_code/tools/shadow-git +320 -0
- painapple_code/tools/shadow-query +120 -0
- painapple_code/tools/vegalite-to-svg.js +79 -0
- painapple_code/turn_query.py +766 -0
- painapple_code/turn_tracker.py +221 -0
- painapple_code/utils/__init__.py +0 -0
- painapple_code/utils/agent_cli.py +306 -0
- painapple_code/utils/chart.py +103 -0
- painapple_code/utils/excalidraw.py +107 -0
- painapple_code/utils/file_lock.py +162 -0
- painapple_code/utils/file_paths.py +648 -0
- painapple_code/utils/generate_icons.py +251 -0
- painapple_code/utils/proc.py +205 -0
- painapple_code/utils/pty_backend.py +59 -0
- painapple_code/utils/pty_posix.py +160 -0
- painapple_code/utils/pty_windows.py +245 -0
- painapple_code/utils/token_profiles.py +91 -0
- painapple_code/viewer_templates.py +657 -0
- painapple_code/welcome_search.py +879 -0
- painapple_code-1.0.0.dist-info/METADATA +292 -0
- painapple_code-1.0.0.dist-info/RECORD +436 -0
- painapple_code-1.0.0.dist-info/WHEEL +5 -0
- painapple_code-1.0.0.dist-info/entry_points.txt +2 -0
- painapple_code-1.0.0.dist-info/licenses/LICENSE +661 -0
- painapple_code-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +261 -0
- painapple_code-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication middleware for pAInapple Code.
|
|
3
|
+
|
|
4
|
+
Simple code-server-style password authentication:
|
|
5
|
+
|
|
6
|
+
- Password lives in ~/.config/painapple-code/config.yaml under the `password:`
|
|
7
|
+
key (mode 0600, parent 0700). Mirrors code-server's config.yaml layout so
|
|
8
|
+
future settings (bind-addr, etc.) can land alongside it.
|
|
9
|
+
- Three auth paths: cookie, ?tkn= query param, Authorization: Bearer header
|
|
10
|
+
- NO request path accepts the password. Every credential is HMAC-derived from
|
|
11
|
+
it, domain-separated, and independently revocable via its own epoch:
|
|
12
|
+
cookie <- HMAC(password, "painapple-cookie-v1:<cookie_epoch>")
|
|
13
|
+
Bearer <- HMAC(password, "painapple-api-v1:<bearer_epoch>") [api_token]
|
|
14
|
+
?tkn= <- the same api_token
|
|
15
|
+
So a leaked CI secret or bootstrap link is not the master credential: it
|
|
16
|
+
cannot open the login form, and bumping bearer_epoch kills it without
|
|
17
|
+
logging out browsers. The password reaches the wire on the login form only.
|
|
18
|
+
- ?tkn= bootstraps the cookie: HTML paths 302-strip, API paths inject Set-Cookie
|
|
19
|
+
- WebSockets auth via cookie or ?tkn=; no HTTP-level auth
|
|
20
|
+
- Fourth path, ?dl=: short-lived HMAC-signed download token bound to one exact
|
|
21
|
+
URL (minted via POST /api/auth/download-token). Lets "copy download link"
|
|
22
|
+
work outside the authed browser context (iPad PWA → Safari) without ever
|
|
23
|
+
putting the password in a shareable URL. Never sets a cookie.
|
|
24
|
+
|
|
25
|
+
Public allowlist: /login, /api/login, /api/logout, /health, /sw.js,
|
|
26
|
+
/manifest.json, /instance-icons/*, /static/css/login.css,
|
|
27
|
+
/static/js/login.js. Also OPTIONS method.
|
|
28
|
+
|
|
29
|
+
Middleware reads password + cookie_token from scope["app"].state at request
|
|
30
|
+
time, so tests can mutate app.state per-fixture without re-adding middleware.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import hmac
|
|
34
|
+
import os
|
|
35
|
+
import secrets
|
|
36
|
+
import time
|
|
37
|
+
from hashlib import sha256
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Literal, Optional
|
|
40
|
+
from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse
|
|
41
|
+
|
|
42
|
+
import yaml
|
|
43
|
+
from starlette.websockets import WebSocket
|
|
44
|
+
|
|
45
|
+
from painapple_code.paths import lock_mode
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
COOKIE_NAME = "painapple_auth"
|
|
49
|
+
COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 days
|
|
50
|
+
|
|
51
|
+
# Domain separators for the key derivation. Opaque HMAC inputs — no user ever
|
|
52
|
+
# sees these, but they are part of the credential's identity: changing one
|
|
53
|
+
# re-derives everything it protects. Renaming them off the old "server-"
|
|
54
|
+
# codename was therefore a deliberate, one-time rotation:
|
|
55
|
+
# - every browser cookie minted under the old strings stops validating
|
|
56
|
+
# (one forced re-login), and
|
|
57
|
+
# - the stored api_token in config.yaml goes stale and is rewritten by
|
|
58
|
+
# sync_derived_config() on the next start, so any script or ?tkn= link
|
|
59
|
+
# holding the old token must re-read it.
|
|
60
|
+
# Safe to do only because this predates real adoption. Do NOT edit these
|
|
61
|
+
# again without treating it as a credential rotation and saying so in the
|
|
62
|
+
# release notes; the trailing -v1 is the handle for that.
|
|
63
|
+
COOKIE_DERIVATION_INFO = b"painapple-cookie-v1"
|
|
64
|
+
API_TOKEN_DERIVATION_INFO = b"painapple-api-v1"
|
|
65
|
+
|
|
66
|
+
# Config keys holding the revocation epochs. Both are treated as OPAQUE
|
|
67
|
+
# discriminators, never parsed as integers here: any change to the stored
|
|
68
|
+
# value changes the derived token. Coercing would introduce a silent
|
|
69
|
+
# non-revoking failure mode (a malformed epoch falling back to the default
|
|
70
|
+
# would leave old credentials valid), which is the one direction a
|
|
71
|
+
# revocation lever must never fail in.
|
|
72
|
+
COOKIE_EPOCH_KEY = "cookie_epoch"
|
|
73
|
+
BEARER_EPOCH_KEY = "bearer_epoch"
|
|
74
|
+
API_TOKEN_KEY = "api_token"
|
|
75
|
+
DEFAULT_EPOCH = 1
|
|
76
|
+
|
|
77
|
+
CONFIG_HEADER = """\
|
|
78
|
+
# pAInapple Code auth config.
|
|
79
|
+
#
|
|
80
|
+
# password Master credential. Used by the login form only. Rotating it
|
|
81
|
+
# resets every other credential below.
|
|
82
|
+
# api_token DERIVED from password + bearer_epoch. Scripts and the
|
|
83
|
+
# ?tkn= bootstrap link use THIS, never the password. Editing
|
|
84
|
+
# it by hand has no effect — it is rewritten on next start.
|
|
85
|
+
# cookie_epoch Bump to log out every browser.
|
|
86
|
+
# bearer_epoch Bump to invalidate every script token and bootstrap link.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
DOWNLOAD_TOKEN_TTL = 5 * 60 # 5 minutes
|
|
90
|
+
DOWNLOAD_TOKEN_INFO = b"painapple-download-v1"
|
|
91
|
+
DOWNLOAD_TOKEN_PARAM = "dl"
|
|
92
|
+
|
|
93
|
+
PUBLIC_PATHS = frozenset({
|
|
94
|
+
"/login",
|
|
95
|
+
"/api/login",
|
|
96
|
+
"/api/logout",
|
|
97
|
+
"/health",
|
|
98
|
+
"/sw.js",
|
|
99
|
+
"/manifest.json",
|
|
100
|
+
"/static/css/login.css",
|
|
101
|
+
# The login page's own script. Externalized from an inline <script> for
|
|
102
|
+
# CSP (script-src 'self'), so it MUST be reachable pre-auth — otherwise
|
|
103
|
+
# the form falls back to a native GET submit and the password lands in
|
|
104
|
+
# the URL instead of a POST to /api/login.
|
|
105
|
+
"/static/js/login.js",
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
PUBLIC_PREFIXES = (
|
|
109
|
+
"/instance-icons/",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def ensure_config_file(path: Path) -> tuple[str, bool]:
|
|
114
|
+
"""Load or create the YAML config at `path`. Repair permissions every call.
|
|
115
|
+
|
|
116
|
+
Returns (password, newly_created).
|
|
117
|
+
"""
|
|
118
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
119
|
+
lock_mode(path.parent, 0o700)
|
|
120
|
+
|
|
121
|
+
if path.exists():
|
|
122
|
+
lock_mode(path, 0o600)
|
|
123
|
+
config = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
124
|
+
if not isinstance(config, dict):
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"{path}: expected a YAML mapping, got {type(config).__name__}"
|
|
127
|
+
)
|
|
128
|
+
password = config.get("password")
|
|
129
|
+
if isinstance(password, str) and password:
|
|
130
|
+
return password, False
|
|
131
|
+
# Existing config but no usable password — generate one and persist.
|
|
132
|
+
password = secrets.token_urlsafe(32)
|
|
133
|
+
config["password"] = password
|
|
134
|
+
_write_config(path, config)
|
|
135
|
+
return password, True
|
|
136
|
+
|
|
137
|
+
password = secrets.token_urlsafe(32)
|
|
138
|
+
_write_config(path, {"password": password})
|
|
139
|
+
return password, True
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _write_config(path: Path, config: dict) -> None:
|
|
143
|
+
"""Write the config atomically as YAML and lock perms to 0600.
|
|
144
|
+
|
|
145
|
+
Atomic because this file is now rewritten on every start whose derived
|
|
146
|
+
material is stale (see sync_derived_config) rather than only at creation:
|
|
147
|
+
a crash mid-write would otherwise truncate the file holding the only copy
|
|
148
|
+
of the master credential. The temp file is created 0600 from birth, so
|
|
149
|
+
the secret is never briefly world-readable.
|
|
150
|
+
"""
|
|
151
|
+
body = CONFIG_HEADER + yaml.safe_dump(config, default_flow_style=False, sort_keys=False)
|
|
152
|
+
tmp = path.with_name(f"{path.name}.tmp{os.getpid()}")
|
|
153
|
+
try:
|
|
154
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
155
|
+
try:
|
|
156
|
+
os.write(fd, body.encode("utf-8"))
|
|
157
|
+
os.fsync(fd)
|
|
158
|
+
finally:
|
|
159
|
+
os.close(fd)
|
|
160
|
+
os.replace(tmp, path)
|
|
161
|
+
except BaseException:
|
|
162
|
+
try:
|
|
163
|
+
os.unlink(tmp)
|
|
164
|
+
except OSError:
|
|
165
|
+
pass
|
|
166
|
+
raise
|
|
167
|
+
lock_mode(path, 0o600)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _derive(password: str, info: bytes, epoch) -> str:
|
|
171
|
+
"""HMAC-SHA256(password, info:epoch) as hex.
|
|
172
|
+
|
|
173
|
+
The epoch is stringified, not parsed — see the note on COOKIE_EPOCH_KEY.
|
|
174
|
+
"""
|
|
175
|
+
return hmac.new(
|
|
176
|
+
password.encode("utf-8"),
|
|
177
|
+
info + b":" + str(epoch).encode("utf-8"),
|
|
178
|
+
sha256,
|
|
179
|
+
).hexdigest()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def derive_cookie_token(password: str, epoch=DEFAULT_EPOCH) -> str:
|
|
183
|
+
"""Derive the browser cookie value from the password via HMAC-SHA256.
|
|
184
|
+
|
|
185
|
+
This separates the cookie value from the password itself — compromising
|
|
186
|
+
a cookie does not reveal the password. Reversing would require brute-force.
|
|
187
|
+
|
|
188
|
+
Bumping `epoch` changes every browser's expected cookie at once, which is
|
|
189
|
+
what "log out everywhere" does. It deliberately does NOT touch the API
|
|
190
|
+
token, so revoking browsers leaves scripts and CI working.
|
|
191
|
+
"""
|
|
192
|
+
return _derive(password, COOKIE_DERIVATION_INFO, epoch)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def derive_api_token(password: str, epoch=DEFAULT_EPOCH) -> str:
|
|
196
|
+
"""Derive the script/automation credential (Bearer and ?tkn=).
|
|
197
|
+
|
|
198
|
+
Separate from the password so a leaked CI secret or a shared bootstrap
|
|
199
|
+
link is not the master credential: it cannot open the login form, and it
|
|
200
|
+
is revocable on its own by bumping `bearer_epoch` — without disturbing
|
|
201
|
+
browsers or forcing a password rotation.
|
|
202
|
+
|
|
203
|
+
Domain-separated from the cookie by its info string, so the two values
|
|
204
|
+
are never interchangeable (cross-presentation simply fails to compare).
|
|
205
|
+
"""
|
|
206
|
+
return _derive(password, API_TOKEN_DERIVATION_INFO, epoch)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def sync_derived_config(path: Path, password: str) -> dict:
|
|
210
|
+
"""Read the epochs, recompute the API token, and persist if stale.
|
|
211
|
+
|
|
212
|
+
Returns {"api_token", "cookie_epoch", "bearer_epoch"}.
|
|
213
|
+
|
|
214
|
+
The API token has to be *readable without the password* — a script that
|
|
215
|
+
derives it itself would have to read the master credential, which defeats
|
|
216
|
+
the entire separation. So it is stored, and this keeps the stored copy
|
|
217
|
+
honest: a hand-edited or stale api_token is silently corrected on start.
|
|
218
|
+
"""
|
|
219
|
+
config = {}
|
|
220
|
+
if path.exists():
|
|
221
|
+
loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
222
|
+
if isinstance(loaded, dict):
|
|
223
|
+
config = loaded
|
|
224
|
+
|
|
225
|
+
cookie_epoch = config.get(COOKIE_EPOCH_KEY, DEFAULT_EPOCH)
|
|
226
|
+
bearer_epoch = config.get(BEARER_EPOCH_KEY, DEFAULT_EPOCH)
|
|
227
|
+
api_token = derive_api_token(password, bearer_epoch)
|
|
228
|
+
|
|
229
|
+
desired = {
|
|
230
|
+
COOKIE_EPOCH_KEY: cookie_epoch,
|
|
231
|
+
BEARER_EPOCH_KEY: bearer_epoch,
|
|
232
|
+
API_TOKEN_KEY: api_token,
|
|
233
|
+
}
|
|
234
|
+
if any(config.get(k) != v for k, v in desired.items()):
|
|
235
|
+
# Preserve every key we don't own, and their order.
|
|
236
|
+
config.update(desired)
|
|
237
|
+
_write_config(path, config)
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
"api_token": api_token,
|
|
241
|
+
"cookie_epoch": cookie_epoch,
|
|
242
|
+
"bearer_epoch": bearer_epoch,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def bump_epoch(path: Path, key: str) -> dict:
|
|
247
|
+
"""Increment one revocation epoch on disk. Returns sync_derived_config()."""
|
|
248
|
+
if key not in (COOKIE_EPOCH_KEY, BEARER_EPOCH_KEY):
|
|
249
|
+
raise ValueError(f"not a revocation epoch: {key!r}")
|
|
250
|
+
config = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
251
|
+
if not isinstance(config, dict):
|
|
252
|
+
raise ValueError(f"{path}: expected a YAML mapping")
|
|
253
|
+
try:
|
|
254
|
+
current = int(config.get(key, DEFAULT_EPOCH))
|
|
255
|
+
except (TypeError, ValueError):
|
|
256
|
+
# Malformed value: restart the counter above the default rather than
|
|
257
|
+
# reusing it, so the new epoch can't collide with a historical one.
|
|
258
|
+
current = DEFAULT_EPOCH
|
|
259
|
+
config[key] = current + 1
|
|
260
|
+
_write_config(path, config)
|
|
261
|
+
password = config.get("password")
|
|
262
|
+
return sync_derived_config(path, password)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _download_signing_key(password: str) -> bytes:
|
|
266
|
+
"""Derive a dedicated signing key so download tokens never expose the
|
|
267
|
+
password or the cookie token, and vice versa."""
|
|
268
|
+
return hmac.new(password.encode("utf-8"), DOWNLOAD_TOKEN_INFO, sha256).digest()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def mint_download_token(
|
|
272
|
+
password: str,
|
|
273
|
+
url: str,
|
|
274
|
+
ttl: int = DOWNLOAD_TOKEN_TTL,
|
|
275
|
+
now: Optional[float] = None,
|
|
276
|
+
) -> tuple[str, int]:
|
|
277
|
+
"""Mint a short-lived token authorizing exactly `url` (local path?query).
|
|
278
|
+
|
|
279
|
+
Stateless: token = "<expiry_epoch>.<hmac-sha256-hex>" signed over
|
|
280
|
+
"<expiry>:<url>". Returns (token, expiry_epoch).
|
|
281
|
+
"""
|
|
282
|
+
exp = int((time.time() if now is None else now) + ttl)
|
|
283
|
+
sig = hmac.new(
|
|
284
|
+
_download_signing_key(password),
|
|
285
|
+
f"{exp}:{url}".encode("utf-8"),
|
|
286
|
+
sha256,
|
|
287
|
+
).hexdigest()
|
|
288
|
+
return f"{exp}.{sig}", exp
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def check_download_token(
|
|
292
|
+
token: str,
|
|
293
|
+
password: str,
|
|
294
|
+
url: str,
|
|
295
|
+
now: Optional[float] = None,
|
|
296
|
+
) -> bool:
|
|
297
|
+
"""Validate a download token against the exact requested URL and expiry."""
|
|
298
|
+
exp_str, _, sig = token.partition(".")
|
|
299
|
+
if not exp_str.isdigit() or not sig:
|
|
300
|
+
return False
|
|
301
|
+
if (time.time() if now is None else now) > int(exp_str):
|
|
302
|
+
return False
|
|
303
|
+
expected = hmac.new(
|
|
304
|
+
_download_signing_key(password),
|
|
305
|
+
f"{exp_str}:{url}".encode("utf-8"),
|
|
306
|
+
sha256,
|
|
307
|
+
).hexdigest()
|
|
308
|
+
return hmac.compare_digest(sig, expected)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _request_url_without_dl(scope) -> str:
|
|
312
|
+
"""Rebuild path?query with the dl= param removed, preserving the raw
|
|
313
|
+
(client-built) percent-encoding of the remaining query segments so the
|
|
314
|
+
string matches what was signed byte-for-byte."""
|
|
315
|
+
path = scope.get("path", "/")
|
|
316
|
+
qs = scope.get("query_string", b"").decode("latin1")
|
|
317
|
+
kept = [
|
|
318
|
+
seg for seg in qs.split("&")
|
|
319
|
+
if seg and not seg.startswith(f"{DOWNLOAD_TOKEN_PARAM}=")
|
|
320
|
+
]
|
|
321
|
+
return path + (f"?{'&'.join(kept)}" if kept else "")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def safe_next(raw: str) -> str:
|
|
325
|
+
"""Sanitize a `next=` parameter to a local path, stripping any tkn= token.
|
|
326
|
+
|
|
327
|
+
Rejects absolute URLs, protocol-relative URLs (//evil), backslash-escape
|
|
328
|
+
(/\\evil), path-traversal segments (`..` / `.`), and anything that
|
|
329
|
+
doesn't start with a single slash. Always parses the query string
|
|
330
|
+
rather than substring-matching, so encoded or reordered tkn= variants
|
|
331
|
+
are still stripped.
|
|
332
|
+
"""
|
|
333
|
+
if not isinstance(raw, str):
|
|
334
|
+
return "/app"
|
|
335
|
+
if not raw or not raw.startswith("/") or raw.startswith("//") or raw.startswith("/\\"):
|
|
336
|
+
return "/app"
|
|
337
|
+
try:
|
|
338
|
+
parsed = urlparse(raw)
|
|
339
|
+
except ValueError:
|
|
340
|
+
return "/app"
|
|
341
|
+
if parsed.scheme or parsed.netloc:
|
|
342
|
+
return "/app"
|
|
343
|
+
if any(seg in ("..", ".") for seg in parsed.path.split("/")):
|
|
344
|
+
return "/app"
|
|
345
|
+
clean_qs = [
|
|
346
|
+
(k, v)
|
|
347
|
+
for k, v in parse_qsl(parsed.query, keep_blank_values=True)
|
|
348
|
+
if k != "tkn"
|
|
349
|
+
]
|
|
350
|
+
rebuilt_query = urlencode(clean_qs)
|
|
351
|
+
return urlunparse(parsed._replace(query=rebuilt_query))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def is_public(path: str) -> bool:
|
|
355
|
+
"""Allowlisted paths bypass auth entirely."""
|
|
356
|
+
if path in PUBLIC_PATHS:
|
|
357
|
+
return True
|
|
358
|
+
for prefix in PUBLIC_PREFIXES:
|
|
359
|
+
if path.startswith(prefix):
|
|
360
|
+
return True
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _parse_cookies(cookie_header: str) -> dict[str, str]:
|
|
365
|
+
"""Parse a Cookie header into a dict."""
|
|
366
|
+
cookies = {}
|
|
367
|
+
for item in cookie_header.split(";"):
|
|
368
|
+
item = item.strip()
|
|
369
|
+
if "=" in item:
|
|
370
|
+
k, _, v = item.partition("=")
|
|
371
|
+
cookies[k.strip()] = v.strip()
|
|
372
|
+
return cookies
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _get_query(scope) -> dict[str, str]:
|
|
376
|
+
qs = scope.get("query_string", b"").decode("latin1")
|
|
377
|
+
return dict(parse_qsl(qs, keep_blank_values=True))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _get_headers(scope) -> dict[bytes, bytes]:
|
|
381
|
+
"""Flatten ASGI headers to a single-value dict.
|
|
382
|
+
|
|
383
|
+
Multi-value headers (Cookie in particular) get joined with `; `, which is
|
|
384
|
+
the same separator used inside a single Cookie header. iPadOS WebKit over
|
|
385
|
+
HTTP/2 splits cookies into multiple `:cookie` pseudo-headers; some
|
|
386
|
+
reverse proxies forward those as separate `Cookie:` headers, and a naive
|
|
387
|
+
`dict(headers)` keeps only the last one — silently dropping the auth cookie
|
|
388
|
+
if WebKit happened to put it earlier.
|
|
389
|
+
"""
|
|
390
|
+
merged: dict[bytes, bytes] = {}
|
|
391
|
+
for name, value in scope.get("headers", []):
|
|
392
|
+
if name in merged:
|
|
393
|
+
sep = b"; " if name == b"cookie" else b", "
|
|
394
|
+
merged[name] = merged[name] + sep + value
|
|
395
|
+
else:
|
|
396
|
+
merged[name] = value
|
|
397
|
+
return merged
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
AuthVia = Optional[Literal["cookie", "bearer", "tkn", "dl"]]
|
|
401
|
+
|
|
402
|
+
# Methods that can change state — CSRF-relevant. GET/HEAD/OPTIONS are safe.
|
|
403
|
+
UNSAFE_METHODS = frozenset({"POST", "PUT", "DELETE", "PATCH"})
|
|
404
|
+
# Ambient credentials a hostile cross-origin page could ride (the browser
|
|
405
|
+
# attaches them automatically). Bearer/dl are set explicitly by the caller,
|
|
406
|
+
# so they aren't CSRF vectors and skip the Origin check. Note auth resolution
|
|
407
|
+
# prefers the cookie when BOTH a cookie and a Bearer header are present, so a
|
|
408
|
+
# Bearer client that also carries a cookie is treated as ambient and Origin-
|
|
409
|
+
# checked — the fail-safe direction (never the reverse).
|
|
410
|
+
AMBIENT_AUTH = frozenset({"cookie", "tkn"})
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _origin_of(url: str) -> Optional[str]:
|
|
414
|
+
"""scheme://host[:port] of a URL, or None if unparseable."""
|
|
415
|
+
try:
|
|
416
|
+
p = urlparse(url)
|
|
417
|
+
except ValueError:
|
|
418
|
+
return None
|
|
419
|
+
if not p.scheme or not p.hostname:
|
|
420
|
+
return None
|
|
421
|
+
netloc = f"{p.hostname}:{p.port}" if p.port else p.hostname
|
|
422
|
+
return f"{p.scheme}://{netloc}"
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _norm_host(scheme: str, hostport: str) -> Optional[str]:
|
|
426
|
+
"""Normalize a Host-style value to lowercase ``host:port``, filling in the
|
|
427
|
+
scheme's default port when absent. ``None`` for an empty value."""
|
|
428
|
+
hostport = hostport.strip().lower()
|
|
429
|
+
if not hostport:
|
|
430
|
+
return None
|
|
431
|
+
if hostport.startswith("["): # IPv6 literal: [::1] or [::1]:8765
|
|
432
|
+
host, _, rest = hostport[1:].partition("]")
|
|
433
|
+
port = rest.lstrip(":")
|
|
434
|
+
elif hostport.count(":") == 1:
|
|
435
|
+
host, _, port = hostport.partition(":")
|
|
436
|
+
else: # bare host, or IPv6 without brackets (no port possible)
|
|
437
|
+
host, port = hostport, ""
|
|
438
|
+
if not port:
|
|
439
|
+
port = "443" if scheme == "https" else "80"
|
|
440
|
+
return f"{host}:{port}"
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _req_scheme(scope, headers) -> str:
|
|
444
|
+
"""Effective request scheme for origin comparison. Honours a trusted
|
|
445
|
+
``X-Forwarded-Proto`` (uvicorn only forwards it from ``--forwarded-allow-ips``
|
|
446
|
+
peers) and maps ws/wss → http/https."""
|
|
447
|
+
xfp = headers.get(b"x-forwarded-proto", b"").decode("latin1").split(",")[0].strip().lower()
|
|
448
|
+
if xfp in ("http", "https"):
|
|
449
|
+
return xfp
|
|
450
|
+
s = (scope.get("scheme") or "http").lower()
|
|
451
|
+
return "https" if s in ("https", "wss") else "http"
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _origin_matches_host(origin: str, headers: dict, scheme: str) -> bool:
|
|
455
|
+
"""True when ``origin`` addresses the SAME host:port the request was sent to
|
|
456
|
+
(its ``Host`` / ``X-Forwarded-Host``). This is the standard, config-free
|
|
457
|
+
CSRF defense: a cross-site page's Origin never matches the server's own host,
|
|
458
|
+
while genuine same-origin traffic — proxied under any hostname or reached
|
|
459
|
+
over the LAN — always does, with no preconfigured allowlist.
|
|
460
|
+
|
|
461
|
+
DNS-rebinding (Origin==Host but Host is an attacker-chosen name) is not
|
|
462
|
+
covered here — it is separately defeated because the auth cookie
|
|
463
|
+
is bound to the real origin's domain and never rides an attacker-domain
|
|
464
|
+
request, and ``TrustedHostMiddleware`` can be enabled for defense-in-depth.
|
|
465
|
+
"""
|
|
466
|
+
try:
|
|
467
|
+
p = urlparse(origin)
|
|
468
|
+
except ValueError:
|
|
469
|
+
return False
|
|
470
|
+
if not p.hostname:
|
|
471
|
+
return False
|
|
472
|
+
o_scheme = (p.scheme or scheme).lower()
|
|
473
|
+
o_netloc = _norm_host(o_scheme, p.netloc)
|
|
474
|
+
if not o_netloc:
|
|
475
|
+
return False
|
|
476
|
+
# Prefer the client-facing host the proxy forwarded; fall back to Host.
|
|
477
|
+
for hdr in (b"x-forwarded-host", b"host"):
|
|
478
|
+
raw = headers.get(hdr, b"").decode("latin1")
|
|
479
|
+
first = raw.split(",")[0].strip()
|
|
480
|
+
if first and _norm_host(o_scheme, first) == o_netloc:
|
|
481
|
+
return True
|
|
482
|
+
return False
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def check_csrf_origin(scope, allowed_origins) -> bool:
|
|
486
|
+
"""True if a state-changing, ambient-credential request is same-origin
|
|
487
|
+
or from a trusted origin. Fail-closed: a missing/``null``/foreign Origin
|
|
488
|
+
(with no same-origin Sec-Fetch signal and no trusted Referer) is rejected.
|
|
489
|
+
"""
|
|
490
|
+
headers = _get_headers(scope)
|
|
491
|
+
|
|
492
|
+
# Modern browsers label the request context directly. Trust only an
|
|
493
|
+
# explicit same-origin / non-navigational ("none") assertion — never
|
|
494
|
+
# "same-site" (a sibling localhost port is same-site but hostile).
|
|
495
|
+
sec_fetch = headers.get(b"sec-fetch-site", b"").decode("latin1").strip().lower()
|
|
496
|
+
if sec_fetch in ("same-origin", "none"):
|
|
497
|
+
return True
|
|
498
|
+
|
|
499
|
+
scheme = _req_scheme(scope, headers)
|
|
500
|
+
origin = headers.get(b"origin", b"").decode("latin1").strip()
|
|
501
|
+
if origin and origin.lower() != "null":
|
|
502
|
+
# Configured trusted origin, OR genuinely same-origin as our own Host
|
|
503
|
+
# (works for any proxied hostname / LAN bind with zero config).
|
|
504
|
+
return origin in allowed_origins or _origin_matches_host(origin, headers, scheme)
|
|
505
|
+
|
|
506
|
+
# No usable Origin — fall back to the Referer's origin.
|
|
507
|
+
referer = headers.get(b"referer", b"").decode("latin1").strip()
|
|
508
|
+
if referer:
|
|
509
|
+
ro = _origin_of(referer)
|
|
510
|
+
if not ro:
|
|
511
|
+
return False
|
|
512
|
+
return ro in allowed_origins or _origin_matches_host(referer, headers, scheme)
|
|
513
|
+
|
|
514
|
+
# Ambient credential, state-changing method, and no origin evidence at
|
|
515
|
+
# all → treat as forged.
|
|
516
|
+
return False
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _cookie_matches(cookies, cookie_token: str) -> bool:
|
|
520
|
+
"""True if the auth cookie carries the expected credential."""
|
|
521
|
+
presented = cookies.get(COOKIE_NAME, "")
|
|
522
|
+
return bool(presented) and hmac.compare_digest(presented, cookie_token)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _redact_auth_cookie(value: str) -> str:
|
|
526
|
+
"""Replace <name>=<value> with <name>=<REDACTED:N> for log safety.
|
|
527
|
+
|
|
528
|
+
Built from COOKIE_NAME rather than a literal so a future rename cannot
|
|
529
|
+
leave this pattern pointing at the old name and print the live cookie's
|
|
530
|
+
value in clear into the auth-debug log.
|
|
531
|
+
"""
|
|
532
|
+
import re
|
|
533
|
+
return re.sub(
|
|
534
|
+
rf"({re.escape(COOKIE_NAME)})=([^;]*)",
|
|
535
|
+
lambda m: f"{m.group(1)}=<REDACTED:{len(m.group(2))}>",
|
|
536
|
+
value,
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _log_csrf_failure(scope, path: str) -> None:
|
|
541
|
+
"""Log a rejected cross-origin state-changing request (Origin mismatch)."""
|
|
542
|
+
import logging
|
|
543
|
+
try:
|
|
544
|
+
headers = _get_headers(scope)
|
|
545
|
+
origin = headers.get(b"origin", b"").decode("latin1", "replace")
|
|
546
|
+
sec_fetch = headers.get(b"sec-fetch-site", b"").decode("latin1", "replace")
|
|
547
|
+
client = scope.get("client", ("?", 0))
|
|
548
|
+
logging.getLogger("painapple-code.auth-debug").warning(
|
|
549
|
+
"CSRF-REJECT %s %s client=%s:%s origin=%r sec-fetch-site=%r",
|
|
550
|
+
scope.get("method", "?"), path,
|
|
551
|
+
client[0] if client else "?", client[1] if client else 0,
|
|
552
|
+
origin, sec_fetch,
|
|
553
|
+
)
|
|
554
|
+
except Exception:
|
|
555
|
+
pass
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
# First-seen identities, deduped so a busy server logs once per new client
|
|
559
|
+
# rather than once per request. Bounded: a table this size means either a
|
|
560
|
+
# genuinely busy server or a UA-randomising client, and in both cases more
|
|
561
|
+
# lines are noise, not signal.
|
|
562
|
+
_SEEN_AUTH: set = set()
|
|
563
|
+
_SEEN_AUTH_CAP = 200
|
|
564
|
+
_seen_auth_full = False
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def record_auth_identity(auth_via: str, client_ip: Optional[str], user_agent: str) -> None:
|
|
568
|
+
"""Log the first time a (path, client, agent) combination authenticates.
|
|
569
|
+
|
|
570
|
+
This is the defensible half of the "bind the cookie to a fingerprint"
|
|
571
|
+
instinct. Binding is theatre — every realistic way a credential leaks
|
|
572
|
+
(a stolen tablet, a browser-profile backup, a proxy log) hands over the
|
|
573
|
+
user agent alongside it, and IP binding breaks the roaming-tablet case
|
|
574
|
+
this product is built for. Observation costs nothing and can't lock
|
|
575
|
+
anyone out.
|
|
576
|
+
|
|
577
|
+
What it buys: the revoke button becomes actionable. "Something I don't
|
|
578
|
+
recognise authenticated" is the signal that makes someone press it.
|
|
579
|
+
|
|
580
|
+
Never records credential material — only how, from where, and as what.
|
|
581
|
+
"""
|
|
582
|
+
global _seen_auth_full
|
|
583
|
+
ua = (user_agent or "")[:120]
|
|
584
|
+
key = (auth_via, client_ip or "?", ua)
|
|
585
|
+
if key in _SEEN_AUTH:
|
|
586
|
+
return
|
|
587
|
+
if len(_SEEN_AUTH) >= _SEEN_AUTH_CAP:
|
|
588
|
+
if not _seen_auth_full:
|
|
589
|
+
_seen_auth_full = True
|
|
590
|
+
import logging
|
|
591
|
+
logging.getLogger("painapple-code.auth").info(
|
|
592
|
+
"AUTH-NEW table full (%d) — further new clients not logged",
|
|
593
|
+
_SEEN_AUTH_CAP,
|
|
594
|
+
)
|
|
595
|
+
return
|
|
596
|
+
_SEEN_AUTH.add(key)
|
|
597
|
+
import logging
|
|
598
|
+
logging.getLogger("painapple-code.auth").info(
|
|
599
|
+
"AUTH-NEW via=%s client=%s ua=%r", auth_via, client_ip or "?", ua
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _client_identity(scope) -> tuple:
|
|
604
|
+
"""(client_ip, user_agent) for an ASGI scope, proxy-aware."""
|
|
605
|
+
headers = _get_headers(scope)
|
|
606
|
+
xff = headers.get(b"x-forwarded-for", b"").decode("latin1").split(",")[0].strip()
|
|
607
|
+
if not xff:
|
|
608
|
+
client = scope.get("client") or ("?", 0)
|
|
609
|
+
xff = client[0] if client else "?"
|
|
610
|
+
ua = headers.get(b"user-agent", b"").decode("latin1", "replace")
|
|
611
|
+
return xff, ua
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _log_auth_failure(scope, path: str) -> None:
|
|
615
|
+
"""Log the shape (not contents) of cookies on a failing request, so we can
|
|
616
|
+
correlate intermittent 401s with what the client actually sent."""
|
|
617
|
+
import logging
|
|
618
|
+
try:
|
|
619
|
+
cookie_entries = [v.decode("latin1", "replace") for n, v in scope.get("headers", []) if n == b"cookie"]
|
|
620
|
+
has_auth_cookie = any(f"{COOKIE_NAME}=" in c for c in cookie_entries)
|
|
621
|
+
redacted = [_redact_auth_cookie(c)[:200] for c in cookie_entries]
|
|
622
|
+
client = scope.get("client", ("?", 0))
|
|
623
|
+
logging.getLogger("painapple-code.auth-debug").warning(
|
|
624
|
+
"AUTH-FAIL %s %s client=%s:%s cookies=%d has_auth_cookie=%s entries=%r",
|
|
625
|
+
scope.get("method", "?"), path,
|
|
626
|
+
client[0] if client else "?", client[1] if client else 0,
|
|
627
|
+
len(cookie_entries), has_auth_cookie, redacted,
|
|
628
|
+
)
|
|
629
|
+
except Exception:
|
|
630
|
+
pass
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def check_http_auth_detailed(
|
|
634
|
+
scope,
|
|
635
|
+
password: str,
|
|
636
|
+
cookie_token: str,
|
|
637
|
+
api_token: str,
|
|
638
|
+
) -> AuthVia:
|
|
639
|
+
"""Check HTTP auth; return which path authed, or None.
|
|
640
|
+
|
|
641
|
+
Uses hmac.compare_digest for all comparisons.
|
|
642
|
+
|
|
643
|
+
`password` is needed only to verify ?dl= download tokens (signed with a
|
|
644
|
+
key derived from it). No request path accepts the password itself —
|
|
645
|
+
Bearer and ?tkn= take `api_token`, the browser takes `cookie_token`.
|
|
646
|
+
Because the two derivations are domain-separated, presenting a cookie
|
|
647
|
+
value as a Bearer (or vice versa) simply fails to compare.
|
|
648
|
+
"""
|
|
649
|
+
headers = _get_headers(scope)
|
|
650
|
+
|
|
651
|
+
# 1. Cookie
|
|
652
|
+
cookie_header = headers.get(b"cookie", b"").decode("latin1")
|
|
653
|
+
if cookie_header:
|
|
654
|
+
cookies = _parse_cookies(cookie_header)
|
|
655
|
+
if _cookie_matches(cookies, cookie_token):
|
|
656
|
+
return "cookie"
|
|
657
|
+
|
|
658
|
+
# 2. Authorization: Bearer
|
|
659
|
+
auth_header = headers.get(b"authorization", b"").decode("latin1")
|
|
660
|
+
if auth_header.lower().startswith("bearer "):
|
|
661
|
+
presented = auth_header[7:].strip()
|
|
662
|
+
if presented and hmac.compare_digest(presented, api_token):
|
|
663
|
+
return "bearer"
|
|
664
|
+
|
|
665
|
+
# 3. Query ?tkn=
|
|
666
|
+
query = _get_query(scope)
|
|
667
|
+
presented = query.get("tkn", "")
|
|
668
|
+
if presented and hmac.compare_digest(presented, api_token):
|
|
669
|
+
return "tkn"
|
|
670
|
+
|
|
671
|
+
# 4. Query ?dl= — short-lived signed download token, bound to this URL.
|
|
672
|
+
# Grants access to this request only: no cookie is set (see middleware).
|
|
673
|
+
presented = query.get(DOWNLOAD_TOKEN_PARAM, "")
|
|
674
|
+
if presented and check_download_token(
|
|
675
|
+
presented, password, _request_url_without_dl(scope)
|
|
676
|
+
):
|
|
677
|
+
return "dl"
|
|
678
|
+
|
|
679
|
+
return None
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def check_websocket_auth(
|
|
683
|
+
websocket: WebSocket,
|
|
684
|
+
cookie_token: str,
|
|
685
|
+
api_token: str,
|
|
686
|
+
) -> bool:
|
|
687
|
+
"""WebSocket auth via cookie or ?tkn=. No Authorization header for WS.
|
|
688
|
+
|
|
689
|
+
Takes no password: the upgrade accepts the derived credentials only.
|
|
690
|
+
"""
|
|
691
|
+
if _cookie_matches(websocket.cookies, cookie_token):
|
|
692
|
+
return True
|
|
693
|
+
tkn = websocket.query_params.get("tkn", "")
|
|
694
|
+
if tkn and hmac.compare_digest(tkn, api_token):
|
|
695
|
+
return True
|
|
696
|
+
return False
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def check_websocket_origin(websocket: WebSocket, allowed_origins) -> bool:
|
|
700
|
+
"""Origin gate for a WebSocket handshake (WS bypasses CORS entirely).
|
|
701
|
+
|
|
702
|
+
Browsers ALWAYS send an Origin header on a WS handshake, so a present but
|
|
703
|
+
foreign Origin is the cross-site attack we block. A missing Origin means a
|
|
704
|
+
non-browser client (script/native) — not a CSRF vector, and still gated by
|
|
705
|
+
the cookie/tkn check — so we allow it. ``null`` is treated as foreign.
|
|
706
|
+
"""
|
|
707
|
+
origin = (websocket.headers.get("origin") or "").strip()
|
|
708
|
+
if not origin:
|
|
709
|
+
return True
|
|
710
|
+
if origin.lower() == "null":
|
|
711
|
+
return False
|
|
712
|
+
if origin in (allowed_origins or set()):
|
|
713
|
+
return True
|
|
714
|
+
# Genuinely same-origin as the handshake's own Host (config-free) — the
|
|
715
|
+
# common case for a proxied hostname or a LAN bind.
|
|
716
|
+
headers = _get_headers(websocket.scope)
|
|
717
|
+
return _origin_matches_host(origin, headers, _req_scheme(websocket.scope, headers))
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
class AuthMiddleware:
|
|
721
|
+
"""Pure ASGI middleware that gates all HTTP requests by auth.
|
|
722
|
+
|
|
723
|
+
WebSocket auth is NOT enforced here — each WS handler accepts then
|
|
724
|
+
closes(1008) if unauth. That pattern plays nicer with Starlette's WS
|
|
725
|
+
state machine than trying to reject before accept.
|
|
726
|
+
|
|
727
|
+
Reads password + cookie_token from scope["app"].state at request time so
|
|
728
|
+
tests can mutate state without re-adding middleware.
|
|
729
|
+
"""
|
|
730
|
+
|
|
731
|
+
def __init__(self, app):
|
|
732
|
+
self.app = app
|
|
733
|
+
|
|
734
|
+
async def __call__(self, scope, receive, send):
|
|
735
|
+
if scope["type"] not in ("http", "websocket"):
|
|
736
|
+
await self.app(scope, receive, send)
|
|
737
|
+
return
|
|
738
|
+
|
|
739
|
+
path = scope.get("path", "/")
|
|
740
|
+
|
|
741
|
+
# OPTIONS passes through so CORS preflight works
|
|
742
|
+
if scope["type"] == "http" and scope.get("method") == "OPTIONS":
|
|
743
|
+
await self.app(scope, receive, send)
|
|
744
|
+
return
|
|
745
|
+
|
|
746
|
+
if is_public(path):
|
|
747
|
+
await self.app(scope, receive, send)
|
|
748
|
+
return
|
|
749
|
+
|
|
750
|
+
if scope["type"] == "websocket":
|
|
751
|
+
# WS handler does accept-then-close(1008) itself.
|
|
752
|
+
await self.app(scope, receive, send)
|
|
753
|
+
return
|
|
754
|
+
|
|
755
|
+
# HTTP path
|
|
756
|
+
state = scope["app"].state
|
|
757
|
+
password = getattr(state, "auth_password", None)
|
|
758
|
+
cookie_token = getattr(state, "auth_cookie_token", None)
|
|
759
|
+
api_token = getattr(state, "auth_api_token", None)
|
|
760
|
+
|
|
761
|
+
if password is None or cookie_token is None or api_token is None:
|
|
762
|
+
# Fail-closed: if auth state isn't initialized, reject everything.
|
|
763
|
+
await self._send_unauth_http(scope, send)
|
|
764
|
+
return
|
|
765
|
+
|
|
766
|
+
auth_via = check_http_auth_detailed(scope, password, cookie_token, api_token)
|
|
767
|
+
if auth_via is None:
|
|
768
|
+
_log_auth_failure(scope, path)
|
|
769
|
+
await self._send_unauth_http(scope, send)
|
|
770
|
+
return
|
|
771
|
+
|
|
772
|
+
# ?dl= is a one-URL download grant, deliberately shareable outside the
|
|
773
|
+
# authed browser (iPad PWA -> Safari). Logging it would record the
|
|
774
|
+
# recipient of every copied link as an "identity", which is noise.
|
|
775
|
+
if auth_via != "dl":
|
|
776
|
+
ip, ua = _client_identity(scope)
|
|
777
|
+
record_auth_identity(auth_via, ip, ua)
|
|
778
|
+
|
|
779
|
+
# CSRF/origin boundary: a state-changing request authed by an ambient
|
|
780
|
+
# credential (cookie/tkn) must come from a trusted origin. Bearer/dl
|
|
781
|
+
# callers set their credential explicitly and are exempt.
|
|
782
|
+
if scope.get("method", "GET") in UNSAFE_METHODS and auth_via in AMBIENT_AUTH:
|
|
783
|
+
allowed = getattr(state, "allowed_origins", None) or set()
|
|
784
|
+
if not check_csrf_origin(scope, allowed):
|
|
785
|
+
_log_csrf_failure(scope, path)
|
|
786
|
+
await self._send_forbidden_http(scope, send)
|
|
787
|
+
return
|
|
788
|
+
|
|
789
|
+
if auth_via == "tkn":
|
|
790
|
+
if self._is_html_target(scope):
|
|
791
|
+
await self._redirect_strip_tkn(scope, send, cookie_token)
|
|
792
|
+
return
|
|
793
|
+
# API / other: inject Set-Cookie on the downstream response
|
|
794
|
+
send = self._wrap_send_with_cookie(
|
|
795
|
+
send,
|
|
796
|
+
cookie_token=cookie_token,
|
|
797
|
+
secure=self._is_https(scope),
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
await self.app(scope, receive, send)
|
|
801
|
+
|
|
802
|
+
@staticmethod
|
|
803
|
+
def _is_https(scope) -> bool:
|
|
804
|
+
headers = _get_headers(scope)
|
|
805
|
+
forwarded = headers.get(b"x-forwarded-proto", b"").decode("latin1")
|
|
806
|
+
if forwarded:
|
|
807
|
+
return forwarded == "https"
|
|
808
|
+
return scope.get("scheme") == "https"
|
|
809
|
+
|
|
810
|
+
@staticmethod
|
|
811
|
+
def _is_html_target(scope) -> bool:
|
|
812
|
+
path = scope.get("path", "/")
|
|
813
|
+
if path in ("/", "/app", "/test", "/triage"):
|
|
814
|
+
return True
|
|
815
|
+
headers = _get_headers(scope)
|
|
816
|
+
accept = headers.get(b"accept", b"").decode("latin1")
|
|
817
|
+
return "text/html" in accept
|
|
818
|
+
|
|
819
|
+
@staticmethod
|
|
820
|
+
def _build_cookie_header(cookie_token: str, secure: bool) -> bytes:
|
|
821
|
+
parts = [
|
|
822
|
+
f"{COOKIE_NAME}={cookie_token}",
|
|
823
|
+
"HttpOnly",
|
|
824
|
+
"SameSite=Lax",
|
|
825
|
+
"Path=/",
|
|
826
|
+
f"Max-Age={COOKIE_MAX_AGE}",
|
|
827
|
+
]
|
|
828
|
+
if secure:
|
|
829
|
+
parts.append("Secure")
|
|
830
|
+
return "; ".join(parts).encode("latin1")
|
|
831
|
+
|
|
832
|
+
def _wrap_send_with_cookie(self, send, cookie_token: str, secure: bool):
|
|
833
|
+
cookie_header = self._build_cookie_header(cookie_token, secure)
|
|
834
|
+
|
|
835
|
+
async def wrapped_send(message):
|
|
836
|
+
if message["type"] == "http.response.start":
|
|
837
|
+
headers = list(message.get("headers", []))
|
|
838
|
+
headers.append((b"set-cookie", cookie_header))
|
|
839
|
+
message = {**message, "headers": headers}
|
|
840
|
+
await send(message)
|
|
841
|
+
|
|
842
|
+
return wrapped_send
|
|
843
|
+
|
|
844
|
+
async def _redirect_strip_tkn(self, scope, send, cookie_token: str):
|
|
845
|
+
path = scope.get("path", "/")
|
|
846
|
+
qs = scope.get("query_string", b"").decode("latin1")
|
|
847
|
+
clean = [
|
|
848
|
+
(k, v)
|
|
849
|
+
for k, v in parse_qsl(qs, keep_blank_values=True)
|
|
850
|
+
if k != "tkn"
|
|
851
|
+
]
|
|
852
|
+
target = path + (f"?{urlencode(clean)}" if clean else "")
|
|
853
|
+
secure = self._is_https(scope)
|
|
854
|
+
await send({
|
|
855
|
+
"type": "http.response.start",
|
|
856
|
+
"status": 302,
|
|
857
|
+
"headers": [
|
|
858
|
+
(b"location", target.encode("latin1")),
|
|
859
|
+
(b"set-cookie", self._build_cookie_header(cookie_token, secure)),
|
|
860
|
+
(b"cache-control", b"no-store"),
|
|
861
|
+
],
|
|
862
|
+
})
|
|
863
|
+
await send({"type": "http.response.body", "body": b""})
|
|
864
|
+
|
|
865
|
+
async def _send_forbidden_http(self, scope, send):
|
|
866
|
+
"""403 for a rejected cross-origin state-changing request."""
|
|
867
|
+
await send({
|
|
868
|
+
"type": "http.response.start",
|
|
869
|
+
"status": 403,
|
|
870
|
+
"headers": [
|
|
871
|
+
(b"content-type", b"application/json"),
|
|
872
|
+
(b"cache-control", b"no-store"),
|
|
873
|
+
],
|
|
874
|
+
})
|
|
875
|
+
await send({
|
|
876
|
+
"type": "http.response.body",
|
|
877
|
+
"body": b'{"error":"origin_forbidden"}',
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
async def _send_unauth_http(self, scope, send):
|
|
881
|
+
path = scope.get("path", "/")
|
|
882
|
+
headers = _get_headers(scope)
|
|
883
|
+
accept = headers.get(b"accept", b"").decode("latin1")
|
|
884
|
+
is_api = path.startswith("/api/")
|
|
885
|
+
is_html = not is_api and (
|
|
886
|
+
"text/html" in accept
|
|
887
|
+
or path in ("/", "/app", "/test", "/triage", "/sessions")
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
if is_api:
|
|
891
|
+
await send({
|
|
892
|
+
"type": "http.response.start",
|
|
893
|
+
"status": 401,
|
|
894
|
+
"headers": [
|
|
895
|
+
(b"content-type", b"application/json"),
|
|
896
|
+
(b"cache-control", b"no-store"),
|
|
897
|
+
],
|
|
898
|
+
})
|
|
899
|
+
await send({
|
|
900
|
+
"type": "http.response.body",
|
|
901
|
+
"body": b'{"error":"auth_required"}',
|
|
902
|
+
})
|
|
903
|
+
return
|
|
904
|
+
|
|
905
|
+
if is_html:
|
|
906
|
+
qs = scope.get("query_string", b"").decode("latin1")
|
|
907
|
+
raw_next = path + (f"?{qs}" if qs else "")
|
|
908
|
+
sanitized = safe_next(raw_next)
|
|
909
|
+
location = f"/login?next={quote(sanitized, safe='')}"
|
|
910
|
+
await send({
|
|
911
|
+
"type": "http.response.start",
|
|
912
|
+
"status": 302,
|
|
913
|
+
"headers": [
|
|
914
|
+
(b"location", location.encode("latin1")),
|
|
915
|
+
(b"cache-control", b"no-store"),
|
|
916
|
+
],
|
|
917
|
+
})
|
|
918
|
+
await send({"type": "http.response.body", "body": b""})
|
|
919
|
+
return
|
|
920
|
+
|
|
921
|
+
# Everything else (static assets, etc.) — 401 text/plain
|
|
922
|
+
await send({
|
|
923
|
+
"type": "http.response.start",
|
|
924
|
+
"status": 401,
|
|
925
|
+
"headers": [
|
|
926
|
+
(b"content-type", b"text/plain"),
|
|
927
|
+
(b"cache-control", b"no-store"),
|
|
928
|
+
],
|
|
929
|
+
})
|
|
930
|
+
await send({"type": "http.response.body", "body": b"unauthorized"})
|