superlocalmemory 3.7.8 → 3.8.1
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/ATTRIBUTION.md +1 -3
- package/CHANGELOG.md +129 -0
- package/README.md +205 -123
- package/package.json +12 -3
- package/plugin/.claude-plugin/plugin.json +2 -3
- package/plugin/CLAUDE.md +8 -8
- package/plugin/agents/slm-governance-advisor.md +80 -0
- package/plugin/agents/slm-loop-runner.md +71 -0
- package/plugin/agents/slm-memory-advisor.md +10 -5
- package/plugin/agents/slm-optimize-advisor.md +9 -3
- package/plugin/commands/slm-loop.md +31 -0
- package/plugin/hooks/hooks.json +79 -0
- package/plugin/requirements.txt +1 -1
- package/plugin/scripts/slm-launch +46 -7
- package/plugin/settings.json +9 -0
- package/plugin/skills/slm-cache/SKILL.md +9 -1
- package/plugin/skills/slm-compress/SKILL.md +8 -1
- package/plugin/skills/slm-governance/SKILL.md +248 -0
- package/plugin/skills/slm-graph/SKILL.md +17 -3
- package/plugin/skills/slm-loop/SKILL.md +99 -0
- package/plugin/skills/slm-mesh/SKILL.md +282 -0
- package/plugin/skills/slm-profile/SKILL.md +148 -0
- package/plugin/skills/slm-recall/SKILL.md +46 -10
- package/plugin/skills/slm-remember/SKILL.md +48 -1
- package/plugin/skills/slm-scope/SKILL.md +176 -0
- package/plugin/skills/slm-session/SKILL.md +24 -1
- package/plugin/skills/slm-status/SKILL.md +18 -1
- package/plugin-src/rules/AGENTS.md +57 -18
- package/plugin-src/skills/slm-cache/SKILL.md +9 -1
- package/plugin-src/skills/slm-compress/SKILL.md +8 -1
- package/plugin-src/skills/slm-graph/SKILL.md +17 -3
- package/plugin-src/skills/slm-recall/SKILL.md +46 -10
- package/plugin-src/skills/slm-remember/SKILL.md +48 -1
- package/plugin-src/skills/slm-session/SKILL.md +24 -1
- package/plugin-src/skills/slm-status/SKILL.md +18 -1
- package/pyproject.toml +2 -1
- package/scripts/postinstall/validation.js +2 -0
- package/scripts/postinstall-interactive.js +74 -2
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/__init__.py +3 -0
- package/src/superlocalmemory/access/rbac.py +477 -0
- package/src/superlocalmemory/cli/commands.py +228 -17
- package/src/superlocalmemory/cli/compress_cmd.py +17 -7
- package/src/superlocalmemory/cli/daemon.py +7 -0
- package/src/superlocalmemory/cli/loop_cmd.py +187 -0
- package/src/superlocalmemory/cli/main.py +49 -8
- package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
- package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
- package/src/superlocalmemory/cli/pending_store.py +49 -13
- package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
- package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
- package/src/superlocalmemory/cli/setup_wizard.py +22 -13
- package/src/superlocalmemory/cli/version_banner.py +17 -3
- package/src/superlocalmemory/compliance/audit.py +6 -0
- package/src/superlocalmemory/compliance/gdpr.py +128 -138
- package/src/superlocalmemory/compliance/retention.py +176 -45
- package/src/superlocalmemory/core/backend_orchestrator.py +23 -59
- package/src/superlocalmemory/core/community_summary.py +267 -0
- package/src/superlocalmemory/core/config.py +216 -3
- package/src/superlocalmemory/core/consolidation_engine.py +95 -22
- package/src/superlocalmemory/core/context_cache.py +61 -18
- package/src/superlocalmemory/core/embedding_worker.py +21 -7
- package/src/superlocalmemory/core/embeddings.py +131 -46
- package/src/superlocalmemory/core/engine.py +41 -22
- package/src/superlocalmemory/core/engine_ingestion.py +359 -43
- package/src/superlocalmemory/core/engine_wiring.py +13 -0
- package/src/superlocalmemory/core/entity_community.py +178 -0
- package/src/superlocalmemory/core/graph_analyzer.py +39 -2
- package/src/superlocalmemory/core/graph_pruner.py +13 -8
- package/src/superlocalmemory/core/ingestion_command.py +134 -25
- package/src/superlocalmemory/core/injection.py +12 -7
- package/src/superlocalmemory/core/key_expander.py +138 -0
- package/src/superlocalmemory/core/maintenance.py +23 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +17 -7
- package/src/superlocalmemory/core/modes.py +1 -1
- package/src/superlocalmemory/core/mutations.py +2 -2
- package/src/superlocalmemory/core/pii.py +105 -0
- package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
- package/src/superlocalmemory/core/recall_pipeline.py +7 -3
- package/src/superlocalmemory/core/recall_worker.py +20 -6
- package/src/superlocalmemory/core/scale_engine.py +60 -1
- package/src/superlocalmemory/core/security_primitives.py +40 -2
- package/src/superlocalmemory/core/store_pipeline.py +186 -29
- package/src/superlocalmemory/core/worker_pool.py +21 -6
- package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
- package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
- package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
- package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
- package/src/superlocalmemory/evolution/evolution_store.py +122 -45
- package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
- package/src/superlocalmemory/evolution/model_selection.py +160 -0
- package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
- package/src/superlocalmemory/evolution/triggers.py +22 -13
- package/src/superlocalmemory/graph/cozo_backend.py +43 -20
- package/src/superlocalmemory/hooks/adapter_base.py +5 -1
- package/src/superlocalmemory/hooks/auto_recall.py +13 -1
- package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
- package/src/superlocalmemory/hooks/codex_assets.py +64 -5
- package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
- package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
- package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
- package/src/superlocalmemory/hooks/portable_kit.py +148 -3
- package/src/superlocalmemory/infra/backup.py +12 -1
- package/src/superlocalmemory/infra/daemon_identity.py +40 -4
- package/src/superlocalmemory/infra/data_root.py +43 -4
- package/src/superlocalmemory/infra/event_bus.py +107 -24
- package/src/superlocalmemory/infra/rate_limiter.py +93 -0
- package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
- package/src/superlocalmemory/ingestion/credentials.py +1 -1
- package/src/superlocalmemory/learning/cross_project.py +28 -19
- package/src/superlocalmemory/learning/model_rollback.py +3 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
- package/src/superlocalmemory/learning/reward.py +50 -0
- package/src/superlocalmemory/learning/reward_proxy.py +42 -9
- package/src/superlocalmemory/learning/source_quality.py +523 -1
- package/src/superlocalmemory/loops/__init__.py +56 -0
- package/src/superlocalmemory/loops/budget.py +58 -0
- package/src/superlocalmemory/loops/engine.py +164 -0
- package/src/superlocalmemory/loops/ledger.py +263 -0
- package/src/superlocalmemory/loops/models.py +152 -0
- package/src/superlocalmemory/loops/rules.py +52 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
- package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
- package/src/superlocalmemory/mcp/profiles.py +103 -0
- package/src/superlocalmemory/mcp/server.py +32 -79
- package/src/superlocalmemory/mcp/tools_active.py +4 -7
- package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
- package/src/superlocalmemory/mcp/tools_core.py +12 -4
- package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
- package/src/superlocalmemory/mcp/tools_learning.py +2 -2
- package/src/superlocalmemory/mcp/tools_loops.py +300 -0
- package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
- package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
- package/src/superlocalmemory/mesh/broker.py +237 -129
- package/src/superlocalmemory/mesh/remote_sync.py +50 -8
- package/src/superlocalmemory/optimize/NOTICE +1 -6
- package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
- package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
- package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
- package/src/superlocalmemory/optimize/compress/align.py +32 -26
- package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
- package/src/superlocalmemory/optimize/compress/router.py +105 -22
- package/src/superlocalmemory/optimize/config/defaults.py +1 -1
- package/src/superlocalmemory/optimize/config/schema.py +87 -4
- package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
- package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
- package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
- package/src/superlocalmemory/optimize/storage/db.py +38 -9
- package/src/superlocalmemory/optimize/storage/schema.py +10 -0
- package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
- package/src/superlocalmemory/retrieval/agentic.py +1 -1
- package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
- package/src/superlocalmemory/retrieval/engine.py +221 -47
- package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
- package/src/superlocalmemory/retrieval/reranker.py +3 -4
- package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
- package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
- package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
- package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
- package/src/superlocalmemory/retrieval/time_window.py +181 -0
- package/src/superlocalmemory/server/api.py +4 -4
- package/src/superlocalmemory/server/config_file.py +90 -0
- package/src/superlocalmemory/server/origin.py +50 -0
- package/src/superlocalmemory/server/profile_runtime.py +125 -8
- package/src/superlocalmemory/server/rbac_enforce.py +142 -0
- package/src/superlocalmemory/server/recall_health.py +24 -3
- package/src/superlocalmemory/server/recall_serializer.py +19 -1
- package/src/superlocalmemory/server/routes/abstraction.py +115 -0
- package/src/superlocalmemory/server/routes/agents.py +128 -38
- package/src/superlocalmemory/server/routes/backup.py +317 -70
- package/src/superlocalmemory/server/routes/behavioral.py +349 -71
- package/src/superlocalmemory/server/routes/brain.py +69 -12
- package/src/superlocalmemory/server/routes/chat.py +10 -5
- package/src/superlocalmemory/server/routes/compliance.py +171 -21
- package/src/superlocalmemory/server/routes/config_api.py +438 -0
- package/src/superlocalmemory/server/routes/data_io.py +30 -8
- package/src/superlocalmemory/server/routes/entity.py +108 -26
- package/src/superlocalmemory/server/routes/events.py +24 -8
- package/src/superlocalmemory/server/routes/evolution.py +189 -68
- package/src/superlocalmemory/server/routes/helpers.py +16 -1
- package/src/superlocalmemory/server/routes/ingest.py +7 -4
- package/src/superlocalmemory/server/routes/insights.py +3 -3
- package/src/superlocalmemory/server/routes/learning.py +289 -118
- package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
- package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
- package/src/superlocalmemory/server/routes/memories.py +182 -57
- package/src/superlocalmemory/server/routes/mesh.py +200 -31
- package/src/superlocalmemory/server/routes/optimize.py +33 -1
- package/src/superlocalmemory/server/routes/prewarm.py +2 -0
- package/src/superlocalmemory/server/routes/profiles.py +63 -17
- package/src/superlocalmemory/server/routes/ratelimit.py +132 -0
- package/src/superlocalmemory/server/routes/rbac.py +367 -0
- package/src/superlocalmemory/server/routes/stats.py +103 -158
- package/src/superlocalmemory/server/routes/tiers.py +11 -9
- package/src/superlocalmemory/server/routes/token.py +3 -13
- package/src/superlocalmemory/server/routes/v3_api.py +247 -89
- package/src/superlocalmemory/server/routes/ws.py +5 -2
- package/src/superlocalmemory/server/security_middleware.py +12 -5
- package/src/superlocalmemory/server/ui.py +20 -5
- package/src/superlocalmemory/server/unified_daemon.py +827 -72
- package/src/superlocalmemory/server/write_identity.py +38 -8
- package/src/superlocalmemory/storage/database.py +265 -53
- package/src/superlocalmemory/storage/migration_runner.py +132 -1
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
- package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
- package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
- package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
- package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
- package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
- package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
- package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
- package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
- package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/storage/schema.py +136 -1
- package/src/superlocalmemory/storage/schema_v32.py +2 -0
- package/src/superlocalmemory/storage/schema_v343.py +24 -12
- package/src/superlocalmemory/storage/schema_v347.py +4 -0
- package/src/superlocalmemory/trust/gate.py +49 -8
- package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
- package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
- package/src/superlocalmemory/ui/css/design-system.css +621 -0
- package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
- package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
- package/src/superlocalmemory/ui/favicon.svg +35 -4
- package/src/superlocalmemory/ui/index.html +303 -173
- package/src/superlocalmemory/ui/js/brain.js +5 -20
- package/src/superlocalmemory/ui/js/core.js +100 -41
- package/src/superlocalmemory/ui/js/dashboard.js +403 -65
- package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
- package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
- package/src/superlocalmemory/ui/js/math-health.js +1 -1
- package/src/superlocalmemory/ui/js/memories.js +15 -4
- package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
- package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
- package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
- package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
- package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
- package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
- package/src/superlocalmemory/ui/js/od-agents.js +544 -0
- package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
- package/src/superlocalmemory/ui/js/od-backup.js +871 -0
- package/src/superlocalmemory/ui/js/od-brain.js +816 -0
- package/src/superlocalmemory/ui/js/od-entities.js +579 -0
- package/src/superlocalmemory/ui/js/od-graph.js +600 -0
- package/src/superlocalmemory/ui/js/od-health.js +539 -0
- package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
- package/src/superlocalmemory/ui/js/od-memories.js +929 -0
- package/src/superlocalmemory/ui/js/od-mesh.js +553 -0
- package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
- package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
- package/src/superlocalmemory/ui/js/od-settings.js +1107 -0
- package/src/superlocalmemory/ui/js/od-shell.js +809 -0
- package/src/superlocalmemory/ui/js/od-skills.js +600 -0
- package/src/superlocalmemory/ui/js/od-team.js +258 -0
- package/src/superlocalmemory/ui/js/profiles.js +159 -46
- package/src/superlocalmemory/ui/js/settings.js +17 -3
- package/src/superlocalmemory/ui/js/timeline.js +34 -5
- package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
- package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
- package/plugin-src/.mcp.json +0 -12
- package/plugin-src/agents/slm-memory-advisor.md +0 -44
- package/plugin-src/agents/slm-optimize-advisor.md +0 -38
- package/plugin-src/hooks/.gitkeep +0 -0
- package/plugin-src/hooks/hooks.json +0 -23
- package/plugin-src/manifest.json +0 -25
- package/plugin-src/requirements.txt +0 -1
- package/plugin-src/rules/CLAUDE.md.fragment +0 -44
- package/plugin-src/scripts/ensure-venv.bat +0 -122
- package/plugin-src/scripts/ensure-venv.sh +0 -105
- package/plugin-src/scripts/slm-launch +0 -23
- package/plugin-src/scripts/slm-launch.bat +0 -23
- package/plugin-src/settings.json +0 -16
- package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
|
@@ -8,22 +8,162 @@ Routes:
|
|
|
8
8
|
Cloud: /api/backup/destinations, /api/backup/connect/github, /api/backup/connect/gdrive,
|
|
9
9
|
/api/backup/disconnect/{id}, /api/backup/sync, /api/backup/export
|
|
10
10
|
"""
|
|
11
|
-
import logging
|
|
12
11
|
import gzip
|
|
13
12
|
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import html as _html
|
|
15
|
+
import logging
|
|
16
|
+
import secrets
|
|
14
17
|
import shutil
|
|
18
|
+
import tempfile
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
15
21
|
import urllib.parse
|
|
16
22
|
|
|
17
23
|
from fastapi import APIRouter, HTTPException, Request
|
|
18
24
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
|
19
25
|
from pydantic import BaseModel, Field
|
|
20
|
-
from
|
|
26
|
+
from starlette.background import BackgroundTask
|
|
21
27
|
|
|
22
|
-
from .helpers import
|
|
28
|
+
from .helpers import DB_PATH, MEMORY_DIR, BackupConfigRequest
|
|
23
29
|
|
|
24
30
|
logger = logging.getLogger("superlocalmemory.routes.backup")
|
|
25
31
|
router = APIRouter()
|
|
26
32
|
|
|
33
|
+
_OAUTH_STATE_TTL_SECONDS = 10 * 60
|
|
34
|
+
_OAUTH_STATE_LOCK = threading.Lock()
|
|
35
|
+
_OAUTH_STATES: dict[str, dict[str, object]] = {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _require_read(request: Request) -> None:
|
|
39
|
+
"""Require READ on the active profile for backup metadata."""
|
|
40
|
+
from superlocalmemory.access.rbac import Permission
|
|
41
|
+
from superlocalmemory.server.rbac_enforce import require_permission
|
|
42
|
+
|
|
43
|
+
require_permission(request, Permission.READ)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _require_manage(request: Request) -> dict:
|
|
47
|
+
"""Require MANAGE for backup configuration, creation, and cloud access."""
|
|
48
|
+
from superlocalmemory.server.rbac_enforce import require_manage
|
|
49
|
+
|
|
50
|
+
return require_manage(request)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _require_oauth_start(request: Request) -> None:
|
|
54
|
+
"""Allow OAuth initiation from the exact dashboard or an authenticated admin."""
|
|
55
|
+
from superlocalmemory.server.origin import origin_is_daemon
|
|
56
|
+
|
|
57
|
+
fetch_site = request.headers.get("sec-fetch-site", "").lower()
|
|
58
|
+
if fetch_site == "cross-site":
|
|
59
|
+
raise HTTPException(
|
|
60
|
+
status_code=403,
|
|
61
|
+
detail="Cross-site OAuth initiation is not allowed.",
|
|
62
|
+
)
|
|
63
|
+
principal = _require_manage(request)
|
|
64
|
+
origin = request.headers.get("origin", "")
|
|
65
|
+
if origin:
|
|
66
|
+
descriptor = getattr(
|
|
67
|
+
getattr(request.app, "state", None),
|
|
68
|
+
"daemon_descriptor",
|
|
69
|
+
None,
|
|
70
|
+
)
|
|
71
|
+
daemon_port = (
|
|
72
|
+
getattr(descriptor, "port", None)
|
|
73
|
+
or request.url.port
|
|
74
|
+
or 8765
|
|
75
|
+
)
|
|
76
|
+
if not origin_is_daemon(origin, port=int(daemon_port)):
|
|
77
|
+
raise HTTPException(
|
|
78
|
+
status_code=403,
|
|
79
|
+
detail="OAuth initiation requires the local dashboard origin.",
|
|
80
|
+
)
|
|
81
|
+
elif fetch_site and fetch_site != "same-origin":
|
|
82
|
+
# Browser top-level navigations commonly omit Origin. Sec-Fetch-Site
|
|
83
|
+
# still distinguishes this daemon's own page (same-origin) from a
|
|
84
|
+
# different localhost port (same-site).
|
|
85
|
+
raise HTTPException(
|
|
86
|
+
status_code=403,
|
|
87
|
+
detail="OAuth initiation requires the local dashboard origin.",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
host = request.client.host if request.client else ""
|
|
91
|
+
is_loopback = host in {"127.0.0.1", "::1", "localhost", "testclient"}
|
|
92
|
+
if not is_loopback and principal.get("kind") != "user":
|
|
93
|
+
raise HTTPException(
|
|
94
|
+
status_code=403,
|
|
95
|
+
detail=(
|
|
96
|
+
"OAuth connections must start from the local dashboard or an "
|
|
97
|
+
"authenticated admin session."
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _oauth_context(request: Request) -> str:
|
|
103
|
+
"""Fingerprint the local browser/session that initiated an OAuth flow.
|
|
104
|
+
|
|
105
|
+
OAuth callbacks cannot carry the install-token header, so the random state
|
|
106
|
+
is bound to stable browser context available on both the start and callback:
|
|
107
|
+
loopback client address, dashboard user session (when present), and user
|
|
108
|
+
agent. Only the digest is retained in memory.
|
|
109
|
+
"""
|
|
110
|
+
client_host = request.client.host if request.client else ""
|
|
111
|
+
session = request.headers.get("X-SLM-User-Session", "")
|
|
112
|
+
if not session:
|
|
113
|
+
session = request.cookies.get("slm_session", "")
|
|
114
|
+
user_agent = request.headers.get("user-agent", "")
|
|
115
|
+
material = "\x00".join((client_host, session, user_agent))
|
|
116
|
+
return hashlib.sha256(material.encode("utf-8")).hexdigest()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _issue_oauth_state(provider: str, request: Request) -> str:
|
|
120
|
+
"""Create a cryptographically random, expiring, provider-bound state."""
|
|
121
|
+
now = time.monotonic()
|
|
122
|
+
state = secrets.token_urlsafe(32)
|
|
123
|
+
record = {
|
|
124
|
+
"provider": provider,
|
|
125
|
+
"context": _oauth_context(request),
|
|
126
|
+
"expires_at": now + _OAUTH_STATE_TTL_SECONDS,
|
|
127
|
+
}
|
|
128
|
+
with _OAUTH_STATE_LOCK:
|
|
129
|
+
expired = [
|
|
130
|
+
key for key, value in _OAUTH_STATES.items()
|
|
131
|
+
if float(value.get("expires_at", 0)) <= now
|
|
132
|
+
]
|
|
133
|
+
for key in expired:
|
|
134
|
+
_OAUTH_STATES.pop(key, None)
|
|
135
|
+
_OAUTH_STATES[state] = record
|
|
136
|
+
return state
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _consume_oauth_state(state: str, provider: str, request: Request) -> bool:
|
|
140
|
+
"""Atomically consume and validate OAuth state.
|
|
141
|
+
|
|
142
|
+
The pop happens under the lock, so parallel/replayed callbacks cannot both
|
|
143
|
+
exchange the same authorization code. A mismatched provider or browser
|
|
144
|
+
context fails closed and the suspicious state cannot be retried.
|
|
145
|
+
"""
|
|
146
|
+
if not state:
|
|
147
|
+
return False
|
|
148
|
+
with _OAUTH_STATE_LOCK:
|
|
149
|
+
record = _OAUTH_STATES.pop(state, None)
|
|
150
|
+
if not record or float(record.get("expires_at", 0)) <= time.monotonic():
|
|
151
|
+
return False
|
|
152
|
+
return (
|
|
153
|
+
hmac.compare_digest(str(record.get("provider", "")), provider)
|
|
154
|
+
and hmac.compare_digest(
|
|
155
|
+
str(record.get("context", "")),
|
|
156
|
+
_oauth_context(request),
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _internal_error(detail: str = "Internal server error") -> HTTPException:
|
|
162
|
+
"""SEC-H-02: log full traceback server-side; return a generic message to the client."""
|
|
163
|
+
logger.exception("backup route error")
|
|
164
|
+
return HTTPException(status_code=500, detail=detail)
|
|
165
|
+
|
|
166
|
+
|
|
27
167
|
# Feature flags
|
|
28
168
|
BACKUP_AVAILABLE = False
|
|
29
169
|
CLOUD_AVAILABLE = False
|
|
@@ -35,9 +175,11 @@ except ImportError:
|
|
|
35
175
|
|
|
36
176
|
try:
|
|
37
177
|
from superlocalmemory.infra.cloud_backup import (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
178
|
+
connect_github,
|
|
179
|
+
connect_google_drive,
|
|
180
|
+
get_destinations,
|
|
181
|
+
remove_destination,
|
|
182
|
+
sync_all_destinations,
|
|
41
183
|
)
|
|
42
184
|
CLOUD_AVAILABLE = True
|
|
43
185
|
except ImportError:
|
|
@@ -67,8 +209,9 @@ class GDriveClientConfig(BaseModel):
|
|
|
67
209
|
# ---- Local backup routes (existing) ---------------------------------------
|
|
68
210
|
|
|
69
211
|
@router.get("/api/backup/status")
|
|
70
|
-
|
|
212
|
+
def backup_status(request: Request):
|
|
71
213
|
"""Get auto-backup system status + cloud destinations."""
|
|
214
|
+
_require_read(request)
|
|
72
215
|
if not BACKUP_AVAILABLE:
|
|
73
216
|
return {"status": "not_implemented", "message": "Backup module not available"}
|
|
74
217
|
try:
|
|
@@ -79,13 +222,14 @@ async def backup_status():
|
|
|
79
222
|
else:
|
|
80
223
|
status["cloud_destinations"] = []
|
|
81
224
|
return status
|
|
82
|
-
except Exception
|
|
83
|
-
raise
|
|
225
|
+
except Exception:
|
|
226
|
+
raise _internal_error("Backup status error")
|
|
84
227
|
|
|
85
228
|
|
|
86
229
|
@router.post("/api/backup/create")
|
|
87
|
-
|
|
230
|
+
def backup_create(request: Request):
|
|
88
231
|
"""Create a manual backup immediately."""
|
|
232
|
+
_require_manage(request)
|
|
89
233
|
if not BACKUP_AVAILABLE:
|
|
90
234
|
return {"success": False, "message": "Backup module not available"}
|
|
91
235
|
try:
|
|
@@ -98,86 +242,102 @@ async def backup_create():
|
|
|
98
242
|
"status": manager.get_status(),
|
|
99
243
|
}
|
|
100
244
|
return {"success": False, "message": "Backup failed"}
|
|
101
|
-
except Exception
|
|
102
|
-
raise
|
|
245
|
+
except Exception:
|
|
246
|
+
raise _internal_error("Backup create error")
|
|
103
247
|
|
|
104
248
|
|
|
105
249
|
@router.post("/api/backup/configure")
|
|
106
|
-
|
|
250
|
+
def backup_configure(request: Request, payload: BackupConfigRequest):
|
|
107
251
|
"""Update auto-backup configuration."""
|
|
252
|
+
_require_manage(request)
|
|
108
253
|
if not BACKUP_AVAILABLE:
|
|
109
254
|
return {"success": False, "message": "Backup module not available"}
|
|
110
255
|
try:
|
|
111
256
|
manager = _get_backup_manager()
|
|
112
257
|
result = manager.configure(
|
|
113
|
-
interval_hours=
|
|
114
|
-
max_backups=
|
|
115
|
-
enabled=
|
|
258
|
+
interval_hours=payload.interval_hours,
|
|
259
|
+
max_backups=payload.max_backups,
|
|
260
|
+
enabled=payload.enabled,
|
|
116
261
|
)
|
|
117
262
|
return {"success": True, "message": "Backup configuration updated", "status": result}
|
|
118
|
-
except Exception
|
|
119
|
-
raise
|
|
263
|
+
except Exception:
|
|
264
|
+
raise _internal_error("Backup configure error")
|
|
120
265
|
|
|
121
266
|
|
|
122
267
|
@router.get("/api/backup/list")
|
|
123
|
-
|
|
268
|
+
def backup_list(request: Request):
|
|
124
269
|
"""List all available backups."""
|
|
270
|
+
_require_read(request)
|
|
125
271
|
if not BACKUP_AVAILABLE:
|
|
126
272
|
return {"backups": [], "count": 0, "message": "Backup module not available"}
|
|
273
|
+
# This GET is not covered by the mutation middleware. The local machine
|
|
274
|
+
# owner (loopback) is trusted; a remote caller must present a credential;
|
|
275
|
+
# non-loopback uncredentialed callers fail closed. Using the mutation-actor
|
|
276
|
+
# boundary (not require_write_actor) so the same-origin dashboard — whose
|
|
277
|
+
# fetch wrapper only attaches the install token to mutating requests — can
|
|
278
|
+
# still read its own backup list.
|
|
279
|
+
from superlocalmemory.server.write_identity import require_http_mutation_actor
|
|
280
|
+
require_http_mutation_actor(request, getattr(request.app.state, "daemon_descriptor", None),
|
|
281
|
+
actor_kind="backup-list")
|
|
127
282
|
try:
|
|
128
283
|
manager = _get_backup_manager()
|
|
129
284
|
backups = manager.list_backups()
|
|
130
285
|
return {"backups": backups, "count": len(backups)}
|
|
131
|
-
except Exception
|
|
132
|
-
raise
|
|
286
|
+
except Exception:
|
|
287
|
+
raise _internal_error("Backup list error")
|
|
133
288
|
|
|
134
289
|
|
|
135
290
|
# ---- Cloud destination routes (v3.4.10) -----------------------------------
|
|
136
291
|
|
|
137
292
|
@router.get("/api/backup/destinations")
|
|
138
|
-
|
|
293
|
+
def list_destinations(request: Request):
|
|
139
294
|
"""List all configured cloud backup destinations."""
|
|
295
|
+
_require_read(request)
|
|
140
296
|
if not CLOUD_AVAILABLE:
|
|
141
297
|
return {"destinations": [], "cloud_available": False}
|
|
142
298
|
return {"destinations": get_destinations(DB_PATH), "cloud_available": True}
|
|
143
299
|
|
|
144
300
|
|
|
145
301
|
@router.post("/api/backup/connect/github")
|
|
146
|
-
|
|
302
|
+
def connect_github_route(request: Request, payload: GitHubConnectRequest):
|
|
147
303
|
"""Connect GitHub as a backup destination using PAT."""
|
|
304
|
+
_require_manage(request)
|
|
148
305
|
if not CLOUD_AVAILABLE:
|
|
149
306
|
raise HTTPException(status_code=501, detail="Cloud backup module not available")
|
|
150
|
-
result = connect_github(
|
|
307
|
+
result = connect_github(payload.pat, payload.repo_name)
|
|
151
308
|
if "error" in result:
|
|
152
309
|
raise HTTPException(status_code=400, detail=result["error"])
|
|
153
310
|
return result
|
|
154
311
|
|
|
155
312
|
|
|
156
313
|
@router.post("/api/backup/connect/gdrive/config")
|
|
157
|
-
|
|
314
|
+
def configure_gdrive_client(request: Request, payload: GDriveClientConfig):
|
|
158
315
|
"""Store Google OAuth client credentials (one-time setup)."""
|
|
316
|
+
_require_manage(request)
|
|
159
317
|
if not CLOUD_AVAILABLE:
|
|
160
318
|
raise HTTPException(status_code=501, detail="Cloud backup module not available")
|
|
161
319
|
from superlocalmemory.infra.cloud_backup import _store_credential
|
|
162
|
-
_store_credential("gdrive_client_id",
|
|
163
|
-
_store_credential("gdrive_client_secret",
|
|
320
|
+
_store_credential("gdrive_client_id", payload.client_id)
|
|
321
|
+
_store_credential("gdrive_client_secret", payload.client_secret)
|
|
164
322
|
return {"success": True, "message": "Google OAuth client configured"}
|
|
165
323
|
|
|
166
324
|
|
|
167
325
|
@router.post("/api/backup/connect/gdrive")
|
|
168
|
-
|
|
326
|
+
def connect_gdrive_route(request: Request, payload: GDriveConnectRequest):
|
|
169
327
|
"""Complete Google Drive OAuth2 flow with authorization code."""
|
|
328
|
+
_require_manage(request)
|
|
170
329
|
if not CLOUD_AVAILABLE:
|
|
171
330
|
raise HTTPException(status_code=501, detail="Cloud backup module not available")
|
|
172
|
-
result = connect_google_drive(
|
|
331
|
+
result = connect_google_drive(payload.auth_code, payload.redirect_uri)
|
|
173
332
|
if "error" in result:
|
|
174
333
|
raise HTTPException(status_code=400, detail=result["error"])
|
|
175
334
|
return result
|
|
176
335
|
|
|
177
336
|
|
|
178
337
|
@router.delete("/api/backup/disconnect/{dest_id}")
|
|
179
|
-
|
|
338
|
+
def disconnect_destination(request: Request, dest_id: str):
|
|
180
339
|
"""Remove a cloud backup destination."""
|
|
340
|
+
_require_manage(request)
|
|
181
341
|
if not CLOUD_AVAILABLE:
|
|
182
342
|
raise HTTPException(status_code=501, detail="Cloud backup module not available")
|
|
183
343
|
ok = remove_destination(dest_id, DB_PATH)
|
|
@@ -187,16 +347,16 @@ async def disconnect_destination(dest_id: str):
|
|
|
187
347
|
|
|
188
348
|
|
|
189
349
|
@router.post("/api/backup/sync")
|
|
190
|
-
|
|
350
|
+
def sync_cloud(request: Request):
|
|
191
351
|
"""Manually trigger sync to all cloud destinations.
|
|
192
352
|
|
|
193
353
|
Runs the upload in a background thread so it doesn't block the
|
|
194
354
|
dashboard. Returns immediately with status 'syncing'. The actual
|
|
195
355
|
upload status is reflected in the destination's last_sync_status.
|
|
196
356
|
"""
|
|
197
|
-
import asyncio
|
|
198
357
|
import threading
|
|
199
358
|
|
|
359
|
+
_require_manage(request)
|
|
200
360
|
if not CLOUD_AVAILABLE:
|
|
201
361
|
raise HTTPException(status_code=501, detail="Cloud backup module not available")
|
|
202
362
|
if not BACKUP_AVAILABLE:
|
|
@@ -221,15 +381,26 @@ async def sync_cloud():
|
|
|
221
381
|
return {
|
|
222
382
|
"success": True,
|
|
223
383
|
"backup": filename,
|
|
224
|
-
"sync": {
|
|
384
|
+
"sync": {
|
|
385
|
+
"status": "syncing",
|
|
386
|
+
"message": (
|
|
387
|
+
"Upload started in background. Check destination status for progress."
|
|
388
|
+
),
|
|
389
|
+
},
|
|
225
390
|
}
|
|
226
391
|
|
|
227
392
|
|
|
228
393
|
# ---- Export / Download route (v3.4.10) ------------------------------------
|
|
229
394
|
|
|
230
|
-
@router.
|
|
231
|
-
|
|
232
|
-
"""Create and download a compressed backup archive.
|
|
395
|
+
@router.post("/api/backup/export")
|
|
396
|
+
def export_backup(request: Request):
|
|
397
|
+
"""Create and download a compressed backup archive.
|
|
398
|
+
|
|
399
|
+
Export is a credentialed mutation because it creates a local snapshot.
|
|
400
|
+
The compressed transport file is temporary and is removed after the
|
|
401
|
+
response completes; the normal retention policy owns the snapshot itself.
|
|
402
|
+
"""
|
|
403
|
+
_require_manage(request)
|
|
233
404
|
if not BACKUP_AVAILABLE:
|
|
234
405
|
raise HTTPException(status_code=501, detail="Backup module not available")
|
|
235
406
|
|
|
@@ -242,16 +413,28 @@ async def export_backup():
|
|
|
242
413
|
if not backup_path.exists():
|
|
243
414
|
raise HTTPException(status_code=500, detail="Backup file not found")
|
|
244
415
|
|
|
245
|
-
# Compress
|
|
246
|
-
|
|
247
|
-
with
|
|
248
|
-
|
|
249
|
-
|
|
416
|
+
# Compress into a one-response transport file. Never retain another full
|
|
417
|
+
# copy beside the bounded snapshot set.
|
|
418
|
+
with tempfile.NamedTemporaryFile(
|
|
419
|
+
prefix="slm-export-",
|
|
420
|
+
suffix=".db.gz",
|
|
421
|
+
dir=backup_path.parent,
|
|
422
|
+
delete=False,
|
|
423
|
+
) as temp_file:
|
|
424
|
+
gz_path = backup_path.parent / temp_file.name
|
|
425
|
+
try:
|
|
426
|
+
with open(backup_path, "rb") as f_in:
|
|
427
|
+
with gzip.open(gz_path, "wb") as f_out:
|
|
428
|
+
shutil.copyfileobj(f_in, f_out)
|
|
429
|
+
except Exception:
|
|
430
|
+
gz_path.unlink(missing_ok=True)
|
|
431
|
+
raise
|
|
250
432
|
|
|
251
433
|
return FileResponse(
|
|
252
434
|
path=str(gz_path),
|
|
253
435
|
media_type="application/gzip",
|
|
254
|
-
filename=
|
|
436
|
+
filename=f"{backup_path.name}.gz",
|
|
437
|
+
background=BackgroundTask(gz_path.unlink, missing_ok=True),
|
|
255
438
|
)
|
|
256
439
|
|
|
257
440
|
|
|
@@ -308,7 +491,6 @@ p {{ color: #999; margin: 0 0 20px; font-size: 13px; }}
|
|
|
308
491
|
# ``str.format`` doesn't escape HTML, so a hostile OAuth callback can
|
|
309
492
|
# inject ``<script>`` into these pages. These helpers HTML-escape every
|
|
310
493
|
# interpolated value before emitting the template.
|
|
311
|
-
import html as _html
|
|
312
494
|
|
|
313
495
|
|
|
314
496
|
def _oauth_error_page(icon: str, error: str) -> str:
|
|
@@ -329,8 +511,9 @@ def _oauth_success_page(icon: str, title: str, message: str) -> str:
|
|
|
329
511
|
# ---- Google OAuth SSO Flow ------------------------------------------------
|
|
330
512
|
|
|
331
513
|
@router.get("/api/backup/oauth/google/start")
|
|
332
|
-
|
|
514
|
+
def google_oauth_start(request: Request):
|
|
333
515
|
"""Start Google OAuth2 flow — redirects to Google's login page."""
|
|
516
|
+
_require_oauth_start(request)
|
|
334
517
|
if not CLOUD_AVAILABLE:
|
|
335
518
|
return HTMLResponse(_oauth_error_page(icon="⚠", error="Cloud backup module not available"))
|
|
336
519
|
|
|
@@ -398,7 +581,7 @@ a { color: #00D4AA; }
|
|
|
398
581
|
</div>
|
|
399
582
|
|
|
400
583
|
<p style="color:#555;font-size:11px;margin-top:16px;">
|
|
401
|
-
|
|
584
|
+
Credentials use your OS credential store when available, with a permission-restricted local fallback.
|
|
402
585
|
Full guide: <a href="https://github.com/qualixar/superlocalmemory/wiki/Cloud-Backup#google-drive-backup" target="_blank">Cloud Backup Wiki</a>
|
|
403
586
|
</p>
|
|
404
587
|
</div>
|
|
@@ -408,20 +591,29 @@ async function saveAndConnect() {
|
|
|
408
591
|
var csec = document.getElementById('csec').value.trim();
|
|
409
592
|
if (!cid || !csec) { document.getElementById('status').innerHTML = '<span style="color:#ff4757">Both fields required</span>'; return; }
|
|
410
593
|
document.getElementById('saveBtn').disabled = true;
|
|
411
|
-
document.getElementById('status')
|
|
594
|
+
var status = document.getElementById('status');
|
|
595
|
+
status.style.color = '#999';
|
|
596
|
+
status.textContent = 'Saving...';
|
|
412
597
|
try {
|
|
598
|
+
var tokenResp = await fetch('/internal/token', {credentials: 'same-origin'});
|
|
599
|
+
if (!tokenResp.ok) throw new Error('Could not authorize this dashboard');
|
|
600
|
+
var tokenData = await tokenResp.json();
|
|
601
|
+
if (!tokenData.token) throw new Error('Could not authorize this dashboard');
|
|
413
602
|
var resp = await fetch('/api/backup/connect/gdrive/config', {
|
|
414
|
-
method: 'POST',
|
|
603
|
+
method: 'POST', credentials: 'same-origin',
|
|
604
|
+
headers: {'Content-Type': 'application/json', 'X-Install-Token': tokenData.token},
|
|
415
605
|
body: JSON.stringify({client_id: cid, client_secret: csec})
|
|
416
606
|
});
|
|
417
607
|
if (resp.ok) {
|
|
418
608
|
window.location.href = '/api/backup/oauth/google/start';
|
|
419
609
|
} else {
|
|
420
|
-
|
|
610
|
+
status.style.color = '#ff4757';
|
|
611
|
+
status.textContent = 'Failed to save';
|
|
421
612
|
document.getElementById('saveBtn').disabled = false;
|
|
422
613
|
}
|
|
423
614
|
} catch(e) {
|
|
424
|
-
|
|
615
|
+
status.style.color = '#ff4757';
|
|
616
|
+
status.textContent = 'Error: ' + e.message;
|
|
425
617
|
document.getElementById('saveBtn').disabled = false;
|
|
426
618
|
}
|
|
427
619
|
}
|
|
@@ -430,22 +622,37 @@ async function saveAndConnect() {
|
|
|
430
622
|
# Build the Google OAuth URL
|
|
431
623
|
base_url = str(request.base_url).rstrip("/")
|
|
432
624
|
redirect_uri = f"{base_url}/api/backup/oauth/google/callback"
|
|
625
|
+
state = _issue_oauth_state("google", request)
|
|
433
626
|
|
|
434
627
|
params = urllib.parse.urlencode({
|
|
435
628
|
"client_id": client_id,
|
|
436
629
|
"redirect_uri": redirect_uri,
|
|
437
630
|
"response_type": "code",
|
|
438
|
-
"scope": "https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/userinfo.email",
|
|
631
|
+
"scope": "openid https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/userinfo.email",
|
|
439
632
|
"access_type": "offline",
|
|
440
633
|
"prompt": "consent",
|
|
634
|
+
"state": state,
|
|
441
635
|
})
|
|
442
636
|
|
|
443
637
|
return RedirectResponse(f"https://accounts.google.com/o/oauth2/v2/auth?{params}")
|
|
444
638
|
|
|
445
639
|
|
|
446
640
|
@router.get("/api/backup/oauth/google/callback")
|
|
447
|
-
|
|
641
|
+
def google_oauth_callback(
|
|
642
|
+
request: Request,
|
|
643
|
+
code: str = "",
|
|
644
|
+
error: str = "",
|
|
645
|
+
state: str = "",
|
|
646
|
+
):
|
|
448
647
|
"""Google OAuth2 callback — exchanges code for tokens."""
|
|
648
|
+
if not _consume_oauth_state(state, "google", request):
|
|
649
|
+
return HTMLResponse(
|
|
650
|
+
_oauth_error_page(
|
|
651
|
+
icon="❌",
|
|
652
|
+
error="Invalid, expired, or already-used OAuth state. Start the connection again.",
|
|
653
|
+
),
|
|
654
|
+
status_code=400,
|
|
655
|
+
)
|
|
449
656
|
if error:
|
|
450
657
|
return HTMLResponse(_oauth_error_page(icon="❌", error=f"Google denied access: {error}"))
|
|
451
658
|
|
|
@@ -463,7 +670,7 @@ async def google_oauth_callback(request: Request, code: str = "", error: str = "
|
|
|
463
670
|
return HTMLResponse(_oauth_success_page(
|
|
464
671
|
icon="☁️",
|
|
465
672
|
title="Google Drive Connected!",
|
|
466
|
-
message=f"Signed in as {result.get('email', 'unknown')}.
|
|
673
|
+
message=f"Signed in as {result.get('email', 'unknown')}. The destination is configured; sync status will appear after an upload."
|
|
467
674
|
))
|
|
468
675
|
|
|
469
676
|
|
|
@@ -473,8 +680,9 @@ async def google_oauth_callback(request: Request, code: str = "", error: str = "
|
|
|
473
680
|
# otherwise fall back to Device Flow with a nice UI.
|
|
474
681
|
|
|
475
682
|
@router.get("/api/backup/oauth/github/start")
|
|
476
|
-
|
|
683
|
+
def github_oauth_start(request: Request):
|
|
477
684
|
"""Start GitHub OAuth flow."""
|
|
685
|
+
_require_oauth_start(request)
|
|
478
686
|
if not CLOUD_AVAILABLE:
|
|
479
687
|
return HTMLResponse(_oauth_error_page(icon="⚠", error="Cloud backup module not available"))
|
|
480
688
|
|
|
@@ -487,12 +695,13 @@ async def github_oauth_start(request: Request):
|
|
|
487
695
|
# Full OAuth Web Flow — browser redirects to GitHub login
|
|
488
696
|
base_url = str(request.base_url).rstrip("/")
|
|
489
697
|
redirect_uri = f"{base_url}/api/backup/oauth/github/callback"
|
|
698
|
+
state = _issue_oauth_state("github", request)
|
|
490
699
|
|
|
491
700
|
params = urllib.parse.urlencode({
|
|
492
701
|
"client_id": gh_client_id,
|
|
493
702
|
"redirect_uri": redirect_uri,
|
|
494
703
|
"scope": "repo",
|
|
495
|
-
"state":
|
|
704
|
+
"state": state,
|
|
496
705
|
})
|
|
497
706
|
return RedirectResponse(f"https://github.com/login/oauth/authorize?{params}")
|
|
498
707
|
|
|
@@ -534,38 +743,61 @@ a { color: #00D4AA; }
|
|
|
534
743
|
|
|
535
744
|
<p class="hint">
|
|
536
745
|
Need a token? <a href="https://github.com/settings/tokens/new?scopes=repo&description=SLM+Backup" target="_blank">Create one here</a> (select <code>repo</code> scope).
|
|
537
|
-
Your token
|
|
746
|
+
Your token uses the OS credential store when available, with a permission-restricted local fallback.
|
|
538
747
|
</p>
|
|
539
748
|
</div>
|
|
540
749
|
<script>
|
|
541
750
|
async function doConnect() {
|
|
542
751
|
var pat = document.getElementById('pat').value.trim();
|
|
543
752
|
var repo = document.getElementById('repo').value.trim();
|
|
544
|
-
|
|
753
|
+
var status = document.getElementById('status');
|
|
754
|
+
if (!pat) { status.style.color = '#ff4757'; status.textContent = 'Token required'; return; }
|
|
545
755
|
|
|
546
756
|
var btn = document.getElementById('connectBtn');
|
|
547
757
|
btn.disabled = true; btn.textContent = 'Connecting...';
|
|
548
|
-
|
|
758
|
+
status.style.color = '#999';
|
|
759
|
+
status.textContent = 'Verifying token and creating repo...';
|
|
549
760
|
|
|
550
761
|
try {
|
|
762
|
+
var tokenResp = await fetch('/internal/token', {credentials: 'same-origin'});
|
|
763
|
+
if (!tokenResp.ok) throw new Error('Could not authorize this dashboard');
|
|
764
|
+
var tokenData = await tokenResp.json();
|
|
765
|
+
if (!tokenData.token) throw new Error('Could not authorize this dashboard');
|
|
551
766
|
var resp = await fetch('/api/backup/connect/github', {
|
|
552
767
|
method: 'POST',
|
|
553
|
-
|
|
768
|
+
credentials: 'same-origin',
|
|
769
|
+
headers: {'Content-Type': 'application/json', 'X-Install-Token': tokenData.token},
|
|
554
770
|
body: JSON.stringify({pat: pat, repo_name: repo || 'slm-backup'})
|
|
555
771
|
});
|
|
556
772
|
var data = await resp.json();
|
|
557
773
|
if (resp.ok) {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
774
|
+
var card = document.createElement('div');
|
|
775
|
+
card.className = 'card';
|
|
776
|
+
card.style.cssText = 'text-align:center;background:rgba(255,255,255,0.05);border:1px solid rgba(0,212,170,0.3);border-radius:16px;padding:40px;max-width:400px;';
|
|
777
|
+
var icon = document.createElement('div');
|
|
778
|
+
icon.style.cssText = 'font-size:48px;margin-bottom:16px;';
|
|
779
|
+
icon.textContent = '\u2705';
|
|
780
|
+
var title = document.createElement('h2');
|
|
781
|
+
title.style.cssText = 'color:#00D4AA;margin:0 0 8px;';
|
|
782
|
+
title.textContent = 'GitHub Connected!';
|
|
783
|
+
var repoText = document.createElement('p');
|
|
784
|
+
repoText.style.cssText = 'color:#999;margin:0 0 20px;';
|
|
785
|
+
repoText.textContent = 'Repository: ' + (data.repo || repo);
|
|
786
|
+
var closeBtn = document.createElement('button');
|
|
787
|
+
closeBtn.className = 'btn';
|
|
788
|
+
closeBtn.style.cssText = 'background:#00D4AA;color:#0a0a0f;border:none;padding:10px 24px;border-radius:8px;cursor:pointer;font-weight:600;';
|
|
789
|
+
closeBtn.textContent = 'Close Window';
|
|
790
|
+
closeBtn.addEventListener('click', function () { window.close(); });
|
|
791
|
+
card.append(icon, title, repoText, closeBtn);
|
|
792
|
+
document.body.replaceChildren(card);
|
|
563
793
|
} else {
|
|
564
|
-
|
|
794
|
+
status.style.color = '#ff4757';
|
|
795
|
+
status.textContent = data.detail || 'Connection failed';
|
|
565
796
|
btn.disabled = false; btn.textContent = 'Connect';
|
|
566
797
|
}
|
|
567
798
|
} catch(e) {
|
|
568
|
-
|
|
799
|
+
status.style.color = '#ff4757';
|
|
800
|
+
status.textContent = 'Connection failed';
|
|
569
801
|
btn.disabled = false; btn.textContent = 'Connect';
|
|
570
802
|
}
|
|
571
803
|
}
|
|
@@ -573,17 +805,31 @@ async function doConnect() {
|
|
|
573
805
|
|
|
574
806
|
|
|
575
807
|
@router.get("/api/backup/oauth/github/callback")
|
|
576
|
-
|
|
808
|
+
def github_oauth_callback(
|
|
809
|
+
request: Request,
|
|
810
|
+
code: str = "",
|
|
811
|
+
error: str = "",
|
|
812
|
+
state: str = "",
|
|
813
|
+
):
|
|
577
814
|
"""GitHub OAuth callback — exchanges code for access token."""
|
|
815
|
+
if not _consume_oauth_state(state, "github", request):
|
|
816
|
+
return HTMLResponse(
|
|
817
|
+
_oauth_error_page(
|
|
818
|
+
icon="❌",
|
|
819
|
+
error="Invalid, expired, or already-used OAuth state. Start the connection again.",
|
|
820
|
+
),
|
|
821
|
+
status_code=400,
|
|
822
|
+
)
|
|
578
823
|
if error:
|
|
579
824
|
return HTMLResponse(_oauth_error_page(icon="❌", error=f"GitHub denied access: {error}"))
|
|
580
825
|
|
|
581
826
|
if not code:
|
|
582
827
|
return HTMLResponse(_oauth_error_page(icon="❌", error="No authorization code received"))
|
|
583
828
|
|
|
584
|
-
from superlocalmemory.infra.cloud_backup import _get_credential, _store_credential
|
|
585
829
|
import httpx
|
|
586
830
|
|
|
831
|
+
from superlocalmemory.infra.cloud_backup import _get_credential
|
|
832
|
+
|
|
587
833
|
gh_client_id = _get_credential("github_client_id")
|
|
588
834
|
gh_client_secret = _get_credential("github_client_secret")
|
|
589
835
|
|
|
@@ -611,8 +857,9 @@ async def github_oauth_callback(request: Request, code: str = "", error: str = "
|
|
|
611
857
|
return HTMLResponse(_oauth_success_page(
|
|
612
858
|
icon="✅",
|
|
613
859
|
title="GitHub Connected!",
|
|
614
|
-
message=f"Repository: {result.get('repo', 'slm-backup')}.
|
|
860
|
+
message=f"Repository: {result.get('repo', 'slm-backup')}. The destination is configured; sync status will appear after an upload."
|
|
615
861
|
))
|
|
616
862
|
|
|
617
|
-
except Exception
|
|
618
|
-
|
|
863
|
+
except Exception:
|
|
864
|
+
logger.exception("GitHub OAuth callback failed")
|
|
865
|
+
return HTMLResponse(_oauth_error_page(icon="❌", error="GitHub connection failed"))
|