painapple-code 1.0.0rc1__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 +13 -0
- painapple_code/_version.py +24 -0
- painapple_code/auth_middleware.py +507 -0
- painapple_code/bridge_paths.py +1028 -0
- painapple_code/cli/__init__.py +32 -0
- painapple_code/cli/docker/__init__.py +116 -0
- painapple_code/cli/docker/commands.py +757 -0
- painapple_code/cli/docker/config.py +364 -0
- painapple_code/cli/docker/runtime.py +88 -0
- painapple_code/cli/docker/wizard.py +415 -0
- painapple_code/cli/ui.py +404 -0
- painapple_code/cost_analytics.py +884 -0
- painapple_code/data/models.defaults.yaml +15 -0
- painapple_code/data/models.yaml +15 -0
- painapple_code/data/presets.defaults.json +32 -0
- painapple_code/data/strings.yaml +1573 -0
- painapple_code/helpers.py +140 -0
- painapple_code/prompt_explorer.py +984 -0
- painapple_code/providers/__init__.py +215 -0
- painapple_code/providers/base.py +515 -0
- painapple_code/providers/claude/__init__.py +69 -0
- painapple_code/providers/claude/capabilities.py +167 -0
- painapple_code/providers/claude/errors.py +66 -0
- painapple_code/providers/claude/launch.py +47 -0
- painapple_code/providers/claude/summary.py +83 -0
- painapple_code/providers/claude/translate.py +61 -0
- painapple_code/routes/__init__.py +0 -0
- painapple_code/routes/api_agents.py +440 -0
- painapple_code/routes/api_bridge.py +132 -0
- painapple_code/routes/api_bridge_commit_sections.py +127 -0
- painapple_code/routes/api_bridge_config.py +506 -0
- painapple_code/routes/api_bridge_session_prefs.py +212 -0
- painapple_code/routes/api_browser.py +521 -0
- painapple_code/routes/api_commands.py +368 -0
- painapple_code/routes/api_costs.py +128 -0
- painapple_code/routes/api_exec.py +50 -0
- painapple_code/routes/api_files.py +493 -0
- painapple_code/routes/api_git.py +651 -0
- painapple_code/routes/api_logs.py +532 -0
- painapple_code/routes/api_plugins.py +273 -0
- painapple_code/routes/api_project_config.py +294 -0
- painapple_code/routes/api_prompts.py +231 -0
- painapple_code/routes/api_session_stash.py +205 -0
- painapple_code/routes/api_session_welcome.py +138 -0
- painapple_code/routes/api_sessions.py +540 -0
- painapple_code/routes/api_shadow.py +323 -0
- painapple_code/routes/api_shadow_db.py +473 -0
- painapple_code/routes/api_shadow_files.py +327 -0
- painapple_code/routes/api_shadow_search.py +476 -0
- painapple_code/routes/api_skills.py +609 -0
- painapple_code/routes/api_tasks.py +138 -0
- painapple_code/routes/api_terminal.py +589 -0
- painapple_code/routes/api_upload.py +213 -0
- painapple_code/routes/api_viewer.py +188 -0
- painapple_code/routes/dependencies.py +17 -0
- painapple_code/routes/ws_chat.py +679 -0
- painapple_code/server.py +1349 -0
- painapple_code/server_logging.py +242 -0
- painapple_code/services/__init__.py +0 -0
- painapple_code/services/agent_session.py +2236 -0
- painapple_code/session_store.py +254 -0
- painapple_code/session_store_core.py +820 -0
- painapple_code/session_store_migration.py +144 -0
- painapple_code/shadow_db.py +760 -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 +1133 -0
- painapple_code/shadow_git_frontmatter.py +314 -0
- painapple_code/shadow_git_sections.py +520 -0
- painapple_code/shadow_git_summary.py +457 -0
- painapple_code/shadow_parser.py +560 -0
- painapple_code/static/css/00-variables.css +177 -0
- painapple_code/static/css/01-base.css +150 -0
- painapple_code/static/css/02-tooltips.css +36 -0
- painapple_code/static/css/09-left-rail.css +311 -0
- painapple_code/static/css/10-header.css +691 -0
- painapple_code/static/css/11-connection-bar.css +120 -0
- painapple_code/static/css/20-messages.css +739 -0
- painapple_code/static/css/21-tool-blocks.css +2326 -0
- painapple_code/static/css/22-edit-diff.css +574 -0
- painapple_code/static/css/23-thinking.css +1570 -0
- painapple_code/static/css/24-background-tasks.css +355 -0
- painapple_code/static/css/25-context-block.css +252 -0
- painapple_code/static/css/30-markdown.css +531 -0
- painapple_code/static/css/40-input.css +1955 -0
- painapple_code/static/css/41-autocomplete.css +127 -0
- painapple_code/static/css/42-activity-strip.css +88 -0
- painapple_code/static/css/43-file-autocomplete.css +136 -0
- painapple_code/static/css/44-snippets-autocomplete.css +237 -0
- painapple_code/static/css/45-selection-bar.css +362 -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 +105 -0
- painapple_code/static/css/50-welcome.css +2969 -0
- painapple_code/static/css/51-modals.css +294 -0
- painapple_code/static/css/52-utilities.css +77 -0
- painapple_code/static/css/53-image-upload.css +415 -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-plan-approval.css +204 -0
- painapple_code/static/css/56-question-form.css +496 -0
- painapple_code/static/css/57-swipe-indicator.css +95 -0
- painapple_code/static/css/58-session-families.css +626 -0
- painapple_code/static/css/61-log-explorer.css +683 -0
- painapple_code/static/css/62-json-tree.css +411 -0
- painapple_code/static/css/62-skills-widget.css +648 -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 +514 -0
- painapple_code/static/css/65-snippets-widget.css +425 -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 +854 -0
- painapple_code/static/css/66-git-panel.css +795 -0
- painapple_code/static/css/67-diff-viewer.css +576 -0
- painapple_code/static/css/67-editor-view.css +675 -0
- painapple_code/static/css/67-file-preview-widget.css +1317 -0
- painapple_code/static/css/68-browser-widget.css +254 -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-helpers-install-widget.css +437 -0
- painapple_code/static/css/68-terminal-view.css +98 -0
- painapple_code/static/css/68-uploads-widget.css +163 -0
- painapple_code/static/css/69-cost-analytics.css +537 -0
- painapple_code/static/css/70-config-panel.css +3227 -0
- painapple_code/static/css/70-excalidraw.css +136 -0
- painapple_code/static/css/70-prompt-explorer.css +782 -0
- painapple_code/static/css/71-chart.css +111 -0
- painapple_code/static/css/72-discussion-sidebar.css +685 -0
- painapple_code/static/css/74-debug-logs.css +656 -0
- painapple_code/static/css/76-history-explorer.css +680 -0
- painapple_code/static/css/80-open-dialog.css +146 -0
- painapple_code/static/css/80-quick-switcher.css +260 -0
- painapple_code/static/css/80-widget-system.css +1733 -0
- painapple_code/static/css/81-quick-actions.css +1011 -0
- painapple_code/static/css/82-context-popover.css +1180 -0
- painapple_code/static/css/83-context-menu.css +300 -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 +341 -0
- painapple_code/static/css/login.css +198 -0
- painapple_code/static/feature-triage.html +1208 -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/activity-strip.js +181 -0
- painapple_code/static/js/app-context.js +157 -0
- painapple_code/static/js/app.js +5875 -0
- painapple_code/static/js/auth-fetch.js +46 -0
- painapple_code/static/js/background-tasks.js +274 -0
- painapple_code/static/js/caret-position.js +171 -0
- painapple_code/static/js/chat-navigator.js +683 -0
- painapple_code/static/js/chat-search.js +323 -0
- painapple_code/static/js/command-executor.js +172 -0
- painapple_code/static/js/command-store.js +497 -0
- painapple_code/static/js/components.js +982 -0
- painapple_code/static/js/config.js +158 -0
- painapple_code/static/js/context-menu.js +570 -0
- painapple_code/static/js/controllers/chat-controller.js +3667 -0
- painapple_code/static/js/controllers/dialog-controller.js +185 -0
- painapple_code/static/js/controllers/tab-controller.js +1505 -0
- painapple_code/static/js/controllers/thinking-controller.js +905 -0
- painapple_code/static/js/diff-utils.js +484 -0
- painapple_code/static/js/editor-view.js +597 -0
- painapple_code/static/js/effort-settings.js +369 -0
- painapple_code/static/js/file-autocomplete.js +515 -0
- painapple_code/static/js/file-tabs.js +120 -0
- painapple_code/static/js/gestures.js +448 -0
- painapple_code/static/js/grid-switcher.js +332 -0
- painapple_code/static/js/input-handler.js +922 -0
- painapple_code/static/js/keyboard-bar.js +495 -0
- painapple_code/static/js/linkify-utils.js +200 -0
- painapple_code/static/js/open-dialog.js +870 -0
- painapple_code/static/js/orphan-terminals.js +366 -0
- painapple_code/static/js/perf-marks.js +102 -0
- painapple_code/static/js/permission-settings.js +347 -0
- painapple_code/static/js/preview/json-tree.js +213 -0
- painapple_code/static/js/preview/preview-edit.js +366 -0
- painapple_code/static/js/preview/preview-events.js +239 -0
- painapple_code/static/js/preview/preview-history.js +533 -0
- painapple_code/static/js/preview/preview-inline-edit.js +785 -0
- painapple_code/static/js/preview/preview-poll.js +102 -0
- painapple_code/static/js/preview/preview-render.js +445 -0
- painapple_code/static/js/preview/preview-search.js +391 -0
- painapple_code/static/js/preview/preview-state.js +175 -0
- painapple_code/static/js/preview/preview-utils.js +244 -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/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 +32 -0
- painapple_code/static/js/preview-plugins/json-plugin.js +131 -0
- painapple_code/static/js/preview-plugins/jsonl-plugin.js +170 -0
- painapple_code/static/js/preview-plugins/markdown-plugin.js +76 -0
- painapple_code/static/js/preview-plugins/panzoom-plugin.js +62 -0
- painapple_code/static/js/preview-plugins/plugin-helpers.js +264 -0
- painapple_code/static/js/prompt-favorites.js +187 -0
- painapple_code/static/js/quick-actions-menu.js +1708 -0
- painapple_code/static/js/quick-actions-registry.js +1147 -0
- painapple_code/static/js/quick-switcher/controller.js +217 -0
- painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
- painapple_code/static/js/quick-switcher/index.js +28 -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 +296 -0
- painapple_code/static/js/quick-switcher/providers/panel-provider.js +130 -0
- painapple_code/static/js/quick-switcher/providers/project-provider.js +254 -0
- painapple_code/static/js/quick-switcher/providers/skills-provider.js +108 -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 +350 -0
- painapple_code/static/js/recent-opens.js +40 -0
- painapple_code/static/js/scroll-manager.js +488 -0
- painapple_code/static/js/scroll-state-machine.js +359 -0
- painapple_code/static/js/selection/action-bar.js +749 -0
- painapple_code/static/js/selection/index.js +15 -0
- painapple_code/static/js/selection/selection-handler.js +779 -0
- painapple_code/static/js/selection/state.js +107 -0
- painapple_code/static/js/session/agent-progress.js +202 -0
- painapple_code/static/js/session/handle-agent-message.js +448 -0
- painapple_code/static/js/session/handle-message.js +435 -0
- painapple_code/static/js/session/interactive.js +305 -0
- painapple_code/static/js/session/message-store.js +204 -0
- painapple_code/static/js/session/messages.js +244 -0
- painapple_code/static/js/session/persistence.js +111 -0
- painapple_code/static/js/session/restore.js +123 -0
- painapple_code/static/js/session/sync.js +227 -0
- painapple_code/static/js/session-container-pool.js +340 -0
- painapple_code/static/js/session.js +912 -0
- painapple_code/static/js/shortcut-hints.js +240 -0
- painapple_code/static/js/shortcuts.js +929 -0
- painapple_code/static/js/skills-autocomplete.js +262 -0
- painapple_code/static/js/snippets-autocomplete.js +738 -0
- painapple_code/static/js/stash-ui.js +458 -0
- painapple_code/static/js/stash.js +555 -0
- painapple_code/static/js/status-bar.js +737 -0
- painapple_code/static/js/token-profile.js +250 -0
- painapple_code/static/js/tool-renderer-blocks.js +1576 -0
- painapple_code/static/js/tool-renderer-thinking.js +819 -0
- painapple_code/static/js/tool-renderer.js +1473 -0
- painapple_code/static/js/tooltips.js +193 -0
- painapple_code/static/js/upload-manager.js +688 -0
- painapple_code/static/js/utils.js +453 -0
- painapple_code/static/js/welcome/api.js +356 -0
- painapple_code/static/js/welcome/cards.js +547 -0
- painapple_code/static/js/welcome/context-menu.js +563 -0
- painapple_code/static/js/welcome/families.js +763 -0
- painapple_code/static/js/welcome/preview.js +234 -0
- painapple_code/static/js/welcome/state.js +128 -0
- painapple_code/static/js/welcome.js +1777 -0
- painapple_code/static/js/widget-system/base-widget.js +708 -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 +114 -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 +813 -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 +1170 -0
- painapple_code/static/js/widgets/active-sessions-widget.js +748 -0
- painapple_code/static/js/widgets/agents-widget.js +897 -0
- painapple_code/static/js/widgets/browser-widget.js +409 -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 +223 -0
- painapple_code/static/js/widgets/config/gestures.js +161 -0
- painapple_code/static/js/widgets/config/models-tab.js +296 -0
- painapple_code/static/js/widgets/config/quick-actions-tab.js +641 -0
- painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
- painapple_code/static/js/widgets/config/state.js +483 -0
- painapple_code/static/js/widgets/config/system-controls.js +304 -0
- painapple_code/static/js/widgets/config-widget.js +1384 -0
- painapple_code/static/js/widgets/cost-analytics-widget.js +629 -0
- painapple_code/static/js/widgets/debug-widget.js +693 -0
- painapple_code/static/js/widgets/diff-viewer-widget.js +1606 -0
- painapple_code/static/js/widgets/discussion-widget.js +1074 -0
- painapple_code/static/js/widgets/file-explorer-widget.js +2147 -0
- painapple_code/static/js/widgets/file-preview-widget.js +754 -0
- painapple_code/static/js/widgets/git-widget.js +793 -0
- painapple_code/static/js/widgets/helpers-install-widget.js +569 -0
- painapple_code/static/js/widgets/history-explorer-widget.js +2699 -0
- painapple_code/static/js/widgets/image-preview-widget.js +433 -0
- painapple_code/static/js/widgets/index.js +162 -0
- painapple_code/static/js/widgets/log-explorer-widget.js +1002 -0
- painapple_code/static/js/widgets/plugins-widget.js +305 -0
- painapple_code/static/js/widgets/prompt-explorer-widget.js +833 -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 +335 -0
- painapple_code/static/js/widgets/tasks-widget.js +381 -0
- painapple_code/static/js/widgets/terminal/connection.js +283 -0
- painapple_code/static/js/widgets/terminal/gestures.js +330 -0
- painapple_code/static/js/widgets/terminal/init.js +518 -0
- painapple_code/static/js/widgets/terminal/link-providers.js +440 -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 +210 -0
- painapple_code/static/js/widgets/terminal-widget.js +791 -0
- painapple_code/static/js/widgets/uploads-widget.js +309 -0
- painapple_code/static/js/widgets/zen-widget.js +999 -0
- painapple_code/static/login.html +156 -0
- painapple_code/static/manifest.json +73 -0
- painapple_code/static/sw.js +277 -0
- painapple_code/static/vendor/README.md +49 -0
- painapple_code/static/vendor/codemirror.js +32 -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/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 +660 -0
- painapple_code/subprocess_registry.py +535 -0
- painapple_code/tls_cert.py +86 -0
- painapple_code/tools/agents/shadow-git-helper.md +399 -0
- painapple_code/tools/excalidraw-to-svg.js +143 -0
- painapple_code/tools/install-helpers.sh +148 -0
- painapple_code/tools/shadow-git +262 -0
- painapple_code/tools/shadow-query +108 -0
- painapple_code/tools/vegalite-to-svg.js +79 -0
- painapple_code/turn_query.py +766 -0
- painapple_code/turn_tracker.py +194 -0
- painapple_code/utils/__init__.py +0 -0
- painapple_code/utils/agent_cli.py +297 -0
- painapple_code/utils/chart.py +103 -0
- painapple_code/utils/excalidraw.py +107 -0
- painapple_code/utils/file_paths.py +340 -0
- painapple_code/utils/generate_icons.py +154 -0
- painapple_code/utils/token_profiles.py +83 -0
- painapple_code/viewer_templates.py +647 -0
- painapple_code/welcome_search.py +829 -0
- painapple_code-1.0.0rc1.dist-info/METADATA +382 -0
- painapple_code-1.0.0rc1.dist-info/RECORD +362 -0
- painapple_code-1.0.0rc1.dist-info/WHEEL +5 -0
- painapple_code-1.0.0rc1.dist-info/entry_points.txt +2 -0
- painapple_code-1.0.0rc1.dist-info/licenses/LICENSE +661 -0
- painapple_code-1.0.0rc1.dist-info/scm_file_list.json +476 -0
- painapple_code-1.0.0rc1.dist-info/scm_version.json +8 -0
- painapple_code-1.0.0rc1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""painapple-code: WebSocket bridge server for remote Claude Code clients.
|
|
2
|
+
|
|
3
|
+
Internal modules use absolute imports rooted at this package, e.g.
|
|
4
|
+
``from painapple_code.session_store import SessionStore``.
|
|
5
|
+
|
|
6
|
+
Two well-known paths:
|
|
7
|
+
|
|
8
|
+
* ``PACKAGE_DIR`` — the installed package directory, containing
|
|
9
|
+
``static/``, ``data/`` (strings/models/presets), and runtime
|
|
10
|
+
``tools/``. Resolves the same in editable installs and wheels;
|
|
11
|
+
this is what server code should use for shipped assets.
|
|
12
|
+
* ``REPO_ROOT`` — the surrounding repo checkout (``parent.parent.parent``
|
|
13
|
+
from this file). Only meaningful in editable/dev installs; use it for
|
|
14
|
+
developer-only paths like ``tests/perf`` or the optional
|
|
15
|
+
``docs/features/`` triage data. In a wheel install this points
|
|
16
|
+
somewhere under ``site-packages`` and should not be relied on.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
PACKAGE_DIR: Path = Path(__file__).resolve().parent
|
|
22
|
+
REPO_ROOT: Path = PACKAGE_DIR.parent.parent
|
|
23
|
+
|
|
24
|
+
# _version.py is generated by setuptools-scm at build/install time (see
|
|
25
|
+
# pyproject.toml). Missing means an editable install in a tree where the
|
|
26
|
+
# build hook hasn't run yet — fall back rather than crashing imports.
|
|
27
|
+
try:
|
|
28
|
+
from ._version import __version__
|
|
29
|
+
except ImportError:
|
|
30
|
+
__version__ = "0.0.0+unknown"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Entry point for ``python -m painapple_code`` and the ``painapple-code``
|
|
2
|
+
console script.
|
|
3
|
+
|
|
4
|
+
Delegates to :func:`painapple_code.cli.main`, which dispatches known
|
|
5
|
+
subcommands (``docker``, ``serve``) and falls through to the server's
|
|
6
|
+
flat argument parser for everything else — so bare invocations keep
|
|
7
|
+
working exactly as before the CLI grew subcommands.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from painapple_code.cli import main
|
|
11
|
+
|
|
12
|
+
if __name__ == "__main__":
|
|
13
|
+
main()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '1.0.0rc1'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 0, 0, 'rc1')
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,507 @@
|
|
|
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
|
+
- Cookie value is HMAC-derived from password (never stored as raw password)
|
|
11
|
+
- ?tkn= bootstraps the cookie: HTML paths 302-strip, API paths inject Set-Cookie
|
|
12
|
+
- WebSockets auth via cookie or ?tkn=; no HTTP-level auth
|
|
13
|
+
- Fourth path, ?dl=: short-lived HMAC-signed download token bound to one exact
|
|
14
|
+
URL (minted via POST /api/auth/download-token). Lets "copy download link"
|
|
15
|
+
work outside the authed browser context (iPad PWA → Safari) without ever
|
|
16
|
+
putting the password in a shareable URL. Never sets a cookie.
|
|
17
|
+
|
|
18
|
+
Public allowlist: /login, /api/login, /api/logout, /health, /sw.js,
|
|
19
|
+
/manifest.json, /instance-icons/*, /static/css/login.css. Also OPTIONS method.
|
|
20
|
+
|
|
21
|
+
Middleware reads password + cookie_token from scope["app"].state at request
|
|
22
|
+
time, so tests can mutate app.state per-fixture without re-adding middleware.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import hmac
|
|
26
|
+
import os
|
|
27
|
+
import secrets
|
|
28
|
+
import time
|
|
29
|
+
from hashlib import sha256
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Literal, Optional
|
|
32
|
+
from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse
|
|
33
|
+
|
|
34
|
+
import yaml
|
|
35
|
+
from starlette.websockets import WebSocket
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
COOKIE_NAME = "bridge_auth"
|
|
39
|
+
COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 days
|
|
40
|
+
COOKIE_DERIVATION_INFO = b"bridge-cookie-v1"
|
|
41
|
+
|
|
42
|
+
DOWNLOAD_TOKEN_TTL = 5 * 60 # 5 minutes
|
|
43
|
+
DOWNLOAD_TOKEN_INFO = b"bridge-download-v1"
|
|
44
|
+
DOWNLOAD_TOKEN_PARAM = "dl"
|
|
45
|
+
|
|
46
|
+
PUBLIC_PATHS = frozenset({
|
|
47
|
+
"/login",
|
|
48
|
+
"/api/login",
|
|
49
|
+
"/api/logout",
|
|
50
|
+
"/health",
|
|
51
|
+
"/sw.js",
|
|
52
|
+
"/manifest.json",
|
|
53
|
+
"/static/css/login.css",
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
PUBLIC_PREFIXES = (
|
|
57
|
+
"/instance-icons/",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def ensure_config_file(path: Path) -> tuple[str, bool]:
|
|
62
|
+
"""Load or create the YAML config at `path`. Repair permissions every call.
|
|
63
|
+
|
|
64
|
+
Returns (password, newly_created).
|
|
65
|
+
"""
|
|
66
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
67
|
+
os.chmod(path.parent, 0o700)
|
|
68
|
+
|
|
69
|
+
if path.exists():
|
|
70
|
+
os.chmod(path, 0o600)
|
|
71
|
+
config = yaml.safe_load(path.read_text()) or {}
|
|
72
|
+
if not isinstance(config, dict):
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"{path}: expected a YAML mapping, got {type(config).__name__}"
|
|
75
|
+
)
|
|
76
|
+
password = config.get("password")
|
|
77
|
+
if isinstance(password, str) and password:
|
|
78
|
+
return password, False
|
|
79
|
+
# Existing config but no usable password — generate one and persist.
|
|
80
|
+
password = secrets.token_urlsafe(32)
|
|
81
|
+
config["password"] = password
|
|
82
|
+
_write_config(path, config)
|
|
83
|
+
return password, True
|
|
84
|
+
|
|
85
|
+
password = secrets.token_urlsafe(32)
|
|
86
|
+
_write_config(path, {"password": password})
|
|
87
|
+
return password, True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _write_config(path: Path, config: dict) -> None:
|
|
91
|
+
"""Write the config dict as YAML and lock perms to 0600."""
|
|
92
|
+
path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False))
|
|
93
|
+
os.chmod(path, 0o600)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def derive_cookie_token(password: str) -> str:
|
|
97
|
+
"""Derive the cookie value from the password via HMAC-SHA256.
|
|
98
|
+
|
|
99
|
+
This separates the cookie value from the password itself — compromising
|
|
100
|
+
a cookie does not reveal the password. Reversing would require brute-force.
|
|
101
|
+
"""
|
|
102
|
+
return hmac.new(
|
|
103
|
+
password.encode("utf-8"),
|
|
104
|
+
COOKIE_DERIVATION_INFO,
|
|
105
|
+
sha256,
|
|
106
|
+
).hexdigest()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _download_signing_key(password: str) -> bytes:
|
|
110
|
+
"""Derive a dedicated signing key so download tokens never expose the
|
|
111
|
+
password or the cookie token, and vice versa."""
|
|
112
|
+
return hmac.new(password.encode("utf-8"), DOWNLOAD_TOKEN_INFO, sha256).digest()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def mint_download_token(
|
|
116
|
+
password: str,
|
|
117
|
+
url: str,
|
|
118
|
+
ttl: int = DOWNLOAD_TOKEN_TTL,
|
|
119
|
+
now: Optional[float] = None,
|
|
120
|
+
) -> tuple[str, int]:
|
|
121
|
+
"""Mint a short-lived token authorizing exactly `url` (local path?query).
|
|
122
|
+
|
|
123
|
+
Stateless: token = "<expiry_epoch>.<hmac-sha256-hex>" signed over
|
|
124
|
+
"<expiry>:<url>". Returns (token, expiry_epoch).
|
|
125
|
+
"""
|
|
126
|
+
exp = int((time.time() if now is None else now) + ttl)
|
|
127
|
+
sig = hmac.new(
|
|
128
|
+
_download_signing_key(password),
|
|
129
|
+
f"{exp}:{url}".encode("utf-8"),
|
|
130
|
+
sha256,
|
|
131
|
+
).hexdigest()
|
|
132
|
+
return f"{exp}.{sig}", exp
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def check_download_token(
|
|
136
|
+
token: str,
|
|
137
|
+
password: str,
|
|
138
|
+
url: str,
|
|
139
|
+
now: Optional[float] = None,
|
|
140
|
+
) -> bool:
|
|
141
|
+
"""Validate a download token against the exact requested URL and expiry."""
|
|
142
|
+
exp_str, _, sig = token.partition(".")
|
|
143
|
+
if not exp_str.isdigit() or not sig:
|
|
144
|
+
return False
|
|
145
|
+
if (time.time() if now is None else now) > int(exp_str):
|
|
146
|
+
return False
|
|
147
|
+
expected = hmac.new(
|
|
148
|
+
_download_signing_key(password),
|
|
149
|
+
f"{exp_str}:{url}".encode("utf-8"),
|
|
150
|
+
sha256,
|
|
151
|
+
).hexdigest()
|
|
152
|
+
return hmac.compare_digest(sig, expected)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _request_url_without_dl(scope) -> str:
|
|
156
|
+
"""Rebuild path?query with the dl= param removed, preserving the raw
|
|
157
|
+
(client-built) percent-encoding of the remaining query segments so the
|
|
158
|
+
string matches what was signed byte-for-byte."""
|
|
159
|
+
path = scope.get("path", "/")
|
|
160
|
+
qs = scope.get("query_string", b"").decode("latin1")
|
|
161
|
+
kept = [
|
|
162
|
+
seg for seg in qs.split("&")
|
|
163
|
+
if seg and not seg.startswith(f"{DOWNLOAD_TOKEN_PARAM}=")
|
|
164
|
+
]
|
|
165
|
+
return path + (f"?{'&'.join(kept)}" if kept else "")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def safe_next(raw: str) -> str:
|
|
169
|
+
"""Sanitize a `next=` parameter to a local path, stripping any tkn= token.
|
|
170
|
+
|
|
171
|
+
Rejects absolute URLs, protocol-relative URLs (//evil), backslash-escape
|
|
172
|
+
(/\\evil), path-traversal segments (`..` / `.`), and anything that
|
|
173
|
+
doesn't start with a single slash. Always parses the query string
|
|
174
|
+
rather than substring-matching, so encoded or reordered tkn= variants
|
|
175
|
+
are still stripped.
|
|
176
|
+
"""
|
|
177
|
+
if not isinstance(raw, str):
|
|
178
|
+
return "/app"
|
|
179
|
+
if not raw or not raw.startswith("/") or raw.startswith("//") or raw.startswith("/\\"):
|
|
180
|
+
return "/app"
|
|
181
|
+
try:
|
|
182
|
+
parsed = urlparse(raw)
|
|
183
|
+
except ValueError:
|
|
184
|
+
return "/app"
|
|
185
|
+
if parsed.scheme or parsed.netloc:
|
|
186
|
+
return "/app"
|
|
187
|
+
if any(seg in ("..", ".") for seg in parsed.path.split("/")):
|
|
188
|
+
return "/app"
|
|
189
|
+
clean_qs = [
|
|
190
|
+
(k, v)
|
|
191
|
+
for k, v in parse_qsl(parsed.query, keep_blank_values=True)
|
|
192
|
+
if k != "tkn"
|
|
193
|
+
]
|
|
194
|
+
rebuilt_query = urlencode(clean_qs)
|
|
195
|
+
return urlunparse(parsed._replace(query=rebuilt_query))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def is_public(path: str) -> bool:
|
|
199
|
+
"""Allowlisted paths bypass auth entirely."""
|
|
200
|
+
if path in PUBLIC_PATHS:
|
|
201
|
+
return True
|
|
202
|
+
for prefix in PUBLIC_PREFIXES:
|
|
203
|
+
if path.startswith(prefix):
|
|
204
|
+
return True
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _parse_cookies(cookie_header: str) -> dict[str, str]:
|
|
209
|
+
"""Parse a Cookie header into a dict."""
|
|
210
|
+
cookies = {}
|
|
211
|
+
for item in cookie_header.split(";"):
|
|
212
|
+
item = item.strip()
|
|
213
|
+
if "=" in item:
|
|
214
|
+
k, _, v = item.partition("=")
|
|
215
|
+
cookies[k.strip()] = v.strip()
|
|
216
|
+
return cookies
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _get_query(scope) -> dict[str, str]:
|
|
220
|
+
qs = scope.get("query_string", b"").decode("latin1")
|
|
221
|
+
return dict(parse_qsl(qs, keep_blank_values=True))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _get_headers(scope) -> dict[bytes, bytes]:
|
|
225
|
+
"""Flatten ASGI headers to a single-value dict.
|
|
226
|
+
|
|
227
|
+
Multi-value headers (Cookie in particular) get joined with `; `, which is
|
|
228
|
+
the same separator used inside a single Cookie header. iPadOS WebKit over
|
|
229
|
+
HTTP/2 splits cookies into multiple `:cookie` pseudo-headers; some
|
|
230
|
+
reverse proxies forward those as separate `Cookie:` headers, and a naive
|
|
231
|
+
`dict(headers)` keeps only the last one — silently dropping `bridge_auth`
|
|
232
|
+
if WebKit happened to put it earlier.
|
|
233
|
+
"""
|
|
234
|
+
merged: dict[bytes, bytes] = {}
|
|
235
|
+
for name, value in scope.get("headers", []):
|
|
236
|
+
if name in merged:
|
|
237
|
+
sep = b"; " if name == b"cookie" else b", "
|
|
238
|
+
merged[name] = merged[name] + sep + value
|
|
239
|
+
else:
|
|
240
|
+
merged[name] = value
|
|
241
|
+
return merged
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
AuthVia = Optional[Literal["cookie", "bearer", "tkn", "dl"]]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _redact_bridge_auth(value: str) -> str:
|
|
248
|
+
"""Replace bridge_auth=<value> with bridge_auth=<REDACTED:N> for log safety."""
|
|
249
|
+
import re
|
|
250
|
+
return re.sub(r"bridge_auth=([^;]*)", lambda m: f"bridge_auth=<REDACTED:{len(m.group(1))}>", value)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _log_auth_failure(scope, path: str) -> None:
|
|
254
|
+
"""Log the shape (not contents) of cookies on a failing request, so we can
|
|
255
|
+
correlate intermittent 401s with what the client actually sent."""
|
|
256
|
+
import logging
|
|
257
|
+
try:
|
|
258
|
+
cookie_entries = [v.decode("latin1", "replace") for n, v in scope.get("headers", []) if n == b"cookie"]
|
|
259
|
+
has_bridge_auth = any("bridge_auth=" in c for c in cookie_entries)
|
|
260
|
+
redacted = [_redact_bridge_auth(c)[:200] for c in cookie_entries]
|
|
261
|
+
client = scope.get("client", ("?", 0))
|
|
262
|
+
logging.getLogger("painapple-code.auth-debug").warning(
|
|
263
|
+
"AUTH-FAIL %s %s client=%s:%s cookies=%d has_bridge_auth=%s entries=%r",
|
|
264
|
+
scope.get("method", "?"), path,
|
|
265
|
+
client[0] if client else "?", client[1] if client else 0,
|
|
266
|
+
len(cookie_entries), has_bridge_auth, redacted,
|
|
267
|
+
)
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def check_http_auth_detailed(
|
|
273
|
+
scope,
|
|
274
|
+
password: str,
|
|
275
|
+
cookie_token: str,
|
|
276
|
+
) -> AuthVia:
|
|
277
|
+
"""Check HTTP auth; return which path authed, or None.
|
|
278
|
+
|
|
279
|
+
Uses hmac.compare_digest for all comparisons.
|
|
280
|
+
"""
|
|
281
|
+
headers = _get_headers(scope)
|
|
282
|
+
|
|
283
|
+
# 1. Cookie
|
|
284
|
+
cookie_header = headers.get(b"cookie", b"").decode("latin1")
|
|
285
|
+
if cookie_header:
|
|
286
|
+
cookies = _parse_cookies(cookie_header)
|
|
287
|
+
presented = cookies.get(COOKIE_NAME, "")
|
|
288
|
+
if presented and hmac.compare_digest(presented, cookie_token):
|
|
289
|
+
return "cookie"
|
|
290
|
+
|
|
291
|
+
# 2. Authorization: Bearer
|
|
292
|
+
auth_header = headers.get(b"authorization", b"").decode("latin1")
|
|
293
|
+
if auth_header.lower().startswith("bearer "):
|
|
294
|
+
presented = auth_header[7:].strip()
|
|
295
|
+
if presented and hmac.compare_digest(presented, password):
|
|
296
|
+
return "bearer"
|
|
297
|
+
|
|
298
|
+
# 3. Query ?tkn=
|
|
299
|
+
query = _get_query(scope)
|
|
300
|
+
presented = query.get("tkn", "")
|
|
301
|
+
if presented and hmac.compare_digest(presented, password):
|
|
302
|
+
return "tkn"
|
|
303
|
+
|
|
304
|
+
# 4. Query ?dl= — short-lived signed download token, bound to this URL.
|
|
305
|
+
# Grants access to this request only: no cookie is set (see middleware).
|
|
306
|
+
presented = query.get(DOWNLOAD_TOKEN_PARAM, "")
|
|
307
|
+
if presented and check_download_token(
|
|
308
|
+
presented, password, _request_url_without_dl(scope)
|
|
309
|
+
):
|
|
310
|
+
return "dl"
|
|
311
|
+
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def check_websocket_auth(
|
|
316
|
+
websocket: WebSocket,
|
|
317
|
+
password: str,
|
|
318
|
+
cookie_token: str,
|
|
319
|
+
) -> bool:
|
|
320
|
+
"""WebSocket auth via cookie or ?tkn=. No Authorization header for WS."""
|
|
321
|
+
presented = websocket.cookies.get(COOKIE_NAME, "")
|
|
322
|
+
if presented and hmac.compare_digest(presented, cookie_token):
|
|
323
|
+
return True
|
|
324
|
+
tkn = websocket.query_params.get("tkn", "")
|
|
325
|
+
if tkn and hmac.compare_digest(tkn, password):
|
|
326
|
+
return True
|
|
327
|
+
return False
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class AuthMiddleware:
|
|
331
|
+
"""Pure ASGI middleware that gates all HTTP requests by auth.
|
|
332
|
+
|
|
333
|
+
WebSocket auth is NOT enforced here — each WS handler accepts then
|
|
334
|
+
closes(1008) if unauth. That pattern plays nicer with Starlette's WS
|
|
335
|
+
state machine than trying to reject before accept.
|
|
336
|
+
|
|
337
|
+
Reads password + cookie_token from scope["app"].state at request time so
|
|
338
|
+
tests can mutate state without re-adding middleware.
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
def __init__(self, app):
|
|
342
|
+
self.app = app
|
|
343
|
+
|
|
344
|
+
async def __call__(self, scope, receive, send):
|
|
345
|
+
if scope["type"] not in ("http", "websocket"):
|
|
346
|
+
await self.app(scope, receive, send)
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
path = scope.get("path", "/")
|
|
350
|
+
|
|
351
|
+
# OPTIONS passes through so CORS preflight works
|
|
352
|
+
if scope["type"] == "http" and scope.get("method") == "OPTIONS":
|
|
353
|
+
await self.app(scope, receive, send)
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
if is_public(path):
|
|
357
|
+
await self.app(scope, receive, send)
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
if scope["type"] == "websocket":
|
|
361
|
+
# WS handler does accept-then-close(1008) itself.
|
|
362
|
+
await self.app(scope, receive, send)
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
# HTTP path
|
|
366
|
+
state = scope["app"].state
|
|
367
|
+
password = getattr(state, "auth_password", None)
|
|
368
|
+
cookie_token = getattr(state, "auth_cookie_token", None)
|
|
369
|
+
|
|
370
|
+
if password is None or cookie_token is None:
|
|
371
|
+
# Fail-closed: if auth state isn't initialized, reject everything.
|
|
372
|
+
await self._send_unauth_http(scope, send)
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
auth_via = check_http_auth_detailed(scope, password, cookie_token)
|
|
376
|
+
if auth_via is None:
|
|
377
|
+
_log_auth_failure(scope, path)
|
|
378
|
+
await self._send_unauth_http(scope, send)
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
if auth_via == "tkn":
|
|
382
|
+
if self._is_html_target(scope):
|
|
383
|
+
await self._redirect_strip_tkn(scope, send, cookie_token)
|
|
384
|
+
return
|
|
385
|
+
# API / other: inject Set-Cookie on the downstream response
|
|
386
|
+
send = self._wrap_send_with_cookie(
|
|
387
|
+
send,
|
|
388
|
+
cookie_token=cookie_token,
|
|
389
|
+
secure=self._is_https(scope),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
await self.app(scope, receive, send)
|
|
393
|
+
|
|
394
|
+
@staticmethod
|
|
395
|
+
def _is_https(scope) -> bool:
|
|
396
|
+
headers = _get_headers(scope)
|
|
397
|
+
forwarded = headers.get(b"x-forwarded-proto", b"").decode("latin1")
|
|
398
|
+
if forwarded:
|
|
399
|
+
return forwarded == "https"
|
|
400
|
+
return scope.get("scheme") == "https"
|
|
401
|
+
|
|
402
|
+
@staticmethod
|
|
403
|
+
def _is_html_target(scope) -> bool:
|
|
404
|
+
path = scope.get("path", "/")
|
|
405
|
+
if path in ("/", "/app", "/test", "/triage"):
|
|
406
|
+
return True
|
|
407
|
+
headers = _get_headers(scope)
|
|
408
|
+
accept = headers.get(b"accept", b"").decode("latin1")
|
|
409
|
+
return "text/html" in accept
|
|
410
|
+
|
|
411
|
+
@staticmethod
|
|
412
|
+
def _build_cookie_header(cookie_token: str, secure: bool) -> bytes:
|
|
413
|
+
parts = [
|
|
414
|
+
f"{COOKIE_NAME}={cookie_token}",
|
|
415
|
+
"HttpOnly",
|
|
416
|
+
"SameSite=Lax",
|
|
417
|
+
"Path=/",
|
|
418
|
+
f"Max-Age={COOKIE_MAX_AGE}",
|
|
419
|
+
]
|
|
420
|
+
if secure:
|
|
421
|
+
parts.append("Secure")
|
|
422
|
+
return "; ".join(parts).encode("latin1")
|
|
423
|
+
|
|
424
|
+
def _wrap_send_with_cookie(self, send, cookie_token: str, secure: bool):
|
|
425
|
+
cookie_header = self._build_cookie_header(cookie_token, secure)
|
|
426
|
+
|
|
427
|
+
async def wrapped_send(message):
|
|
428
|
+
if message["type"] == "http.response.start":
|
|
429
|
+
headers = list(message.get("headers", []))
|
|
430
|
+
headers.append((b"set-cookie", cookie_header))
|
|
431
|
+
message = {**message, "headers": headers}
|
|
432
|
+
await send(message)
|
|
433
|
+
|
|
434
|
+
return wrapped_send
|
|
435
|
+
|
|
436
|
+
async def _redirect_strip_tkn(self, scope, send, cookie_token: str):
|
|
437
|
+
path = scope.get("path", "/")
|
|
438
|
+
qs = scope.get("query_string", b"").decode("latin1")
|
|
439
|
+
clean = [
|
|
440
|
+
(k, v)
|
|
441
|
+
for k, v in parse_qsl(qs, keep_blank_values=True)
|
|
442
|
+
if k != "tkn"
|
|
443
|
+
]
|
|
444
|
+
target = path + (f"?{urlencode(clean)}" if clean else "")
|
|
445
|
+
secure = self._is_https(scope)
|
|
446
|
+
await send({
|
|
447
|
+
"type": "http.response.start",
|
|
448
|
+
"status": 302,
|
|
449
|
+
"headers": [
|
|
450
|
+
(b"location", target.encode("latin1")),
|
|
451
|
+
(b"set-cookie", self._build_cookie_header(cookie_token, secure)),
|
|
452
|
+
(b"cache-control", b"no-store"),
|
|
453
|
+
],
|
|
454
|
+
})
|
|
455
|
+
await send({"type": "http.response.body", "body": b""})
|
|
456
|
+
|
|
457
|
+
async def _send_unauth_http(self, scope, send):
|
|
458
|
+
path = scope.get("path", "/")
|
|
459
|
+
headers = _get_headers(scope)
|
|
460
|
+
accept = headers.get(b"accept", b"").decode("latin1")
|
|
461
|
+
is_api = path.startswith("/api/")
|
|
462
|
+
is_html = not is_api and (
|
|
463
|
+
"text/html" in accept
|
|
464
|
+
or path in ("/", "/app", "/test", "/triage", "/sessions")
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
if is_api:
|
|
468
|
+
await send({
|
|
469
|
+
"type": "http.response.start",
|
|
470
|
+
"status": 401,
|
|
471
|
+
"headers": [
|
|
472
|
+
(b"content-type", b"application/json"),
|
|
473
|
+
(b"cache-control", b"no-store"),
|
|
474
|
+
],
|
|
475
|
+
})
|
|
476
|
+
await send({
|
|
477
|
+
"type": "http.response.body",
|
|
478
|
+
"body": b'{"error":"auth_required"}',
|
|
479
|
+
})
|
|
480
|
+
return
|
|
481
|
+
|
|
482
|
+
if is_html:
|
|
483
|
+
qs = scope.get("query_string", b"").decode("latin1")
|
|
484
|
+
raw_next = path + (f"?{qs}" if qs else "")
|
|
485
|
+
sanitized = safe_next(raw_next)
|
|
486
|
+
location = f"/login?next={quote(sanitized, safe='')}"
|
|
487
|
+
await send({
|
|
488
|
+
"type": "http.response.start",
|
|
489
|
+
"status": 302,
|
|
490
|
+
"headers": [
|
|
491
|
+
(b"location", location.encode("latin1")),
|
|
492
|
+
(b"cache-control", b"no-store"),
|
|
493
|
+
],
|
|
494
|
+
})
|
|
495
|
+
await send({"type": "http.response.body", "body": b""})
|
|
496
|
+
return
|
|
497
|
+
|
|
498
|
+
# Everything else (static assets, etc.) — 401 text/plain
|
|
499
|
+
await send({
|
|
500
|
+
"type": "http.response.start",
|
|
501
|
+
"status": 401,
|
|
502
|
+
"headers": [
|
|
503
|
+
(b"content-type", b"text/plain"),
|
|
504
|
+
(b"cache-control", b"no-store"),
|
|
505
|
+
],
|
|
506
|
+
})
|
|
507
|
+
await send({"type": "http.response.body", "body": b"unauthorized"})
|