personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'hopper-wxoolngy-manifest-endpoints'

+187 -13
+8 -1
apps/import/journal_sources.py
··· 14 14 from flask import abort, g, request 15 15 16 16 from apps.utils import get_app_storage_path 17 + from convey import state 17 18 18 19 logger = logging.getLogger(__name__) 19 20 ··· 22 23 23 24 24 25 def is_valid_journal_source_name(name: str) -> bool: 25 - return bool(name) and name not in {".", ".."} and "/" not in name and "\\" not in name 26 + return ( 27 + bool(name) and name not in {".", ".."} and "/" not in name and "\\" not in name 28 + ) 26 29 27 30 28 31 def generate_key() -> str: ··· 96 99 area_dir.mkdir(parents=True, exist_ok=True) 97 100 (area_dir / "state.json").write_text("{}", encoding="utf-8") 98 101 return state_dir 102 + 103 + 104 + def get_state_directory(key_prefix: str) -> Path: 105 + return Path(state.journal_root) / "imports" / key_prefix 99 106 100 107 101 108 def require_journal_source(f):
+28 -9
apps/import/routes.py
··· 9 9 from pathlib import Path 10 10 from typing import Any 11 11 12 - from flask import Blueprint, jsonify, render_template, request 12 + from flask import Blueprint, abort, g, jsonify, render_template, request 13 13 from werkzeug.utils import secure_filename 14 14 15 - from .journal_sources import ( 16 - create_state_directory, 17 - find_journal_source_by_name, 18 - generate_key, 19 - is_valid_journal_source_name, 20 - list_journal_sources, 21 - save_journal_source, 22 - ) 23 15 from apps.utils import log_app_action 24 16 from convey import emit, state 25 17 from media import MEDIA_EXTENSIONS ··· 36 28 write_import_metadata, 37 29 ) 38 30 from think.utils import now_ms 31 + 32 + from .journal_sources import ( 33 + STATE_AREAS, 34 + create_state_directory, 35 + find_journal_source_by_name, 36 + generate_key, 37 + get_state_directory, 38 + is_valid_journal_source_name, 39 + list_journal_sources, 40 + require_journal_source, 41 + save_journal_source, 42 + ) 39 43 40 44 import_bp = Blueprint( 41 45 "app:import", ··· 929 933 "stats": source.get("stats", {}), 930 934 } 931 935 ) 936 + 937 + 938 + @import_bp.route("/journal/<key_prefix>/manifest/<area>") 939 + @require_journal_source 940 + def journal_source_manifest(key_prefix: str, area: str) -> Any: 941 + if g.journal_source["key"][:8] != key_prefix: 942 + abort(403, description="Key prefix mismatch") 943 + if area not in STATE_AREAS: 944 + abort(404, description="Unknown manifest area") 945 + state_path = get_state_directory(key_prefix) / area / "state.json" 946 + try: 947 + data = json.loads(state_path.read_text(encoding="utf-8")) 948 + except (OSError, json.JSONDecodeError): 949 + data = {} 950 + return jsonify(data)
+2
convey/root.py
··· 98 98 "app:observer.ingest_transfer", 99 99 "app:observer.ingest_manifest", 100 100 "app:observer.ingest_manifest_day", 101 + # Journal-source manifest endpoints use key-based auth, not session 102 + "app:import.journal_source_manifest", 101 103 }: 102 104 return None 103 105
+149 -3
tests/test_journal_sources.py
··· 9 9 from importlib import import_module 10 10 11 11 import pytest 12 - from flask import Flask, g, jsonify 12 + from flask import Flask, abort, g, jsonify 13 13 14 14 import convey.state 15 15 from think.utils import now_ms ··· 19 19 create_state_directory = journal_sources.create_state_directory 20 20 find_journal_source_by_name = journal_sources.find_journal_source_by_name 21 21 generate_key = journal_sources.generate_key 22 + get_state_directory = journal_sources.get_state_directory 22 23 is_valid_journal_source_name = journal_sources.is_valid_journal_source_name 23 24 list_journal_sources = journal_sources.list_journal_sources 24 25 load_journal_source = journal_sources.load_journal_source ··· 29 30 @pytest.fixture 30 31 def journal_env(tmp_path, monkeypatch): 31 32 monkeypatch.setattr(convey.state, "journal_root", str(tmp_path), raising=False) 32 - (tmp_path / "apps" / "import" / "journal_sources").mkdir(parents=True, exist_ok=True) 33 + (tmp_path / "apps" / "import" / "journal_sources").mkdir( 34 + parents=True, exist_ok=True 35 + ) 33 36 return tmp_path 34 37 35 38 ··· 49 52 "config_received": 0, 50 53 }, 51 54 } 55 + 56 + 57 + @pytest.fixture 58 + def manifest_env(journal_env): 59 + """Journal env with a saved source and state directory.""" 60 + key = generate_key() 61 + source = _source("manifest-test", key, created_at=123) 62 + save_journal_source(source) 63 + create_state_directory(journal_env, key[:8]) 64 + return {"root": journal_env, "key": key, "source": source} 65 + 66 + 67 + @pytest.fixture 68 + def manifest_app(manifest_env): 69 + app = Flask(__name__) 70 + 71 + @app.route("/journal/<key_prefix>/manifest/<area>") 72 + @require_journal_source 73 + def journal_source_manifest(key_prefix: str, area: str): 74 + if g.journal_source["key"][:8] != key_prefix: 75 + abort(403, description="Key prefix mismatch") 76 + if area not in STATE_AREAS: 77 + abort(404, description="Unknown manifest area") 78 + state_path = get_state_directory(key_prefix) / area / "state.json" 79 + try: 80 + data = json.loads(state_path.read_text(encoding="utf-8")) 81 + except (OSError, json.JSONDecodeError): 82 + data = {} 83 + return jsonify(data) 84 + 85 + return app 52 86 53 87 54 88 def test_generate_key(): ··· 124 158 125 159 def test_invalid_name_rejected(journal_env): 126 160 assert is_valid_journal_source_name("../alpha") is False 127 - assert save_journal_source(_source("../alpha", generate_key(), created_at=123)) is False 161 + assert ( 162 + save_journal_source(_source("../alpha", generate_key(), created_at=123)) 163 + is False 164 + ) 128 165 assert find_journal_source_by_name("../alpha") is None 129 166 assert not (journal_env.parent / "alpha.json").exists() 130 167 ··· 215 252 ) 216 253 217 254 assert response.status_code == 403 255 + 256 + 257 + @pytest.mark.parametrize("area", STATE_AREAS) 258 + def test_manifest_empty_state(manifest_app, manifest_env, area): 259 + response = manifest_app.test_client().get( 260 + f"/journal/{manifest_env['key'][:8]}/manifest/{area}", 261 + headers={"Authorization": f"Bearer {manifest_env['key']}"}, 262 + ) 263 + 264 + assert response.status_code == 200 265 + assert response.get_json() == {} 266 + 267 + 268 + def test_manifest_populated_state(manifest_app, manifest_env): 269 + data = {"days": {"2026-04-01": {"count": 5}}} 270 + state_path = ( 271 + get_state_directory(manifest_env["key"][:8]) / "segments" / "state.json" 272 + ) 273 + state_path.write_text(json.dumps(data), encoding="utf-8") 274 + 275 + response = manifest_app.test_client().get( 276 + f"/journal/{manifest_env['key'][:8]}/manifest/segments", 277 + headers={"Authorization": f"Bearer {manifest_env['key']}"}, 278 + ) 279 + 280 + assert response.status_code == 200 281 + assert response.get_json() == data 282 + 283 + 284 + def test_manifest_missing_state_file(manifest_app, manifest_env): 285 + state_path = ( 286 + get_state_directory(manifest_env["key"][:8]) / "segments" / "state.json" 287 + ) 288 + state_path.unlink() 289 + 290 + response = manifest_app.test_client().get( 291 + f"/journal/{manifest_env['key'][:8]}/manifest/segments", 292 + headers={"Authorization": f"Bearer {manifest_env['key']}"}, 293 + ) 294 + 295 + assert response.status_code == 200 296 + assert response.get_json() == {} 297 + 298 + 299 + def test_manifest_malformed_json(manifest_app, manifest_env): 300 + state_path = ( 301 + get_state_directory(manifest_env["key"][:8]) / "segments" / "state.json" 302 + ) 303 + state_path.write_text("not valid json{{{", encoding="utf-8") 304 + 305 + response = manifest_app.test_client().get( 306 + f"/journal/{manifest_env['key'][:8]}/manifest/segments", 307 + headers={"Authorization": f"Bearer {manifest_env['key']}"}, 308 + ) 309 + 310 + assert response.status_code == 200 311 + assert response.get_json() == {} 312 + 313 + 314 + def test_manifest_invalid_area(manifest_app, manifest_env): 315 + response = manifest_app.test_client().get( 316 + f"/journal/{manifest_env['key'][:8]}/manifest/invalid_area", 317 + headers={"Authorization": f"Bearer {manifest_env['key']}"}, 318 + ) 319 + 320 + assert response.status_code == 404 321 + 322 + 323 + def test_manifest_key_prefix_mismatch(manifest_app, manifest_env): 324 + other_prefix = "deadbeef" 325 + assert other_prefix != manifest_env["key"][:8] 326 + 327 + response = manifest_app.test_client().get( 328 + f"/journal/{other_prefix}/manifest/segments", 329 + headers={"Authorization": f"Bearer {manifest_env['key']}"}, 330 + ) 331 + 332 + assert response.status_code == 403 333 + 334 + 335 + def test_manifest_auth_missing(manifest_app, manifest_env): 336 + response = manifest_app.test_client().get( 337 + f"/journal/{manifest_env['key'][:8]}/manifest/segments" 338 + ) 339 + 340 + assert response.status_code == 401 341 + 342 + 343 + def test_manifest_auth_invalid(manifest_app, manifest_env): 344 + response = manifest_app.test_client().get( 345 + f"/journal/{manifest_env['key'][:8]}/manifest/segments", 346 + headers={"Authorization": "Bearer does-not-exist"}, 347 + ) 348 + 349 + assert response.status_code == 401 350 + 351 + 352 + def test_manifest_auth_revoked(manifest_app, manifest_env): 353 + source = manifest_env["source"] 354 + source["revoked"] = True 355 + source["revoked_at"] = now_ms() 356 + assert save_journal_source(source) is True 357 + 358 + response = manifest_app.test_client().get( 359 + f"/journal/{manifest_env['key'][:8]}/manifest/segments", 360 + headers={"Authorization": f"Bearer {manifest_env['key']}"}, 361 + ) 362 + 363 + assert response.status_code == 403