memgres 0.13.0__tar.gz → 0.13.2__tar.gz
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.
- {memgres-0.13.0 → memgres-0.13.2}/PKG-INFO +1 -1
- {memgres-0.13.0 → memgres-0.13.2}/memgres/_version.py +1 -1
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0023_relink_after_parser_fix.sql +14 -1
- {memgres-0.13.0 → memgres-0.13.2}/memgres/relink.py +27 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/admission.py +8 -2
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/auth_routes.py +9 -2
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/oidc.py +3 -1
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/routes.py +28 -3
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/app.css +60 -3
- memgres-0.13.2/memgres/web/static/js/gate.js +237 -0
- memgres-0.13.2/memgres/web/static/js/md.js +46 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/memory.js +103 -23
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/viz.js +12 -3
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/locales/en.json +4 -1
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/locales/ru.json +4 -1
- memgres-0.13.2/memgres/web/static/vendor/LICENSE-dompurify.txt +202 -0
- memgres-0.13.2/memgres/web/static/vendor/LICENSE-marked.txt +44 -0
- memgres-0.13.2/memgres/web/static/vendor/marked.esm.js +77 -0
- memgres-0.13.2/memgres/web/static/vendor/purify.es.js +2721 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres.egg-info/PKG-INFO +1 -1
- {memgres-0.13.0 → memgres-0.13.2}/memgres.egg-info/SOURCES.txt +5 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_links.py +28 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_web_oidc.py +43 -4
- memgres-0.13.0/memgres/web/static/js/gate.js +0 -122
- {memgres-0.13.0 → memgres-0.13.2}/LICENSE +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/README.md +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/__init__.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/admin.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/admin_cli.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/blame.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/bootstrap.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/config.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/delimiters.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/diffing.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/embed_worker.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/embeddings.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/healthcheck.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/identity.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/indexing.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/info.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/lines.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/links.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/mcp_server.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0001_core.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0002_identity.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0003_history_author.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0004_title.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0005_chunk_index.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0006_reader_floor.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0007_embed_retry.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0008_service_roles.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0009_create_namespace_right.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0010_namespace_alias.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0012_user_profile.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0013_hash_version.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0015_normalize_tags.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0016_valid_at.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0017_memory_link.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0018_links_built.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0019_memory_usage.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0021_enrollment_key.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0022_user_disabled.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/migrations/0024_web_panel.sql +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/paths.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/periodic.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/reembed.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/schema.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/search.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/segments.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/server.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/store.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/tags.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/token_cli.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/vector/__init__.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/vector/base.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/vector/pgvector.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/vector/qdrant.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/__init__.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/memory.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/oidc_config.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/people.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/sessions.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/spaces.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts/OFL-JetBrainsMono.txt +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts/OFL-Onest.txt +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts/jetbrainsmono-cyrillic-ext.woff2 +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts/jetbrainsmono-cyrillic.woff2 +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts/jetbrainsmono-latin.woff2 +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts/onest-cyrillic-ext.woff2 +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts/onest-cyrillic.woff2 +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts/onest-latin-ext.woff2 +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts/onest-latin.woff2 +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/fonts.css +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/index.html +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/account.js +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/admin.js +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/api.js +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/app.js +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/i18n.js +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/people.js +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/signins.js +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/space.js +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/tokens.js +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/js/ui.js +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/vendor/LICENSE-d3.txt +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/static/vendor/d3.min.js +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/web/tokens.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres/worker.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres.egg-info/dependency_links.txt +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres.egg-info/entry_points.txt +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres.egg-info/requires.txt +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/memgres.egg-info/top_level.txt +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/pyproject.toml +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/setup.cfg +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_admin_two_way.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_blame_integration.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_chunk_index.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_claim_and_reembed.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_config.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_diffing.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_embed_worker.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_embeddings.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_enrollment.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_healthcheck.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_identity_integration.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_lexical_match.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_limits.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_list.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_mcp_admin_tools.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_mcp_error_messages.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_mcp_http_transport.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_mcp_instructions.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_mcp_recall_schema.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_mcp_tool_visibility.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_mcp_tool_visibility_http.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_migration_upgrade.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_multi_space_search.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_path_addressing.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_qdrant_ca.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_qdrant_integration.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_replace_build.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_require_title.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_required_fields.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_retention.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_roles_bootstrap.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_search_integration.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_security_followups.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_security_integration.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_segments.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_segments_store.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_server_info.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_server_integration.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_snippets.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_store_integration.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_tags.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_token_sink.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_usage.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_valid_at.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_web_memory.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_web_people.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_web_session.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_web_tokens.py +0 -0
- {memgres-0.13.0 → memgres-0.13.2}/tests/test_write_ergonomics.py +0 -0
|
@@ -13,4 +13,17 @@
|
|
|
13
13
|
--
|
|
14
14
|
-- Additive by nature — an older client reading this database is no worse off
|
|
15
15
|
-- than it was, so the compatibility floor does not move.
|
|
16
|
-
|
|
16
|
+
-- ONCE. Every migration file runs again at every start (they are meant to be
|
|
17
|
+
-- idempotent), and a bare UPDATE here cleared the flag on every start — so every
|
|
18
|
+
-- start rebuilt the whole link graph, and two servers starting together raced on
|
|
19
|
+
-- it. The marker column makes the clearing happen the first time only; on a
|
|
20
|
+
-- database that has been through this file before, it happens one last time.
|
|
21
|
+
DO $$
|
|
22
|
+
BEGIN
|
|
23
|
+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
24
|
+
WHERE table_schema = current_schema() AND table_name = 'memgres_meta'
|
|
25
|
+
AND column_name = 'relinked_after_0023') THEN
|
|
26
|
+
ALTER TABLE memgres_meta ADD COLUMN relinked_after_0023 boolean NOT NULL DEFAULT true;
|
|
27
|
+
UPDATE memgres_meta SET links_built = false;
|
|
28
|
+
END IF;
|
|
29
|
+
END $$;
|
|
@@ -20,6 +20,12 @@ _log = logging.getLogger("memgres.relink")
|
|
|
20
20
|
|
|
21
21
|
BATCH = 500
|
|
22
22
|
|
|
23
|
+
# One rebuild at a time across processes. Two servers starting together (the MCP
|
|
24
|
+
# door and the REST/panel door) both found the flag clear and rebuilt at once,
|
|
25
|
+
# and one of them died on the other's rows. A session-level advisory lock: held
|
|
26
|
+
# for the rebuild, gone with the connection if the process is.
|
|
27
|
+
LOCK_KEY = 0x6D656D67_6C6E6B # "memglnk"
|
|
28
|
+
|
|
23
29
|
|
|
24
30
|
class _NoEmbedder:
|
|
25
31
|
"""Stands in for an embedder so `Store` does not build the real one. The
|
|
@@ -55,11 +61,32 @@ def rebuild(conn, cfg, *, force: bool = False) -> int:
|
|
|
55
61
|
the flag false — so the server came up serving a link graph that was perfectly
|
|
56
62
|
empty while saying it had built one. Owning the commit here means the work is
|
|
57
63
|
durable when this returns, whatever the caller does next."""
|
|
64
|
+
with conn.cursor() as cur:
|
|
65
|
+
cur.execute("SELECT pg_try_advisory_lock(%s)", (LOCK_KEY,))
|
|
66
|
+
mine = cur.fetchone()[0]
|
|
67
|
+
if not mine:
|
|
68
|
+
conn.commit()
|
|
69
|
+
_log.info("the link index is being rebuilt by another process; not starting a second pass")
|
|
70
|
+
return 0
|
|
71
|
+
try:
|
|
72
|
+
return _rebuild(conn, cfg, force=force)
|
|
73
|
+
finally:
|
|
74
|
+
try:
|
|
75
|
+
with conn.cursor() as cur:
|
|
76
|
+
cur.execute("SELECT pg_advisory_unlock(%s)", (LOCK_KEY,))
|
|
77
|
+
conn.commit()
|
|
78
|
+
except Exception: # a broken connection releases the lock by closing
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _rebuild(conn, cfg, *, force: bool) -> int:
|
|
58
83
|
from .store import Store
|
|
59
84
|
with conn.cursor() as cur:
|
|
85
|
+
# read under the lock: another process may have just finished
|
|
60
86
|
cur.execute("SELECT links_built FROM memgres_meta")
|
|
61
87
|
row = cur.fetchone()
|
|
62
88
|
if row and row[0] and not force:
|
|
89
|
+
conn.commit()
|
|
63
90
|
return 0
|
|
64
91
|
|
|
65
92
|
# `_sync_links` touches neither embeddings nor vectors; passing a
|
|
@@ -88,8 +88,10 @@ def _request(cur, provider: Provider, claims: dict, suggested: Optional[str]) ->
|
|
|
88
88
|
if verified_email(claims) is None:
|
|
89
89
|
# A request an administrator must judge needs at least an address the
|
|
90
90
|
# provider vouches for; without one, anyone who can make accounts at an
|
|
91
|
-
# open provider could fill the queue with nobodies.
|
|
92
|
-
|
|
91
|
+
# open provider could fill the queue with nobodies. Said as it is: the
|
|
92
|
+
# person can fix this at the provider, and "no account" would send them
|
|
93
|
+
# to the wrong place.
|
|
94
|
+
return Outcome("denied", reason="email_unverified")
|
|
93
95
|
cur.execute("DELETE FROM signin_request WHERE status <> 'pending' "
|
|
94
96
|
"AND decided_at < now() - make_interval(days => %s)", (REJECTION_MEMORY_DAYS * 3,))
|
|
95
97
|
cur.execute("SELECT count(*), count(*) FILTER (WHERE provider = %s) FROM signin_request "
|
|
@@ -193,6 +195,10 @@ def admit(conn, cfg, provider: Provider, claims: dict, *, link_user_id: Optional
|
|
|
193
195
|
|
|
194
196
|
# ─── nobody ─────────────────────────────────────────────────────────
|
|
195
197
|
if provider.on_no_match == "deny":
|
|
198
|
+
# with a confirmed address this could have matched an account; say
|
|
199
|
+
# what is missing rather than that nothing exists
|
|
200
|
+
if email is None and provider.on_email_match != "deny":
|
|
201
|
+
return Outcome("denied", reason="email_unverified")
|
|
196
202
|
return Outcome("denied", reason="no_account")
|
|
197
203
|
if provider.on_no_match == "pending":
|
|
198
204
|
return _request(cur, provider, claims, None)
|
|
@@ -57,8 +57,12 @@ def mount(app, cfg, pool, panel, providers, fetch=None) -> None:
|
|
|
57
57
|
starts.fail(key)
|
|
58
58
|
state, nonce, browser = secrets.token_urlsafe(32), secrets.token_urlsafe(32), secrets.token_urlsafe(32)
|
|
59
59
|
verifier, challenge = new_pkce()
|
|
60
|
+
# Linking asks the provider to show its account chooser: a browser often
|
|
61
|
+
# holds another session there (a service admin, a colleague's), and the
|
|
62
|
+
# provider would otherwise hand that one back without asking.
|
|
60
63
|
url = client.authorize_url(redirect_uri=_redirect_uri(request, pid), state=state,
|
|
61
|
-
nonce=nonce, challenge=challenge
|
|
64
|
+
nonce=nonce, challenge=challenge,
|
|
65
|
+
prompt="select_account" if link_session else None)
|
|
62
66
|
with pool.connection() as conn, conn.transaction(), conn.cursor() as cur:
|
|
63
67
|
cur.execute("DELETE FROM oidc_flow WHERE expires_at < now()")
|
|
64
68
|
cur.execute("SELECT count(*) FROM oidc_flow")
|
|
@@ -166,7 +170,10 @@ def mount(app, cfg, pool, panel, providers, fetch=None) -> None:
|
|
|
166
170
|
except psycopg.errors.UniqueViolation:
|
|
167
171
|
# two callbacks linking the same sign-in at the same moment: one wins
|
|
168
172
|
return _go(f"{back}?auth=denied_taken")
|
|
169
|
-
|
|
173
|
+
# a refusal is what an administrator will be asked about: log its reason
|
|
174
|
+
# (never the person's email) where the default log level shows it
|
|
175
|
+
(log.warning if outcome.kind == "denied" else log.info)(
|
|
176
|
+
"oidc %s: %s%s", pid, outcome.kind, f" ({outcome.reason})" if outcome.reason else "")
|
|
170
177
|
if outcome.kind == "signed_in":
|
|
171
178
|
r = _go("/memory")
|
|
172
179
|
panel["set_cookie"](r, sid)
|
|
@@ -126,13 +126,15 @@ class Client:
|
|
|
126
126
|
|
|
127
127
|
# ─── the flow ───────────────────────────────────────────────────────────
|
|
128
128
|
def authorize_url(self, *, redirect_uri: str, state: str, nonce: str, challenge: str,
|
|
129
|
-
login_hint: Optional[str] = None) -> str:
|
|
129
|
+
login_hint: Optional[str] = None, prompt: Optional[str] = None) -> str:
|
|
130
130
|
meta = self.metadata()
|
|
131
131
|
q = {"response_type": "code", "client_id": self.p.client_id, "redirect_uri": redirect_uri,
|
|
132
132
|
"scope": " ".join(self.p.scopes), "state": state, "nonce": nonce,
|
|
133
133
|
"code_challenge": challenge, "code_challenge_method": "S256"}
|
|
134
134
|
if login_hint:
|
|
135
135
|
q["login_hint"] = login_hint
|
|
136
|
+
if prompt:
|
|
137
|
+
q["prompt"] = prompt
|
|
136
138
|
sep = "&" if "?" in meta["authorization_endpoint"] else "?"
|
|
137
139
|
return meta["authorization_endpoint"] + sep + urllib.parse.urlencode(q)
|
|
138
140
|
|
|
@@ -12,6 +12,7 @@ Two rules keep a browser session from widening what an attacker can reach:
|
|
|
12
12
|
``SameSite=Strict`` as well; that is the second lock, not the only one.
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
|
+
import logging
|
|
15
16
|
import threading
|
|
16
17
|
import time
|
|
17
18
|
from pathlib import Path
|
|
@@ -75,6 +76,27 @@ class _Throttle:
|
|
|
75
76
|
self._fails.pop(key, None)
|
|
76
77
|
|
|
77
78
|
|
|
79
|
+
class _ScrubSignInQuery(logging.Filter):
|
|
80
|
+
"""Keep the query string of /ui/auth/* out of the access log.
|
|
81
|
+
|
|
82
|
+
The provider's redirect back carries a one-time code and the state. Both are
|
|
83
|
+
spent by the time the line is written and useless without the PKCE verifier,
|
|
84
|
+
but a log is kept and copied, and there is no reason for them to be in it."""
|
|
85
|
+
|
|
86
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
87
|
+
args = record.args
|
|
88
|
+
if isinstance(args, tuple) and len(args) >= 3 and isinstance(args[2], str) \
|
|
89
|
+
and args[2].startswith("/ui/auth/") and "?" in args[2]:
|
|
90
|
+
record.args = args[:2] + (args[2].split("?", 1)[0] + "?…",) + args[3:]
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _scrub_access_log() -> None:
|
|
95
|
+
access = logging.getLogger("uvicorn.access")
|
|
96
|
+
if not any(isinstance(f, _ScrubSignInQuery) for f in access.filters):
|
|
97
|
+
access.addFilter(_ScrubSignInQuery())
|
|
98
|
+
|
|
99
|
+
|
|
78
100
|
def mount(app, cfg, pool, make_store, *, oidc_fetch=None) -> None:
|
|
79
101
|
from fastapi import Body, HTTPException, Request, Response
|
|
80
102
|
from fastapi.responses import FileResponse, RedirectResponse
|
|
@@ -83,6 +105,7 @@ def mount(app, cfg, pool, make_store, *, oidc_fetch=None) -> None:
|
|
|
83
105
|
from . import oidc_config
|
|
84
106
|
cookie_name = "__Host-memgres_session" if cfg.web_cookie_secure else "memgres_session"
|
|
85
107
|
throttle = _Throttle()
|
|
108
|
+
_scrub_access_log()
|
|
86
109
|
# read at startup: a broken provider file stops the server rather than
|
|
87
110
|
# leaving a sign-in door that half-works
|
|
88
111
|
providers = oidc_config.load(cfg.oidc_config, key_mode=cfg.key_mode)
|
|
@@ -172,17 +195,19 @@ def mount(app, cfg, pool, make_store, *, oidc_fetch=None) -> None:
|
|
|
172
195
|
return FileResponse(shell, media_type="text/html",
|
|
173
196
|
headers={"Cache-Control": "no-cache"})
|
|
174
197
|
|
|
198
|
+
# HEAD too: a page answers HEAD as it answers GET (RFC 9110), and uptime
|
|
199
|
+
# checks use it — a 405 there reads as "the panel is down"
|
|
175
200
|
for p in APP_PATHS:
|
|
176
|
-
app.add_api_route(p, _page, methods=["GET"], include_in_schema=False)
|
|
201
|
+
app.add_api_route(p, _page, methods=["GET", "HEAD"], include_in_schema=False)
|
|
177
202
|
|
|
178
|
-
@app.
|
|
203
|
+
@app.api_route("/signin", methods=["GET", "HEAD"], include_in_schema=False)
|
|
179
204
|
def signin_page():
|
|
180
205
|
with pool.connection() as conn:
|
|
181
206
|
if not sessions.admin_has_linked_signin(conn):
|
|
182
207
|
return RedirectResponse("/signin/admin?from=signin", status_code=307)
|
|
183
208
|
return _page()
|
|
184
209
|
|
|
185
|
-
@app.
|
|
210
|
+
@app.api_route("/signin/admin", methods=["GET", "HEAD"], include_in_schema=False)
|
|
186
211
|
def signin_admin_page():
|
|
187
212
|
return _page()
|
|
188
213
|
|
|
@@ -96,7 +96,8 @@
|
|
|
96
96
|
.search input::placeholder { color: var(--muted); }
|
|
97
97
|
.search svg { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); }
|
|
98
98
|
|
|
99
|
-
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap:
|
|
99
|
+
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 12px var(--insp-w, 360px); gap: 0; min-height: 0; }
|
|
100
|
+
.workspace.with-results { grid-template-columns: minmax(0, 1fr) 310px 12px var(--insp-w, 360px); }
|
|
100
101
|
.stage { position: relative; min-height: 0; border-radius: 14px; overflow: hidden; background: radial-gradient(1200px 800px at 50% 50%, #182038 0%, var(--canvas) 55%, #0f1424 100%); box-shadow: inset 0 0 0 1px var(--line-soft); }
|
|
101
102
|
.viz-host { position: absolute; inset: 0; }
|
|
102
103
|
.stagebar { position: absolute; left: 10px; right: 10px; top: 10px; z-index: 4; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; pointer-events: none; }
|
|
@@ -304,10 +305,15 @@
|
|
|
304
305
|
.snips button[aria-selected="true"] { color: var(--bright); background: var(--panel-2); }
|
|
305
306
|
.toast { position: fixed; left: 50%; bottom: calc(22px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%); z-index: 60; background: var(--panel-2); color: var(--bright); padding: 9px 14px; border-radius: 10px; box-shadow: 0 0 0 1px var(--line), 0 12px 30px rgba(0,0,0,.45); font-size: 13px; max-width: calc(100% - 32px); box-sizing: border-box; }
|
|
306
307
|
|
|
307
|
-
@media (max-width: 1100px) {
|
|
308
|
+
@media (max-width: 1100px) {
|
|
309
|
+
.workspace { grid-template-columns: minmax(0, 1fr) 12px var(--insp-w, 320px); }
|
|
310
|
+
.workspace.with-results { grid-template-columns: minmax(0, 1fr) 280px 12px var(--insp-w, 320px); }
|
|
311
|
+
}
|
|
308
312
|
@media (max-width: 960px) {
|
|
309
313
|
body { overflow: auto; }
|
|
310
|
-
.workspace { grid-template-columns: 1fr; grid-template-rows: minmax(380px, 1fr) auto; }
|
|
314
|
+
.workspace, .workspace.with-results { grid-template-columns: 1fr; grid-template-rows: minmax(380px, 1fr) auto auto; gap: 10px; }
|
|
315
|
+
.splitter { display: none; }
|
|
316
|
+
.results { margin-left: 0 !important; max-height: 50vh; }
|
|
311
317
|
.inspector { max-height: none; }
|
|
312
318
|
.gate { grid-template-columns: 1fr; grid-template-rows: 200px auto; height: auto; min-height: 100%; }
|
|
313
319
|
.word { font-size: 32px; }
|
|
@@ -432,3 +438,54 @@ a.person:hover { color: var(--focus); box-shadow: inset 0 -1px 0 var(--focus); }
|
|
|
432
438
|
.perm-badge.role { color: var(--admin); box-shadow: inset 0 0 0 1px rgba(255,162,76,.45); }
|
|
433
439
|
.every-list { max-height: min(60vh, 520px); overflow: auto; }
|
|
434
440
|
.shell.collapsed .visiting-h, .shell.collapsed .every-link, .shell.collapsed .spaceitem .forget { display: none; }
|
|
441
|
+
|
|
442
|
+
/* the sign-in picture can be pulled about; the words over it stay out of the way */
|
|
443
|
+
.gate-art canvas { touch-action: none; }
|
|
444
|
+
.gate-art .words { pointer-events: none; }
|
|
445
|
+
|
|
446
|
+
/* ─── search results column, the record panel's edge, markdown bodies ─── */
|
|
447
|
+
.results { margin-left: 10px; min-height: 0; overflow: auto; border-radius: 14px; background: var(--panel); box-shadow: inset 0 0 0 1px var(--line-soft); padding: 14px; display: flex; flex-direction: column; gap: 10px; }
|
|
448
|
+
.res-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
|
|
449
|
+
.res-head b { display: block; color: var(--bright); font-weight: 600; font-size: 14px; margin-top: 4px; overflow-wrap: anywhere; }
|
|
450
|
+
.res-wait { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 13px; padding: 6px 2px; }
|
|
451
|
+
.list.stale { opacity: .5; transition: opacity .15s; }
|
|
452
|
+
.list button[aria-current="true"] { background: var(--panel-2); box-shadow: inset 0 0 0 1px var(--line); }
|
|
453
|
+
.spinner { width: 14px; height: 14px; border-radius: 50%; border: 2px solid var(--line); border-top-color: var(--focus); animation: spin .8s linear infinite; flex: none; box-sizing: border-box; }
|
|
454
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
455
|
+
.search.busy svg { visibility: hidden; }
|
|
456
|
+
.search.busy::before { content: ""; position: absolute; left: 10px; top: 50%; width: 14px; height: 14px; margin-top: -7px; border-radius: 50%; border: 2px solid var(--line); border-top-color: var(--focus); animation: spin .8s linear infinite; box-sizing: border-box; }
|
|
457
|
+
|
|
458
|
+
.splitter { cursor: col-resize; position: relative; touch-action: none; outline: none; }
|
|
459
|
+
.splitter::after { content: ""; position: absolute; top: 18%; bottom: 18%; left: 50%; width: 2px; margin-left: -1px; border-radius: 2px; background: transparent; transition: background .15s; }
|
|
460
|
+
.splitter:hover::after, .splitter.on::after, .splitter:focus-visible::after { background: var(--focus); }
|
|
461
|
+
|
|
462
|
+
.body.md { white-space: normal; overflow-wrap: anywhere; }
|
|
463
|
+
.body.md > :first-child { margin-top: 0; }
|
|
464
|
+
.body.md > :last-child { margin-bottom: 0; }
|
|
465
|
+
.body.md p { margin: .55em 0; }
|
|
466
|
+
.body.md h1, .body.md h2, .body.md h3, .body.md h4, .body.md h5, .body.md h6 { color: var(--bright); line-height: 1.3; margin: 1.1em 0 .45em; font-weight: 600; }
|
|
467
|
+
.body.md h1 { font-size: 1.25em; } .body.md h2 { font-size: 1.15em; } .body.md h3 { font-size: 1.05em; } .body.md h4, .body.md h5, .body.md h6 { font-size: 1em; }
|
|
468
|
+
.body.md ul, .body.md ol { margin: .5em 0; padding-left: 1.35em; }
|
|
469
|
+
.body.md li { margin: .2em 0; }
|
|
470
|
+
.body.md li > p { margin: .2em 0; }
|
|
471
|
+
.body.md strong { color: var(--bright); font-weight: 600; }
|
|
472
|
+
.body.md a { color: var(--focus); text-underline-offset: 3px; }
|
|
473
|
+
.body.md blockquote { margin: .6em 0; padding: .1em 0 .1em 12px; border-left: 3px solid var(--line); color: var(--muted); }
|
|
474
|
+
.body.md hr { border: 0; border-top: 1px solid var(--line); margin: 1em 0; }
|
|
475
|
+
.body.md pre { background: var(--canvas); border-radius: 8px; padding: 10px 12px; overflow-x: auto; margin: .6em 0; }
|
|
476
|
+
.body.md pre code { background: none; padding: 0; font-size: 12px; line-height: 1.55; white-space: pre; }
|
|
477
|
+
.body.md table { display: block; overflow-x: auto; max-width: 100%; border-collapse: collapse; margin: .7em 0; font-size: 12.5px; width: max-content; }
|
|
478
|
+
.body.md th, .body.md td { border: 1px solid var(--line); padding: 5px 9px; white-space: normal; vertical-align: top; text-align: left; min-width: 60px; }
|
|
479
|
+
.body.md th { background: var(--canvas); color: var(--bright); font-weight: 600; font-size: 12.5px; }
|
|
480
|
+
.body.md tr:last-child td { border-bottom: 1px solid var(--line); }
|
|
481
|
+
.body.md input[type=checkbox] { margin-right: 6px; }
|
|
482
|
+
.body.md .md-img { color: var(--muted); font-style: italic; }
|
|
483
|
+
|
|
484
|
+
/* the space's own node */
|
|
485
|
+
.node.hubnode .hub-ring { fill: none; stroke-width: 1.4; opacity: .5; stroke-linejoin: round; }
|
|
486
|
+
.node.hubnode .ring.hub { stroke-width: 3.2; fill-opacity: .22; }
|
|
487
|
+
.node.hubnode .halo { opacity: .12; stroke-width: 18; }
|
|
488
|
+
.node.hubnode.sel .halo { opacity: .22; }
|
|
489
|
+
@media (prefers-reduced-motion: reduce) { .spinner, .search.busy::before { animation-duration: 2.4s; } }
|
|
490
|
+
/* the results header — the query and × — stays put while the list scrolls under it */
|
|
491
|
+
.results > .res-head { position: sticky; top: -14px; z-index: 2; margin: -14px -14px 0; padding: 14px 14px 10px; background: var(--panel); border-radius: 14px 14px 0 0; box-shadow: 0 1px 0 var(--line-soft); }
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// /signin (providers) and /signin/admin (a token, for administrators).
|
|
2
|
+
|
|
3
|
+
import { post, ApiError } from "./api.js";
|
|
4
|
+
import { applyStatic, t } from "./i18n.js";
|
|
5
|
+
import { $, esc, hexPath, PALETTE, reducedMotion, rnd } from "./ui.js";
|
|
6
|
+
|
|
7
|
+
let raf = null;
|
|
8
|
+
|
|
9
|
+
export function showGate({ path, options, onSignedIn }) {
|
|
10
|
+
$("#shell").hidden = true;
|
|
11
|
+
$("#gate").hidden = false;
|
|
12
|
+
const admin = path.startsWith("/signin/admin");
|
|
13
|
+
$("#door-main").hidden = admin;
|
|
14
|
+
$("#door-admin").hidden = !admin;
|
|
15
|
+
if (admin) {
|
|
16
|
+
const redirected = new URLSearchParams(location.search).get("from") === "signin" || options.admin_redirect;
|
|
17
|
+
$("#redirected").hidden = !redirected;
|
|
18
|
+
$("#admin-back").hidden = redirected;
|
|
19
|
+
$("#admin-err").hidden = true;
|
|
20
|
+
const form = $("#door-admin");
|
|
21
|
+
form.onsubmit = async (e) => {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
const input = $("#admin-token"), err = $("#admin-err"), btn = $("#admin-go");
|
|
24
|
+
const token = input.value.trim();
|
|
25
|
+
if (!token) { err.textContent = t("gate.admin.empty"); err.hidden = false; return; }
|
|
26
|
+
btn.disabled = true;
|
|
27
|
+
try {
|
|
28
|
+
const session = await post("/session/token", { token });
|
|
29
|
+
input.value = "";
|
|
30
|
+
stopArt();
|
|
31
|
+
onSignedIn(session);
|
|
32
|
+
} catch (ex) {
|
|
33
|
+
err.textContent = ex instanceof ApiError && ex.status === 429 ? t("gate.admin.throttled")
|
|
34
|
+
: ex instanceof ApiError && ex.status === 403 ? t("gate.admin.refused") : t("err.network");
|
|
35
|
+
err.hidden = false;
|
|
36
|
+
} finally {
|
|
37
|
+
btn.disabled = false;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
setTimeout(() => $("#admin-token").focus(), 0);
|
|
41
|
+
} else {
|
|
42
|
+
renderProviders(options.providers || []);
|
|
43
|
+
const result = new URLSearchParams(location.search).get("auth");
|
|
44
|
+
const note = $("#auth-note");
|
|
45
|
+
note.hidden = !result;
|
|
46
|
+
if (result) {
|
|
47
|
+
note.className = "auth-note " + (result === "pending" ? "wait" : "bad");
|
|
48
|
+
note.textContent = t("auth." + result) === "auth." + result ? t("auth.failed") : t("auth." + result);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
applyStatic($("#gate"));
|
|
52
|
+
startArt();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function renderProviders(providers) {
|
|
56
|
+
const box = $("#providers");
|
|
57
|
+
if (!providers.length) {
|
|
58
|
+
box.innerHTML = `<div class="empty-prov">${esc(t("gate.noProviders"))}</div>`;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
box.innerHTML = providers.map((p) => `<a class="prov-btn" href="/ui/auth/${encodeURIComponent(p.id)}/start">
|
|
62
|
+
<span class="prov">${esc((p.label || p.id).slice(0, 3))}</span>
|
|
63
|
+
<span><b>${esc(t("gate.continueWith", { name: p.label || p.id }))}</b>${p.hint ? `<small>${esc(p.hint)}</small>` : ""}</span></a>`).join("");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function hideGate() {
|
|
67
|
+
stopArt();
|
|
68
|
+
$("#gate").hidden = true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── the picture on the left: a memory you can pull about ──────────────────
|
|
72
|
+
// A small tree like a real space — a root, a handful of branches, records under
|
|
73
|
+
// them, a few links across — laid out by d3-force. Any hexagon but the root can be
|
|
74
|
+
// dragged, and its neighbours follow on their links. The layout is settled before
|
|
75
|
+
// the first frame, so it never opens as a tangle.
|
|
76
|
+
let art = null;
|
|
77
|
+
|
|
78
|
+
function buildGraph() {
|
|
79
|
+
const nodes = [{ id: 0, r: 34, c: "#62e38d", depth: 0, fx: 0, fy: 0 }];
|
|
80
|
+
const links = [];
|
|
81
|
+
const add = (parent, depth, r, c, bare) => {
|
|
82
|
+
const n = { id: nodes.length, r, c, depth, bare, ph: rnd("ph" + nodes.length) * 6.28 };
|
|
83
|
+
nodes.push(n);
|
|
84
|
+
links.push({ source: parent.id, target: n.id, depth });
|
|
85
|
+
return n;
|
|
86
|
+
};
|
|
87
|
+
const hubs = 9;
|
|
88
|
+
for (let h = 0; h < hubs; h++) {
|
|
89
|
+
const c = PALETTE[h % PALETTE.length];
|
|
90
|
+
const hub = add(nodes[0], 1, 13 + rnd("hr" + h) * 7, c, h % 4 === 3);
|
|
91
|
+
const kids = 4 + Math.floor(rnd("hk" + h) * 5);
|
|
92
|
+
for (let k = 0; k < kids; k++) {
|
|
93
|
+
const key = `${h}.${k}`;
|
|
94
|
+
const child = add(hub, 2, 6 + rnd("cr" + key) ** 2 * 8, c, rnd("cb" + key) < 0.14);
|
|
95
|
+
if (rnd("cg" + key) < 0.32) {
|
|
96
|
+
const grand = 1 + Math.floor(rnd("gn" + key) * 3);
|
|
97
|
+
for (let g = 0; g < grand; g++) add(child, 3, 4.5 + rnd("gr" + key + g) * 3.5, c, false);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// a few links across branches, the way records cite each other
|
|
102
|
+
const leaves = nodes.filter((n) => n.depth >= 2);
|
|
103
|
+
for (let x = 0; x < 12; x++) {
|
|
104
|
+
const a = leaves[Math.floor(rnd("xa" + x) * leaves.length)], b = leaves[Math.floor(rnd("xb" + x) * leaves.length)];
|
|
105
|
+
if (a !== b && a.c !== b.c) links.push({ source: a.id, target: b.id, depth: 9, cross: true });
|
|
106
|
+
}
|
|
107
|
+
return { nodes, links };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function startArt() {
|
|
111
|
+
stopArt();
|
|
112
|
+
const d3 = window.d3, cv = $("#gate-canvas");
|
|
113
|
+
if (!d3 || !cv) return;
|
|
114
|
+
const ctx = cv.getContext("2d"), still = reducedMotion();
|
|
115
|
+
const { nodes, links } = buildGraph();
|
|
116
|
+
// The layout lives in a fixed circle and is scaled to whatever room the page
|
|
117
|
+
// gives it, so a phone shows the same picture smaller rather than a cropped one.
|
|
118
|
+
const bound = 260;
|
|
119
|
+
const contain = () => {
|
|
120
|
+
for (const n of nodes) {
|
|
121
|
+
if (n.fx != null && n.depth === 0) continue;
|
|
122
|
+
const d = Math.hypot(n.x, n.y), lim = bound - n.r;
|
|
123
|
+
if (d > lim) { n.x *= lim / d; n.y *= lim / d; n.vx *= 0.5; n.vy *= 0.5; }
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const sim = d3.forceSimulation(nodes)
|
|
127
|
+
.force("link", d3.forceLink(links).id((n) => n.id)
|
|
128
|
+
.distance((l) => l.cross ? 150 : l.depth === 1 ? 118 : l.depth === 2 ? 40 : 24)
|
|
129
|
+
.strength((l) => l.cross ? 0.02 : 0.7))
|
|
130
|
+
.force("charge", d3.forceManyBody().strength((n) => n.depth === 0 ? -400 : -26 - n.r * 3).distanceMax(240))
|
|
131
|
+
.force("collide", d3.forceCollide((n) => n.r + 4).iterations(2))
|
|
132
|
+
.force("contain", contain)
|
|
133
|
+
.stop();
|
|
134
|
+
nodes.forEach((n, i) => { if (i) { const a = rnd("ia" + i) * 6.28, d = 60 + rnd("id" + i) * 160; n.x = Math.cos(a) * d; n.y = Math.sin(a) * d; } });
|
|
135
|
+
for (let i = 0; i < 320; i++) sim.tick();
|
|
136
|
+
sim.alphaTarget(0).alpha(0);
|
|
137
|
+
sim.on("tick", () => {}); // ticks run on d3's own timer only while dragging
|
|
138
|
+
|
|
139
|
+
const view = { cx: 0, cy: 0, dpr: 1, k: 1 };
|
|
140
|
+
const toGraph = (e) => {
|
|
141
|
+
const r = cv.getBoundingClientRect();
|
|
142
|
+
return [(e.clientX - r.left - view.cx) / view.k, (e.clientY - r.top - view.cy) / view.k];
|
|
143
|
+
};
|
|
144
|
+
const hit = ([x, y]) => {
|
|
145
|
+
let best = null, bestD = Infinity;
|
|
146
|
+
for (const n of nodes) {
|
|
147
|
+
if (n.depth === 0) continue;
|
|
148
|
+
const d = Math.hypot(n.x - x, n.y - y);
|
|
149
|
+
if (d < n.r + 8 / view.k && d < bestD) { best = n; bestD = d; }
|
|
150
|
+
}
|
|
151
|
+
return best;
|
|
152
|
+
};
|
|
153
|
+
let dragged = null;
|
|
154
|
+
const onDown = (e) => {
|
|
155
|
+
const n = hit(toGraph(e));
|
|
156
|
+
if (!n) return;
|
|
157
|
+
dragged = n;
|
|
158
|
+
n.fx = n.x; n.fy = n.y;
|
|
159
|
+
cv.setPointerCapture(e.pointerId);
|
|
160
|
+
cv.style.cursor = "grabbing";
|
|
161
|
+
sim.alphaTarget(0.25).restart();
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
};
|
|
164
|
+
const onMove = (e) => {
|
|
165
|
+
const p = toGraph(e);
|
|
166
|
+
if (!dragged) { cv.style.cursor = hit(p) ? "grab" : ""; return; }
|
|
167
|
+
const d = Math.hypot(p[0], p[1]), lim = bound - dragged.r;
|
|
168
|
+
const k = d > lim ? lim / d : 1;
|
|
169
|
+
dragged.fx = p[0] * k; dragged.fy = p[1] * k;
|
|
170
|
+
};
|
|
171
|
+
const onUp = (e) => {
|
|
172
|
+
if (!dragged) return;
|
|
173
|
+
dragged.fx = dragged.fy = null;
|
|
174
|
+
dragged = null;
|
|
175
|
+
cv.style.cursor = hit(toGraph(e)) ? "grab" : "";
|
|
176
|
+
sim.alphaTarget(0);
|
|
177
|
+
};
|
|
178
|
+
cv.addEventListener("pointerdown", onDown);
|
|
179
|
+
cv.addEventListener("pointermove", onMove);
|
|
180
|
+
cv.addEventListener("pointerup", onUp);
|
|
181
|
+
cv.addEventListener("pointercancel", onUp);
|
|
182
|
+
|
|
183
|
+
const hex = (x, y, R, draw) => { ctx.save(); ctx.translate(x, y); draw(new Path2D(hexPath(R))); ctx.restore(); };
|
|
184
|
+
const frame = (time) => {
|
|
185
|
+
const dpr = view.dpr = Math.min(2, devicePixelRatio || 1);
|
|
186
|
+
const cw = cv.clientWidth, ch = cv.clientHeight;
|
|
187
|
+
if (cv.width !== cw * dpr || cv.height !== ch * dpr) { cv.width = cw * dpr; cv.height = ch * dpr; }
|
|
188
|
+
view.cx = cw / 2; view.cy = ch * 0.42;
|
|
189
|
+
view.k = Math.min(1.15, Math.min(cw * 0.47, ch * 0.41) / bound);
|
|
190
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
191
|
+
ctx.clearRect(0, 0, cw, ch);
|
|
192
|
+
ctx.translate(view.cx, view.cy);
|
|
193
|
+
ctx.scale(view.k, view.k);
|
|
194
|
+
// a gentle drift, drawn only — the layout itself stays where it settled
|
|
195
|
+
const pos = (n) => (still || n === dragged || n.depth === 0) ? [n.x, n.y]
|
|
196
|
+
: [n.x + Math.sin(time / 2600 + n.ph) * 2.2, n.y + Math.cos(time / 3100 + n.ph) * 2.2];
|
|
197
|
+
for (const l of links) {
|
|
198
|
+
const [x1, y1] = pos(l.source), [x2, y2] = pos(l.target);
|
|
199
|
+
ctx.globalAlpha = l.cross ? 0.22 : 0.4;
|
|
200
|
+
ctx.strokeStyle = l.cross ? "#aeb6d6" : l.target.c;
|
|
201
|
+
ctx.lineWidth = l.cross ? 1 : 1.3;
|
|
202
|
+
ctx.setLineDash(l.cross ? [3, 6] : []);
|
|
203
|
+
ctx.beginPath(); ctx.moveTo(x1, y1);
|
|
204
|
+
ctx.quadraticCurveTo((x1 + x2) / 2 + (y2 - y1) * 0.12, (y1 + y2) / 2 - (x2 - x1) * 0.12, x2, y2);
|
|
205
|
+
ctx.stroke();
|
|
206
|
+
}
|
|
207
|
+
ctx.setLineDash([]);
|
|
208
|
+
for (const n of nodes) {
|
|
209
|
+
if (n.depth === 0) continue;
|
|
210
|
+
const [x, y] = pos(n);
|
|
211
|
+
hex(x, y, n.r, (path) => {
|
|
212
|
+
ctx.strokeStyle = n.c; ctx.fillStyle = n.c; ctx.lineWidth = n === dragged ? 2.6 : 1.8;
|
|
213
|
+
ctx.setLineDash(n.bare ? [3, 4] : []);
|
|
214
|
+
ctx.globalAlpha = n.bare ? 0.04 : n === dragged ? 0.3 : 0.15; ctx.fill(path);
|
|
215
|
+
ctx.globalAlpha = 1; ctx.stroke(path);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
ctx.setLineDash([]);
|
|
219
|
+
ctx.globalAlpha = 1;
|
|
220
|
+
hex(0, 0, nodes[0].r, (p) => { ctx.fillStyle = "#121829"; ctx.strokeStyle = "#62e38d"; ctx.lineWidth = 2.4; ctx.fill(p); ctx.stroke(p); });
|
|
221
|
+
raf = requestAnimationFrame(frame);
|
|
222
|
+
};
|
|
223
|
+
raf = requestAnimationFrame(frame);
|
|
224
|
+
art = { sim, cv, off: () => {
|
|
225
|
+
cv.removeEventListener("pointerdown", onDown);
|
|
226
|
+
cv.removeEventListener("pointermove", onMove);
|
|
227
|
+
cv.removeEventListener("pointerup", onUp);
|
|
228
|
+
cv.removeEventListener("pointercancel", onUp);
|
|
229
|
+
cv.style.cursor = "";
|
|
230
|
+
} };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function stopArt() {
|
|
234
|
+
if (raf) cancelAnimationFrame(raf);
|
|
235
|
+
raf = null;
|
|
236
|
+
if (art) { art.sim.stop(); art.off(); art = null; }
|
|
237
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// A record's body as Markdown: headings, lists, tables, code, links — and the
|
|
2
|
+
// memory's own [[path]] links, which open the record they name.
|
|
3
|
+
//
|
|
4
|
+
// Bodies are written by agents and people, so nothing in them runs: raw HTML is
|
|
5
|
+
// shown as text, only http(s)/mailto links are kept (and open in a new tab),
|
|
6
|
+
// images are not fetched, and the result goes through DOMPurify as well.
|
|
7
|
+
|
|
8
|
+
import { Marked } from "../vendor/marked.esm.js";
|
|
9
|
+
import DOMPurify from "../vendor/purify.es.js";
|
|
10
|
+
import { esc } from "./ui.js";
|
|
11
|
+
|
|
12
|
+
let wikilink = (target, label) => esc(label);
|
|
13
|
+
|
|
14
|
+
const md = new Marked({ gfm: true, breaks: true, async: false });
|
|
15
|
+
md.use({
|
|
16
|
+
extensions: [{
|
|
17
|
+
name: "wikilink",
|
|
18
|
+
level: "inline",
|
|
19
|
+
start(src) { const i = src.indexOf("[["); return i < 0 ? undefined : i; },
|
|
20
|
+
tokenizer(src) {
|
|
21
|
+
const m = /^\[\[([^\]|#\n]+)(?:#[^\]|\n]*)?(?:\|([^\]\n]*))?\]\]/.exec(src);
|
|
22
|
+
if (m) return { type: "wikilink", raw: m[0], target: m[1].trim(), label: (m[2] || m[1]).trim() };
|
|
23
|
+
},
|
|
24
|
+
renderer(token) { return wikilink(token.target, token.label); },
|
|
25
|
+
}],
|
|
26
|
+
renderer: {
|
|
27
|
+
html(token) { return esc(token.text); },
|
|
28
|
+
link({ href, title, tokens }) {
|
|
29
|
+
const text = this.parser.parseInline(tokens);
|
|
30
|
+
if (!/^(https?:|mailto:)/i.test(href || "")) return text;
|
|
31
|
+
return `<a href="${esc(href)}" target="_blank" rel="noopener noreferrer nofollow"${title ? ` title="${esc(title)}"` : ""}>${text}</a>`;
|
|
32
|
+
},
|
|
33
|
+
image({ text }) { return text ? `<span class="md-img">[${esc(text)}]</span>` : ""; },
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// `link(target, label)` returns the HTML for one [[…]] link.
|
|
38
|
+
export function renderMarkdown(body, { link } = {}) {
|
|
39
|
+
wikilink = link || ((t, label) => esc(label));
|
|
40
|
+
try {
|
|
41
|
+
const html = md.parse(String(body ?? ""));
|
|
42
|
+
return DOMPurify.sanitize(html, { ADD_ATTR: ["target"], FORBID_TAGS: ["style", "form", "input", "textarea", "select"] });
|
|
43
|
+
} finally {
|
|
44
|
+
wikilink = (t, label) => esc(label);
|
|
45
|
+
}
|
|
46
|
+
}
|