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.
- datasette_apps/__init__.py +108 -0
- datasette_apps/csp.py +72 -0
- datasette_apps/data_access.py +109 -0
- datasette_apps/db.py +357 -0
- datasette_apps/ids.py +33 -0
- datasette_apps/permissions.py +124 -0
- datasette_apps/prompt.py +103 -0
- datasette_apps/registry.py +1023 -0
- datasette_apps/rendering.py +581 -0
- datasette_apps/static/datasette-apps.css +860 -0
- datasette_apps/templates/_app_crumbs.html +17 -0
- datasette_apps/templates/_llm_prompt.html +259 -0
- datasette_apps/templates/_stored_query_picker.html +192 -0
- datasette_apps/templates/app_create.html +425 -0
- datasette_apps/templates/app_delete.html +22 -0
- datasette_apps/templates/app_edit.html +172 -0
- datasette_apps/templates/app_list.html +50 -0
- datasette_apps/templates/app_revision.html +107 -0
- datasette_apps/templates/app_view.html +40 -0
- datasette_apps/utils.py +2 -0
- datasette_apps/views.py +645 -0
- datasette_apps-0.1a0.dist-info/METADATA +139 -0
- datasette_apps-0.1a0.dist-info/RECORD +27 -0
- datasette_apps-0.1a0.dist-info/WHEEL +5 -0
- datasette_apps-0.1a0.dist-info/entry_points.txt +2 -0
- datasette_apps-0.1a0.dist-info/licenses/LICENSE +201 -0
- datasette_apps-0.1a0.dist-info/top_level.txt +1 -0
|
@@ -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)
|