datasette-apps 0.1a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ from datasette import hookimpl
2
+ from datasette.jump import JumpSQL
3
+
4
+ from .permissions import app_permission_sql, register_app_actions
5
+ from .registry import Registry
6
+ from .views import (
7
+ app_json,
8
+ app_query,
9
+ app_revision,
10
+ apps_index,
11
+ create_app,
12
+ delete_app,
13
+ edit_app,
14
+ launch_app,
15
+ pin_app,
16
+ top_homepage_html,
17
+ unpin_app,
18
+ view_app,
19
+ )
20
+
21
+ __all__ = ["Registry"]
22
+
23
+
24
+ @hookimpl
25
+ def register_routes():
26
+ return [
27
+ (r"^/-/apps$", apps_index),
28
+ (r"^/-/apps/create$", create_app),
29
+ (r"^/-/apps/(?P<id>[^/]+)\.json$", app_json),
30
+ (r"^/-/apps/(?P<id>[^/]+)/revisions/(?P<version>\d+)$", app_revision),
31
+ (r"^/-/apps/(?P<id>[^/]+)/edit$", edit_app),
32
+ (r"^/-/apps/(?P<id>[^/]+)/delete$", delete_app),
33
+ (r"^/-/apps/(?P<id>[^/]+)/pin$", pin_app),
34
+ (r"^/-/apps/(?P<id>[^/]+)/unpin$", unpin_app),
35
+ (r"^/-/apps/(?P<id>[^/]+)/launch$", launch_app),
36
+ (r"^/-/apps/(?P<id>[^/]+)/query$", app_query),
37
+ (r"^/-/apps/(?P<id>[^/]+)$", view_app),
38
+ ]
39
+
40
+
41
+ @hookimpl
42
+ def register_actions(datasette):
43
+ return register_app_actions()
44
+
45
+
46
+ @hookimpl
47
+ def permission_resources_sql(datasette, actor, action):
48
+ return app_permission_sql(actor, action)
49
+
50
+
51
+ @hookimpl
52
+ def jump_items_sql(datasette, actor, request):
53
+ async def inner():
54
+ app_sql, app_params = await datasette.allowed_resources_sql(
55
+ action="view-app", actor=actor
56
+ )
57
+ return JumpSQL(
58
+ sql=f"""
59
+ WITH allowed_apps AS (
60
+ {app_sql}
61
+ )
62
+ SELECT
63
+ 'app' AS type,
64
+ apps.name AS label,
65
+ apps.description AS description,
66
+ json_object(
67
+ 'method', 'path',
68
+ 'path', CASE
69
+ WHEN apps.external = 1
70
+ THEN '/-/apps/' || apps.id || '/launch'
71
+ ELSE apps.path
72
+ END
73
+ ) AS url,
74
+ 'app' || apps.name || ' ' || apps.description || ' ' ||
75
+ apps.id || ' ' || apps.source AS search_text,
76
+ NULL AS display_name
77
+ FROM apps
78
+ JOIN allowed_apps
79
+ ON allowed_apps.parent = 'apps'
80
+ AND allowed_apps.child = apps.id
81
+ WHERE apps.deleted_at IS NULL
82
+ """,
83
+ params=app_params,
84
+ )
85
+
86
+ return inner
87
+
88
+
89
+ @hookimpl
90
+ async def startup(datasette):
91
+ await Registry(datasette).ensure_tables()
92
+
93
+
94
+ @hookimpl
95
+ def top_homepage(datasette, request):
96
+ return top_homepage_html(datasette, request)
97
+
98
+
99
+ @hookimpl
100
+ def extra_css_urls():
101
+ return ["/-/static-plugins/datasette-apps/datasette-apps.css"]
102
+
103
+
104
+ @hookimpl
105
+ def menu_links(datasette, actor, request):
106
+ if not actor:
107
+ return []
108
+ return [{"href": datasette.urls.path("/-/apps"), "label": "Apps"}]
datasette_apps/csp.py ADDED
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import os
5
+ from urllib.parse import urlsplit
6
+
7
+ BASE_DIRECTIVES = [
8
+ "default-src 'none'",
9
+ "script-src 'unsafe-inline'",
10
+ "style-src 'unsafe-inline'",
11
+ ]
12
+
13
+ APP_VIEW_PARENT_CSP = "frame-src 'none';"
14
+
15
+
16
+ def _is_localhost(hostname):
17
+ if not hostname:
18
+ return False
19
+ hostname = hostname.lower()
20
+ if hostname == "localhost" or hostname.endswith(".localhost"):
21
+ return True
22
+ try:
23
+ return ipaddress.ip_address(hostname).is_loopback
24
+ except ValueError:
25
+ return False
26
+
27
+
28
+ def normalize_connect_origin(origin):
29
+ parsed = urlsplit((origin or "").strip())
30
+ allow_insecure_test_origins = os.environ.get(
31
+ "DATASETTE_APPS_ALLOW_INSECURE_TEST_CSP_ORIGINS"
32
+ )
33
+ if parsed.scheme != "https" and not allow_insecure_test_origins:
34
+ raise ValueError("Only https:// origins are allowed")
35
+ if parsed.scheme not in {"http", "https"}:
36
+ raise ValueError("Only http:// and https:// origins are allowed")
37
+ if not parsed.hostname:
38
+ raise ValueError("Origin must include a host")
39
+ if parsed.username or parsed.password:
40
+ raise ValueError("Origin must not include username or password")
41
+ if parsed.query or parsed.fragment:
42
+ raise ValueError("Origin must not include query string or fragment")
43
+ if parsed.path and parsed.path != "/":
44
+ raise ValueError("Origin must not include a path")
45
+ hostname = parsed.hostname.lower()
46
+ if "*" in hostname:
47
+ raise ValueError("Wildcard hosts are not allowed")
48
+ if _is_localhost(hostname) and not allow_insecure_test_origins:
49
+ raise ValueError("Localhost origins are not allowed")
50
+
51
+ # Accessing .port validates the port and raises ValueError if malformed.
52
+ port = parsed.port
53
+ if ":" in hostname:
54
+ netloc = f"[{hostname}]"
55
+ else:
56
+ netloc = hostname
57
+ if port is not None:
58
+ netloc = f"{netloc}:{port}"
59
+ return f"{parsed.scheme}://{netloc}"
60
+
61
+
62
+ def build_csp(connect_origins):
63
+ origins = [normalize_connect_origin(origin) for origin in connect_origins]
64
+ directives = [*BASE_DIRECTIVES]
65
+ if origins:
66
+ element_sources = ["'unsafe-inline'", *origins]
67
+ directives.append(f"script-src-elem {' '.join(element_sources)}")
68
+ directives.append(f"style-src-elem {' '.join(element_sources)}")
69
+ directives.append(f"img-src {' '.join(['data:', 'blob:', *origins])}")
70
+ if origins:
71
+ directives.append(f"connect-src {' '.join(origins)}")
72
+ return "; ".join(directives) + ";"
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import urlencode
4
+
5
+ from datasette.utils import tilde_encode
6
+
7
+ from .registry import Registry
8
+
9
+
10
+ class AppQueryError(Exception):
11
+ pass
12
+
13
+
14
+ async def run_app_query(datasette, app, actor, database_name, sql, params=None):
15
+ allowed_databases = await Registry(datasette).get_sql_databases(app["id"])
16
+ if database_name not in allowed_databases:
17
+ raise AppQueryError("This app is not allowed to query that database")
18
+
19
+ if params is not None and not isinstance(params, dict):
20
+ raise AppQueryError("Query parameters must be an object of named values")
21
+
22
+ query_args = {
23
+ "sql": sql,
24
+ "_shape": "objects",
25
+ "_extra": "columns",
26
+ }
27
+ for key, value in (params or {}).items():
28
+ query_args[key] = "" if value is None else value
29
+
30
+ try:
31
+ database_path = datasette.urls.database(database_name)
32
+ except KeyError as e:
33
+ raise AppQueryError("Database not found") from e
34
+ path = f"{database_path}/-/query.json?" + urlencode(query_args, doseq=True)
35
+ response = await datasette.client.get(path, actor=actor)
36
+ try:
37
+ data = response.json()
38
+ except ValueError as e:
39
+ if response.status_code in (401, 403):
40
+ raise AppQueryError("Permission denied by Datasette") from e
41
+ raise AppQueryError("Query failed") from e
42
+ if response.status_code != 200 or not data.get("ok"):
43
+ raise AppQueryError(data.get("error") or "Query failed")
44
+ return {
45
+ "columns": data.get("columns") or [],
46
+ "rows": data.get("rows") or [],
47
+ }
48
+
49
+
50
+ async def run_app_stored_query(
51
+ datasette, app, actor, database_name, query_name, params=None
52
+ ):
53
+ allowed_queries = await Registry(datasette).get_stored_queries(app["id"])
54
+ if f"{database_name}/{query_name}" not in allowed_queries:
55
+ raise AppQueryError("This app is not allowed to run that stored query")
56
+
57
+ if params is not None and not isinstance(params, dict):
58
+ raise AppQueryError("Query parameters must be an object of named values")
59
+
60
+ stored_query = await datasette.get_query(database_name, query_name)
61
+ if stored_query is None:
62
+ raise AppQueryError("Stored query not found")
63
+
64
+ try:
65
+ database_path = datasette.urls.database(database_name)
66
+ except KeyError as e:
67
+ raise AppQueryError("Database not found") from e
68
+
69
+ query_path = f"{database_path}/{tilde_encode(query_name)}"
70
+ if stored_query.is_write:
71
+ response = await datasette.client.post(
72
+ query_path + "?_json=1",
73
+ actor=actor,
74
+ json={
75
+ key: "" if value is None else value
76
+ for key, value in (params or {}).items()
77
+ },
78
+ )
79
+ return _json_response_or_error(response)
80
+
81
+ query_args = {
82
+ "_shape": "objects",
83
+ "_extra": "columns",
84
+ }
85
+ for key, value in (params or {}).items():
86
+ query_args[key] = "" if value is None else value
87
+ response = await datasette.client.get(
88
+ f"{query_path}.json?" + urlencode(query_args, doseq=True),
89
+ actor=actor,
90
+ )
91
+ data = _json_response_or_error(response)
92
+ return {
93
+ "columns": data.get("columns") or [],
94
+ "rows": data.get("rows") or [],
95
+ }
96
+
97
+
98
+ def _json_response_or_error(response):
99
+ try:
100
+ data = response.json()
101
+ except ValueError as e:
102
+ if response.status_code in (401, 403):
103
+ raise AppQueryError("Permission denied by Datasette") from e
104
+ raise AppQueryError("Stored query failed") from e
105
+ if response.status_code != 200 or data.get("ok") is False:
106
+ raise AppQueryError(
107
+ data.get("error") or data.get("message") or "Stored query failed"
108
+ )
109
+ return data
datasette_apps/db.py ADDED
@@ -0,0 +1,357 @@
1
+ from __future__ import annotations
2
+
3
+ SCHEMA = """
4
+ CREATE TABLE IF NOT EXISTS apps (
5
+ id TEXT PRIMARY KEY,
6
+ external INTEGER NOT NULL DEFAULT 0,
7
+ name TEXT NOT NULL,
8
+ description TEXT NOT NULL DEFAULT '',
9
+ path TEXT NOT NULL,
10
+ source TEXT NOT NULL DEFAULT '',
11
+ metadata TEXT NOT NULL DEFAULT '{}',
12
+ actor_id TEXT,
13
+ is_private INTEGER NOT NULL DEFAULT 1,
14
+ stored_queries TEXT NOT NULL DEFAULT '[]',
15
+ current_version INTEGER,
16
+ deleted_at TEXT,
17
+ deleted_actor_id TEXT,
18
+ created_at TEXT NOT NULL,
19
+ updated_at TEXT NOT NULL,
20
+ CHECK (external IN (0, 1)),
21
+ CHECK (is_private IN (0, 1))
22
+ );
23
+
24
+ CREATE TABLE IF NOT EXISTS app_revisions (
25
+ app_id TEXT NOT NULL REFERENCES apps(id),
26
+ version INTEGER NOT NULL,
27
+ actor_id TEXT,
28
+ name TEXT,
29
+ description TEXT,
30
+ html TEXT,
31
+ is_private INTEGER,
32
+ sql_databases TEXT,
33
+ stored_queries TEXT,
34
+ csp_origins TEXT,
35
+ changed_fields TEXT NOT NULL DEFAULT '[]',
36
+ created_at TEXT NOT NULL,
37
+ PRIMARY KEY (app_id, version),
38
+ CHECK (is_private IN (0, 1) OR is_private IS NULL)
39
+ );
40
+
41
+ CREATE INDEX IF NOT EXISTS idx_apps_updated ON apps(updated_at DESC, id);
42
+ CREATE INDEX IF NOT EXISTS idx_apps_external_updated ON apps(external, updated_at DESC, id);
43
+ CREATE INDEX IF NOT EXISTS idx_apps_source ON apps(source);
44
+
45
+ CREATE VIRTUAL TABLE IF NOT EXISTS apps_fts
46
+ USING fts5(name, description, source, content='apps', content_rowid='rowid');
47
+
48
+ CREATE TRIGGER IF NOT EXISTS apps_ai AFTER INSERT ON apps BEGIN
49
+ INSERT INTO apps_fts(rowid, name, description, source)
50
+ VALUES (new.rowid, new.name, new.description, new.source);
51
+ END;
52
+
53
+ CREATE TRIGGER IF NOT EXISTS apps_ad AFTER DELETE ON apps BEGIN
54
+ INSERT INTO apps_fts(apps_fts, rowid, name, description, source)
55
+ VALUES ('delete', old.rowid, old.name, old.description, old.source);
56
+ END;
57
+
58
+ CREATE TRIGGER IF NOT EXISTS apps_au AFTER UPDATE ON apps BEGIN
59
+ INSERT INTO apps_fts(apps_fts, rowid, name, description, source)
60
+ VALUES ('delete', old.rowid, old.name, old.description, old.source);
61
+ INSERT INTO apps_fts(rowid, name, description, source)
62
+ VALUES (new.rowid, new.name, new.description, new.source);
63
+ END;
64
+
65
+ CREATE TABLE IF NOT EXISTS app_access (
66
+ id INTEGER PRIMARY KEY,
67
+ app_id TEXT REFERENCES apps(id),
68
+ action TEXT NOT NULL,
69
+ subject_type TEXT NOT NULL,
70
+ subject_id TEXT,
71
+ allow INTEGER NOT NULL DEFAULT 1,
72
+ created_at TEXT NOT NULL,
73
+ updated_at TEXT NOT NULL,
74
+ CHECK (subject_type IN ('authenticated')),
75
+ CHECK (allow IN (0, 1))
76
+ );
77
+
78
+ CREATE INDEX IF NOT EXISTS idx_app_access_lookup
79
+ ON app_access(action, app_id, subject_type, subject_id);
80
+
81
+ CREATE TABLE IF NOT EXISTS app_sql_databases (
82
+ app_id TEXT NOT NULL REFERENCES apps(id),
83
+ database_name TEXT NOT NULL,
84
+ created_at TEXT NOT NULL,
85
+ updated_at TEXT NOT NULL,
86
+ PRIMARY KEY (app_id, database_name)
87
+ );
88
+
89
+ CREATE INDEX IF NOT EXISTS idx_app_sql_databases_app
90
+ ON app_sql_databases(app_id, database_name);
91
+
92
+ CREATE TABLE IF NOT EXISTS app_csp_origins (
93
+ id INTEGER PRIMARY KEY,
94
+ app_id TEXT NOT NULL REFERENCES apps(id),
95
+ directive TEXT NOT NULL DEFAULT 'connect-src',
96
+ origin TEXT NOT NULL,
97
+ created_at TEXT NOT NULL,
98
+ updated_at TEXT NOT NULL,
99
+ CHECK (directive IN ('connect-src')),
100
+ UNIQUE (app_id, directive, origin)
101
+ );
102
+
103
+ CREATE INDEX IF NOT EXISTS idx_app_csp_origins_app
104
+ ON app_csp_origins(app_id, directive);
105
+
106
+ CREATE TABLE IF NOT EXISTS app_user_state (
107
+ actor_id TEXT NOT NULL,
108
+ app_id TEXT NOT NULL REFERENCES apps(id),
109
+ last_accessed_at TEXT,
110
+ pinned_at TEXT,
111
+ access_count INTEGER NOT NULL DEFAULT 0,
112
+ PRIMARY KEY (actor_id, app_id)
113
+ );
114
+
115
+ CREATE INDEX IF NOT EXISTS idx_app_user_state_actor_pinned
116
+ ON app_user_state(actor_id, pinned_at DESC, last_accessed_at DESC, app_id);
117
+
118
+ CREATE INDEX IF NOT EXISTS idx_app_user_state_actor_recent
119
+ ON app_user_state(actor_id, last_accessed_at DESC, app_id);
120
+ """
121
+
122
+
123
+ async def ensure_tables(datasette):
124
+ internal_db = datasette.get_internal_database()
125
+
126
+ def create_schema(conn):
127
+ existing_tables = {
128
+ row[0]
129
+ for row in conn.execute(
130
+ "SELECT name FROM sqlite_master WHERE type = 'table'"
131
+ )
132
+ }
133
+ if "apps" in existing_tables:
134
+ app_columns = {row[1] for row in conn.execute("PRAGMA table_info(apps)")}
135
+ if "is_private" not in app_columns:
136
+ conn.execute(
137
+ "ALTER TABLE apps ADD COLUMN is_private INTEGER NOT NULL DEFAULT 1"
138
+ )
139
+ conn.execute("UPDATE apps SET is_private = 0 WHERE external = 1")
140
+ if "app_access" in existing_tables:
141
+ conn.execute("""
142
+ UPDATE apps
143
+ SET is_private = 0
144
+ WHERE id IN (
145
+ SELECT app_id
146
+ FROM app_access
147
+ WHERE action = 'view-app'
148
+ AND subject_type = 'authenticated'
149
+ AND allow = 1
150
+ )
151
+ """)
152
+ if "stored_queries" not in app_columns:
153
+ conn.execute(
154
+ "ALTER TABLE apps ADD COLUMN stored_queries TEXT NOT NULL DEFAULT '[]'"
155
+ )
156
+ if "deleted_at" not in app_columns:
157
+ conn.execute("ALTER TABLE apps ADD COLUMN deleted_at TEXT")
158
+ if "deleted_actor_id" not in app_columns:
159
+ conn.execute("ALTER TABLE apps ADD COLUMN deleted_actor_id TEXT")
160
+ if "apps" in existing_tables:
161
+ conn.execute("""
162
+ CREATE TABLE IF NOT EXISTS app_sql_databases (
163
+ app_id TEXT NOT NULL REFERENCES apps(id),
164
+ database_name TEXT NOT NULL,
165
+ created_at TEXT NOT NULL,
166
+ updated_at TEXT NOT NULL,
167
+ PRIMARY KEY (app_id, database_name)
168
+ )
169
+ """)
170
+ conn.execute("""
171
+ CREATE TABLE IF NOT EXISTS app_csp_origins (
172
+ id INTEGER PRIMARY KEY,
173
+ app_id TEXT NOT NULL REFERENCES apps(id),
174
+ directive TEXT NOT NULL DEFAULT 'connect-src',
175
+ origin TEXT NOT NULL,
176
+ created_at TEXT NOT NULL,
177
+ updated_at TEXT NOT NULL,
178
+ CHECK (directive IN ('connect-src')),
179
+ UNIQUE (app_id, directive, origin)
180
+ )
181
+ """)
182
+ conn.execute("""
183
+ CREATE TABLE IF NOT EXISTS app_revisions (
184
+ app_id TEXT NOT NULL REFERENCES apps(id),
185
+ version INTEGER NOT NULL,
186
+ actor_id TEXT,
187
+ name TEXT,
188
+ description TEXT,
189
+ html TEXT,
190
+ is_private INTEGER,
191
+ sql_databases TEXT,
192
+ stored_queries TEXT,
193
+ csp_origins TEXT,
194
+ changed_fields TEXT NOT NULL DEFAULT '[]',
195
+ created_at TEXT NOT NULL,
196
+ PRIMARY KEY (app_id, version),
197
+ CHECK (is_private IN (0, 1) OR is_private IS NULL)
198
+ )
199
+ """)
200
+ revision_columns = {
201
+ row[1] for row in conn.execute("PRAGMA table_info(app_revisions)")
202
+ }
203
+ if "stored_queries" not in revision_columns:
204
+ conn.execute("ALTER TABLE app_revisions ADD COLUMN stored_queries TEXT")
205
+ if "apps" in existing_tables and "app_versions" in existing_tables:
206
+ conn.execute("""
207
+ INSERT INTO app_revisions (
208
+ app_id, version, actor_id, name, description, html,
209
+ is_private, sql_databases, stored_queries, csp_origins,
210
+ changed_fields, created_at
211
+ )
212
+ SELECT
213
+ apps.id,
214
+ 1,
215
+ apps.actor_id,
216
+ apps.name,
217
+ apps.description,
218
+ COALESCE(
219
+ (
220
+ SELECT app_versions.html
221
+ FROM app_versions
222
+ WHERE app_versions.app_id = apps.id
223
+ AND app_versions.version = apps.current_version
224
+ LIMIT 1
225
+ ),
226
+ (
227
+ SELECT app_versions.html
228
+ FROM app_versions
229
+ WHERE app_versions.app_id = apps.id
230
+ ORDER BY app_versions.version DESC
231
+ LIMIT 1
232
+ ),
233
+ ''
234
+ ),
235
+ apps.is_private,
236
+ COALESCE(
237
+ (
238
+ SELECT json_group_array(database_name)
239
+ FROM (
240
+ SELECT database_name
241
+ FROM app_sql_databases
242
+ WHERE app_sql_databases.app_id = apps.id
243
+ ORDER BY database_name
244
+ )
245
+ ),
246
+ '[]'
247
+ ),
248
+ apps.stored_queries,
249
+ COALESCE(
250
+ (
251
+ SELECT json_group_array(origin)
252
+ FROM (
253
+ SELECT origin
254
+ FROM app_csp_origins
255
+ WHERE app_csp_origins.app_id = apps.id
256
+ AND directive = 'connect-src'
257
+ ORDER BY origin
258
+ )
259
+ ),
260
+ '[]'
261
+ ),
262
+ '["name", "description", "html", "is_private", "sql_databases", "stored_queries", "csp_origins"]',
263
+ apps.updated_at
264
+ FROM apps
265
+ WHERE apps.external = 0
266
+ AND NOT EXISTS (
267
+ SELECT 1
268
+ FROM app_revisions
269
+ WHERE app_revisions.app_id = apps.id
270
+ )
271
+ """)
272
+ conn.execute("""
273
+ UPDATE app_revisions
274
+ SET html = (
275
+ SELECT app_versions.html
276
+ FROM app_versions
277
+ WHERE app_versions.app_id = app_revisions.app_id
278
+ ORDER BY app_versions.version DESC
279
+ LIMIT 1
280
+ )
281
+ WHERE version = 1
282
+ AND (html IS NULL OR html = '')
283
+ AND EXISTS (
284
+ SELECT 1
285
+ FROM app_versions
286
+ WHERE app_versions.app_id = app_revisions.app_id
287
+ AND app_versions.html != ''
288
+ )
289
+ """)
290
+ conn.execute("UPDATE apps SET current_version = 1 WHERE external = 0")
291
+ if "apps" in existing_tables:
292
+ conn.execute("""
293
+ INSERT INTO app_revisions (
294
+ app_id, version, actor_id, name, description, html,
295
+ is_private, sql_databases, stored_queries, csp_origins,
296
+ changed_fields, created_at
297
+ )
298
+ SELECT
299
+ apps.id,
300
+ 1,
301
+ apps.actor_id,
302
+ apps.name,
303
+ apps.description,
304
+ '',
305
+ apps.is_private,
306
+ COALESCE(
307
+ (
308
+ SELECT json_group_array(database_name)
309
+ FROM (
310
+ SELECT database_name
311
+ FROM app_sql_databases
312
+ WHERE app_sql_databases.app_id = apps.id
313
+ ORDER BY database_name
314
+ )
315
+ ),
316
+ '[]'
317
+ ),
318
+ apps.stored_queries,
319
+ COALESCE(
320
+ (
321
+ SELECT json_group_array(origin)
322
+ FROM (
323
+ SELECT origin
324
+ FROM app_csp_origins
325
+ WHERE app_csp_origins.app_id = apps.id
326
+ AND directive = 'connect-src'
327
+ ORDER BY origin
328
+ )
329
+ ),
330
+ '[]'
331
+ ),
332
+ '["name", "description", "html", "is_private", "sql_databases", "stored_queries", "csp_origins"]',
333
+ apps.updated_at
334
+ FROM apps
335
+ WHERE apps.external = 0
336
+ AND NOT EXISTS (
337
+ SELECT 1
338
+ FROM app_revisions
339
+ WHERE app_revisions.app_id = apps.id
340
+ )
341
+ """)
342
+ conn.execute("""
343
+ UPDATE apps
344
+ SET current_version = 1
345
+ WHERE external = 0
346
+ AND current_version IS NOT NULL
347
+ AND NOT EXISTS (
348
+ SELECT 1
349
+ FROM app_revisions
350
+ WHERE app_revisions.app_id = apps.id
351
+ AND app_revisions.version = apps.current_version
352
+ )
353
+ """)
354
+ conn.execute("DROP TABLE IF EXISTS app_versions")
355
+ conn.executescript(SCHEMA)
356
+
357
+ await internal_db.execute_write_fn(create_schema)
datasette_apps/ids.py ADDED
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ import threading
5
+ import time
6
+
7
+ _ENCODING = "0123456789abcdefghjkmnpqrstvwxyz"
8
+ _LOCK = threading.Lock()
9
+ _LAST_MS = -1
10
+ _LAST_RANDOM = 0
11
+
12
+
13
+ def _encode(value, length):
14
+ chars = []
15
+ for _ in range(length):
16
+ chars.append(_ENCODING[value & 31])
17
+ value >>= 5
18
+ return "".join(reversed(chars))
19
+
20
+
21
+ def monotonic_ulid():
22
+ global _LAST_MS, _LAST_RANDOM
23
+
24
+ now_ms = time.time_ns() // 1_000_000
25
+ with _LOCK:
26
+ if now_ms == _LAST_MS:
27
+ _LAST_RANDOM += 1
28
+ if _LAST_RANDOM >= (1 << 80):
29
+ raise OverflowError("ULID randomness overflow")
30
+ else:
31
+ _LAST_MS = now_ms
32
+ _LAST_RANDOM = secrets.randbits(80)
33
+ return _encode(now_ms, 10) + _encode(_LAST_RANDOM, 16)