mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
Compare commits
764 commits
vendored-p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d4448fc56 |
||
|
|
2ca00b6c75 | ||
|
|
03ab359208 | ||
|
|
3eca3ad6d4 | ||
|
|
0a924524be |
||
|
|
170b3ff61c | ||
|
|
c6c2a238c3 | ||
|
|
68f1179bac | ||
|
|
2125115cd9 | ||
|
|
93b455239a | ||
|
|
4b4add4d31 | ||
|
|
5125bef573 | ||
|
|
23a640d38b |
||
|
|
32a425868c |
||
|
|
291f71ec6b |
||
|
|
354d7a2873 |
||
|
|
a508fc4a8e | ||
|
|
8bc9b1ee03 |
||
|
|
1df4028d78 | ||
|
|
257e1c1b1b | ||
|
|
d814e81b32 |
||
|
|
ec99bb46f8 | ||
|
|
3c2254463b |
||
|
|
f12f6cc2ab |
||
|
|
12016342e7 | ||
|
|
b4385a3ff7 | ||
|
|
ce464da34b | ||
|
|
9f74dc22a8 | ||
|
|
8b371495dc | ||
|
|
f257ca6edb |
||
|
|
295e4a2e87 | ||
|
|
95a1fef280 | ||
|
|
dc3f9fe9e4 | ||
|
|
5d4dfcec6b | ||
|
|
b3b8c5831b | ||
|
|
b212895b97 | ||
|
|
18fd373a8f |
||
|
|
c76c3e6e6f | ||
|
|
fa978ec100 | ||
|
|
2459285052 | ||
|
|
506ce5b0ac | ||
|
|
063bf7a96f | ||
|
|
7e09e1bf1b | ||
|
|
e37aa37edc | ||
|
|
b8cee8768e | ||
|
|
5c16c6687d | ||
|
|
a528555e84 |
||
|
|
2b962beaeb | ||
|
|
5705ce0d95 |
||
|
|
1f8995e776 | ||
|
|
47e4060469 | ||
|
|
48982a0ff5 | ||
|
|
223dcc7c0e | ||
|
|
3184bfae54 | ||
|
|
e5f392ae7a | ||
|
|
400fa08e4c |
||
|
|
b7ef968c6f | ||
|
|
ba654b5576 | ||
|
|
e4be95b16c |
||
|
|
87aa798148 | ||
|
|
6a71bde37f |
||
|
|
5247856bd4 | ||
|
|
ce4b0794b2 |
||
|
|
53e6a72a95 | ||
|
|
1289eb0589 | ||
|
|
5da3c9f4bd | ||
|
|
b018eb3171 | ||
|
|
73014abe8b | ||
|
|
b3721eaf50 | ||
|
|
5c537e0a3e | ||
|
|
2c8e92acf2 | ||
|
|
e7ed948238 | ||
|
|
06b442c894 | ||
|
|
6de83bf3a9 | ||
|
|
4fe1765dc3 | ||
|
|
653c94209c | ||
|
|
95286fbb60 | ||
|
|
653475edde | ||
|
|
c652e92049 | ||
|
|
d769e97ab8 | ||
|
|
ee4fcff5c0 | ||
|
|
ee62bf9bdc | ||
|
|
7d9d7acb0b | ||
|
|
5530a19d9f | ||
|
|
6854270da3 | ||
|
|
fb9cd5c72c | ||
|
|
bda69ff1c9 | ||
|
|
59994e18e4 | ||
|
|
62b99b1f55 | ||
|
|
f18d1ecac6 | ||
|
|
e7c7e21277 | ||
|
|
d7d7ead0ef | ||
|
|
20ed5a00e7 | ||
|
|
e4f549301b | ||
|
|
deb0b87e1b | ||
|
|
86ea2d2c99 | ||
|
|
c3eeecfb22 | ||
|
|
ca435d16f6 | ||
|
|
11fb528958 | ||
|
|
08014c9732 | ||
|
|
de21a4209c | ||
|
|
d300200ba5 | ||
|
|
eff4f931af | ||
|
|
82cc3d5c86 | ||
|
|
60ed646d45 | ||
|
|
66f2dbb64a | ||
|
|
10ea23a59c | ||
|
|
4760cb9e06 | ||
|
|
13318feb8e | ||
|
|
a5910f200e | ||
|
|
fabcfd68ad | ||
|
|
6df364cb2c | ||
|
|
d0237187c4 | ||
|
|
ee1d7983ba | ||
|
|
bc81975d85 | ||
|
|
5c6b76f2f0 | ||
|
|
5feb5fcf5d | ||
|
|
60a38cee85 | ||
|
|
b5f41772ca | ||
|
|
ad00bb11f6 | ||
|
|
559a13a8c6 | ||
|
|
09194c72f8 | ||
|
|
d07f8944fa | ||
|
|
562a84e3f9 | ||
|
|
0fb148b1f4 | ||
|
|
e5762b1f22 | ||
|
|
182bfaed8e | ||
|
|
6584c9e03f | ||
|
|
30e2f9064b | ||
|
|
e1582c1424 | ||
|
|
dc241e8691 | ||
|
|
fe2084df66 | ||
|
|
59ccf797c4 | ||
|
|
7aaff5e3d2 | ||
|
|
387afb0f69 | ||
|
|
cde1624d0a | ||
|
|
a0659075a3 | ||
|
|
224084facc | ||
|
|
235962cd35 | ||
|
|
4be7eece8c | ||
|
|
4d03e8c12e | ||
|
|
e8b79970fb | ||
|
|
a2994cc5bb | ||
|
|
7c6bc0b902 | ||
|
|
5138e95d69 | ||
|
|
8674aaa392 | ||
|
|
2620938661 | ||
|
|
23715d6c00 | ||
|
|
58ac5ccd6e | ||
|
|
b311f735f9 | ||
|
|
79879b834a | ||
|
|
a21a1b6c14 | ||
|
|
c0b5ce04c3 | ||
|
|
9172020535 | ||
|
|
4d6730e3c4 | ||
|
|
c7278c73f3 | ||
|
|
96d2e16e83 | ||
|
|
eb5a95ee6e | ||
|
|
8e47f99874 | ||
|
|
7d04211559 | ||
|
|
d73b6f169f | ||
|
|
130dad268d | ||
|
|
e333827687 | ||
|
|
8b098e4b3e | ||
|
|
06af34240f | ||
|
|
7423c1a999 | ||
|
|
98493b7587 | ||
|
|
8b5bf3e487 | ||
|
|
2ed2849a14 | ||
|
|
b8d26754df | ||
|
|
c06e05b7db | ||
|
|
65c427e4ee | ||
|
|
b9c6e7a0f6 | ||
|
|
159b9f3fec | ||
|
|
8e9916b286 | ||
|
|
b1080e7d30 | ||
|
|
5b0baf7cd5 | ||
|
|
2b879e462f | ||
|
|
e951f7e81f |
||
|
|
2df06e1fda |
||
|
|
7ce723edcf |
||
|
|
ec38ad3768 |
||
|
|
659673614a | ||
|
|
e2a739c496 | ||
|
|
27084caa04 |
||
|
|
85da8474d4 |
||
|
|
909448fb7a | ||
|
|
5d09ab3ff1 | ||
|
|
571ce651c1 | ||
|
|
d87bd12dbc | ||
|
|
9dc2a3ffe5 | ||
|
|
7a602140df | ||
|
|
e2497fdb59 | ||
|
|
1c77a7e33f | ||
|
|
6f7f4c7d89 | ||
|
|
f4274e7a2e | ||
|
|
271aa09056 | ||
|
|
d5c6e502fb |
||
|
|
f2485dce9c |
||
|
|
f6446b3095 | ||
|
|
d03273e205 |
||
|
|
d021ce97aa |
||
|
|
7945f4fbf2 | ||
|
|
da209ed2ba |
||
|
|
333f786cb0 | ||
|
|
6e512caa59 | ||
|
|
209bdee0e8 | ||
|
|
e59fd01757 | ||
|
|
cd9182a551 | ||
|
|
7f23411002 | ||
|
|
f95ac19e71 | ||
|
|
53a3b3c80e |
||
|
|
962da77d61 |
||
|
|
b9047d812a | ||
|
|
9e41d19f73 | ||
|
|
f57977a08f | ||
|
|
4dff846271 | ||
|
|
d48e5ae0ce |
||
|
|
b190b87ec6 | ||
|
|
d9a450b197 | ||
|
|
308c243cfd | ||
|
|
37873e02b0 | ||
|
|
34390bbed8 | ||
|
|
1902735c63 |
||
|
|
72f8ac680a | ||
|
|
7077b8b1ba | ||
|
|
e85517dab3 | ||
|
|
dce718961c | ||
|
|
b0b600b79f | ||
|
|
832f76ce26 | ||
|
|
ea9f66f9fb | ||
|
|
a542870bfb |
||
|
|
0bc6a2af89 | ||
|
|
2ec4d8a4d5 | ||
|
|
f601425015 | ||
|
|
6da8d09a14 | ||
|
|
deb482a41e | ||
|
|
2170269258 |
||
|
|
92c4d41ca6 | ||
|
|
dc288056b8 | ||
|
|
9ecce07b08 | ||
|
|
dc1d152476 | ||
|
|
bc46066f9d | ||
|
|
f28ff8e4f0 | ||
|
|
8a63cdccc7 | ||
|
|
34a6b2ac84 | ||
|
|
9028d7f805 | ||
|
|
1f3fb5f96b |
||
|
|
4efcc29d02 |
||
|
|
39dfc7d7d7 |
||
|
|
d444b6aad5 | ||
|
|
7d8dd2ac7f | ||
|
|
0dd41efce6 |
||
|
|
53a8ae1871 | ||
|
|
9cb5700d60 |
||
|
|
6d91d082e0 |
||
|
|
05dfd34fd0 | ||
|
|
160d82f06e |
||
|
|
492378c2a0 | ||
|
|
cf4274f2a3 |
||
|
|
e9d34a99b8 | ||
|
|
06d4ffb92e | ||
|
|
93067668fe | ||
|
|
bf953628bb | ||
|
|
f6bd2bf8b0 | ||
|
|
2e82eb108c | ||
|
|
e9f598609b | ||
|
|
ff710ed7eb | ||
|
|
5bd6853bf4 | ||
|
|
78ce105413 | ||
|
|
2b0a61ee19 | ||
|
|
8dc9bfa2ab | ||
|
|
2ad51baa31 | ||
|
|
bd7d3bb70f | ||
|
|
169ee5d710 |
||
|
|
81b68a143a | ||
|
|
feccfa2a4d |
||
|
|
2edf45b1b6 | ||
|
|
a23c2aee00 |
||
|
|
56adfff8d2 |
||
|
|
c2e8e5085b | ||
|
|
263788906a | ||
|
|
7316dd4ac6 | ||
|
|
62686114ee | ||
|
|
93534fd3d0 | ||
|
|
45c27603d2 | ||
|
|
8f86d2af6a |
||
|
|
64a125b860 | ||
|
|
d118d5c5bb | ||
|
|
b39b01a890 | ||
|
|
780deaa275 | ||
|
|
2b6bfddafc | ||
|
|
7437d40e5d | ||
|
|
9a3c3bfcc7 | ||
|
|
c698d008e0 | ||
|
|
e1bfab3fca |
||
|
|
8f9509f00c |
||
|
|
7d6d471dc5 | ||
|
|
2a08ffed5c |
||
|
|
63714cb2b7 | ||
|
|
d32176c5b8 |
||
|
|
19b6a37336 | ||
|
|
1edb24f124 | ||
|
|
da68662767 |
||
|
|
67e66f36c1 |
||
|
|
261fc8d875 | ||
|
|
eb8545c172 | ||
|
|
54f5604caf | ||
|
|
5af6837725 | ||
|
|
8b6f155b45 | ||
|
|
c92f326ed1 | ||
|
|
feddd61789 | ||
|
|
9cc6f1908f | ||
|
|
e088abdb46 | ||
|
|
828ef9899f | ||
|
|
8d456aae45 | ||
|
|
b8711988b9 | ||
|
|
7339cc51de | ||
|
|
06281a0b8e | ||
|
|
909c85cd2b | ||
|
|
daf5ca02ca | ||
|
|
7b32d5f7d8 | ||
|
|
7818e8b9d1 | ||
|
|
a395256c8c | ||
|
|
090dff542b |
||
|
|
c6e8a4a76c |
||
|
|
4d24bf6b34 | ||
|
|
5de6797d4a | ||
|
|
86335dc722 | ||
|
|
57c1ce0e8b | ||
|
|
6ec0081f5d |
||
|
|
f99c2f5f8c | ||
|
|
c863443ea1 | ||
|
|
dfd4ad558b |
||
|
|
434123425f | ||
|
|
103b4decbd |
||
|
|
158d5d96e9 |
||
|
|
28bf3a933f | ||
|
|
26300738e3 | ||
|
|
27409a7892 | ||
|
|
392ca2e24c | ||
|
|
b36a2d8f4b | ||
|
|
3856a8cb24 | ||
|
|
81629dbeff | ||
|
|
a4fa1ef3bd | ||
|
|
10f9ba1a00 | ||
|
|
5e0e440f2c | ||
|
|
e1c80efff8 | ||
|
|
9906f937d9 | ||
|
|
3a999a85fb | ||
|
|
244f3ff83a | ||
|
|
8bfa3a51c2 | ||
|
|
232a30459b | ||
|
|
47e29e948b | ||
|
|
97de4d6362 | ||
|
|
b89cac3b6a |
||
|
|
5d79974186 |
||
|
|
398a92cf1e | ||
|
|
bd9ed62e5d | ||
|
|
dcd9ea3622 |
||
|
|
c62cfa6de8 | ||
|
|
c954795f9a | ||
|
|
4e944c29e4 | ||
|
|
528d89d1a3 | ||
|
|
b5ccc4d608 | ||
|
|
574687834f | ||
|
|
900d15bcb8 | ||
|
|
569aacd39b |
||
|
|
9989f25709 | ||
|
|
e0794ddd52 | ||
|
|
1e31821d9f | ||
|
|
df8d1c055a |
||
|
|
d0089ba776 | ||
|
|
c64453a4a1 | ||
|
|
ad01f9d321 |
||
|
|
9ac9f0152f | ||
|
|
60c6692f68 |
||
|
|
52a1dac5d2 | ||
|
|
f049103852 | ||
|
|
69c6e95323 | ||
|
|
5d21057cf1 | ||
|
|
5a63ecc557 | ||
|
|
1e901aa690 | ||
|
|
85a1dfe6e0 | ||
|
|
efc7357554 | ||
|
|
503545b203 | ||
|
|
7219a56d1e | ||
|
|
5ea7098e4d | ||
|
|
4ea109ac4d | ||
|
|
6ccef35cc9 | ||
|
|
be4f02335f | ||
|
|
d4bc2b2dfc | ||
|
|
4da581d09b | ||
|
|
b466749e88 | ||
|
|
bcf7ef963f | ||
|
|
2e4a03b2c4 |
||
|
|
bcc4f6bf1f |
||
|
|
890615b3f2 |
||
|
|
959e020297 | ||
|
|
04e8835297 |
||
|
|
b8230694ff |
||
|
|
5c64af6936 | ||
|
|
c3caf36af7 |
||
|
|
7a5adb592a | ||
|
|
a25bf6bea7 | ||
|
|
0f63cb83ed |
||
|
|
7506a89be0 | ||
|
|
48148e66a8 | ||
|
|
2ff4d4a60a | ||
|
|
0b2c6a7ebd | ||
|
|
1fc76fee62 | ||
|
|
c7a4706bcc |
||
|
|
45b88f2056 | ||
|
|
872dae1e1a | ||
|
|
978249beda | ||
|
|
4284c74bc1 |
||
|
|
89c8ca0f3f | ||
|
|
067cc75dfa |
||
|
|
452a587e23 |
||
|
|
4b534b89a5 | ||
|
|
11f7fd38a4 | ||
|
|
a4b401f470 | ||
|
|
3d6d1e3050 |
||
|
|
35deaabcb1 |
||
|
|
4e1188f60f | ||
|
|
85a41987c7 | ||
|
|
d51e63d3bb | ||
|
|
836b1587f0 | ||
|
|
e4f868801a | ||
|
|
f130c7c0a8 | ||
|
|
2da1a6acec | ||
|
|
b7cf0200e2 | ||
|
|
80a9cd9620 | ||
|
|
b0d0a0e5de | ||
|
|
947520c1fe | ||
|
|
10bc805473 | ||
|
|
6763572948 |
||
|
|
b0e5d8afa3 |
||
|
|
6ed7908580 | ||
|
|
f56e043747 | ||
|
|
852f501485 | ||
|
|
16f0b6d822 | ||
|
|
b2ec8717c3 |
||
|
|
a4c96d01b2 | ||
|
|
b645174271 |
||
|
|
c26370485a | ||
|
|
ab040470e2 | ||
|
|
dbfad6d220 | ||
|
|
2200abfa17 | ||
|
|
fbcb103c0c | ||
|
|
e4abae3fd7 |
||
|
|
e86eaaa4f3 |
||
|
|
05707aa16b |
||
|
|
31d5c4ec05 | ||
|
|
fd083e37ec | ||
|
|
98ffad9aed | ||
|
|
9cead33fb9 |
||
|
|
4c3ef03311 |
||
|
|
2caa53a52a |
||
|
|
6bfe104d47 |
||
|
|
30b28c8367 | ||
|
|
bb12229794 | ||
|
|
50da908213 |
||
|
|
a1f3d75a52 |
||
|
|
92b8bf38c0 |
||
|
|
d28f12092d |
||
|
|
2e2825869f | ||
|
|
d8351b08ed | ||
|
|
d9aad1fd04 |
||
|
|
527cec66b0 | ||
|
|
bdf59eb7db | ||
|
|
64fd1d788e | ||
|
|
2ce7872e3b | ||
|
|
17ec309e14 |
||
|
|
01e0558825 |
||
|
|
943df09dcc | ||
|
|
4535568f2c | ||
|
|
33251d04e7 | ||
|
|
a3593c9015 | ||
|
|
4a42476bb7 | ||
|
|
19ab4552e2 | ||
|
|
90cb9ca58d | ||
|
|
856ca68d94 | ||
|
|
e34d09c6ec | ||
|
|
8920d425f4 | ||
|
|
26be9f0445 | ||
|
|
cd57b0f712 | ||
|
|
1377a290cd |
||
|
|
5139c0886a |
||
|
|
adf54f5c80 |
||
|
|
0818182399 | ||
|
|
18dd88ee4d | ||
|
|
dc5171eb1b | ||
|
|
278ac91a4d | ||
|
|
3a51ca9014 |
||
|
|
0f7192b615 | ||
|
|
42ca574720 | ||
|
|
2fd871a906 | ||
|
|
45e6d370ce | ||
|
|
50a6355c08 | ||
|
|
c076fb65e0 | ||
|
|
0183e1a72d | ||
|
|
38fcc96e67 | ||
|
|
3b336d8071 | ||
|
|
d7b21a8623 | ||
|
|
8cd60fd1d8 | ||
|
|
c39d600aef | ||
|
|
99ba051188 | ||
|
|
84b32b447a | ||
|
|
d45a7213ed | ||
|
|
ede6203618 |
||
|
|
d1d78ec0eb |
||
|
|
dda99fc09f |
||
|
|
b49fa446d6 | ||
|
|
9584879534 | ||
|
|
2e43a14da1 | ||
|
|
49184c569c |
||
|
|
d3d16b5ccf |
||
|
|
55c526a537 | ||
|
|
0b0c5cd7a9 | ||
|
|
249fcf8e3e |
||
|
|
5890a20c37 | ||
|
|
4c1e277edb | ||
|
|
30c88e3570 |
||
|
|
bbd5489dbc |
||
|
|
d52402447e |
||
|
|
848a9a420d |
||
|
|
651b78d8e6 | ||
|
|
c025b0180f |
||
|
|
db8cf899e2 |
||
|
|
5c1cfa451d |
||
|
|
3feed1f66e | ||
|
|
d97e82df3c |
||
|
|
56b0758a5f | ||
|
|
25fdbe6b27 | ||
|
|
bd39cb4805 | ||
|
|
1ad92a1d87 | ||
|
|
a53b893c46 |
||
|
|
0b4a286914 | ||
|
|
e4ebef082d |
||
|
|
6a352e99ab |
||
|
|
25a612fe09 | ||
|
|
50fd94e04f | ||
|
|
2c86774179 |
||
|
|
8e70734043 |
||
|
|
4880638f13 |
||
|
|
7dd671310a | ||
|
|
5e672df168 | ||
|
|
7b48664d75 | ||
|
|
0f7c71a86f | ||
|
|
fee658ad05 | ||
|
|
c41278b46f | ||
|
|
adfcec51d6 | ||
|
|
deb5fcbed4 |
||
|
|
572bdb5b80 | ||
|
|
d94a3c4326 |
||
|
|
3c352b7132 | ||
|
|
5bbe2bcc50 | ||
|
|
a2dca62360 | ||
|
|
ca07fff3e2 | ||
|
|
3af313e165 | ||
|
|
994ce46ed4 |
||
|
|
8059c8a27c | ||
|
|
8aa9cf629c | ||
|
|
234230e595 | ||
|
|
1fda4806d4 | ||
|
|
c635f6ebac | ||
|
|
3bd05b854a | ||
|
|
677ba9dddd | ||
|
|
e03aed0002 | ||
|
|
a21c00b54d | ||
|
|
23335e123b | ||
|
|
a27c0a0124 | ||
|
|
0ea139dfe5 | ||
|
|
d1d369456a | ||
|
|
8b73fc6b47 | ||
|
|
63fb750f39 | ||
|
|
89cffcf14c | ||
|
|
9c43b4164d | ||
|
|
0e42444866 | ||
|
|
e70974a4f1 | ||
|
|
42a66c2f04 | ||
|
|
be95359a80 | ||
|
|
ef74d0ff70 | ||
|
|
4a151b15cc | ||
|
|
30f1a0705b | ||
|
|
b998c2793f | ||
|
|
bc88491cb7 | ||
|
|
1335bcb893 | ||
|
|
ebd3358e49 | ||
|
|
d94d363ec0 | ||
|
|
95900b9d02 | ||
|
|
3001eec66a | ||
|
|
425ac4357f | ||
|
|
b077e63dc6 | ||
|
|
5ee954e34b |
||
|
|
013496862f |
||
|
|
0b68996cc5 | ||
|
|
38d28dd958 | ||
|
|
51ee8caa4a | ||
|
|
dc18f62089 | ||
|
|
e054704fb6 | ||
|
|
6e1e815c78 |
||
|
|
8b9d7fdbd8 | ||
|
|
8cac6ff301 | ||
|
|
9ad76d279e | ||
|
|
c094dde3ff | ||
|
|
e238df3959 | ||
|
|
1a3dcf4943 | ||
|
|
420d0a0ee2 | ||
|
|
6e5ab9e7b3 | ||
|
|
d98a8effb1 | ||
|
|
fdf7c27b54 | ||
|
|
d4cc1374f4 | ||
|
|
f84acae98e | ||
|
|
d4b98d3924 |
||
|
|
45979eb723 | ||
|
|
34ad574bac | ||
|
|
a1a372f179 | ||
|
|
260fbb598e | ||
|
|
2aa2adaa8b | ||
|
|
809fad2392 | ||
|
|
c13dada2f8 | ||
|
|
14f1cc4984 | ||
|
|
98eff2cde9 | ||
|
|
e95b490d88 | ||
|
|
9cc1a7c4c8 | ||
|
|
c6a811237c | ||
|
|
3e6a208ba3 | ||
|
|
c5d30b58a1 | ||
|
|
8bf06a76b5 |
||
|
|
e539c1c024 |
||
|
|
bffefc7db0 |
||
|
|
05daa15aac | ||
|
|
34cffff02a | ||
|
|
dee18ed8ce | ||
|
|
9342b60f14 | ||
|
|
6b27537988 | ||
|
|
272982e8a6 |
||
|
|
93ababe6f7 | ||
|
|
cab5b60e09 |
||
|
|
d7e5e3c9f9 | ||
|
|
27efa8c381 | ||
|
|
03f247845e |
||
|
|
e2f71c6f81 |
||
|
|
692fbfc40a | ||
|
|
f3c8da7acd | ||
|
|
99da46f725 | ||
|
|
7fde34cfcb | ||
|
|
9a1536b52a | ||
|
|
31d6a0bc5e | ||
|
|
f0fadc28dd | ||
|
|
418eb7c5c6 |
||
|
|
ec1dde5dd2 |
||
|
|
2cd7ecaa0a | ||
|
|
6bfd71f5c6 | ||
|
|
4c18730e71 | ||
|
|
48725bb4ea | ||
|
|
4ddd77e512 |
||
|
|
8404b21556 | ||
|
|
5518397338 | ||
|
|
6b47734c04 | ||
|
|
9f5321ff1e |
||
|
|
7588d27f4a |
||
|
|
53a8e5bae5 | ||
|
|
4a0bd960e9 | ||
|
|
07aad51176 |
||
|
|
b8fc8e2cd7 |
||
|
|
4d49a5a397 | ||
|
|
6bda225786 | ||
|
|
1154048f79 | ||
|
|
484bef0d3b | ||
|
|
21f8aab531 | ||
|
|
733447d7c7 | ||
|
|
72ac9bf82f | ||
|
|
5be728c2dd | ||
|
|
0fe1619910 | ||
|
|
ee64130fa8 | ||
|
|
c588a89f26 | ||
|
|
b29ccb59c7 | ||
|
|
3ecd131e57 |
||
|
|
63f923d013 | ||
|
|
3db37e9a21 | ||
|
|
83a6872d1b | ||
|
|
52bf222d48 | ||
|
|
98611b3da0 | ||
|
|
22bade4562 | ||
|
|
8494be07ae | ||
|
|
710be684b8 | ||
|
|
b35522c6dd | ||
|
|
b470ab5c41 | ||
|
|
df2cc923c6 | ||
|
|
e15ff2d86e | ||
|
|
3e61a41b9b | ||
|
|
aff7a6985e | ||
|
|
00e233d7a7 | ||
|
|
ae11fa5887 |
||
|
|
6f610e1d94 | ||
|
|
eac028d3f7 | ||
|
|
3652b7472a | ||
|
|
f156bf9e6b | ||
|
|
187d91d686 | ||
|
|
518fc63224 | ||
|
|
575a29c424 | ||
|
|
264d0ab471 | ||
|
|
65521f03db | ||
|
|
612da8eae6 | ||
|
|
db796771e2 | ||
|
|
c603faac5b | ||
|
|
ca66ea57d2 | ||
|
|
51d60d7ddf | ||
|
|
f832435b88 | ||
|
|
fa9cc9efaf | ||
|
|
26262d08f3 | ||
|
|
aacf25cf19 |
||
|
|
bcc781f4c5 | ||
|
|
fb8b6b2311 | ||
|
|
2355067ef5 | ||
|
|
bb030ba46f | ||
|
|
c51d9246b9 | ||
|
|
9b5a73ba4c | ||
|
|
719e757252 | ||
|
|
000eeb4464 | ||
|
|
042881a522 | ||
|
|
0b166befc0 | ||
|
|
497290beaf | ||
|
|
9bec7c38eb | ||
|
|
93a02281da | ||
|
|
00632ded30 | ||
|
|
2865d3956f | ||
|
|
4f16e14d7a | ||
|
|
fedbfcc368 | ||
|
|
9eb9ffae3d | ||
|
|
f6bf2d8045 | ||
|
|
c35859ae3d | ||
|
|
c9b5f5d598 | ||
|
|
61171f0154 | ||
|
|
26af9b9c4a | ||
|
|
641bc4453b | ||
|
|
2ea60e12d9 | ||
|
|
6e788b49ed | ||
|
|
a2a5dff709 | ||
|
|
a51608090b | ||
|
|
6958e21b5c | ||
|
|
b597bb6b3e | ||
|
|
918f356120 | ||
|
|
51c436fed2 | ||
|
|
382a871583 | ||
|
|
af5d5d0243 | ||
|
|
55f860c304 | ||
|
|
c7956eed77 | ||
|
|
c556fad65d | ||
|
|
c36a74ece1 | ||
|
|
c23fa850e7 | ||
|
|
0f013ff497 | ||
|
|
b29e487bc3 | ||
|
|
7ab091e8ef | ||
|
|
68ccb7578b | ||
|
|
42f8b402e6 | ||
|
|
f9ae92b377 | ||
|
|
05b479224f | ||
|
|
6d085af28c | ||
|
|
02ae1a0029 | ||
|
|
83adf55b2d |
198 changed files with 32950 additions and 5519 deletions
10
.github/dependabot.yml
vendored
10
.github/dependabot.yml
vendored
|
|
@ -5,9 +5,7 @@ updates:
|
|||
schedule:
|
||||
interval: daily
|
||||
time: "13:00"
|
||||
open-pull-requests-limit: 10
|
||||
ignore:
|
||||
- dependency-name: black
|
||||
versions:
|
||||
- 21.4b0
|
||||
- 21.4b1
|
||||
groups:
|
||||
python-packages:
|
||||
patterns:
|
||||
- "*"
|
||||
|
|
|
|||
35
.github/workflows/deploy-branch-preview.yml
vendored
Normal file
35
.github/workflows/deploy-branch-preview.yml
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
name: Deploy a Datasette branch preview to Vercel
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: "Branch to deploy"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
deploy-branch-preview:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install datasette-publish-vercel
|
||||
- name: Deploy the preview
|
||||
env:
|
||||
VERCEL_TOKEN: ${{ secrets.BRANCH_PREVIEW_VERCEL_TOKEN }}
|
||||
run: |
|
||||
export BRANCH="${{ github.event.inputs.branch }}"
|
||||
wget https://latest.datasette.io/fixtures.db
|
||||
datasette publish vercel fixtures.db \
|
||||
--branch $BRANCH \
|
||||
--project "datasette-preview-$BRANCH" \
|
||||
--token $VERCEL_TOKEN \
|
||||
--scope datasette \
|
||||
--about "Preview of $BRANCH" \
|
||||
--about_url "https://github.com/simonw/datasette/tree/$BRANCH"
|
||||
88
.github/workflows/deploy-latest.yml
vendored
88
.github/workflows/deploy-latest.yml
vendored
|
|
@ -1,9 +1,11 @@
|
|||
name: Deploy latest.datasette.io
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- main
|
||||
# - 1.0-dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -13,18 +15,12 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out datasette
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v5
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- uses: actions/cache@v3
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
python-version: "3.13"
|
||||
cache: pip
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
|
|
@ -36,13 +32,19 @@ jobs:
|
|||
run: |
|
||||
pytest -n auto -m "not serial"
|
||||
pytest -m "serial"
|
||||
- name: Build fixtures.db
|
||||
run: python tests/fixtures.py fixtures.db fixtures.json plugins --extra-db-filename extra_database.db
|
||||
- name: Build fixtures.db and other files needed to deploy the demo
|
||||
run: |-
|
||||
python tests/fixtures.py \
|
||||
fixtures.db \
|
||||
fixtures-config.json \
|
||||
fixtures-metadata.json \
|
||||
plugins \
|
||||
--extra-db-filename extra_database.db
|
||||
- name: Build docs.db
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
run: |-
|
||||
cd docs
|
||||
sphinx-build -b xml . _build
|
||||
DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build
|
||||
sphinx-to-sqlite ../docs.db _build
|
||||
cd ..
|
||||
- name: Set up the alternate-route demo
|
||||
|
|
@ -56,25 +58,69 @@ jobs:
|
|||
db.route = "alternative-route"
|
||||
' > plugins/alternative_route.py
|
||||
cp fixtures.db fixtures2.db
|
||||
- name: Set up Cloud Run
|
||||
uses: google-github-actions/setup-gcloud@v0
|
||||
- name: And the counters writable canned query demo
|
||||
run: |
|
||||
cat > plugins/counters.py <<EOF
|
||||
from datasette import hookimpl
|
||||
@hookimpl
|
||||
def startup(datasette):
|
||||
db = datasette.add_memory_database("counters")
|
||||
async def inner():
|
||||
await db.execute_write("create table if not exists counters (name text primary key, value integer)")
|
||||
await db.execute_write("insert or ignore into counters (name, value) values ('counter_a', 0)")
|
||||
await db.execute_write("insert or ignore into counters (name, value) values ('counter_b', 0)")
|
||||
await db.execute_write("insert or ignore into counters (name, value) values ('counter_c', 0)")
|
||||
return inner
|
||||
@hookimpl
|
||||
def canned_queries(database):
|
||||
if database == "counters":
|
||||
queries = {}
|
||||
for name in ("counter_a", "counter_b", "counter_c"):
|
||||
queries["increment_{}".format(name)] = {
|
||||
"sql": "update counters set value = value + 1 where name = '{}'".format(name),
|
||||
"on_success_message_sql": "select 'Counter {name} incremented to ' || value from counters where name = '{name}'".format(name=name),
|
||||
"write": True,
|
||||
}
|
||||
queries["decrement_{}".format(name)] = {
|
||||
"sql": "update counters set value = value - 1 where name = '{}'".format(name),
|
||||
"on_success_message_sql": "select 'Counter {name} decremented to ' || value from counters where name = '{name}'".format(name=name),
|
||||
"write": True,
|
||||
}
|
||||
return queries
|
||||
EOF
|
||||
# - name: Make some modifications to metadata.json
|
||||
# run: |
|
||||
# cat fixtures.json | \
|
||||
# jq '.databases |= . + {"ephemeral": {"allow": {"id": "*"}}}' | \
|
||||
# jq '.plugins |= . + {"datasette-ephemeral-tables": {"table_ttl": 900}}' \
|
||||
# > metadata.json
|
||||
# cat metadata.json
|
||||
- id: auth
|
||||
name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v3
|
||||
with:
|
||||
version: '275.0.0'
|
||||
service_account_email: ${{ secrets.GCP_SA_EMAIL }}
|
||||
service_account_key: ${{ secrets.GCP_SA_KEY }}
|
||||
credentials_json: ${{ secrets.GCP_SA_KEY }}
|
||||
- name: Set up Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@v3
|
||||
- name: Deploy to Cloud Run
|
||||
env:
|
||||
LATEST_DATASETTE_SECRET: ${{ secrets.LATEST_DATASETTE_SECRET }}
|
||||
run: |-
|
||||
gcloud config set run/region us-central1
|
||||
gcloud config set project datasette-222320
|
||||
export SUFFIX="-${GITHUB_REF#refs/heads/}"
|
||||
export SUFFIX=${SUFFIX#-main}
|
||||
# Replace 1.0 with one-dot-zero in SUFFIX
|
||||
export SUFFIX=${SUFFIX//1.0/one-dot-zero}
|
||||
datasette publish cloudrun fixtures.db fixtures2.db extra_database.db \
|
||||
-m fixtures.json \
|
||||
-m fixtures-metadata.json \
|
||||
--plugins-dir=plugins \
|
||||
--branch=$GITHUB_SHA \
|
||||
--version-note=$GITHUB_SHA \
|
||||
--extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb" \
|
||||
--service "datasette-latest$SUFFIX"
|
||||
--install 'datasette-ephemeral-tables>=0.2.2' \
|
||||
--service "datasette-latest$SUFFIX" \
|
||||
--secret $LATEST_DATASETTE_SECRET
|
||||
- name: Deploy to docs as well (only for main)
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
run: |-
|
||||
|
|
|
|||
4
.github/workflows/prettier.yml
vendored
4
.github/workflows/prettier.yml
vendored
|
|
@ -10,8 +10,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v2
|
||||
- uses: actions/cache@v2
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/cache@v4
|
||||
name: Configure npm caching
|
||||
with:
|
||||
path: ~/.npm
|
||||
|
|
|
|||
73
.github/workflows/publish.yml
vendored
73
.github/workflows/publish.yml
vendored
|
|
@ -12,20 +12,15 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: actions/cache@v3
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -e '.[test]'
|
||||
|
|
@ -36,47 +31,38 @@ jobs:
|
|||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test]
|
||||
environment: release
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/cache@v3
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-publish-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-publish-pip-
|
||||
python-version: '3.13'
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install setuptools wheel twine
|
||||
- name: Publish
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
|
||||
pip install setuptools wheel build
|
||||
- name: Build
|
||||
run: |
|
||||
python setup.py sdist bdist_wheel
|
||||
twine upload dist/*
|
||||
python -m build
|
||||
- name: Publish
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
|
||||
deploy_static_docs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [deploy]
|
||||
if: "!github.event.release.prerelease"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- uses: actions/cache@v2
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-publish-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-publish-pip-
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install -e .[docs]
|
||||
|
|
@ -84,15 +70,16 @@ jobs:
|
|||
- name: Build docs.db
|
||||
run: |-
|
||||
cd docs
|
||||
sphinx-build -b xml . _build
|
||||
DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build
|
||||
sphinx-to-sqlite ../docs.db _build
|
||||
cd ..
|
||||
- name: Set up Cloud Run
|
||||
uses: google-github-actions/setup-gcloud@v0
|
||||
- id: auth
|
||||
name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
version: '275.0.0'
|
||||
service_account_email: ${{ secrets.GCP_SA_EMAIL }}
|
||||
service_account_key: ${{ secrets.GCP_SA_KEY }}
|
||||
credentials_json: ${{ secrets.GCP_SA_KEY }}
|
||||
- name: Set up Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@v3
|
||||
- name: Deploy stable-docs.datasette.io to Cloud Run
|
||||
run: |-
|
||||
gcloud config set run/region us-central1
|
||||
|
|
@ -105,7 +92,7 @@ jobs:
|
|||
needs: [deploy]
|
||||
if: "!github.event.release.prerelease"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and push to Docker Hub
|
||||
env:
|
||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||
|
|
|
|||
19
.github/workflows/spellcheck.yml
vendored
19
.github/workflows/spellcheck.yml
vendored
|
|
@ -9,22 +9,19 @@ jobs:
|
|||
spellcheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.9
|
||||
- uses: actions/cache@v2
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: '**/pyproject.toml'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -e '.[docs]'
|
||||
- name: Check spelling
|
||||
run: |
|
||||
codespell README.md --ignore-words docs/codespell-ignore-words.txt
|
||||
codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt
|
||||
codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt
|
||||
codespell tests --ignore-words docs/codespell-ignore-words.txt
|
||||
|
|
|
|||
76
.github/workflows/stable-docs.yml
vendored
Normal file
76
.github/workflows/stable-docs.yml
vendored
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
name: Update Stable Docs
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update_stable_docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0 # We need all commits to find docs/ changes
|
||||
- name: Set up Git user
|
||||
run: |
|
||||
git config user.name "Automated"
|
||||
git config user.email "actions@users.noreply.github.com"
|
||||
- name: Create stable branch if it does not yet exist
|
||||
run: |
|
||||
if ! git ls-remote --heads origin stable | grep -qE '\bstable\b'; then
|
||||
# Make sure we have all tags locally
|
||||
git fetch --tags --quiet
|
||||
|
||||
# Latest tag that is just numbers and dots (optionally prefixed with 'v')
|
||||
# e.g., 0.65.2 or v0.65.2 — excludes 1.0a20, 1.0-rc1, etc.
|
||||
LATEST_RELEASE=$(
|
||||
git tag -l --sort=-v:refname \
|
||||
| grep -E '^v?[0-9]+(\.[0-9]+){1,3}$' \
|
||||
| head -n1
|
||||
)
|
||||
|
||||
git checkout -b stable
|
||||
|
||||
# If there are any stable releases, copy docs/ from the most recent
|
||||
if [ -n "$LATEST_RELEASE" ]; then
|
||||
rm -rf docs/
|
||||
git checkout "$LATEST_RELEASE" -- docs/ || true
|
||||
fi
|
||||
|
||||
git commit -m "Populate docs/ from $LATEST_RELEASE" || echo "No changes"
|
||||
git push -u origin stable
|
||||
fi
|
||||
- name: Handle Release
|
||||
if: github.event_name == 'release' && !github.event.release.prerelease
|
||||
run: |
|
||||
git fetch --all
|
||||
git checkout stable
|
||||
git reset --hard ${GITHUB_REF#refs/tags/}
|
||||
git push origin stable --force
|
||||
- name: Handle Commit to Main
|
||||
if: contains(github.event.head_commit.message, '!stable-docs')
|
||||
run: |
|
||||
git fetch origin
|
||||
git checkout -b stable origin/stable
|
||||
# Get the list of modified files in docs/ from the current commit
|
||||
FILES=$(git diff-tree --no-commit-id --name-only -r ${{ github.sha }} -- docs/)
|
||||
# Check if the list of files is non-empty
|
||||
if [[ -n "$FILES" ]]; then
|
||||
# Checkout those files to the stable branch to over-write with their contents
|
||||
for FILE in $FILES; do
|
||||
git checkout ${{ github.sha }} -- $FILE
|
||||
done
|
||||
git add docs/
|
||||
git commit -m "Doc changes from ${{ github.sha }}"
|
||||
git push origin stable
|
||||
else
|
||||
echo "No changes to docs/ in this commit."
|
||||
exit 0
|
||||
fi
|
||||
17
.github/workflows/test-coverage.yml
vendored
17
.github/workflows/test-coverage.yml
vendored
|
|
@ -15,18 +15,13 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out datasette
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.9
|
||||
- uses: actions/cache@v2
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: '**/pyproject.toml'
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
|
|
@ -36,7 +31,7 @@ jobs:
|
|||
run: |-
|
||||
ls -lah
|
||||
cat .coveragerc
|
||||
pytest --cov=datasette --cov-config=.coveragerc --cov-report xml:coverage.xml --cov-report term
|
||||
pytest -m "not serial" --cov=datasette --cov-config=.coveragerc --cov-report xml:coverage.xml --cov-report term -x
|
||||
ls -lah
|
||||
- name: Upload coverage report
|
||||
uses: codecov/codecov-action@v1
|
||||
|
|
|
|||
8
.github/workflows/test-pyodide.yml
vendored
8
.github/workflows/test-pyodide.yml
vendored
|
|
@ -12,15 +12,15 @@ jobs:
|
|||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: 'pip'
|
||||
cache-dependency-path: '**/setup.py'
|
||||
cache-dependency-path: '**/pyproject.toml'
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright/
|
||||
key: ${{ runner.os }}-browsers
|
||||
|
|
|
|||
53
.github/workflows/test-sqlite-support.yml
vendored
Normal file
53
.github/workflows/test-sqlite-support.yml
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
name: Test SQLite versions
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ${{ matrix.platform }}
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ubuntu-latest]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
sqlite-version: [
|
||||
#"3", # latest version
|
||||
"3.46",
|
||||
#"3.45",
|
||||
#"3.27",
|
||||
#"3.26",
|
||||
"3.25",
|
||||
#"3.25.3", # 2018-09-25, window functions breaks test_upsert for some reason on 3.10, skip for now
|
||||
#"3.24", # 2018-06-04, added UPSERT support
|
||||
#"3.23.1" # 2018-04-10, before UPSERT
|
||||
]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
allow-prereleases: true
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- name: Set up SQLite ${{ matrix.sqlite-version }}
|
||||
uses: asg017/sqlite-versions@71ea0de37ae739c33e447af91ba71dda8fcf22e6
|
||||
with:
|
||||
version: ${{ matrix.sqlite-version }}
|
||||
cflags: "-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1"
|
||||
- run: python3 -c "import sqlite3; print(sqlite3.sqlite_version)"
|
||||
- run: echo $LD_LIBRARY_PATH
|
||||
- name: Build extension for --load-extension test
|
||||
run: |-
|
||||
(cd tests && gcc ext.c -fPIC -shared -o ext.so)
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -e '.[test]'
|
||||
pip freeze
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest -n auto -m "not serial"
|
||||
pytest -m "serial"
|
||||
27
.github/workflows/test.yml
vendored
27
.github/workflows/test.yml
vendored
|
|
@ -10,20 +10,16 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: actions/cache@v3
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
allow-prereleases: true
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- name: Build extension for --load-extension test
|
||||
run: |-
|
||||
(cd tests && gcc ext.c -fPIC -shared -o ext.so)
|
||||
|
|
@ -35,6 +31,13 @@ jobs:
|
|||
run: |
|
||||
pytest -n auto -m "not serial"
|
||||
pytest -m "serial"
|
||||
# And the test that exceeds a localhost HTTPS server
|
||||
tests/test_datasette_https_server.sh
|
||||
- name: Install docs dependencies
|
||||
run: |
|
||||
pip install -e '.[docs]'
|
||||
- name: Black
|
||||
run: black --check .
|
||||
- name: Check if cog needs to be run
|
||||
run: |
|
||||
cog --check docs/*.rst
|
||||
|
|
@ -42,3 +45,7 @@ jobs:
|
|||
run: |
|
||||
# This fails on syntax errors, or a diff was applied
|
||||
blacken-docs -l 60 docs/*.rst
|
||||
- name: Test DATASETTE_LOAD_PLUGINS
|
||||
run: |
|
||||
pip install datasette-init datasette-json-html
|
||||
tests/test-datasette-load-plugins.sh
|
||||
|
|
|
|||
3
.github/workflows/tmate.yml
vendored
3
.github/workflows/tmate.yml
vendored
|
|
@ -5,6 +5,7 @@ on:
|
|||
|
||||
permissions:
|
||||
contents: read
|
||||
models: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
@ -13,3 +14,5 @@ jobs:
|
|||
- uses: actions/checkout@v2
|
||||
- name: Setup tmate session
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -5,6 +5,9 @@ scratchpad
|
|||
|
||||
.vscode
|
||||
|
||||
uv.lock
|
||||
data.db
|
||||
|
||||
# We don't use Pipfile, so ignore them
|
||||
Pipfile
|
||||
Pipfile.lock
|
||||
|
|
@ -123,4 +126,4 @@ node_modules
|
|||
# include it in source control.
|
||||
tests/*.dylib
|
||||
tests/*.so
|
||||
tests/*.dll
|
||||
tests/*.dll
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ version: 2
|
|||
build:
|
||||
os: ubuntu-20.04
|
||||
tools:
|
||||
python: "3.9"
|
||||
python: "3.11"
|
||||
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
|
|
|
|||
56
Justfile
Normal file
56
Justfile
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
export DATASETTE_SECRET := "not_a_secret"
|
||||
|
||||
# Run tests and linters
|
||||
@default: test lint
|
||||
|
||||
# Setup project
|
||||
@init:
|
||||
uv sync --extra test --extra docs
|
||||
|
||||
# Run pytest with supplied options
|
||||
@test *options: init
|
||||
uv run pytest -n auto {{options}}
|
||||
|
||||
@codespell:
|
||||
uv run codespell README.md --ignore-words docs/codespell-ignore-words.txt
|
||||
uv run codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt
|
||||
uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt
|
||||
uv run codespell tests --ignore-words docs/codespell-ignore-words.txt
|
||||
|
||||
# Run linters: black, flake8, mypy, cog
|
||||
@lint: codespell
|
||||
uv run black . --check
|
||||
uv run flake8
|
||||
uv run --extra test cog --check README.md docs/*.rst
|
||||
|
||||
# Rebuild docs with cog
|
||||
@cog:
|
||||
uv run --extra test cog -r README.md docs/*.rst
|
||||
|
||||
# Serve live docs on localhost:8000
|
||||
@docs: cog blacken-docs
|
||||
uv run --extra docs make -C docs livehtml
|
||||
|
||||
# Build docs as static HTML
|
||||
@docs-build: cog blacken-docs
|
||||
rm -rf docs/_build && cd docs && uv run make html
|
||||
|
||||
# Apply Black
|
||||
@black:
|
||||
uv run black .
|
||||
|
||||
# Apply blacken-docs
|
||||
@blacken-docs:
|
||||
uv run blacken-docs -l 60 docs/*.rst
|
||||
|
||||
# Apply prettier
|
||||
@prettier:
|
||||
npm run fix
|
||||
|
||||
# Format code with both black and prettier
|
||||
@format: black prettier blacken-docs
|
||||
|
||||
@serve *options:
|
||||
uv run sqlite-utils create-database data.db
|
||||
uv run sqlite-utils create-table data.db docs id integer title text --pk id --ignore
|
||||
uv run python -m datasette data.db --root --reload {{options}}
|
||||
10
README.md
10
README.md
|
|
@ -1,13 +1,13 @@
|
|||
<img src="https://datasette.io/static/datasette-logo.svg" alt="Datasette">
|
||||
|
||||
[](https://pypi.org/project/datasette/)
|
||||
[](https://docs.datasette.io/en/stable/changelog.html)
|
||||
[](https://docs.datasette.io/en/latest/changelog.html)
|
||||
[](https://pypi.org/project/datasette/)
|
||||
[](https://github.com/simonw/datasette/actions?query=workflow%3ATest)
|
||||
[](https://docs.datasette.io/en/latest/?badge=latest)
|
||||
[](https://github.com/simonw/datasette/blob/main/LICENSE)
|
||||
[](https://hub.docker.com/r/datasetteproject/datasette)
|
||||
[](https://discord.gg/ktd74dm5mw)
|
||||
[](https://datasette.io/discord)
|
||||
|
||||
*An open source multi-tool for exploring and publishing data*
|
||||
|
||||
|
|
@ -15,14 +15,14 @@ Datasette is a tool for exploring and publishing data. It helps people take data
|
|||
|
||||
Datasette is aimed at data journalists, museum curators, archivists, local governments, scientists, researchers and anyone else who has data that they wish to share with the world.
|
||||
|
||||
[Explore a demo](https://global-power-plants.datasettes.com/global-power-plants/global-power-plants), watch [a video about the project](https://simonwillison.net/2021/Feb/7/video/) or try it out by [uploading and publishing your own CSV data](https://docs.datasette.io/en/stable/getting_started.html#try-datasette-without-installing-anything-using-glitch).
|
||||
[Explore a demo](https://datasette.io/global-power-plants/global-power-plants), watch [a video about the project](https://simonwillison.net/2021/Feb/7/video/) or try it out [on GitHub Codespaces](https://github.com/datasette/datasette-studio).
|
||||
|
||||
* [datasette.io](https://datasette.io/) is the official project website
|
||||
* Latest [Datasette News](https://datasette.io/news)
|
||||
* Comprehensive documentation: https://docs.datasette.io/
|
||||
* Examples: https://datasette.io/examples
|
||||
* Live demo of current `main` branch: https://latest.datasette.io/
|
||||
* Questions, feedback or want to talk about the project? Join our [Discord](https://discord.gg/ktd74dm5mw)
|
||||
* Questions, feedback or want to talk about the project? Join our [Discord](https://datasette.io/discord)
|
||||
|
||||
Want to stay up-to-date with the project? Subscribe to the [Datasette newsletter](https://datasette.substack.com/) for tips, tricks and news on what's new in the Datasette ecosystem.
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ You can also install it using `pip` or `pipx`:
|
|||
|
||||
pip install datasette
|
||||
|
||||
Datasette requires Python 3.7 or higher. We also have [detailed installation instructions](https://docs.datasette.io/en/stable/installation.html) covering other options such as Docker.
|
||||
Datasette requires Python 3.8 or higher. We also have [detailed installation instructions](https://docs.datasette.io/en/stable/installation.html) covering other options such as Docker.
|
||||
|
||||
## Basic usage
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
from datasette.permissions import Permission # noqa
|
||||
from datasette.version import __version_info__, __version__ # noqa
|
||||
from datasette.events import Event # noqa
|
||||
from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa
|
||||
from datasette.utils import actor_matches_allow # noqa
|
||||
from datasette.views import Context # noqa
|
||||
from .hookspecs import hookimpl # noqa
|
||||
from .hookspecs import hookspec # noqa
|
||||
|
|
|
|||
1536
datasette/app.py
1536
datasette/app.py
File diff suppressed because it is too large
Load diff
445
datasette/cli.py
445
datasette/cli.py
|
|
@ -4,16 +4,17 @@ import click
|
|||
from click import formatting
|
||||
from click.types import CompositeParamType
|
||||
from click_default_group import DefaultGroup
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
from runpy import run_module
|
||||
import shutil
|
||||
from subprocess import call
|
||||
import sys
|
||||
from runpy import run_module
|
||||
import textwrap
|
||||
import webbrowser
|
||||
from .app import (
|
||||
OBSOLETE_SETTINGS,
|
||||
Datasette,
|
||||
DEFAULT_SETTINGS,
|
||||
SETTINGS,
|
||||
|
|
@ -24,11 +25,13 @@ from .utils import (
|
|||
LoadExtension,
|
||||
StartupError,
|
||||
check_connection,
|
||||
deep_dict_update,
|
||||
find_spatialite,
|
||||
parse_metadata,
|
||||
ConnectionProblem,
|
||||
SpatialiteConnectionProblem,
|
||||
initial_path_for_datasette,
|
||||
pairs_to_nested_config,
|
||||
temporary_docker_directory,
|
||||
value_as_boolean,
|
||||
SpatialiteNotFound,
|
||||
|
|
@ -39,6 +42,18 @@ from .utils.sqlite import sqlite3
|
|||
from .utils.testing import TestClient
|
||||
from .version import __version__
|
||||
|
||||
|
||||
def run_sync(coro_func):
|
||||
"""Run an async callable to completion on a fresh event loop."""
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(coro_func())
|
||||
finally:
|
||||
asyncio.set_event_loop(None)
|
||||
loop.close()
|
||||
|
||||
|
||||
# Use Rich for tracebacks if it is installed
|
||||
try:
|
||||
from rich.traceback import install
|
||||
|
|
@ -48,93 +63,65 @@ except ImportError:
|
|||
pass
|
||||
|
||||
|
||||
class Config(click.ParamType):
|
||||
# This will be removed in Datasette 1.0 in favour of class Setting
|
||||
name = "config"
|
||||
|
||||
def convert(self, config, param, ctx):
|
||||
if ":" not in config:
|
||||
self.fail(f'"{config}" should be name:value', param, ctx)
|
||||
return
|
||||
name, value = config.split(":", 1)
|
||||
if name not in DEFAULT_SETTINGS:
|
||||
msg = (
|
||||
OBSOLETE_SETTINGS.get(name)
|
||||
or f"{name} is not a valid option (--help-settings to see all)"
|
||||
)
|
||||
self.fail(
|
||||
msg,
|
||||
param,
|
||||
ctx,
|
||||
)
|
||||
return
|
||||
# Type checking
|
||||
default = DEFAULT_SETTINGS[name]
|
||||
if isinstance(default, bool):
|
||||
try:
|
||||
return name, value_as_boolean(value)
|
||||
except ValueAsBooleanError:
|
||||
self.fail(f'"{name}" should be on/off/true/false/1/0', param, ctx)
|
||||
return
|
||||
elif isinstance(default, int):
|
||||
if not value.isdigit():
|
||||
self.fail(f'"{name}" should be an integer', param, ctx)
|
||||
return
|
||||
return name, int(value)
|
||||
elif isinstance(default, str):
|
||||
return name, value
|
||||
else:
|
||||
# Should never happen:
|
||||
self.fail("Invalid option")
|
||||
|
||||
|
||||
class Setting(CompositeParamType):
|
||||
name = "setting"
|
||||
arity = 2
|
||||
|
||||
def convert(self, config, param, ctx):
|
||||
name, value = config
|
||||
if name not in DEFAULT_SETTINGS:
|
||||
msg = (
|
||||
OBSOLETE_SETTINGS.get(name)
|
||||
or f"{name} is not a valid option (--help-settings to see all)"
|
||||
)
|
||||
self.fail(
|
||||
msg,
|
||||
param,
|
||||
ctx,
|
||||
)
|
||||
return
|
||||
# Type checking
|
||||
default = DEFAULT_SETTINGS[name]
|
||||
if isinstance(default, bool):
|
||||
try:
|
||||
return name, value_as_boolean(value)
|
||||
except ValueAsBooleanError:
|
||||
self.fail(f'"{name}" should be on/off/true/false/1/0', param, ctx)
|
||||
return
|
||||
elif isinstance(default, int):
|
||||
if not value.isdigit():
|
||||
self.fail(f'"{name}" should be an integer', param, ctx)
|
||||
return
|
||||
return name, int(value)
|
||||
elif isinstance(default, str):
|
||||
return name, value
|
||||
else:
|
||||
# Should never happen:
|
||||
self.fail("Invalid option")
|
||||
if name in DEFAULT_SETTINGS:
|
||||
# For backwards compatibility with how this worked prior to
|
||||
# Datasette 1.0, we turn bare setting names into setting.name
|
||||
# Type checking for those older settings
|
||||
default = DEFAULT_SETTINGS[name]
|
||||
name = "settings.{}".format(name)
|
||||
if isinstance(default, bool):
|
||||
try:
|
||||
return name, "true" if value_as_boolean(value) else "false"
|
||||
except ValueAsBooleanError:
|
||||
self.fail(f'"{name}" should be on/off/true/false/1/0', param, ctx)
|
||||
elif isinstance(default, int):
|
||||
if not value.isdigit():
|
||||
self.fail(f'"{name}" should be an integer', param, ctx)
|
||||
return name, value
|
||||
elif isinstance(default, str):
|
||||
return name, value
|
||||
else:
|
||||
# Should never happen:
|
||||
self.fail("Invalid option")
|
||||
return name, value
|
||||
|
||||
|
||||
def sqlite_extensions(fn):
|
||||
return click.option(
|
||||
fn = click.option(
|
||||
"sqlite_extensions",
|
||||
"--load-extension",
|
||||
type=LoadExtension(),
|
||||
envvar="SQLITE_EXTENSIONS",
|
||||
envvar="DATASETTE_LOAD_EXTENSION",
|
||||
multiple=True,
|
||||
help="Path to a SQLite extension to load, and optional entrypoint",
|
||||
)(fn)
|
||||
|
||||
# Wrap it in a custom error handler
|
||||
@functools.wraps(fn)
|
||||
def wrapped(*args, **kwargs):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except AttributeError as e:
|
||||
if "enable_load_extension" in str(e):
|
||||
raise click.ClickException(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
Your Python installation does not have the ability to load SQLite extensions.
|
||||
|
||||
More information: https://datasette.io/help/extensions
|
||||
"""
|
||||
).strip()
|
||||
)
|
||||
raise
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
@click.group(cls=DefaultGroup, default="serve", default_if_no_args=True)
|
||||
@click.version_option(version=__version__)
|
||||
|
|
@ -159,9 +146,7 @@ def inspect(files, inspect_file, sqlite_extensions):
|
|||
This can then be passed to "datasette --inspect-file" to speed up count
|
||||
operations against immutable database files.
|
||||
"""
|
||||
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
|
||||
loop = asyncio.get_event_loop()
|
||||
inspect_data = loop.run_until_complete(inspect_(files, sqlite_extensions))
|
||||
inspect_data = run_sync(lambda: inspect_(files, sqlite_extensions))
|
||||
if inspect_file == "-":
|
||||
sys.stdout.write(json.dumps(inspect_data, indent=2))
|
||||
else:
|
||||
|
|
@ -173,9 +158,6 @@ async def inspect_(files, sqlite_extensions):
|
|||
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
|
||||
data = {}
|
||||
for name, database in app.databases.items():
|
||||
if name == "_internal":
|
||||
# Don't include the in-memory _internal database
|
||||
continue
|
||||
counts = await database.table_counts(limit=3600 * 1000)
|
||||
data[name] = {
|
||||
"hash": database.hash,
|
||||
|
|
@ -201,15 +183,23 @@ pm.hook.publish_subcommand(publish=publish)
|
|||
|
||||
@cli.command()
|
||||
@click.option("--all", help="Include built-in default plugins", is_flag=True)
|
||||
@click.option(
|
||||
"--requirements", help="Output requirements.txt of installed plugins", is_flag=True
|
||||
)
|
||||
@click.option(
|
||||
"--plugins-dir",
|
||||
type=click.Path(exists=True, file_okay=False, dir_okay=True),
|
||||
help="Path to directory containing custom plugins",
|
||||
)
|
||||
def plugins(all, plugins_dir):
|
||||
def plugins(all, requirements, plugins_dir):
|
||||
"""List currently installed plugins"""
|
||||
app = Datasette([], plugins_dir=plugins_dir)
|
||||
click.echo(json.dumps(app._plugins(all=all), indent=4))
|
||||
if requirements:
|
||||
for plugin in app._plugins():
|
||||
if plugin["version"]:
|
||||
click.echo("{}=={}".format(plugin["name"], plugin["version"]))
|
||||
else:
|
||||
click.echo(json.dumps(app._plugins(all=all), indent=4))
|
||||
|
||||
|
||||
@cli.command()
|
||||
|
|
@ -319,15 +309,32 @@ def package(
|
|||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("packages", nargs=-1, required=True)
|
||||
@click.argument("packages", nargs=-1)
|
||||
@click.option(
|
||||
"-U", "--upgrade", is_flag=True, help="Upgrade packages to latest version"
|
||||
)
|
||||
def install(packages, upgrade):
|
||||
@click.option(
|
||||
"-r",
|
||||
"--requirement",
|
||||
type=click.Path(exists=True),
|
||||
help="Install from requirements file",
|
||||
)
|
||||
@click.option(
|
||||
"-e",
|
||||
"--editable",
|
||||
help="Install a project in editable mode from this path",
|
||||
)
|
||||
def install(packages, upgrade, requirement, editable):
|
||||
"""Install plugins and packages from PyPI into the same environment as Datasette"""
|
||||
if not packages and not requirement and not editable:
|
||||
raise click.UsageError("Please specify at least one package to install")
|
||||
args = ["pip", "install"]
|
||||
if upgrade:
|
||||
args += ["--upgrade"]
|
||||
if editable:
|
||||
args += ["--editable", editable]
|
||||
if requirement:
|
||||
args += ["-r", requirement]
|
||||
args += list(packages)
|
||||
sys.argv = args
|
||||
run_module("pip", run_name="__main__")
|
||||
|
|
@ -408,16 +415,17 @@ def uninstall(packages, yes):
|
|||
)
|
||||
@click.option("--memory", is_flag=True, help="Make /_memory database available")
|
||||
@click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
type=Config(),
|
||||
help="Deprecated: set config option using configname:value. Use --setting instead.",
|
||||
multiple=True,
|
||||
type=click.File(mode="r"),
|
||||
help="Path to JSON/YAML Datasette configuration file",
|
||||
)
|
||||
@click.option(
|
||||
"-s",
|
||||
"--setting",
|
||||
"settings",
|
||||
type=Setting(),
|
||||
help="Setting, see docs.datasette.io/en/stable/settings.html",
|
||||
help="nested.key, value setting to use in Datasette configuration",
|
||||
multiple=True,
|
||||
)
|
||||
@click.option(
|
||||
|
|
@ -430,10 +438,28 @@ def uninstall(packages, yes):
|
|||
help="Output URL that sets a cookie authenticating the root user",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--default-deny",
|
||||
help="Deny all permissions by default",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--get",
|
||||
help="Run an HTTP GET request against this path, print results and exit",
|
||||
)
|
||||
@click.option(
|
||||
"--headers",
|
||||
is_flag=True,
|
||||
help="Include HTTP headers in --get output",
|
||||
)
|
||||
@click.option(
|
||||
"--token",
|
||||
help="API token to send with --get requests",
|
||||
)
|
||||
@click.option(
|
||||
"--actor",
|
||||
help="Actor to use for --get requests (JSON string)",
|
||||
)
|
||||
@click.option("--version-note", help="Additional note to show on /-/versions")
|
||||
@click.option("--help-settings", is_flag=True, help="Show available settings")
|
||||
@click.option("--pdb", is_flag=True, help="Launch debugger on any errors")
|
||||
|
|
@ -462,10 +488,17 @@ def uninstall(packages, yes):
|
|||
@click.option(
|
||||
"--ssl-keyfile",
|
||||
help="SSL key file",
|
||||
envvar="DATASETTE_SSL_KEYFILE",
|
||||
)
|
||||
@click.option(
|
||||
"--ssl-certfile",
|
||||
help="SSL certificate file",
|
||||
envvar="DATASETTE_SSL_CERTFILE",
|
||||
)
|
||||
@click.option(
|
||||
"--internal",
|
||||
type=click.Path(),
|
||||
help="Path to a persistent Datasette internal SQLite database",
|
||||
)
|
||||
def serve(
|
||||
files,
|
||||
|
|
@ -486,7 +519,11 @@ def serve(
|
|||
settings,
|
||||
secret,
|
||||
root,
|
||||
default_deny,
|
||||
get,
|
||||
headers,
|
||||
token,
|
||||
actor,
|
||||
version_note,
|
||||
help_settings,
|
||||
pdb,
|
||||
|
|
@ -496,6 +533,7 @@ def serve(
|
|||
nolock,
|
||||
ssl_keyfile,
|
||||
ssl_certfile,
|
||||
internal,
|
||||
return_instance=False,
|
||||
):
|
||||
"""Serve up specified SQLite database files with a web UI"""
|
||||
|
|
@ -516,6 +554,8 @@ def serve(
|
|||
reloader = hupper.start_reloader("datasette.cli.serve")
|
||||
if immutable:
|
||||
reloader.watch_files(immutable)
|
||||
if config:
|
||||
reloader.watch_files([config.name])
|
||||
if metadata:
|
||||
reloader.watch_files([metadata.name])
|
||||
|
||||
|
|
@ -528,41 +568,55 @@ def serve(
|
|||
if metadata:
|
||||
metadata_data = parse_metadata(metadata.read())
|
||||
|
||||
combined_settings = {}
|
||||
config_data = None
|
||||
if config:
|
||||
click.echo(
|
||||
"--config name:value will be deprecated in Datasette 1.0, use --setting name value instead",
|
||||
err=True,
|
||||
)
|
||||
combined_settings.update(config)
|
||||
combined_settings.update(settings)
|
||||
config_data = parse_metadata(config.read())
|
||||
|
||||
config_data = config_data or {}
|
||||
|
||||
# Merge in settings from -s/--setting
|
||||
if settings:
|
||||
settings_updates = pairs_to_nested_config(settings)
|
||||
# Merge recursively, to avoid over-writing nested values
|
||||
# https://github.com/simonw/datasette/issues/2389
|
||||
deep_dict_update(config_data, settings_updates)
|
||||
|
||||
kwargs = dict(
|
||||
immutables=immutable,
|
||||
cache_headers=not reload,
|
||||
cors=cors,
|
||||
inspect_data=inspect_data,
|
||||
config=config_data,
|
||||
metadata=metadata_data,
|
||||
sqlite_extensions=sqlite_extensions,
|
||||
template_dir=template_dir,
|
||||
plugins_dir=plugins_dir,
|
||||
static_mounts=static,
|
||||
settings=combined_settings,
|
||||
settings=None, # These are passed in config= now
|
||||
memory=memory,
|
||||
secret=secret,
|
||||
version_note=version_note,
|
||||
pdb=pdb,
|
||||
crossdb=crossdb,
|
||||
nolock=nolock,
|
||||
internal=internal,
|
||||
default_deny=default_deny,
|
||||
)
|
||||
|
||||
# if files is a single directory, use that as config_dir=
|
||||
if 1 == len(files) and os.path.isdir(files[0]):
|
||||
kwargs["config_dir"] = pathlib.Path(files[0])
|
||||
files = []
|
||||
# Separate directories from files
|
||||
directories = [f for f in files if os.path.isdir(f)]
|
||||
file_paths = [f for f in files if not os.path.isdir(f)]
|
||||
|
||||
# Handle config_dir - only one directory allowed
|
||||
if len(directories) > 1:
|
||||
raise click.ClickException(
|
||||
"Cannot pass multiple directories. Pass a single directory as config_dir."
|
||||
)
|
||||
elif len(directories) == 1:
|
||||
kwargs["config_dir"] = pathlib.Path(directories[0])
|
||||
|
||||
# Verify list of files, create if needed (and --create)
|
||||
for file in files:
|
||||
for file in file_paths:
|
||||
if not pathlib.Path(file).exists():
|
||||
if create:
|
||||
sqlite3.connect(file).execute("vacuum")
|
||||
|
|
@ -573,8 +627,32 @@ def serve(
|
|||
)
|
||||
)
|
||||
|
||||
# De-duplicate files so 'datasette db.db db.db' only attaches one /db
|
||||
files = list(dict.fromkeys(files))
|
||||
# Check for duplicate files by resolving all paths to their absolute forms
|
||||
# Collect all database files that will be loaded (explicit files + config_dir files)
|
||||
all_db_files = []
|
||||
|
||||
# Add explicit files
|
||||
for file in file_paths:
|
||||
all_db_files.append((file, pathlib.Path(file).resolve()))
|
||||
|
||||
# Add config_dir databases if config_dir is set
|
||||
if "config_dir" in kwargs:
|
||||
config_dir = kwargs["config_dir"]
|
||||
for ext in ("db", "sqlite", "sqlite3"):
|
||||
for db_file in config_dir.glob(f"*.{ext}"):
|
||||
all_db_files.append((str(db_file), db_file.resolve()))
|
||||
|
||||
# Check for duplicates
|
||||
seen = {}
|
||||
for original_path, resolved_path in all_db_files:
|
||||
if resolved_path in seen:
|
||||
raise click.ClickException(
|
||||
f"Duplicate database file: '{original_path}' and '{seen[resolved_path]}' "
|
||||
f"both refer to {resolved_path}"
|
||||
)
|
||||
seen[resolved_path] = original_path
|
||||
|
||||
files = file_paths
|
||||
|
||||
try:
|
||||
ds = Datasette(files, **kwargs)
|
||||
|
|
@ -588,15 +666,38 @@ def serve(
|
|||
return ds
|
||||
|
||||
# Run the "startup" plugin hooks
|
||||
asyncio.get_event_loop().run_until_complete(ds.invoke_startup())
|
||||
run_sync(ds.invoke_startup)
|
||||
|
||||
# Run async soundness checks - but only if we're not under pytest
|
||||
asyncio.get_event_loop().run_until_complete(check_databases(ds))
|
||||
run_sync(lambda: check_databases(ds))
|
||||
|
||||
if headers and not get:
|
||||
raise click.ClickException("--headers can only be used with --get")
|
||||
|
||||
if token and not get:
|
||||
raise click.ClickException("--token can only be used with --get")
|
||||
|
||||
if get:
|
||||
client = TestClient(ds)
|
||||
response = client.get(get)
|
||||
click.echo(response.text)
|
||||
request_headers = {}
|
||||
if token:
|
||||
request_headers["Authorization"] = "Bearer {}".format(token)
|
||||
cookies = {}
|
||||
if actor:
|
||||
cookies["ds_actor"] = client.actor_cookie(json.loads(actor))
|
||||
response = client.get(get, headers=request_headers, cookies=cookies)
|
||||
|
||||
if headers:
|
||||
# Output HTTP status code, headers, two newlines, then the response body
|
||||
click.echo(f"HTTP/1.1 {response.status}")
|
||||
for key, value in response.headers.items():
|
||||
click.echo(f"{key}: {value}")
|
||||
if response.text:
|
||||
click.echo()
|
||||
click.echo(response.text)
|
||||
else:
|
||||
click.echo(response.text)
|
||||
|
||||
exit_code = 0 if response.status == 200 else 1
|
||||
sys.exit(exit_code)
|
||||
return
|
||||
|
|
@ -604,16 +705,15 @@ def serve(
|
|||
# Start the server
|
||||
url = None
|
||||
if root:
|
||||
ds.root_enabled = True
|
||||
url = "http://{}:{}{}?token={}".format(
|
||||
host, port, ds.urls.path("-/auth-token"), ds._root_token
|
||||
)
|
||||
print(url)
|
||||
click.echo(url)
|
||||
if open_browser:
|
||||
if url is None:
|
||||
# Figure out most convenient URL - to table, database or homepage
|
||||
path = asyncio.get_event_loop().run_until_complete(
|
||||
initial_path_for_datasette(ds)
|
||||
)
|
||||
path = run_sync(lambda: initial_path_for_datasette(ds))
|
||||
url = f"http://{host}:{port}{path}"
|
||||
webbrowser.open(url)
|
||||
uvicorn_kwargs = dict(
|
||||
|
|
@ -628,6 +728,131 @@ def serve(
|
|||
uvicorn.run(ds.app(), **uvicorn_kwargs)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("id")
|
||||
@click.option(
|
||||
"--secret",
|
||||
help="Secret used for signing the API tokens",
|
||||
envvar="DATASETTE_SECRET",
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"-e",
|
||||
"--expires-after",
|
||||
help="Token should expire after this many seconds",
|
||||
type=int,
|
||||
)
|
||||
@click.option(
|
||||
"alls",
|
||||
"-a",
|
||||
"--all",
|
||||
type=str,
|
||||
metavar="ACTION",
|
||||
multiple=True,
|
||||
help="Restrict token to this action",
|
||||
)
|
||||
@click.option(
|
||||
"databases",
|
||||
"-d",
|
||||
"--database",
|
||||
type=(str, str),
|
||||
metavar="DB ACTION",
|
||||
multiple=True,
|
||||
help="Restrict token to this action on this database",
|
||||
)
|
||||
@click.option(
|
||||
"resources",
|
||||
"-r",
|
||||
"--resource",
|
||||
type=(str, str, str),
|
||||
metavar="DB RESOURCE ACTION",
|
||||
multiple=True,
|
||||
help="Restrict token to this action on this database resource (a table, SQL view or named query)",
|
||||
)
|
||||
@click.option(
|
||||
"--debug",
|
||||
help="Show decoded token",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--plugins-dir",
|
||||
type=click.Path(exists=True, file_okay=False, dir_okay=True),
|
||||
help="Path to directory containing custom plugins",
|
||||
)
|
||||
def create_token(
|
||||
id, secret, expires_after, alls, databases, resources, debug, plugins_dir
|
||||
):
|
||||
"""
|
||||
Create a signed API token for the specified actor ID
|
||||
|
||||
Example:
|
||||
|
||||
datasette create-token root --secret mysecret
|
||||
|
||||
To allow only "view-database-download" for all databases:
|
||||
|
||||
\b
|
||||
datasette create-token root --secret mysecret \\
|
||||
--all view-database-download
|
||||
|
||||
To allow "create-table" against a specific database:
|
||||
|
||||
\b
|
||||
datasette create-token root --secret mysecret \\
|
||||
--database mydb create-table
|
||||
|
||||
To allow "insert-row" against a specific table:
|
||||
|
||||
\b
|
||||
datasette create-token root --secret myscret \\
|
||||
--resource mydb mytable insert-row
|
||||
|
||||
Restricted actions can be specified multiple times using
|
||||
multiple --all, --database, and --resource options.
|
||||
|
||||
Add --debug to see a decoded version of the token.
|
||||
"""
|
||||
ds = Datasette(secret=secret, plugins_dir=plugins_dir)
|
||||
|
||||
# Run ds.invoke_startup() in an event loop
|
||||
run_sync(ds.invoke_startup)
|
||||
|
||||
# Warn about any unknown actions
|
||||
actions = []
|
||||
actions.extend(alls)
|
||||
actions.extend([p[1] for p in databases])
|
||||
actions.extend([p[2] for p in resources])
|
||||
for action in actions:
|
||||
if not ds.actions.get(action):
|
||||
click.secho(
|
||||
f" Unknown permission: {action} ",
|
||||
fg="red",
|
||||
err=True,
|
||||
)
|
||||
|
||||
restrict_database = {}
|
||||
for database, action in databases:
|
||||
restrict_database.setdefault(database, []).append(action)
|
||||
restrict_resource = {}
|
||||
for database, resource, action in resources:
|
||||
restrict_resource.setdefault(database, {}).setdefault(resource, []).append(
|
||||
action
|
||||
)
|
||||
|
||||
token = ds.create_token(
|
||||
id,
|
||||
expires_after=expires_after,
|
||||
restrict_all=alls,
|
||||
restrict_database=restrict_database,
|
||||
restrict_resource=restrict_resource,
|
||||
)
|
||||
click.echo(token)
|
||||
if debug:
|
||||
encoded = token[len("dstok_") :]
|
||||
click.echo("\nDecoded:\n")
|
||||
click.echo(json.dumps(ds.unsign(encoded, namespace="token"), indent=2))
|
||||
|
||||
|
||||
pm.hook.register_commands(cli=cli)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from collections import namedtuple
|
|||
from pathlib import Path
|
||||
import janus
|
||||
import queue
|
||||
import sqlite_utils
|
||||
import sys
|
||||
import threading
|
||||
import uuid
|
||||
|
|
@ -14,11 +15,13 @@ from .utils import (
|
|||
detect_spatialite,
|
||||
get_all_foreign_keys,
|
||||
get_outbound_foreign_keys,
|
||||
md5_not_usedforsecurity,
|
||||
sqlite_timelimit,
|
||||
sqlite3,
|
||||
table_columns,
|
||||
table_column_details,
|
||||
)
|
||||
from .utils.sqlite import sqlite_version
|
||||
from .inspect import inspect_hash
|
||||
|
||||
connections = threading.local()
|
||||
|
|
@ -27,10 +30,22 @@ AttachedDatabase = namedtuple("AttachedDatabase", ("seq", "name", "file"))
|
|||
|
||||
|
||||
class Database:
|
||||
# For table counts stop at this many rows:
|
||||
count_limit = 10000
|
||||
_thread_local_id_counter = 1
|
||||
|
||||
def __init__(
|
||||
self, ds, path=None, is_mutable=True, is_memory=False, memory_name=None
|
||||
self,
|
||||
ds,
|
||||
path=None,
|
||||
is_mutable=True,
|
||||
is_memory=False,
|
||||
memory_name=None,
|
||||
mode=None,
|
||||
):
|
||||
self.name = None
|
||||
self._thread_local_id = f"x{self._thread_local_id_counter}"
|
||||
Database._thread_local_id_counter += 1
|
||||
self.route = None
|
||||
self.ds = ds
|
||||
self.path = path
|
||||
|
|
@ -49,6 +64,7 @@ class Database:
|
|||
self._write_connection = None
|
||||
# This is used to track all file connections so they can be closed
|
||||
self._all_file_connections = []
|
||||
self.mode = mode
|
||||
|
||||
@property
|
||||
def cached_table_counts(self):
|
||||
|
|
@ -62,6 +78,12 @@ class Database:
|
|||
}
|
||||
return self._cached_table_counts
|
||||
|
||||
@property
|
||||
def color(self):
|
||||
if self.hash:
|
||||
return self.hash[:6]
|
||||
return md5_not_usedforsecurity(self.name)[:6]
|
||||
|
||||
def suggest_name(self):
|
||||
if self.path:
|
||||
return Path(self.path).stem
|
||||
|
|
@ -71,18 +93,20 @@ class Database:
|
|||
return "db"
|
||||
|
||||
def connect(self, write=False):
|
||||
extra_kwargs = {}
|
||||
if write:
|
||||
extra_kwargs["isolation_level"] = "IMMEDIATE"
|
||||
if self.memory_name:
|
||||
uri = "file:{}?mode=memory&cache=shared".format(self.memory_name)
|
||||
conn = sqlite3.connect(
|
||||
uri,
|
||||
uri=True,
|
||||
check_same_thread=False,
|
||||
uri, uri=True, check_same_thread=False, **extra_kwargs
|
||||
)
|
||||
if not write:
|
||||
conn.execute("PRAGMA query_only=1")
|
||||
return conn
|
||||
if self.is_memory:
|
||||
return sqlite3.connect(":memory:", uri=True)
|
||||
|
||||
# mode=ro or immutable=1?
|
||||
if self.is_mutable:
|
||||
qs = "?mode=ro"
|
||||
|
|
@ -93,8 +117,10 @@ class Database:
|
|||
assert not (write and not self.is_mutable)
|
||||
if write:
|
||||
qs = ""
|
||||
if self.mode is not None:
|
||||
qs = f"?mode={self.mode}"
|
||||
conn = sqlite3.connect(
|
||||
f"file:{self.path}{qs}", uri=True, check_same_thread=False
|
||||
f"file:{self.path}{qs}", uri=True, check_same_thread=False, **extra_kwargs
|
||||
)
|
||||
self._all_file_connections.append(conn)
|
||||
return conn
|
||||
|
|
@ -106,8 +132,7 @@ class Database:
|
|||
|
||||
async def execute_write(self, sql, params=None, block=True):
|
||||
def _inner(conn):
|
||||
with conn:
|
||||
return conn.execute(sql, params or [])
|
||||
return conn.execute(sql, params or [])
|
||||
|
||||
with trace("sql", database=self.name, sql=sql.strip(), params=params):
|
||||
results = await self.execute_write_fn(_inner, block=block)
|
||||
|
|
@ -115,11 +140,12 @@ class Database:
|
|||
|
||||
async def execute_write_script(self, sql, block=True):
|
||||
def _inner(conn):
|
||||
with conn:
|
||||
return conn.executescript(sql)
|
||||
return conn.executescript(sql)
|
||||
|
||||
with trace("sql", database=self.name, sql=sql.strip(), executescript=True):
|
||||
results = await self.execute_write_fn(_inner, block=block)
|
||||
results = await self.execute_write_fn(
|
||||
_inner, block=block, transaction=False
|
||||
)
|
||||
return results
|
||||
|
||||
async def execute_write_many(self, sql, params_seq, block=True):
|
||||
|
|
@ -132,8 +158,7 @@ class Database:
|
|||
count += 1
|
||||
yield param
|
||||
|
||||
with conn:
|
||||
return conn.executemany(sql, count_params(params_seq)), count
|
||||
return conn.executemany(sql, count_params(params_seq)), count
|
||||
|
||||
with trace(
|
||||
"sql", database=self.name, sql=sql.strip(), executemany=True
|
||||
|
|
@ -142,25 +167,60 @@ class Database:
|
|||
kwargs["count"] = count
|
||||
return results
|
||||
|
||||
async def execute_write_fn(self, fn, block=True):
|
||||
async def execute_isolated_fn(self, fn):
|
||||
# Open a new connection just for the duration of this function
|
||||
# blocking the write queue to avoid any writes occurring during it
|
||||
if self.ds.executor is None:
|
||||
# non-threaded mode
|
||||
isolated_connection = self.connect(write=True)
|
||||
try:
|
||||
result = fn(isolated_connection)
|
||||
finally:
|
||||
isolated_connection.close()
|
||||
try:
|
||||
self._all_file_connections.remove(isolated_connection)
|
||||
except ValueError:
|
||||
# Was probably a memory connection
|
||||
pass
|
||||
return result
|
||||
else:
|
||||
# Threaded mode - send to write thread
|
||||
return await self._send_to_write_thread(fn, isolated_connection=True)
|
||||
|
||||
async def execute_write_fn(self, fn, block=True, transaction=True):
|
||||
if self.ds.executor is None:
|
||||
# non-threaded mode
|
||||
if self._write_connection is None:
|
||||
self._write_connection = self.connect(write=True)
|
||||
self.ds._prepare_connection(self._write_connection, self.name)
|
||||
return fn(self._write_connection)
|
||||
if transaction:
|
||||
with self._write_connection:
|
||||
return fn(self._write_connection)
|
||||
else:
|
||||
return fn(self._write_connection)
|
||||
else:
|
||||
return await self._send_to_write_thread(
|
||||
fn, block=block, transaction=transaction
|
||||
)
|
||||
|
||||
# threaded mode
|
||||
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
|
||||
async def _send_to_write_thread(
|
||||
self, fn, block=True, isolated_connection=False, transaction=True
|
||||
):
|
||||
if self._write_queue is None:
|
||||
self._write_queue = queue.Queue()
|
||||
if self._write_thread is None:
|
||||
self._write_thread = threading.Thread(
|
||||
target=self._execute_writes, daemon=True
|
||||
)
|
||||
self._write_thread.name = "_execute_writes for database {}".format(
|
||||
self.name
|
||||
)
|
||||
self._write_thread.start()
|
||||
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
|
||||
reply_queue = janus.Queue()
|
||||
self._write_queue.put(WriteTask(fn, task_id, reply_queue))
|
||||
self._write_queue.put(
|
||||
WriteTask(fn, task_id, reply_queue, isolated_connection, transaction)
|
||||
)
|
||||
if block:
|
||||
result = await reply_queue.async_q.get()
|
||||
if isinstance(result, Exception):
|
||||
|
|
@ -185,12 +245,32 @@ class Database:
|
|||
if conn_exception is not None:
|
||||
result = conn_exception
|
||||
else:
|
||||
try:
|
||||
result = task.fn(conn)
|
||||
except Exception as e:
|
||||
sys.stderr.write("{}\n".format(e))
|
||||
sys.stderr.flush()
|
||||
result = e
|
||||
if task.isolated_connection:
|
||||
isolated_connection = self.connect(write=True)
|
||||
try:
|
||||
result = task.fn(isolated_connection)
|
||||
except Exception as e:
|
||||
sys.stderr.write("{}\n".format(e))
|
||||
sys.stderr.flush()
|
||||
result = e
|
||||
finally:
|
||||
isolated_connection.close()
|
||||
try:
|
||||
self._all_file_connections.remove(isolated_connection)
|
||||
except ValueError:
|
||||
# Was probably a memory connection
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
if task.transaction:
|
||||
with conn:
|
||||
result = task.fn(conn)
|
||||
else:
|
||||
result = task.fn(conn)
|
||||
except Exception as e:
|
||||
sys.stderr.write("{}\n".format(e))
|
||||
sys.stderr.flush()
|
||||
result = e
|
||||
task.reply_queue.sync_q.put(result)
|
||||
|
||||
async def execute_fn(self, fn):
|
||||
|
|
@ -203,11 +283,11 @@ class Database:
|
|||
|
||||
# threaded mode
|
||||
def in_thread():
|
||||
conn = getattr(connections, self.name, None)
|
||||
conn = getattr(connections, self._thread_local_id, None)
|
||||
if not conn:
|
||||
conn = self.connect()
|
||||
self.ds._prepare_connection(conn, self.name)
|
||||
setattr(connections, self.name, conn)
|
||||
setattr(connections, self._thread_local_id, conn)
|
||||
return fn(conn)
|
||||
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
|
|
@ -305,7 +385,7 @@ class Database:
|
|||
try:
|
||||
table_count = (
|
||||
await self.execute(
|
||||
f"select count(*) from [{table}]",
|
||||
f"select count(*) from (select * from [{table}] limit {self.count_limit + 1})",
|
||||
custom_time_limit=limit,
|
||||
)
|
||||
).rows[0][0]
|
||||
|
|
@ -330,7 +410,12 @@ class Database:
|
|||
# But SQLite prior to 3.16.0 doesn't support pragma functions
|
||||
results = await self.execute("PRAGMA database_list;")
|
||||
# {'seq': 0, 'name': 'main', 'file': ''}
|
||||
return [AttachedDatabase(*row) for row in results.rows if row["seq"] > 0]
|
||||
return [
|
||||
AttachedDatabase(*row)
|
||||
for row in results.rows
|
||||
# Filter out the SQLite internal "temp" database, refs #2557
|
||||
if row["seq"] > 0 and row["name"] != "temp"
|
||||
]
|
||||
|
||||
async def table_exists(self, table):
|
||||
results = await self.execute(
|
||||
|
|
@ -338,6 +423,12 @@ class Database:
|
|||
)
|
||||
return bool(results.rows)
|
||||
|
||||
async def view_exists(self, table):
|
||||
results = await self.execute(
|
||||
"select 1 from sqlite_master where type='view' and name=?", params=(table,)
|
||||
)
|
||||
return bool(results.rows)
|
||||
|
||||
async def table_names(self):
|
||||
results = await self.execute(
|
||||
"select name from sqlite_master where type='table'"
|
||||
|
|
@ -357,12 +448,38 @@ class Database:
|
|||
return await self.execute_fn(lambda conn: detect_fts(conn, table))
|
||||
|
||||
async def label_column_for_table(self, table):
|
||||
explicit_label_column = self.ds.table_metadata(self.name, table).get(
|
||||
explicit_label_column = (await self.ds.table_config(self.name, table)).get(
|
||||
"label_column"
|
||||
)
|
||||
if explicit_label_column:
|
||||
return explicit_label_column
|
||||
column_names = await self.execute_fn(lambda conn: table_columns(conn, table))
|
||||
|
||||
def column_details(conn):
|
||||
# Returns {column_name: (type, is_unique)}
|
||||
db = sqlite_utils.Database(conn)
|
||||
columns = db[table].columns_dict
|
||||
indexes = db[table].indexes
|
||||
details = {}
|
||||
for name in columns:
|
||||
is_unique = any(
|
||||
index
|
||||
for index in indexes
|
||||
if index.columns == [name] and index.unique
|
||||
)
|
||||
details[name] = (columns[name], is_unique)
|
||||
return details
|
||||
|
||||
column_details = await self.execute_fn(column_details)
|
||||
# Is there just one unique column that's text?
|
||||
unique_text_columns = [
|
||||
name
|
||||
for name, (type_, is_unique) in column_details.items()
|
||||
if is_unique and type_ is str
|
||||
]
|
||||
if len(unique_text_columns) == 1:
|
||||
return unique_text_columns[0]
|
||||
|
||||
column_names = list(column_details.keys())
|
||||
# Is there a name or title column?
|
||||
name_or_title = [c for c in column_names if c.lower() in ("name", "title")]
|
||||
if name_or_title:
|
||||
|
|
@ -372,6 +489,7 @@ class Database:
|
|||
column_names
|
||||
and len(column_names) == 2
|
||||
and ("id" in column_names or "pk" in column_names)
|
||||
and not set(column_names) == {"id", "pk"}
|
||||
):
|
||||
return [c for c in column_names if c not in ("id", "pk")][0]
|
||||
# Couldn't find a label:
|
||||
|
|
@ -383,21 +501,107 @@ class Database:
|
|||
)
|
||||
|
||||
async def hidden_table_names(self):
|
||||
# Mark tables 'hidden' if they relate to FTS virtual tables
|
||||
hidden_tables = [
|
||||
r[0]
|
||||
for r in (
|
||||
await self.execute(
|
||||
hidden_tables = []
|
||||
# Add any tables marked as hidden in config
|
||||
db_config = self.ds.config.get("databases", {}).get(self.name, {})
|
||||
if "tables" in db_config:
|
||||
hidden_tables += [
|
||||
t for t in db_config["tables"] if db_config["tables"][t].get("hidden")
|
||||
]
|
||||
|
||||
if sqlite_version()[1] >= 37:
|
||||
hidden_tables += [
|
||||
x[0]
|
||||
for x in await self.execute(
|
||||
"""
|
||||
with shadow_tables as (
|
||||
select name
|
||||
from pragma_table_list
|
||||
where [type] = 'shadow'
|
||||
order by name
|
||||
),
|
||||
core_tables as (
|
||||
select name
|
||||
from sqlite_master
|
||||
WHERE name in ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
|
||||
OR substr(name, 1, 1) == '_'
|
||||
),
|
||||
combined as (
|
||||
select name from shadow_tables
|
||||
union all
|
||||
select name from core_tables
|
||||
)
|
||||
select name from combined order by 1
|
||||
"""
|
||||
select name from sqlite_master
|
||||
where rootpage = 0
|
||||
and (
|
||||
sql like '%VIRTUAL TABLE%USING FTS%'
|
||||
) or name in ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
|
||||
"""
|
||||
)
|
||||
).rows
|
||||
]
|
||||
else:
|
||||
hidden_tables += [
|
||||
x[0]
|
||||
for x in await self.execute(
|
||||
"""
|
||||
WITH base AS (
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE name IN ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
|
||||
OR substr(name, 1, 1) == '_'
|
||||
),
|
||||
fts_suffixes AS (
|
||||
SELECT column1 AS suffix
|
||||
FROM (VALUES ('_data'), ('_idx'), ('_docsize'), ('_content'), ('_config'))
|
||||
),
|
||||
fts5_names AS (
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE sql LIKE '%VIRTUAL TABLE%USING FTS%'
|
||||
),
|
||||
fts5_shadow_tables AS (
|
||||
SELECT
|
||||
printf('%s%s', fts5_names.name, fts_suffixes.suffix) AS name
|
||||
FROM fts5_names
|
||||
JOIN fts_suffixes
|
||||
),
|
||||
fts3_suffixes AS (
|
||||
SELECT column1 AS suffix
|
||||
FROM (VALUES ('_content'), ('_segdir'), ('_segments'), ('_stat'), ('_docsize'))
|
||||
),
|
||||
fts3_names AS (
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE sql LIKE '%VIRTUAL TABLE%USING FTS3%'
|
||||
OR sql LIKE '%VIRTUAL TABLE%USING FTS4%'
|
||||
),
|
||||
fts3_shadow_tables AS (
|
||||
SELECT
|
||||
printf('%s%s', fts3_names.name, fts3_suffixes.suffix) AS name
|
||||
FROM fts3_names
|
||||
JOIN fts3_suffixes
|
||||
),
|
||||
final AS (
|
||||
SELECT name FROM base
|
||||
UNION ALL
|
||||
SELECT name FROM fts5_shadow_tables
|
||||
UNION ALL
|
||||
SELECT name FROM fts3_shadow_tables
|
||||
)
|
||||
SELECT name FROM final ORDER BY 1
|
||||
"""
|
||||
)
|
||||
]
|
||||
# Also hide any FTS tables that have a content= argument
|
||||
hidden_tables += [
|
||||
x[0]
|
||||
for x in await self.execute(
|
||||
"""
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE sql LIKE '%VIRTUAL TABLE%'
|
||||
AND sql LIKE '%USING FTS%'
|
||||
AND sql LIKE '%content=%'
|
||||
"""
|
||||
)
|
||||
]
|
||||
|
||||
has_spatialite = await self.execute_fn(detect_spatialite)
|
||||
if has_spatialite:
|
||||
# Also hide Spatialite internal tables
|
||||
|
|
@ -426,21 +630,6 @@ class Database:
|
|||
)
|
||||
).rows
|
||||
]
|
||||
# Add any from metadata.json
|
||||
db_metadata = self.ds.metadata(database=self.name)
|
||||
if "tables" in db_metadata:
|
||||
hidden_tables += [
|
||||
t
|
||||
for t in db_metadata["tables"]
|
||||
if db_metadata["tables"][t].get("hidden")
|
||||
]
|
||||
# Also mark as hidden any tables which start with the name of a hidden table
|
||||
# e.g. "searchable_fts" implies "searchable_fts_content" should be hidden
|
||||
for table_name in await self.table_names():
|
||||
for hidden_table in hidden_tables[:]:
|
||||
if table_name.startswith(hidden_table):
|
||||
hidden_tables.append(table_name)
|
||||
continue
|
||||
|
||||
return hidden_tables
|
||||
|
||||
|
|
@ -492,12 +681,14 @@ class Database:
|
|||
|
||||
|
||||
class WriteTask:
|
||||
__slots__ = ("fn", "task_id", "reply_queue")
|
||||
__slots__ = ("fn", "task_id", "reply_queue", "isolated_connection", "transaction")
|
||||
|
||||
def __init__(self, fn, task_id, reply_queue):
|
||||
def __init__(self, fn, task_id, reply_queue, isolated_connection, transaction):
|
||||
self.fn = fn
|
||||
self.task_id = task_id
|
||||
self.reply_queue = reply_queue
|
||||
self.isolated_connection = isolated_connection
|
||||
self.transaction = transaction
|
||||
|
||||
|
||||
class QueryInterrupted(Exception):
|
||||
|
|
@ -506,6 +697,9 @@ class QueryInterrupted(Exception):
|
|||
self.sql = sql
|
||||
self.params = params
|
||||
|
||||
def __str__(self):
|
||||
return "QueryInterrupted: {}".format(self.e)
|
||||
|
||||
|
||||
class MultipleValues(Exception):
|
||||
pass
|
||||
|
|
@ -533,6 +727,9 @@ class Results:
|
|||
else:
|
||||
raise MultipleValues
|
||||
|
||||
def dicts(self):
|
||||
return [dict(row) for row in self.rows]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.rows)
|
||||
|
||||
|
|
|
|||
101
datasette/default_actions.py
Normal file
101
datasette/default_actions.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.permissions import Action
|
||||
from datasette.resources import (
|
||||
DatabaseResource,
|
||||
TableResource,
|
||||
QueryResource,
|
||||
)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def register_actions():
|
||||
"""Register the core Datasette actions."""
|
||||
return (
|
||||
# Global actions (no resource_class)
|
||||
Action(
|
||||
name="view-instance",
|
||||
abbr="vi",
|
||||
description="View Datasette instance",
|
||||
),
|
||||
Action(
|
||||
name="permissions-debug",
|
||||
abbr="pd",
|
||||
description="Access permission debug tool",
|
||||
),
|
||||
Action(
|
||||
name="debug-menu",
|
||||
abbr="dm",
|
||||
description="View debug menu items",
|
||||
),
|
||||
# Database-level actions (parent-level)
|
||||
Action(
|
||||
name="view-database",
|
||||
abbr="vd",
|
||||
description="View database",
|
||||
resource_class=DatabaseResource,
|
||||
),
|
||||
Action(
|
||||
name="view-database-download",
|
||||
abbr="vdd",
|
||||
description="Download database file",
|
||||
resource_class=DatabaseResource,
|
||||
also_requires="view-database",
|
||||
),
|
||||
Action(
|
||||
name="execute-sql",
|
||||
abbr="es",
|
||||
description="Execute read-only SQL queries",
|
||||
resource_class=DatabaseResource,
|
||||
also_requires="view-database",
|
||||
),
|
||||
Action(
|
||||
name="create-table",
|
||||
abbr="ct",
|
||||
description="Create tables",
|
||||
resource_class=DatabaseResource,
|
||||
),
|
||||
# Table-level actions (child-level)
|
||||
Action(
|
||||
name="view-table",
|
||||
abbr="vt",
|
||||
description="View table",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="insert-row",
|
||||
abbr="ir",
|
||||
description="Insert rows",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="delete-row",
|
||||
abbr="dr",
|
||||
description="Delete rows",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="update-row",
|
||||
abbr="ur",
|
||||
description="Update rows",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="alter-table",
|
||||
abbr="at",
|
||||
description="Alter tables",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="drop-table",
|
||||
abbr="dt",
|
||||
description="Drop tables",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
# Query-level actions (child-level)
|
||||
Action(
|
||||
name="view-query",
|
||||
abbr="vq",
|
||||
description="View named query results",
|
||||
resource_class=QueryResource,
|
||||
),
|
||||
)
|
||||
|
|
@ -24,9 +24,12 @@ def now(key, request):
|
|||
if key == "epoch":
|
||||
return int(time.time())
|
||||
elif key == "date_utc":
|
||||
return datetime.datetime.utcnow().date().isoformat()
|
||||
return datetime.datetime.now(datetime.timezone.utc).date().isoformat()
|
||||
elif key == "datetime_utc":
|
||||
return datetime.datetime.utcnow().strftime(r"%Y-%m-%dT%H:%M:%S") + "Z"
|
||||
return (
|
||||
datetime.datetime.now(datetime.timezone.utc).strftime(r"%Y-%m-%dT%H:%M:%S")
|
||||
+ "Z"
|
||||
)
|
||||
else:
|
||||
raise KeyError
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from datasette import hookimpl
|
|||
@hookimpl
|
||||
def menu_links(datasette, actor):
|
||||
async def inner():
|
||||
if not await datasette.permission_allowed(actor, "debug-menu"):
|
||||
if not await datasette.allowed(action="debug-menu", actor=actor):
|
||||
return []
|
||||
|
||||
return [
|
||||
|
|
@ -17,10 +17,6 @@ def menu_links(datasette, actor):
|
|||
"href": datasette.urls.path("/-/versions"),
|
||||
"label": "Version info",
|
||||
},
|
||||
{
|
||||
"href": datasette.urls.path("/-/metadata"),
|
||||
"label": "Metadata",
|
||||
},
|
||||
{
|
||||
"href": datasette.urls.path("/-/settings"),
|
||||
"label": "Settings",
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.utils import actor_matches_allow
|
||||
|
||||
|
||||
@hookimpl(tryfirst=True)
|
||||
def permission_allowed(datasette, actor, action, resource):
|
||||
async def inner():
|
||||
if action in ("permissions-debug", "debug-menu"):
|
||||
if actor and actor.get("id") == "root":
|
||||
return True
|
||||
elif action == "view-instance":
|
||||
allow = datasette.metadata("allow")
|
||||
if allow is not None:
|
||||
return actor_matches_allow(actor, allow)
|
||||
elif action == "view-database":
|
||||
if resource == "_internal" and (actor is None or actor.get("id") != "root"):
|
||||
return False
|
||||
database_allow = datasette.metadata("allow", database=resource)
|
||||
if database_allow is None:
|
||||
return None
|
||||
return actor_matches_allow(actor, database_allow)
|
||||
elif action == "view-table":
|
||||
database, table = resource
|
||||
tables = datasette.metadata("tables", database=database) or {}
|
||||
table_allow = (tables.get(table) or {}).get("allow")
|
||||
if table_allow is None:
|
||||
return None
|
||||
return actor_matches_allow(actor, table_allow)
|
||||
elif action == "view-query":
|
||||
# Check if this query has a "allow" block in metadata
|
||||
database, query_name = resource
|
||||
query = await datasette.get_canned_query(database, query_name, actor)
|
||||
assert query is not None
|
||||
allow = query.get("allow")
|
||||
if allow is None:
|
||||
return None
|
||||
return actor_matches_allow(actor, allow)
|
||||
elif action == "execute-sql":
|
||||
# Use allow_sql block from database block, or from top-level
|
||||
database_allow_sql = datasette.metadata("allow_sql", database=resource)
|
||||
if database_allow_sql is None:
|
||||
database_allow_sql = datasette.metadata("allow_sql")
|
||||
if database_allow_sql is None:
|
||||
return None
|
||||
return actor_matches_allow(actor, database_allow_sql)
|
||||
|
||||
return inner
|
||||
59
datasette/default_permissions/__init__.py
Normal file
59
datasette/default_permissions/__init__.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""
|
||||
Default permission implementations for Datasette.
|
||||
|
||||
This module provides the built-in permission checking logic through implementations
|
||||
of the permission_resources_sql hook. The hooks are organized by their purpose:
|
||||
|
||||
1. Actor Restrictions - Enforces _r allowlists embedded in actor tokens
|
||||
2. Root User - Grants full access when --root flag is used
|
||||
3. Config Rules - Applies permissions from datasette.yaml
|
||||
4. Default Settings - Enforces default_allow_sql and default view permissions
|
||||
|
||||
IMPORTANT: These hooks return PermissionSQL objects that are combined using SQL
|
||||
UNION/INTERSECT operations. The order of evaluation is:
|
||||
- restriction_sql fields are INTERSECTed (all must match)
|
||||
- Regular sql fields are UNIONed and evaluated with cascading priority
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
from datasette import hookimpl
|
||||
|
||||
# Re-export all hooks and public utilities
|
||||
from .restrictions import (
|
||||
actor_restrictions_sql,
|
||||
restrictions_allow_action,
|
||||
ActorRestrictions,
|
||||
)
|
||||
from .root import root_user_permissions_sql
|
||||
from .config import config_permissions_sql
|
||||
from .defaults import (
|
||||
default_allow_sql_check,
|
||||
default_action_permissions_sql,
|
||||
DEFAULT_ALLOW_ACTIONS,
|
||||
)
|
||||
from .tokens import actor_from_signed_api_token
|
||||
|
||||
|
||||
@hookimpl
|
||||
def skip_csrf(scope) -> Optional[bool]:
|
||||
"""Skip CSRF check for JSON content-type requests."""
|
||||
if scope["type"] == "http":
|
||||
headers = scope.get("headers") or {}
|
||||
if dict(headers).get(b"content-type") == b"application/json":
|
||||
return True
|
||||
return None
|
||||
|
||||
|
||||
@hookimpl
|
||||
def canned_queries(datasette: "Datasette", database: str, actor) -> dict:
|
||||
"""Return canned queries defined in datasette.yaml configuration."""
|
||||
queries = (
|
||||
((datasette.config or {}).get("databases") or {}).get(database) or {}
|
||||
).get("queries") or {}
|
||||
return queries
|
||||
442
datasette/default_permissions/config.py
Normal file
442
datasette/default_permissions/config.py
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
"""
|
||||
Config-based permission handling for Datasette.
|
||||
|
||||
Applies permission rules from datasette.yaml configuration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, List, Optional, Set, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.permissions import PermissionSQL
|
||||
from datasette.utils import actor_matches_allow
|
||||
|
||||
from .helpers import PermissionRowCollector, get_action_name_variants
|
||||
|
||||
|
||||
class ConfigPermissionProcessor:
|
||||
"""
|
||||
Processes permission rules from datasette.yaml configuration.
|
||||
|
||||
Configuration structure:
|
||||
|
||||
permissions: # Root-level permissions block
|
||||
view-instance:
|
||||
id: admin
|
||||
|
||||
databases:
|
||||
mydb:
|
||||
permissions: # Database-level permissions
|
||||
view-database:
|
||||
id: admin
|
||||
allow: # Database-level allow block (for view-*)
|
||||
id: viewer
|
||||
allow_sql: # execute-sql allow block
|
||||
id: analyst
|
||||
tables:
|
||||
users:
|
||||
permissions: # Table-level permissions
|
||||
view-table:
|
||||
id: admin
|
||||
allow: # Table-level allow block
|
||||
id: viewer
|
||||
queries:
|
||||
my_query:
|
||||
permissions: # Query-level permissions
|
||||
view-query:
|
||||
id: admin
|
||||
allow: # Query-level allow block
|
||||
id: viewer
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
):
|
||||
self.datasette = datasette
|
||||
self.actor = actor
|
||||
self.action = action
|
||||
self.config = datasette.config or {}
|
||||
self.collector = PermissionRowCollector(prefix="cfg")
|
||||
|
||||
# Pre-compute action variants
|
||||
self.action_checks = get_action_name_variants(datasette, action)
|
||||
self.action_obj = datasette.actions.get(action)
|
||||
|
||||
# Parse restrictions if present
|
||||
self.has_restrictions = actor and "_r" in actor if actor else False
|
||||
self.restrictions = actor.get("_r", {}) if actor else {}
|
||||
|
||||
# Pre-compute restriction info for efficiency
|
||||
self.restricted_databases: Set[str] = set()
|
||||
self.restricted_tables: Set[Tuple[str, str]] = set()
|
||||
|
||||
if self.has_restrictions:
|
||||
self.restricted_databases = {
|
||||
db_name
|
||||
for db_name, db_actions in (self.restrictions.get("d") or {}).items()
|
||||
if self.action_checks.intersection(db_actions)
|
||||
}
|
||||
self.restricted_tables = {
|
||||
(db_name, table_name)
|
||||
for db_name, tables in (self.restrictions.get("r") or {}).items()
|
||||
for table_name, table_actions in tables.items()
|
||||
if self.action_checks.intersection(table_actions)
|
||||
}
|
||||
# Tables implicitly reference their parent databases
|
||||
self.restricted_databases.update(db for db, _ in self.restricted_tables)
|
||||
|
||||
def evaluate_allow_block(self, allow_block: Any) -> Optional[bool]:
|
||||
"""Evaluate an allow block against the current actor."""
|
||||
if allow_block is None:
|
||||
return None
|
||||
return actor_matches_allow(self.actor, allow_block)
|
||||
|
||||
def is_in_restriction_allowlist(
|
||||
self,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
) -> bool:
|
||||
"""Check if resource is allowed by actor restrictions."""
|
||||
if not self.has_restrictions:
|
||||
return True # No restrictions, all resources allowed
|
||||
|
||||
# Check global allowlist
|
||||
if self.action_checks.intersection(self.restrictions.get("a", [])):
|
||||
return True
|
||||
|
||||
# Check database-level allowlist
|
||||
if parent and self.action_checks.intersection(
|
||||
self.restrictions.get("d", {}).get(parent, [])
|
||||
):
|
||||
return True
|
||||
|
||||
# Check table-level allowlist
|
||||
if parent:
|
||||
table_restrictions = (self.restrictions.get("r", {}) or {}).get(parent, {})
|
||||
if child:
|
||||
table_actions = table_restrictions.get(child, [])
|
||||
if self.action_checks.intersection(table_actions):
|
||||
return True
|
||||
else:
|
||||
# Parent query should proceed if any child in this database is allowlisted
|
||||
for table_actions in table_restrictions.values():
|
||||
if self.action_checks.intersection(table_actions):
|
||||
return True
|
||||
|
||||
# Parent/child both None: include if any restrictions exist for this action
|
||||
if parent is None and child is None:
|
||||
if self.action_checks.intersection(self.restrictions.get("a", [])):
|
||||
return True
|
||||
if self.restricted_databases:
|
||||
return True
|
||||
if self.restricted_tables:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def add_permissions_rule(
|
||||
self,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
permissions_block: Optional[dict],
|
||||
scope_desc: str,
|
||||
) -> None:
|
||||
"""Add a rule from a permissions:{action} block."""
|
||||
if permissions_block is None:
|
||||
return
|
||||
|
||||
action_allow_block = permissions_block.get(self.action)
|
||||
result = self.evaluate_allow_block(action_allow_block)
|
||||
|
||||
self.collector.add(
|
||||
parent=parent,
|
||||
child=child,
|
||||
allow=result,
|
||||
reason=f"config {'allow' if result else 'deny'} {scope_desc}",
|
||||
if_not_none=True,
|
||||
)
|
||||
|
||||
def add_allow_block_rule(
|
||||
self,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
allow_block: Any,
|
||||
scope_desc: str,
|
||||
) -> None:
|
||||
"""
|
||||
Add rules from an allow:{} block.
|
||||
|
||||
For allow blocks, if the block exists but doesn't match the actor,
|
||||
this is treated as a deny. We also handle the restriction-gate logic.
|
||||
"""
|
||||
if allow_block is None:
|
||||
return
|
||||
|
||||
# Skip if resource is not in restriction allowlist
|
||||
if not self.is_in_restriction_allowlist(parent, child):
|
||||
return
|
||||
|
||||
result = self.evaluate_allow_block(allow_block)
|
||||
bool_result = bool(result)
|
||||
|
||||
self.collector.add(
|
||||
parent,
|
||||
child,
|
||||
bool_result,
|
||||
f"config {'allow' if result else 'deny'} {scope_desc}",
|
||||
)
|
||||
|
||||
# Handle restriction-gate: add explicit denies for restricted resources
|
||||
self._add_restriction_gate_denies(parent, child, bool_result, scope_desc)
|
||||
|
||||
def _add_restriction_gate_denies(
|
||||
self,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
is_allowed: bool,
|
||||
scope_desc: str,
|
||||
) -> None:
|
||||
"""
|
||||
When a config rule denies at a higher level, add explicit denies
|
||||
for restricted resources to prevent child-level allows from
|
||||
incorrectly granting access.
|
||||
"""
|
||||
if is_allowed or child is not None or not self.has_restrictions:
|
||||
return
|
||||
|
||||
if not self.action_obj:
|
||||
return
|
||||
|
||||
reason = f"config deny {scope_desc} (restriction gate)"
|
||||
|
||||
if parent is None:
|
||||
# Root-level deny: add denies for all restricted resources
|
||||
if self.action_obj.takes_parent:
|
||||
for db_name in self.restricted_databases:
|
||||
self.collector.add(db_name, None, False, reason)
|
||||
if self.action_obj.takes_child:
|
||||
for db_name, table_name in self.restricted_tables:
|
||||
self.collector.add(db_name, table_name, False, reason)
|
||||
else:
|
||||
# Database-level deny: add denies for tables in that database
|
||||
if self.action_obj.takes_child:
|
||||
for db_name, table_name in self.restricted_tables:
|
||||
if db_name == parent:
|
||||
self.collector.add(db_name, table_name, False, reason)
|
||||
|
||||
def process(self) -> Optional[PermissionSQL]:
|
||||
"""Process all config rules and return combined PermissionSQL."""
|
||||
self._process_root_permissions()
|
||||
self._process_databases()
|
||||
self._process_root_allow_blocks()
|
||||
|
||||
return self.collector.to_permission_sql()
|
||||
|
||||
def _process_root_permissions(self) -> None:
|
||||
"""Process root-level permissions block."""
|
||||
root_perms = self.config.get("permissions") or {}
|
||||
self.add_permissions_rule(
|
||||
None,
|
||||
None,
|
||||
root_perms,
|
||||
f"permissions for {self.action}",
|
||||
)
|
||||
|
||||
def _process_databases(self) -> None:
|
||||
"""Process database-level and nested configurations."""
|
||||
databases = self.config.get("databases") or {}
|
||||
|
||||
for db_name, db_config in databases.items():
|
||||
self._process_database(db_name, db_config or {})
|
||||
|
||||
def _process_database(self, db_name: str, db_config: dict) -> None:
|
||||
"""Process a single database's configuration."""
|
||||
# Database-level permissions block
|
||||
db_perms = db_config.get("permissions") or {}
|
||||
self.add_permissions_rule(
|
||||
db_name,
|
||||
None,
|
||||
db_perms,
|
||||
f"permissions for {self.action} on {db_name}",
|
||||
)
|
||||
|
||||
# Process tables
|
||||
for table_name, table_config in (db_config.get("tables") or {}).items():
|
||||
self._process_table(db_name, table_name, table_config or {})
|
||||
|
||||
# Process queries
|
||||
for query_name, query_config in (db_config.get("queries") or {}).items():
|
||||
self._process_query(db_name, query_name, query_config)
|
||||
|
||||
# Database-level allow blocks
|
||||
self._process_database_allow_blocks(db_name, db_config)
|
||||
|
||||
def _process_table(
|
||||
self,
|
||||
db_name: str,
|
||||
table_name: str,
|
||||
table_config: dict,
|
||||
) -> None:
|
||||
"""Process a single table's configuration."""
|
||||
# Table-level permissions block
|
||||
table_perms = table_config.get("permissions") or {}
|
||||
self.add_permissions_rule(
|
||||
db_name,
|
||||
table_name,
|
||||
table_perms,
|
||||
f"permissions for {self.action} on {db_name}/{table_name}",
|
||||
)
|
||||
|
||||
# Table-level allow block (for view-table)
|
||||
if self.action == "view-table":
|
||||
self.add_allow_block_rule(
|
||||
db_name,
|
||||
table_name,
|
||||
table_config.get("allow"),
|
||||
f"allow for {self.action} on {db_name}/{table_name}",
|
||||
)
|
||||
|
||||
def _process_query(
|
||||
self,
|
||||
db_name: str,
|
||||
query_name: str,
|
||||
query_config: Any,
|
||||
) -> None:
|
||||
"""Process a single query's configuration."""
|
||||
# Query config can be a string (just SQL) or dict
|
||||
if not isinstance(query_config, dict):
|
||||
return
|
||||
|
||||
# Query-level permissions block
|
||||
query_perms = query_config.get("permissions") or {}
|
||||
self.add_permissions_rule(
|
||||
db_name,
|
||||
query_name,
|
||||
query_perms,
|
||||
f"permissions for {self.action} on {db_name}/{query_name}",
|
||||
)
|
||||
|
||||
# Query-level allow block (for view-query)
|
||||
if self.action == "view-query":
|
||||
self.add_allow_block_rule(
|
||||
db_name,
|
||||
query_name,
|
||||
query_config.get("allow"),
|
||||
f"allow for {self.action} on {db_name}/{query_name}",
|
||||
)
|
||||
|
||||
def _process_database_allow_blocks(
|
||||
self,
|
||||
db_name: str,
|
||||
db_config: dict,
|
||||
) -> None:
|
||||
"""Process database-level allow/allow_sql blocks."""
|
||||
# view-database allow block
|
||||
if self.action == "view-database":
|
||||
self.add_allow_block_rule(
|
||||
db_name,
|
||||
None,
|
||||
db_config.get("allow"),
|
||||
f"allow for {self.action} on {db_name}",
|
||||
)
|
||||
|
||||
# execute-sql allow_sql block
|
||||
if self.action == "execute-sql":
|
||||
self.add_allow_block_rule(
|
||||
db_name,
|
||||
None,
|
||||
db_config.get("allow_sql"),
|
||||
f"allow_sql for {db_name}",
|
||||
)
|
||||
|
||||
# view-table uses database-level allow for inheritance
|
||||
if self.action == "view-table":
|
||||
self.add_allow_block_rule(
|
||||
db_name,
|
||||
None,
|
||||
db_config.get("allow"),
|
||||
f"allow for {self.action} on {db_name}",
|
||||
)
|
||||
|
||||
# view-query uses database-level allow for inheritance
|
||||
if self.action == "view-query":
|
||||
self.add_allow_block_rule(
|
||||
db_name,
|
||||
None,
|
||||
db_config.get("allow"),
|
||||
f"allow for {self.action} on {db_name}",
|
||||
)
|
||||
|
||||
def _process_root_allow_blocks(self) -> None:
|
||||
"""Process root-level allow/allow_sql blocks."""
|
||||
root_allow = self.config.get("allow")
|
||||
|
||||
if self.action == "view-instance":
|
||||
self.add_allow_block_rule(
|
||||
None,
|
||||
None,
|
||||
root_allow,
|
||||
"allow for view-instance",
|
||||
)
|
||||
|
||||
if self.action == "view-database":
|
||||
self.add_allow_block_rule(
|
||||
None,
|
||||
None,
|
||||
root_allow,
|
||||
"allow for view-database",
|
||||
)
|
||||
|
||||
if self.action == "view-table":
|
||||
self.add_allow_block_rule(
|
||||
None,
|
||||
None,
|
||||
root_allow,
|
||||
"allow for view-table",
|
||||
)
|
||||
|
||||
if self.action == "view-query":
|
||||
self.add_allow_block_rule(
|
||||
None,
|
||||
None,
|
||||
root_allow,
|
||||
"allow for view-query",
|
||||
)
|
||||
|
||||
if self.action == "execute-sql":
|
||||
self.add_allow_block_rule(
|
||||
None,
|
||||
None,
|
||||
self.config.get("allow_sql"),
|
||||
"allow_sql",
|
||||
)
|
||||
|
||||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def config_permissions_sql(
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> Optional[List[PermissionSQL]]:
|
||||
"""
|
||||
Apply permission rules from datasette.yaml configuration.
|
||||
|
||||
This processes:
|
||||
- permissions: blocks at root, database, table, and query levels
|
||||
- allow: blocks for view-* actions
|
||||
- allow_sql: blocks for execute-sql action
|
||||
"""
|
||||
processor = ConfigPermissionProcessor(datasette, actor, action)
|
||||
result = processor.process()
|
||||
|
||||
if result is None:
|
||||
return []
|
||||
|
||||
return [result]
|
||||
70
datasette/default_permissions/defaults.py
Normal file
70
datasette/default_permissions/defaults.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""
|
||||
Default permission settings for Datasette.
|
||||
|
||||
Provides default allow rules for standard view/execute actions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.permissions import PermissionSQL
|
||||
|
||||
|
||||
# Actions that are allowed by default (unless --default-deny is used)
|
||||
DEFAULT_ALLOW_ACTIONS = frozenset(
|
||||
{
|
||||
"view-instance",
|
||||
"view-database",
|
||||
"view-database-download",
|
||||
"view-table",
|
||||
"view-query",
|
||||
"execute-sql",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def default_allow_sql_check(
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> Optional[PermissionSQL]:
|
||||
"""
|
||||
Enforce the default_allow_sql setting.
|
||||
|
||||
When default_allow_sql is false (the default), execute-sql is denied
|
||||
unless explicitly allowed by config or other rules.
|
||||
"""
|
||||
if action == "execute-sql":
|
||||
if not datasette.setting("default_allow_sql"):
|
||||
return PermissionSQL.deny(reason="default_allow_sql is false")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def default_action_permissions_sql(
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> Optional[PermissionSQL]:
|
||||
"""
|
||||
Provide default allow rules for standard view/execute actions.
|
||||
|
||||
These defaults are skipped when datasette is started with --default-deny.
|
||||
The restriction_sql mechanism (from actor_restrictions_sql) will still
|
||||
filter these results if the actor has restrictions.
|
||||
"""
|
||||
if datasette.default_deny:
|
||||
return None
|
||||
|
||||
if action in DEFAULT_ALLOW_ACTIONS:
|
||||
reason = f"default allow for {action}".replace("'", "''")
|
||||
return PermissionSQL.allow(reason=reason)
|
||||
|
||||
return None
|
||||
85
datasette/default_permissions/helpers.py
Normal file
85
datasette/default_permissions/helpers.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""
|
||||
Shared helper utilities for default permission implementations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, List, Optional, Set
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
from datasette.permissions import PermissionSQL
|
||||
|
||||
|
||||
def get_action_name_variants(datasette: "Datasette", action: str) -> Set[str]:
|
||||
"""
|
||||
Get all name variants for an action (full name and abbreviation).
|
||||
|
||||
Example:
|
||||
get_action_name_variants(ds, "view-table") -> {"view-table", "vt"}
|
||||
"""
|
||||
variants = {action}
|
||||
action_obj = datasette.actions.get(action)
|
||||
if action_obj and action_obj.abbr:
|
||||
variants.add(action_obj.abbr)
|
||||
return variants
|
||||
|
||||
|
||||
def action_in_list(datasette: "Datasette", action: str, action_list: list) -> bool:
|
||||
"""Check if an action (or its abbreviation) is in a list."""
|
||||
return bool(get_action_name_variants(datasette, action).intersection(action_list))
|
||||
|
||||
|
||||
@dataclass
|
||||
class PermissionRow:
|
||||
"""A single permission rule row."""
|
||||
|
||||
parent: Optional[str]
|
||||
child: Optional[str]
|
||||
allow: bool
|
||||
reason: str
|
||||
|
||||
|
||||
class PermissionRowCollector:
|
||||
"""Collects permission rows and converts them to PermissionSQL."""
|
||||
|
||||
def __init__(self, prefix: str = "row"):
|
||||
self.rows: List[PermissionRow] = []
|
||||
self.prefix = prefix
|
||||
|
||||
def add(
|
||||
self,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
allow: Optional[bool],
|
||||
reason: str,
|
||||
if_not_none: bool = False,
|
||||
) -> None:
|
||||
"""Add a permission row. If if_not_none=True, only add if allow is not None."""
|
||||
if if_not_none and allow is None:
|
||||
return
|
||||
self.rows.append(PermissionRow(parent, child, allow, reason))
|
||||
|
||||
def to_permission_sql(self) -> Optional[PermissionSQL]:
|
||||
"""Convert collected rows to a PermissionSQL object."""
|
||||
if not self.rows:
|
||||
return None
|
||||
|
||||
parts = []
|
||||
params = {}
|
||||
|
||||
for idx, row in enumerate(self.rows):
|
||||
key = f"{self.prefix}_{idx}"
|
||||
parts.append(
|
||||
f"SELECT :{key}_parent AS parent, :{key}_child AS child, "
|
||||
f":{key}_allow AS allow, :{key}_reason AS reason"
|
||||
)
|
||||
params[f"{key}_parent"] = row.parent
|
||||
params[f"{key}_child"] = row.child
|
||||
params[f"{key}_allow"] = 1 if row.allow else 0
|
||||
params[f"{key}_reason"] = row.reason
|
||||
|
||||
sql = "\nUNION ALL\n".join(parts)
|
||||
return PermissionSQL(sql=sql, params=params)
|
||||
195
datasette/default_permissions/restrictions.py
Normal file
195
datasette/default_permissions/restrictions.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"""
|
||||
Actor restriction handling for Datasette permissions.
|
||||
|
||||
This module handles the _r (restrictions) key in actor dictionaries, which
|
||||
contains allowlists of resources the actor can access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, List, Optional, Set, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.permissions import PermissionSQL
|
||||
|
||||
from .helpers import action_in_list, get_action_name_variants
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActorRestrictions:
|
||||
"""Parsed actor restrictions from the _r key."""
|
||||
|
||||
global_actions: List[str] # _r.a - globally allowed actions
|
||||
database_actions: dict # _r.d - {db_name: [actions]}
|
||||
table_actions: dict # _r.r - {db_name: {table: [actions]}}
|
||||
|
||||
@classmethod
|
||||
def from_actor(cls, actor: Optional[dict]) -> Optional["ActorRestrictions"]:
|
||||
"""Parse restrictions from actor dict. Returns None if no restrictions."""
|
||||
if not actor:
|
||||
return None
|
||||
assert isinstance(actor, dict), "actor must be a dictionary"
|
||||
|
||||
restrictions = actor.get("_r")
|
||||
if restrictions is None:
|
||||
return None
|
||||
|
||||
return cls(
|
||||
global_actions=restrictions.get("a", []),
|
||||
database_actions=restrictions.get("d", {}),
|
||||
table_actions=restrictions.get("r", {}),
|
||||
)
|
||||
|
||||
def is_action_globally_allowed(self, datasette: "Datasette", action: str) -> bool:
|
||||
"""Check if action is in the global allowlist."""
|
||||
return action_in_list(datasette, action, self.global_actions)
|
||||
|
||||
def get_allowed_databases(self, datasette: "Datasette", action: str) -> Set[str]:
|
||||
"""Get database names where this action is allowed."""
|
||||
allowed = set()
|
||||
for db_name, db_actions in self.database_actions.items():
|
||||
if action_in_list(datasette, action, db_actions):
|
||||
allowed.add(db_name)
|
||||
return allowed
|
||||
|
||||
def get_allowed_tables(
|
||||
self, datasette: "Datasette", action: str
|
||||
) -> Set[Tuple[str, str]]:
|
||||
"""Get (database, table) pairs where this action is allowed."""
|
||||
allowed = set()
|
||||
for db_name, tables in self.table_actions.items():
|
||||
for table_name, table_actions in tables.items():
|
||||
if action_in_list(datasette, action, table_actions):
|
||||
allowed.add((db_name, table_name))
|
||||
return allowed
|
||||
|
||||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def actor_restrictions_sql(
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> Optional[List[PermissionSQL]]:
|
||||
"""
|
||||
Handle actor restriction-based permission rules.
|
||||
|
||||
When an actor has an "_r" key, it contains an allowlist of resources they
|
||||
can access. This function returns restriction_sql that filters the final
|
||||
results to only include resources in that allowlist.
|
||||
|
||||
The _r structure:
|
||||
{
|
||||
"a": ["vi", "pd"], # Global actions allowed
|
||||
"d": {"mydb": ["vt", "es"]}, # Database-level actions
|
||||
"r": {"mydb": {"users": ["vt"]}} # Table-level actions
|
||||
}
|
||||
"""
|
||||
if not actor:
|
||||
return None
|
||||
|
||||
restrictions = ActorRestrictions.from_actor(actor)
|
||||
|
||||
if restrictions is None:
|
||||
# No restrictions - all resources allowed
|
||||
return []
|
||||
|
||||
# If globally allowed, no filtering needed
|
||||
if restrictions.is_action_globally_allowed(datasette, action):
|
||||
return []
|
||||
|
||||
# Build restriction SQL
|
||||
allowed_dbs = restrictions.get_allowed_databases(datasette, action)
|
||||
allowed_tables = restrictions.get_allowed_tables(datasette, action)
|
||||
|
||||
# If nothing is allowed for this action, return empty-set restriction
|
||||
if not allowed_dbs and not allowed_tables:
|
||||
return [
|
||||
PermissionSQL(
|
||||
params={"deny": f"actor restrictions: {action} not in allowlist"},
|
||||
restriction_sql="SELECT NULL AS parent, NULL AS child WHERE 0",
|
||||
)
|
||||
]
|
||||
|
||||
# Build UNION of allowed resources
|
||||
selects = []
|
||||
params = {}
|
||||
counter = 0
|
||||
|
||||
# Database-level entries (parent, NULL) - allows all children
|
||||
for db_name in allowed_dbs:
|
||||
key = f"restr_{counter}"
|
||||
counter += 1
|
||||
selects.append(f"SELECT :{key}_parent AS parent, NULL AS child")
|
||||
params[f"{key}_parent"] = db_name
|
||||
|
||||
# Table-level entries (parent, child)
|
||||
for db_name, table_name in allowed_tables:
|
||||
key = f"restr_{counter}"
|
||||
counter += 1
|
||||
selects.append(f"SELECT :{key}_parent AS parent, :{key}_child AS child")
|
||||
params[f"{key}_parent"] = db_name
|
||||
params[f"{key}_child"] = table_name
|
||||
|
||||
restriction_sql = "\nUNION ALL\n".join(selects)
|
||||
|
||||
return [PermissionSQL(params=params, restriction_sql=restriction_sql)]
|
||||
|
||||
|
||||
def restrictions_allow_action(
|
||||
datasette: "Datasette",
|
||||
restrictions: dict,
|
||||
action: str,
|
||||
resource: Optional[str | Tuple[str, str]],
|
||||
) -> bool:
|
||||
"""
|
||||
Check if restrictions allow the requested action on the requested resource.
|
||||
|
||||
This is a synchronous utility function for use by other code that needs
|
||||
to quickly check restriction allowlists.
|
||||
|
||||
Args:
|
||||
datasette: The Datasette instance
|
||||
restrictions: The _r dict from an actor
|
||||
action: The action name to check
|
||||
resource: None for global, str for database, (db, table) tuple for table
|
||||
|
||||
Returns:
|
||||
True if allowed, False if denied
|
||||
"""
|
||||
# Does this action have an abbreviation?
|
||||
to_check = get_action_name_variants(datasette, action)
|
||||
|
||||
# Check global level (any resource)
|
||||
all_allowed = restrictions.get("a")
|
||||
if all_allowed is not None:
|
||||
assert isinstance(all_allowed, list)
|
||||
if to_check.intersection(all_allowed):
|
||||
return True
|
||||
|
||||
# Check database level
|
||||
if resource:
|
||||
if isinstance(resource, str):
|
||||
database_name = resource
|
||||
else:
|
||||
database_name = resource[0]
|
||||
database_allowed = restrictions.get("d", {}).get(database_name)
|
||||
if database_allowed is not None:
|
||||
assert isinstance(database_allowed, list)
|
||||
if to_check.intersection(database_allowed):
|
||||
return True
|
||||
|
||||
# Check table/resource level
|
||||
if resource is not None and not isinstance(resource, str) and len(resource) == 2:
|
||||
database, table = resource
|
||||
table_allowed = restrictions.get("r", {}).get(database, {}).get(table)
|
||||
if table_allowed is not None:
|
||||
assert isinstance(table_allowed, list)
|
||||
if to_check.intersection(table_allowed):
|
||||
return True
|
||||
|
||||
# This action is not explicitly allowed, so reject it
|
||||
return False
|
||||
29
datasette/default_permissions/root.py
Normal file
29
datasette/default_permissions/root.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""
|
||||
Root user permission handling for Datasette.
|
||||
|
||||
Grants full permissions to the root user when --root flag is used.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.permissions import PermissionSQL
|
||||
|
||||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def root_user_permissions_sql(
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
) -> Optional[PermissionSQL]:
|
||||
"""
|
||||
Grant root user full permissions when --root flag is used.
|
||||
"""
|
||||
if not datasette.root_enabled:
|
||||
return None
|
||||
if actor is not None and actor.get("id") == "root":
|
||||
return PermissionSQL.allow(reason="root user")
|
||||
95
datasette/default_permissions/tokens.py
Normal file
95
datasette/default_permissions/tokens.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""
|
||||
Token authentication for Datasette.
|
||||
|
||||
Handles signed API tokens (dstok_ prefix).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
import itsdangerous
|
||||
|
||||
from datasette import hookimpl
|
||||
|
||||
|
||||
@hookimpl(specname="actor_from_request")
|
||||
def actor_from_signed_api_token(datasette: "Datasette", request) -> Optional[dict]:
|
||||
"""
|
||||
Authenticate requests using signed API tokens (dstok_ prefix).
|
||||
|
||||
Token structure (signed JSON):
|
||||
{
|
||||
"a": "actor_id", # Actor ID
|
||||
"t": 1234567890, # Timestamp (Unix epoch)
|
||||
"d": 3600, # Optional: Duration in seconds
|
||||
"_r": {...} # Optional: Restrictions
|
||||
}
|
||||
"""
|
||||
prefix = "dstok_"
|
||||
|
||||
# Check if tokens are enabled
|
||||
if not datasette.setting("allow_signed_tokens"):
|
||||
return None
|
||||
|
||||
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
|
||||
|
||||
# Get authorization header
|
||||
authorization = request.headers.get("authorization")
|
||||
if not authorization:
|
||||
return None
|
||||
if not authorization.startswith("Bearer "):
|
||||
return None
|
||||
|
||||
token = authorization[len("Bearer ") :]
|
||||
if not token.startswith(prefix):
|
||||
return None
|
||||
|
||||
# Remove prefix and verify signature
|
||||
token = token[len(prefix) :]
|
||||
try:
|
||||
decoded = datasette.unsign(token, namespace="token")
|
||||
except itsdangerous.BadSignature:
|
||||
return None
|
||||
|
||||
# Validate timestamp
|
||||
if "t" not in decoded:
|
||||
return None
|
||||
created = decoded["t"]
|
||||
if not isinstance(created, int):
|
||||
return None
|
||||
|
||||
# Handle duration/expiry
|
||||
duration = decoded.get("d")
|
||||
if duration is not None and not isinstance(duration, int):
|
||||
return None
|
||||
|
||||
# Apply max TTL if configured
|
||||
if (duration is None and max_signed_tokens_ttl) or (
|
||||
duration is not None
|
||||
and max_signed_tokens_ttl
|
||||
and duration > max_signed_tokens_ttl
|
||||
):
|
||||
duration = max_signed_tokens_ttl
|
||||
|
||||
# Check expiry
|
||||
if duration:
|
||||
if time.time() - created > duration:
|
||||
return None
|
||||
|
||||
# Build actor dict
|
||||
actor = {"id": decoded["a"], "token": "dstok"}
|
||||
|
||||
# Copy restrictions if present
|
||||
if "_r" in decoded:
|
||||
actor["_r"] = decoded["_r"]
|
||||
|
||||
# Add expiry timestamp if applicable
|
||||
if duration:
|
||||
actor["token_expires"] = created + duration
|
||||
|
||||
return actor
|
||||
235
datasette/events.py
Normal file
235
datasette/events.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
from abc import ABC, abstractproperty
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datasette.hookspecs import hookimpl
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event(ABC):
|
||||
@abstractproperty
|
||||
def name(self):
|
||||
pass
|
||||
|
||||
created: datetime = field(
|
||||
init=False, default_factory=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
actor: dict | None
|
||||
|
||||
def properties(self):
|
||||
properties = asdict(self)
|
||||
properties.pop("actor", None)
|
||||
properties.pop("created", None)
|
||||
return properties
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginEvent(Event):
|
||||
"""
|
||||
Event name: ``login``
|
||||
|
||||
A user (represented by ``event.actor``) has logged in.
|
||||
"""
|
||||
|
||||
name = "login"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogoutEvent(Event):
|
||||
"""
|
||||
Event name: ``logout``
|
||||
|
||||
A user (represented by ``event.actor``) has logged out.
|
||||
"""
|
||||
|
||||
name = "logout"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreateTokenEvent(Event):
|
||||
"""
|
||||
Event name: ``create-token``
|
||||
|
||||
A user created an API token.
|
||||
|
||||
:ivar expires_after: Number of seconds after which this token will expire.
|
||||
:type expires_after: int or None
|
||||
:ivar restrict_all: Restricted permissions for this token.
|
||||
:type restrict_all: list
|
||||
:ivar restrict_database: Restricted database permissions for this token.
|
||||
:type restrict_database: dict
|
||||
:ivar restrict_resource: Restricted resource permissions for this token.
|
||||
:type restrict_resource: dict
|
||||
"""
|
||||
|
||||
name = "create-token"
|
||||
expires_after: int | None
|
||||
restrict_all: list
|
||||
restrict_database: dict
|
||||
restrict_resource: dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreateTableEvent(Event):
|
||||
"""
|
||||
Event name: ``create-table``
|
||||
|
||||
A new table has been created in the database.
|
||||
|
||||
:ivar database: The name of the database where the table was created.
|
||||
:type database: str
|
||||
:ivar table: The name of the table that was created
|
||||
:type table: str
|
||||
:ivar schema: The SQL schema definition for the new table.
|
||||
:type schema: str
|
||||
"""
|
||||
|
||||
name = "create-table"
|
||||
database: str
|
||||
table: str
|
||||
schema: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DropTableEvent(Event):
|
||||
"""
|
||||
Event name: ``drop-table``
|
||||
|
||||
A table has been dropped from the database.
|
||||
|
||||
:ivar database: The name of the database where the table was dropped.
|
||||
:type database: str
|
||||
:ivar table: The name of the table that was dropped
|
||||
:type table: str
|
||||
"""
|
||||
|
||||
name = "drop-table"
|
||||
database: str
|
||||
table: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlterTableEvent(Event):
|
||||
"""
|
||||
Event name: ``alter-table``
|
||||
|
||||
A table has been altered.
|
||||
|
||||
:ivar database: The name of the database where the table was altered
|
||||
:type database: str
|
||||
:ivar table: The name of the table that was altered
|
||||
:type table: str
|
||||
:ivar before_schema: The table's SQL schema before the alteration
|
||||
:type before_schema: str
|
||||
:ivar after_schema: The table's SQL schema after the alteration
|
||||
:type after_schema: str
|
||||
"""
|
||||
|
||||
name = "alter-table"
|
||||
database: str
|
||||
table: str
|
||||
before_schema: str
|
||||
after_schema: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class InsertRowsEvent(Event):
|
||||
"""
|
||||
Event name: ``insert-rows``
|
||||
|
||||
Rows were inserted into a table.
|
||||
|
||||
:ivar database: The name of the database where the rows were inserted.
|
||||
:type database: str
|
||||
:ivar table: The name of the table where the rows were inserted.
|
||||
:type table: str
|
||||
:ivar num_rows: The number of rows that were requested to be inserted.
|
||||
:type num_rows: int
|
||||
:ivar ignore: Was ignore set?
|
||||
:type ignore: bool
|
||||
:ivar replace: Was replace set?
|
||||
:type replace: bool
|
||||
"""
|
||||
|
||||
name = "insert-rows"
|
||||
database: str
|
||||
table: str
|
||||
num_rows: int
|
||||
ignore: bool
|
||||
replace: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpsertRowsEvent(Event):
|
||||
"""
|
||||
Event name: ``upsert-rows``
|
||||
|
||||
Rows were upserted into a table.
|
||||
|
||||
:ivar database: The name of the database where the rows were inserted.
|
||||
:type database: str
|
||||
:ivar table: The name of the table where the rows were inserted.
|
||||
:type table: str
|
||||
:ivar num_rows: The number of rows that were requested to be inserted.
|
||||
:type num_rows: int
|
||||
"""
|
||||
|
||||
name = "upsert-rows"
|
||||
database: str
|
||||
table: str
|
||||
num_rows: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateRowEvent(Event):
|
||||
"""
|
||||
Event name: ``update-row``
|
||||
|
||||
A row was updated in a table.
|
||||
|
||||
:ivar database: The name of the database where the row was updated.
|
||||
:type database: str
|
||||
:ivar table: The name of the table where the row was updated.
|
||||
:type table: str
|
||||
:ivar pks: The primary key values of the updated row.
|
||||
"""
|
||||
|
||||
name = "update-row"
|
||||
database: str
|
||||
table: str
|
||||
pks: list
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeleteRowEvent(Event):
|
||||
"""
|
||||
Event name: ``delete-row``
|
||||
|
||||
A row was deleted from a table.
|
||||
|
||||
:ivar database: The name of the database where the row was deleted.
|
||||
:type database: str
|
||||
:ivar table: The name of the table where the row was deleted.
|
||||
:type table: str
|
||||
:ivar pks: The primary key values of the deleted row.
|
||||
"""
|
||||
|
||||
name = "delete-row"
|
||||
database: str
|
||||
table: str
|
||||
pks: list
|
||||
|
||||
|
||||
@hookimpl
|
||||
def register_events():
|
||||
return [
|
||||
LoginEvent,
|
||||
LogoutEvent,
|
||||
CreateTableEvent,
|
||||
CreateTokenEvent,
|
||||
AlterTableEvent,
|
||||
DropTableEvent,
|
||||
InsertRowsEvent,
|
||||
UpsertRowsEvent,
|
||||
UpdateRowEvent,
|
||||
DeleteRowEvent,
|
||||
]
|
||||
|
|
@ -11,8 +11,8 @@ from datasette.utils import (
|
|||
)
|
||||
|
||||
|
||||
def load_facet_configs(request, table_metadata):
|
||||
# Given a request and the metadata configuration for a table, return
|
||||
def load_facet_configs(request, table_config):
|
||||
# Given a request and the configuration for a table, return
|
||||
# a dictionary of selected facets, their lists of configs and for each
|
||||
# config whether it came from the request or the metadata.
|
||||
#
|
||||
|
|
@ -20,21 +20,21 @@ def load_facet_configs(request, table_metadata):
|
|||
# {"source": "metadata", "config": config1},
|
||||
# {"source": "request", "config": config2}]}
|
||||
facet_configs = {}
|
||||
table_metadata = table_metadata or {}
|
||||
metadata_facets = table_metadata.get("facets", [])
|
||||
for metadata_config in metadata_facets:
|
||||
if isinstance(metadata_config, str):
|
||||
table_config = table_config or {}
|
||||
table_facet_configs = table_config.get("facets", [])
|
||||
for facet_config in table_facet_configs:
|
||||
if isinstance(facet_config, str):
|
||||
type = "column"
|
||||
metadata_config = {"simple": metadata_config}
|
||||
facet_config = {"simple": facet_config}
|
||||
else:
|
||||
assert (
|
||||
len(metadata_config.values()) == 1
|
||||
len(facet_config.values()) == 1
|
||||
), "Metadata config dicts should be {type: config}"
|
||||
type, metadata_config = list(metadata_config.items())[0]
|
||||
if isinstance(metadata_config, str):
|
||||
metadata_config = {"simple": metadata_config}
|
||||
type, facet_config = list(facet_config.items())[0]
|
||||
if isinstance(facet_config, str):
|
||||
facet_config = {"simple": facet_config}
|
||||
facet_configs.setdefault(type, []).append(
|
||||
{"source": "metadata", "config": metadata_config}
|
||||
{"source": "metadata", "config": facet_config}
|
||||
)
|
||||
qs_pairs = urllib.parse.parse_qs(request.query_string, keep_blank_values=True)
|
||||
for key, values in qs_pairs.items():
|
||||
|
|
@ -45,13 +45,12 @@ def load_facet_configs(request, table_metadata):
|
|||
elif key.startswith("_facet_"):
|
||||
type = key[len("_facet_") :]
|
||||
for value in values:
|
||||
# The value is the config - either JSON or not
|
||||
if value.startswith("{"):
|
||||
config = json.loads(value)
|
||||
else:
|
||||
config = {"simple": value}
|
||||
# The value is the facet_config - either JSON or not
|
||||
facet_config = (
|
||||
json.loads(value) if value.startswith("{") else {"simple": value}
|
||||
)
|
||||
facet_configs.setdefault(type, []).append(
|
||||
{"source": "request", "config": config}
|
||||
{"source": "request", "config": facet_config}
|
||||
)
|
||||
return facet_configs
|
||||
|
||||
|
|
@ -66,6 +65,8 @@ def register_facet_classes():
|
|||
|
||||
class Facet:
|
||||
type = None
|
||||
# How many rows to consider when suggesting facets:
|
||||
suggest_consider = 1000
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -75,7 +76,7 @@ class Facet:
|
|||
sql=None,
|
||||
table=None,
|
||||
params=None,
|
||||
metadata=None,
|
||||
table_config=None,
|
||||
row_count=None,
|
||||
):
|
||||
assert table or sql, "Must provide either table= or sql="
|
||||
|
|
@ -86,12 +87,12 @@ class Facet:
|
|||
self.table = table
|
||||
self.sql = sql or f"select * from [{table}]"
|
||||
self.params = params or []
|
||||
self.metadata = metadata
|
||||
self.table_config = table_config
|
||||
# row_count can be None, in which case we calculate it ourselves:
|
||||
self.row_count = row_count
|
||||
|
||||
def get_configs(self):
|
||||
configs = load_facet_configs(self.request, self.metadata)
|
||||
configs = load_facet_configs(self.request, self.table_config)
|
||||
return configs.get(self.type) or []
|
||||
|
||||
def get_querystring_pairs(self):
|
||||
|
|
@ -104,10 +105,15 @@ class Facet:
|
|||
max_returned_rows = self.ds.setting("max_returned_rows")
|
||||
table_facet_size = None
|
||||
if self.table:
|
||||
tables_metadata = self.ds.metadata("tables", database=self.database) or {}
|
||||
table_metadata = tables_metadata.get(self.table) or {}
|
||||
if table_metadata:
|
||||
table_facet_size = table_metadata.get("facet_size")
|
||||
config_facet_size = (
|
||||
self.ds.config.get("databases", {})
|
||||
.get(self.database, {})
|
||||
.get("tables", {})
|
||||
.get(self.table, {})
|
||||
.get("facet_size")
|
||||
)
|
||||
if config_facet_size:
|
||||
table_facet_size = config_facet_size
|
||||
custom_facet_size = self.request.args.get("_facet_size")
|
||||
if custom_facet_size:
|
||||
if custom_facet_size == "max":
|
||||
|
|
@ -141,17 +147,6 @@ class Facet:
|
|||
)
|
||||
).columns
|
||||
|
||||
async def get_row_count(self):
|
||||
if self.row_count is None:
|
||||
self.row_count = (
|
||||
await self.ds.execute(
|
||||
self.database,
|
||||
f"select count(*) from ({self.sql})",
|
||||
self.params,
|
||||
)
|
||||
).rows[0][0]
|
||||
return self.row_count
|
||||
|
||||
|
||||
class ColumnFacet(Facet):
|
||||
type = "column"
|
||||
|
|
@ -166,13 +161,16 @@ class ColumnFacet(Facet):
|
|||
if column in already_enabled:
|
||||
continue
|
||||
suggested_facet_sql = """
|
||||
select {column} as value, count(*) as n from (
|
||||
{sql}
|
||||
) where value is not null
|
||||
with limited as (select * from ({sql}) limit {suggest_consider})
|
||||
select {column} as value, count(*) as n from limited
|
||||
where value is not null
|
||||
group by value
|
||||
limit {limit}
|
||||
""".format(
|
||||
column=escape_sqlite(column), sql=self.sql, limit=facet_size + 1
|
||||
column=escape_sqlite(column),
|
||||
sql=self.sql,
|
||||
limit=facet_size + 1,
|
||||
suggest_consider=self.suggest_consider,
|
||||
)
|
||||
distinct_values = None
|
||||
try:
|
||||
|
|
@ -207,6 +205,17 @@ class ColumnFacet(Facet):
|
|||
continue
|
||||
return suggested_facets
|
||||
|
||||
async def get_row_count(self):
|
||||
if self.row_count is None:
|
||||
self.row_count = (
|
||||
await self.ds.execute(
|
||||
self.database,
|
||||
f"select count(*) from (select * from ({self.sql}) limit {self.suggest_consider})",
|
||||
self.params,
|
||||
)
|
||||
).rows[0][0]
|
||||
return self.row_count
|
||||
|
||||
async def facet_results(self):
|
||||
facet_results = []
|
||||
facets_timed_out = []
|
||||
|
|
@ -253,7 +262,7 @@ class ColumnFacet(Facet):
|
|||
# Attempt to expand foreign keys into labels
|
||||
values = [row["value"] for row in facet_rows]
|
||||
expanded = await self.ds.expand_foreign_keys(
|
||||
self.database, self.table, column, values
|
||||
self.request.actor, self.database, self.table, column, values
|
||||
)
|
||||
else:
|
||||
expanded = {}
|
||||
|
|
@ -309,11 +318,14 @@ class ArrayFacet(Facet):
|
|||
continue
|
||||
# Is every value in this column either null or a JSON array?
|
||||
suggested_facet_sql = """
|
||||
with limited as (select * from ({sql}) limit {suggest_consider})
|
||||
select distinct json_type({column})
|
||||
from ({sql})
|
||||
from limited
|
||||
where {column} is not null and {column} != ''
|
||||
""".format(
|
||||
column=escape_sqlite(column), sql=self.sql
|
||||
column=escape_sqlite(column),
|
||||
sql=self.sql,
|
||||
suggest_consider=self.suggest_consider,
|
||||
)
|
||||
try:
|
||||
results = await self.ds.execute(
|
||||
|
|
@ -398,7 +410,9 @@ class ArrayFacet(Facet):
|
|||
order by
|
||||
count(*) desc, value limit {limit}
|
||||
""".format(
|
||||
col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1
|
||||
col=escape_sqlite(column),
|
||||
sql=self.sql,
|
||||
limit=facet_size + 1,
|
||||
)
|
||||
try:
|
||||
facet_rows_results = await self.ds.execute(
|
||||
|
|
@ -466,8 +480,8 @@ class DateFacet(Facet):
|
|||
# Does this column contain any dates in the first 100 rows?
|
||||
suggested_facet_sql = """
|
||||
select date({column}) from (
|
||||
{sql}
|
||||
) where {column} glob "????-??-*" limit 100;
|
||||
select * from ({sql}) limit 100
|
||||
) where {column} glob "????-??-*"
|
||||
""".format(
|
||||
column=escape_sqlite(column), sql=self.sql
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.resources import DatabaseResource
|
||||
from datasette.views.base import DatasetteError
|
||||
from datasette.utils.asgi import BadRequest
|
||||
import json
|
||||
import numbers
|
||||
from .utils import detect_json1, escape_sqlite, path_with_removed_args
|
||||
|
||||
|
||||
|
|
@ -13,11 +13,10 @@ def where_filters(request, database, datasette):
|
|||
where_clauses = []
|
||||
extra_wheres_for_ui = []
|
||||
if "_where" in request.args:
|
||||
if not await datasette.permission_allowed(
|
||||
request.actor,
|
||||
"execute-sql",
|
||||
resource=database,
|
||||
default=True,
|
||||
if not await datasette.allowed(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(database=database),
|
||||
actor=request.actor,
|
||||
):
|
||||
raise DatasetteError("_where= is not allowed", status=403)
|
||||
else:
|
||||
|
|
@ -50,7 +49,7 @@ def search_filters(request, database, table, datasette):
|
|||
extra_context = {}
|
||||
|
||||
# Figure out which fts_table to use
|
||||
table_metadata = datasette.table_metadata(database, table)
|
||||
table_metadata = await datasette.table_config(database, table)
|
||||
db = datasette.get_database(database)
|
||||
fts_table = request.args.get("_fts_table")
|
||||
fts_table = fts_table or table_metadata.get("fts_table")
|
||||
|
|
@ -80,9 +79,9 @@ def search_filters(request, database, table, datasette):
|
|||
"{fts_pk} in (select rowid from {fts_table} where {fts_table} match {match_clause})".format(
|
||||
fts_table=escape_sqlite(fts_table),
|
||||
fts_pk=escape_sqlite(fts_pk),
|
||||
match_clause=":search"
|
||||
if search_mode_raw
|
||||
else "escape_fts(:search)",
|
||||
match_clause=(
|
||||
":search" if search_mode_raw else "escape_fts(:search)"
|
||||
),
|
||||
)
|
||||
)
|
||||
human_descriptions.append(f'search matches "{search}"')
|
||||
|
|
@ -99,9 +98,11 @@ def search_filters(request, database, table, datasette):
|
|||
"rowid in (select rowid from {fts_table} where {search_col} match {match_clause})".format(
|
||||
fts_table=escape_sqlite(fts_table),
|
||||
search_col=escape_sqlite(search_col),
|
||||
match_clause=":search_{}".format(i)
|
||||
if search_mode_raw
|
||||
else "escape_fts(:search_{})".format(i),
|
||||
match_clause=(
|
||||
":search_{}".format(i)
|
||||
if search_mode_raw
|
||||
else "escape_fts(:search_{})".format(i)
|
||||
),
|
||||
)
|
||||
)
|
||||
human_descriptions.append(
|
||||
|
|
@ -279,6 +280,13 @@ class Filters:
|
|||
'{c} contains "{v}"',
|
||||
format="%{}%",
|
||||
),
|
||||
TemplatedFilter(
|
||||
"notcontains",
|
||||
"does not contain",
|
||||
'"{c}" not like :{p}',
|
||||
'{c} does not contain "{v}"',
|
||||
format="%{}%",
|
||||
),
|
||||
TemplatedFilter(
|
||||
"endswith",
|
||||
"ends with",
|
||||
|
|
@ -359,12 +367,8 @@ class Filters:
|
|||
)
|
||||
_filters_by_key = {f.key: f for f in _filters}
|
||||
|
||||
def __init__(self, pairs, units=None, ureg=None):
|
||||
if units is None:
|
||||
units = {}
|
||||
def __init__(self, pairs):
|
||||
self.pairs = pairs
|
||||
self.units = units
|
||||
self.ureg = ureg
|
||||
|
||||
def lookups(self):
|
||||
"""Yields (lookup, display, no_argument) pairs"""
|
||||
|
|
@ -404,20 +408,6 @@ class Filters:
|
|||
def has_selections(self):
|
||||
return bool(self.pairs)
|
||||
|
||||
def convert_unit(self, column, value):
|
||||
"""If the user has provided a unit in the query, convert it into the column unit, if present."""
|
||||
if column not in self.units:
|
||||
return value
|
||||
|
||||
# Try to interpret the value as a unit
|
||||
value = self.ureg(value)
|
||||
if isinstance(value, numbers.Number):
|
||||
# It's just a bare number, assume it's the column unit
|
||||
return value
|
||||
|
||||
column_unit = self.ureg(self.units[column])
|
||||
return value.to(column_unit).magnitude
|
||||
|
||||
def build_where_clauses(self, table):
|
||||
sql_bits = []
|
||||
params = {}
|
||||
|
|
@ -425,9 +415,7 @@ class Filters:
|
|||
for column, lookup, value in self.selections():
|
||||
filter = self._filters_by_key.get(lookup, None)
|
||||
if filter:
|
||||
sql_bit, param = filter.where_clause(
|
||||
table, column, self.convert_unit(column, value), i
|
||||
)
|
||||
sql_bit, param = filter.where_clause(table, column, value, i)
|
||||
sql_bits.append(sql_bit)
|
||||
if param is not None:
|
||||
if not isinstance(param, list):
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
from os import stat
|
||||
from datasette import hookimpl, Response
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
from datasette import hookimpl, Response
|
||||
from .utils import await_me_maybe, add_cors_headers
|
||||
from .utils import add_cors_headers
|
||||
from .utils.asgi import (
|
||||
Base400,
|
||||
Forbidden,
|
||||
)
|
||||
from .views.base import DatasetteError
|
||||
from markupsafe import Markup
|
||||
import pdb
|
||||
import traceback
|
||||
from .plugins import pm
|
||||
|
||||
try:
|
||||
import ipdb as pdb
|
||||
except ImportError:
|
||||
import pdb
|
||||
|
||||
try:
|
||||
import rich
|
||||
|
|
@ -57,7 +59,8 @@ def handle_exception(datasette, request, exception):
|
|||
if request.path.split("?")[0].endswith(".json"):
|
||||
return Response.json(info, status=status, headers=headers)
|
||||
else:
|
||||
template = datasette.jinja_env.select_template(templates)
|
||||
environment = datasette.get_jinja_environment(request)
|
||||
template = environment.select_template(templates)
|
||||
return Response.html(
|
||||
await template.render_async(
|
||||
dict(
|
||||
|
|
|
|||
|
|
@ -10,11 +10,6 @@ def startup(datasette):
|
|||
"""Fires directly after Datasette first starts running"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def get_metadata(datasette, key, database, table):
|
||||
"""Return metadata to be merged into Datasette's metadata dictionary"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def asgi_wrapper(datasette):
|
||||
"""Returns an ASGI middleware callable to wrap our ASGI application with"""
|
||||
|
|
@ -60,7 +55,7 @@ def publish_subcommand(publish):
|
|||
|
||||
|
||||
@hookspec
|
||||
def render_cell(row, value, column, table, database, datasette):
|
||||
def render_cell(row, value, column, table, database, datasette, request):
|
||||
"""Customize rendering of HTML table cell values"""
|
||||
|
||||
|
||||
|
|
@ -74,6 +69,11 @@ def register_facet_classes():
|
|||
"""Register Facet subclasses"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def register_actions(datasette):
|
||||
"""Register actions: returns a list of datasette.permission.Action objects"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def register_routes(datasette):
|
||||
"""Register URL routes: return a list of (regex, view_function) pairs"""
|
||||
|
|
@ -89,6 +89,16 @@ def actor_from_request(datasette, request):
|
|||
"""Return an actor dictionary based on the incoming request"""
|
||||
|
||||
|
||||
@hookspec(firstresult=True)
|
||||
def actors_from_ids(datasette, actor_ids):
|
||||
"""Returns a dictionary mapping those IDs to actor dictionaries"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def jinja2_environment_from_request(datasette, request, env):
|
||||
"""Return a Jinja2 environment based on the incoming request"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def filters_from_request(request, database, table, datasette):
|
||||
"""
|
||||
|
|
@ -101,8 +111,15 @@ def filters_from_request(request, database, table, datasette):
|
|||
|
||||
|
||||
@hookspec
|
||||
def permission_allowed(datasette, actor, action, resource):
|
||||
"""Check if actor is allowed to perform this action - return True, False or None"""
|
||||
def permission_resources_sql(datasette, actor, action):
|
||||
"""Return SQL query fragments for permission checks on resources.
|
||||
|
||||
Returns None, a PermissionSQL object, or a list of PermissionSQL objects.
|
||||
Each PermissionSQL contains SQL that should return rows with columns:
|
||||
parent (str|None), child (str|None), allow (int), reason (str).
|
||||
|
||||
Used to efficiently check permissions across multiple resources at once.
|
||||
"""
|
||||
|
||||
|
||||
@hookspec
|
||||
|
|
@ -125,16 +142,36 @@ def menu_links(datasette, actor, request):
|
|||
"""Links for the navigation menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def row_actions(datasette, actor, request, database, table, row):
|
||||
"""Links for the row actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def table_actions(datasette, actor, database, table, request):
|
||||
"""Links for the table actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def view_actions(datasette, actor, database, view, request):
|
||||
"""Links for the view actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def query_actions(datasette, actor, database, query_name, request, sql, params):
|
||||
"""Links for the query and canned query actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def database_actions(datasette, actor, database, request):
|
||||
"""Links for the database actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def homepage_actions(datasette, actor, request):
|
||||
"""Links for the homepage actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def skip_csrf(datasette, scope):
|
||||
"""Mechanism for skipping CSRF checks for certain requests"""
|
||||
|
|
@ -143,3 +180,43 @@ def skip_csrf(datasette, scope):
|
|||
@hookspec
|
||||
def handle_exception(datasette, request, exception):
|
||||
"""Handle an uncaught exception. Can return a Response or None."""
|
||||
|
||||
|
||||
@hookspec
|
||||
def track_event(datasette, event):
|
||||
"""Respond to an event tracked by Datasette"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def register_events(datasette):
|
||||
"""Return a list of Event subclasses to use with track_event()"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def top_homepage(datasette, request):
|
||||
"""HTML to include at the top of the homepage"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def top_database(datasette, request, database):
|
||||
"""HTML to include at the top of the database page"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def top_table(datasette, request, database, table):
|
||||
"""HTML to include at the top of the table page"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def top_row(datasette, request, database, table, row):
|
||||
"""HTML to include at the top of the row page"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def top_query(datasette, request, database, sql):
|
||||
"""HTML to include at the top of the query results page"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def top_canned_query(datasette, request, database, query_name):
|
||||
"""HTML to include at the top of the canned query page"""
|
||||
|
|
|
|||
210
datasette/permissions.py
Normal file
210
datasette/permissions.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, NamedTuple
|
||||
import contextvars
|
||||
|
||||
|
||||
# Context variable to track when permission checks should be skipped
|
||||
_skip_permission_checks = contextvars.ContextVar(
|
||||
"skip_permission_checks", default=False
|
||||
)
|
||||
|
||||
|
||||
class SkipPermissions:
|
||||
"""Context manager to temporarily skip permission checks.
|
||||
|
||||
This is not a stable API and may change in future releases.
|
||||
|
||||
Usage:
|
||||
with SkipPermissions():
|
||||
# Permission checks are skipped within this block
|
||||
response = await datasette.client.get("/protected")
|
||||
"""
|
||||
|
||||
def __enter__(self):
|
||||
self.token = _skip_permission_checks.set(True)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
_skip_permission_checks.reset(self.token)
|
||||
return False
|
||||
|
||||
|
||||
class Resource(ABC):
|
||||
"""
|
||||
Base class for all resource types.
|
||||
|
||||
Each subclass represents a type of resource (e.g., TableResource, DatabaseResource).
|
||||
The class itself carries metadata about the resource type.
|
||||
Instances represent specific resources.
|
||||
"""
|
||||
|
||||
# Class-level metadata (subclasses must define these)
|
||||
name: str = None # e.g., "table", "database", "model"
|
||||
parent_class: type["Resource"] | None = None # e.g., DatabaseResource for tables
|
||||
|
||||
# Instance-level optional extra attributes
|
||||
reasons: list[str] | None = None
|
||||
include_reasons: bool | None = None
|
||||
|
||||
def __init__(self, parent: str | None = None, child: str | None = None):
|
||||
"""
|
||||
Create a resource instance.
|
||||
|
||||
Args:
|
||||
parent: The parent identifier (meaning depends on resource type)
|
||||
child: The child identifier (meaning depends on resource type)
|
||||
"""
|
||||
self.parent = parent
|
||||
self.child = child
|
||||
self._private = None # Sentinel to track if private was set
|
||||
|
||||
@property
|
||||
def private(self) -> bool:
|
||||
"""
|
||||
Whether this resource is private (accessible to actor but not anonymous).
|
||||
|
||||
This property is only available on Resource objects returned from
|
||||
allowed_resources() when include_is_private=True is used.
|
||||
|
||||
Raises:
|
||||
AttributeError: If accessed without calling include_is_private=True
|
||||
"""
|
||||
if self._private is None:
|
||||
raise AttributeError(
|
||||
"The 'private' attribute is only available when using "
|
||||
"allowed_resources(..., include_is_private=True)"
|
||||
)
|
||||
return self._private
|
||||
|
||||
@private.setter
|
||||
def private(self, value: bool):
|
||||
self._private = value
|
||||
|
||||
@classmethod
|
||||
def __init_subclass__(cls):
|
||||
"""
|
||||
Validate resource hierarchy doesn't exceed 2 levels.
|
||||
|
||||
Raises:
|
||||
ValueError: If this resource would create a 3-level hierarchy
|
||||
"""
|
||||
super().__init_subclass__()
|
||||
|
||||
if cls.parent_class is None:
|
||||
return # Top of hierarchy, nothing to validate
|
||||
|
||||
# Check if our parent has a parent - that would create 3 levels
|
||||
if cls.parent_class.parent_class is not None:
|
||||
# We have a parent, and that parent has a parent
|
||||
# This creates a 3-level hierarchy, which is not allowed
|
||||
raise ValueError(
|
||||
f"Resource {cls.__name__} creates a 3-level hierarchy: "
|
||||
f"{cls.parent_class.parent_class.__name__} -> {cls.parent_class.__name__} -> {cls.__name__}. "
|
||||
f"Maximum 2 levels allowed (parent -> child)."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def resources_sql(cls) -> str:
|
||||
"""
|
||||
Return SQL query that returns all resources of this type.
|
||||
|
||||
Must return two columns: parent, child
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class AllowedResource(NamedTuple):
|
||||
"""A resource with the reason it was allowed (for debugging)."""
|
||||
|
||||
resource: Resource
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class Action:
|
||||
name: str
|
||||
description: str | None
|
||||
abbr: str | None = None
|
||||
resource_class: type[Resource] | None = None
|
||||
also_requires: str | None = None # Optional action name that must also be allowed
|
||||
|
||||
@property
|
||||
def takes_parent(self) -> bool:
|
||||
"""
|
||||
Whether this action requires a parent identifier when instantiating its resource.
|
||||
|
||||
Returns False for global-only actions (no resource_class).
|
||||
Returns True for all actions with a resource_class (all resources require a parent identifier).
|
||||
"""
|
||||
return self.resource_class is not None
|
||||
|
||||
@property
|
||||
def takes_child(self) -> bool:
|
||||
"""
|
||||
Whether this action requires a child identifier when instantiating its resource.
|
||||
|
||||
Returns False for global actions (no resource_class).
|
||||
Returns False for parent-level resources (DatabaseResource - parent_class is None).
|
||||
Returns True for child-level resources (TableResource, QueryResource - have a parent_class).
|
||||
"""
|
||||
if self.resource_class is None:
|
||||
return False
|
||||
return self.resource_class.parent_class is not None
|
||||
|
||||
|
||||
_reason_id = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class PermissionSQL:
|
||||
"""
|
||||
A plugin contributes SQL that yields:
|
||||
parent TEXT NULL,
|
||||
child TEXT NULL,
|
||||
allow INTEGER, -- 1 allow, 0 deny
|
||||
reason TEXT
|
||||
|
||||
For restriction-only plugins, sql can be None and only restriction_sql is provided.
|
||||
"""
|
||||
|
||||
sql: str | None = (
|
||||
None # SQL that SELECTs the 4 columns above (can be None for restriction-only)
|
||||
)
|
||||
params: dict[str, Any] | None = (
|
||||
None # bound params for the SQL (values only; no ':' prefix)
|
||||
)
|
||||
source: str | None = None # System will set this to the plugin name
|
||||
restriction_sql: str | None = (
|
||||
None # Optional SQL that returns (parent, child) for restriction filtering
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def allow(cls, reason: str, _allow: bool = True) -> "PermissionSQL":
|
||||
global _reason_id
|
||||
i = _reason_id
|
||||
_reason_id += 1
|
||||
return cls(
|
||||
sql=f"SELECT NULL AS parent, NULL AS child, {1 if _allow else 0} AS allow, :reason_{i} AS reason",
|
||||
params={f"reason_{i}": reason},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def deny(cls, reason: str) -> "PermissionSQL":
|
||||
return cls.allow(reason=reason, _allow=False)
|
||||
|
||||
|
||||
# This is obsolete, replaced by Action and ResourceType
|
||||
@dataclass
|
||||
class Permission:
|
||||
name: str
|
||||
abbr: str | None
|
||||
description: str | None
|
||||
takes_database: bool
|
||||
takes_resource: bool
|
||||
default: bool
|
||||
# This is deliberately undocumented: it's considered an internal
|
||||
# implementation detail for view-table/view-database and should
|
||||
# not be used by plugins as it may change in the future.
|
||||
implies_can_view: bool = False
|
||||
|
|
@ -1,9 +1,20 @@
|
|||
import importlib
|
||||
import os
|
||||
import pluggy
|
||||
import pkg_resources
|
||||
from pprint import pprint
|
||||
import sys
|
||||
from . import hookspecs
|
||||
|
||||
if sys.version_info >= (3, 9):
|
||||
import importlib.resources as importlib_resources
|
||||
else:
|
||||
import importlib_resources
|
||||
if sys.version_info >= (3, 10):
|
||||
import importlib.metadata as importlib_metadata
|
||||
else:
|
||||
import importlib_metadata
|
||||
|
||||
|
||||
DEFAULT_PLUGINS = (
|
||||
"datasette.publish.heroku",
|
||||
"datasette.publish.cloudrun",
|
||||
|
|
@ -12,20 +23,65 @@ DEFAULT_PLUGINS = (
|
|||
"datasette.sql_functions",
|
||||
"datasette.actor_auth_cookie",
|
||||
"datasette.default_permissions",
|
||||
"datasette.default_actions",
|
||||
"datasette.default_magic_parameters",
|
||||
"datasette.blob_renderer",
|
||||
"datasette.default_menu_links",
|
||||
"datasette.handle_exception",
|
||||
"datasette.forbidden",
|
||||
"datasette.events",
|
||||
)
|
||||
|
||||
pm = pluggy.PluginManager("datasette")
|
||||
pm.add_hookspecs(hookspecs)
|
||||
|
||||
if not hasattr(sys, "_called_from_test"):
|
||||
DATASETTE_TRACE_PLUGINS = os.environ.get("DATASETTE_TRACE_PLUGINS", None)
|
||||
|
||||
|
||||
def before(hook_name, hook_impls, kwargs):
|
||||
print(file=sys.stderr)
|
||||
print(f"{hook_name}:", file=sys.stderr)
|
||||
pprint(kwargs, width=40, indent=4, stream=sys.stderr)
|
||||
print("Hook implementations:", file=sys.stderr)
|
||||
pprint(hook_impls, width=40, indent=4, stream=sys.stderr)
|
||||
|
||||
|
||||
def after(outcome, hook_name, hook_impls, kwargs):
|
||||
results = outcome.get_result()
|
||||
if not isinstance(results, list):
|
||||
results = [results]
|
||||
print("Results:", file=sys.stderr)
|
||||
pprint(results, width=40, indent=4, stream=sys.stderr)
|
||||
|
||||
|
||||
if DATASETTE_TRACE_PLUGINS:
|
||||
pm.add_hookcall_monitoring(before, after)
|
||||
|
||||
|
||||
DATASETTE_LOAD_PLUGINS = os.environ.get("DATASETTE_LOAD_PLUGINS", None)
|
||||
|
||||
if not hasattr(sys, "_called_from_test") and DATASETTE_LOAD_PLUGINS is None:
|
||||
# Only load plugins if not running tests
|
||||
pm.load_setuptools_entrypoints("datasette")
|
||||
|
||||
# Load any plugins specified in DATASETTE_LOAD_PLUGINS")
|
||||
if DATASETTE_LOAD_PLUGINS is not None:
|
||||
for package_name in [
|
||||
name for name in DATASETTE_LOAD_PLUGINS.split(",") if name.strip()
|
||||
]:
|
||||
try:
|
||||
distribution = importlib_metadata.distribution(package_name)
|
||||
entry_points = distribution.entry_points
|
||||
for entry_point in entry_points:
|
||||
if entry_point.group == "datasette":
|
||||
mod = entry_point.load()
|
||||
pm.register(mod, name=entry_point.name)
|
||||
# Ensure name can be found in plugin_to_distinfo later:
|
||||
pm._plugin_distinfo.append((mod, distribution))
|
||||
except importlib_metadata.PackageNotFoundError:
|
||||
sys.stderr.write("Plugin {} could not be found\n".format(package_name))
|
||||
|
||||
|
||||
# Load default plugins
|
||||
for plugin in DEFAULT_PLUGINS:
|
||||
mod = importlib.import_module(plugin)
|
||||
|
|
@ -38,21 +94,24 @@ def get_plugins():
|
|||
for plugin in pm.get_plugins():
|
||||
static_path = None
|
||||
templates_path = None
|
||||
if plugin.__name__ not in DEFAULT_PLUGINS:
|
||||
plugin_name = (
|
||||
plugin.__name__
|
||||
if hasattr(plugin, "__name__")
|
||||
else plugin.__class__.__name__
|
||||
)
|
||||
if plugin_name not in DEFAULT_PLUGINS:
|
||||
try:
|
||||
if pkg_resources.resource_isdir(plugin.__name__, "static"):
|
||||
static_path = pkg_resources.resource_filename(
|
||||
plugin.__name__, "static"
|
||||
if (importlib_resources.files(plugin_name) / "static").is_dir():
|
||||
static_path = str(importlib_resources.files(plugin_name) / "static")
|
||||
if (importlib_resources.files(plugin_name) / "templates").is_dir():
|
||||
templates_path = str(
|
||||
importlib_resources.files(plugin_name) / "templates"
|
||||
)
|
||||
if pkg_resources.resource_isdir(plugin.__name__, "templates"):
|
||||
templates_path = pkg_resources.resource_filename(
|
||||
plugin.__name__, "templates"
|
||||
)
|
||||
except (KeyError, ImportError):
|
||||
# Caused by --plugins_dir= plugins - KeyError/ImportError thrown in Py3.5
|
||||
except (TypeError, ModuleNotFoundError):
|
||||
# Caused by --plugins_dir= plugins
|
||||
pass
|
||||
plugin_info = {
|
||||
"name": plugin.__name__,
|
||||
"name": plugin_name,
|
||||
"static_path": static_path,
|
||||
"templates_path": templates_path,
|
||||
"hooks": [h.name for h in pm.get_hookcallers(plugin)],
|
||||
|
|
@ -60,6 +119,6 @@ def get_plugins():
|
|||
distinfo = plugin_to_distinfo.get(plugin)
|
||||
if distinfo:
|
||||
plugin_info["version"] = distinfo.version
|
||||
plugin_info["name"] = distinfo.project_name
|
||||
plugin_info["name"] = distinfo.name or distinfo.project_name
|
||||
plugins.append(plugin_info)
|
||||
return plugins
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import click
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
from subprocess import check_call, check_output
|
||||
from subprocess import CalledProcessError, check_call, check_output
|
||||
|
||||
from .common import (
|
||||
add_common_publish_arguments_and_options,
|
||||
|
|
@ -23,7 +23,9 @@ def publish_subcommand(publish):
|
|||
help="Application name to use when building",
|
||||
)
|
||||
@click.option(
|
||||
"--service", default="", help="Cloud Run service to deploy (or over-write)"
|
||||
"--service",
|
||||
default="",
|
||||
help="Cloud Run service to deploy (or over-write)",
|
||||
)
|
||||
@click.option("--spatialite", is_flag=True, help="Enable SpatialLite extension")
|
||||
@click.option(
|
||||
|
|
@ -55,13 +57,32 @@ def publish_subcommand(publish):
|
|||
@click.option(
|
||||
"--max-instances",
|
||||
type=int,
|
||||
help="Maximum Cloud Run instances",
|
||||
default=1,
|
||||
show_default=True,
|
||||
help="Maximum Cloud Run instances (use 0 to remove the limit)",
|
||||
)
|
||||
@click.option(
|
||||
"--min-instances",
|
||||
type=int,
|
||||
help="Minimum Cloud Run instances",
|
||||
)
|
||||
@click.option(
|
||||
"--artifact-repository",
|
||||
default="datasette",
|
||||
show_default=True,
|
||||
help="Artifact Registry repository to store the image",
|
||||
)
|
||||
@click.option(
|
||||
"--artifact-region",
|
||||
default="us",
|
||||
show_default=True,
|
||||
help="Artifact Registry location (region or multi-region)",
|
||||
)
|
||||
@click.option(
|
||||
"--artifact-project",
|
||||
default=None,
|
||||
help="Project ID for Artifact Registry (defaults to the active project)",
|
||||
)
|
||||
def cloudrun(
|
||||
files,
|
||||
metadata,
|
||||
|
|
@ -91,6 +112,9 @@ def publish_subcommand(publish):
|
|||
apt_get_extras,
|
||||
max_instances,
|
||||
min_instances,
|
||||
artifact_repository,
|
||||
artifact_region,
|
||||
artifact_project,
|
||||
):
|
||||
"Publish databases to Datasette running on Cloud Run"
|
||||
fail_if_publish_binary_not_installed(
|
||||
|
|
@ -100,6 +124,21 @@ def publish_subcommand(publish):
|
|||
"gcloud config get-value project", shell=True, universal_newlines=True
|
||||
).strip()
|
||||
|
||||
artifact_project = artifact_project or project
|
||||
|
||||
# Ensure Artifact Registry exists for the target image
|
||||
_ensure_artifact_registry(
|
||||
artifact_project=artifact_project,
|
||||
artifact_region=artifact_region,
|
||||
artifact_repository=artifact_repository,
|
||||
)
|
||||
|
||||
artifact_host = (
|
||||
artifact_region
|
||||
if artifact_region.endswith("-docker.pkg.dev")
|
||||
else f"{artifact_region}-docker.pkg.dev"
|
||||
)
|
||||
|
||||
if not service:
|
||||
# Show the user their current services, then prompt for one
|
||||
click.echo("Please provide a service name for this deployment\n")
|
||||
|
|
@ -117,6 +156,11 @@ def publish_subcommand(publish):
|
|||
click.echo("")
|
||||
service = click.prompt("Service name", type=str)
|
||||
|
||||
image_id = (
|
||||
f"{artifact_host}/{artifact_project}/"
|
||||
f"{artifact_repository}/datasette-{service}"
|
||||
)
|
||||
|
||||
extra_metadata = {
|
||||
"title": title,
|
||||
"license": license,
|
||||
|
|
@ -173,7 +217,6 @@ def publish_subcommand(publish):
|
|||
print(fp.read())
|
||||
print("\n====================\n")
|
||||
|
||||
image_id = f"gcr.io/{project}/{name}"
|
||||
check_call(
|
||||
"gcloud builds submit --tag {}{}".format(
|
||||
image_id, " --timeout {}".format(timeout) if timeout else ""
|
||||
|
|
@ -187,7 +230,7 @@ def publish_subcommand(publish):
|
|||
("--max-instances", max_instances),
|
||||
("--min-instances", min_instances),
|
||||
):
|
||||
if value:
|
||||
if value is not None:
|
||||
extra_deploy_options.append("{} {}".format(option, value))
|
||||
check_call(
|
||||
"gcloud run deploy --allow-unauthenticated --platform=managed --image {} {}{}".format(
|
||||
|
|
@ -199,6 +242,52 @@ def publish_subcommand(publish):
|
|||
)
|
||||
|
||||
|
||||
def _ensure_artifact_registry(artifact_project, artifact_region, artifact_repository):
|
||||
"""Ensure Artifact Registry API is enabled and the repository exists."""
|
||||
|
||||
enable_cmd = (
|
||||
"gcloud services enable artifactregistry.googleapis.com "
|
||||
f"--project {artifact_project} --quiet"
|
||||
)
|
||||
try:
|
||||
check_call(enable_cmd, shell=True)
|
||||
except CalledProcessError as exc:
|
||||
raise click.ClickException(
|
||||
"Failed to enable artifactregistry.googleapis.com. "
|
||||
"Please ensure you have permissions to manage services."
|
||||
) from exc
|
||||
|
||||
describe_cmd = (
|
||||
"gcloud artifacts repositories describe {repo} --project {project} "
|
||||
"--location {location} --quiet"
|
||||
).format(
|
||||
repo=artifact_repository,
|
||||
project=artifact_project,
|
||||
location=artifact_region,
|
||||
)
|
||||
try:
|
||||
check_call(describe_cmd, shell=True)
|
||||
return
|
||||
except CalledProcessError:
|
||||
create_cmd = (
|
||||
"gcloud artifacts repositories create {repo} --repository-format=docker "
|
||||
'--location {location} --project {project} --description "Datasette Cloud Run images" --quiet'
|
||||
).format(
|
||||
repo=artifact_repository,
|
||||
location=artifact_region,
|
||||
project=artifact_project,
|
||||
)
|
||||
try:
|
||||
check_call(create_cmd, shell=True)
|
||||
click.echo(f"Created Artifact Registry repository '{artifact_repository}'")
|
||||
except CalledProcessError as exc:
|
||||
raise click.ClickException(
|
||||
"Failed to create Artifact Registry repository. "
|
||||
"Use --artifact-repository/--artifact-region to point to an existing repo "
|
||||
"or create one manually."
|
||||
) from exc
|
||||
|
||||
|
||||
def get_existing_services():
|
||||
services = json.loads(
|
||||
check_output(
|
||||
|
|
@ -214,6 +303,7 @@ def get_existing_services():
|
|||
"url": service["status"]["address"]["url"],
|
||||
}
|
||||
for service in services
|
||||
if "url" in service["status"]
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ from datasette import hookimpl
|
|||
import click
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shlex
|
||||
import shutil
|
||||
from subprocess import call, check_output
|
||||
import tempfile
|
||||
|
||||
|
|
@ -28,6 +30,11 @@ def publish_subcommand(publish):
|
|||
"--tar",
|
||||
help="--tar option to pass to Heroku, e.g. --tar=/usr/local/bin/gtar",
|
||||
)
|
||||
@click.option(
|
||||
"--generate-dir",
|
||||
type=click.Path(dir_okay=True, file_okay=False),
|
||||
help="Output generated application files and stop without deploying",
|
||||
)
|
||||
def heroku(
|
||||
files,
|
||||
metadata,
|
||||
|
|
@ -49,6 +56,7 @@ def publish_subcommand(publish):
|
|||
about_url,
|
||||
name,
|
||||
tar,
|
||||
generate_dir,
|
||||
):
|
||||
"Publish databases to Datasette running on Heroku"
|
||||
fail_if_publish_binary_not_installed(
|
||||
|
|
@ -105,6 +113,16 @@ def publish_subcommand(publish):
|
|||
secret,
|
||||
extra_metadata,
|
||||
):
|
||||
if generate_dir:
|
||||
# Recursively copy files from current working directory to it
|
||||
if pathlib.Path(generate_dir).exists():
|
||||
raise click.ClickException("Directory already exists")
|
||||
shutil.copytree(".", generate_dir)
|
||||
click.echo(
|
||||
f"Generated files written to {generate_dir}, stopping without deploying",
|
||||
err=True,
|
||||
)
|
||||
return
|
||||
app_name = None
|
||||
if name:
|
||||
# Check to see if this app already exists
|
||||
|
|
@ -176,7 +194,7 @@ def temporary_heroku_directory(
|
|||
fp.write(json.dumps(metadata_content, indent=2))
|
||||
|
||||
with open("runtime.txt", "w") as fp:
|
||||
fp.write("python-3.8.10")
|
||||
fp.write("python-3.11.0")
|
||||
|
||||
if branch:
|
||||
install = [
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from datasette.utils import (
|
|||
remove_infinites,
|
||||
CustomJSONEncoder,
|
||||
path_from_row_pks,
|
||||
sqlite3,
|
||||
)
|
||||
from datasette.utils.asgi import Response
|
||||
|
||||
|
|
@ -19,14 +20,14 @@ def convert_specific_columns_to_json(rows, columns, json_cols):
|
|||
if column in json_cols:
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except (TypeError, ValueError) as e:
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
new_row.append(value)
|
||||
new_rows.append(new_row)
|
||||
return new_rows
|
||||
|
||||
|
||||
def json_renderer(args, data, view_name):
|
||||
def json_renderer(request, args, data, error, truncated=None):
|
||||
"""Render a response as JSON"""
|
||||
status_code = 200
|
||||
|
||||
|
|
@ -44,28 +45,38 @@ def json_renderer(args, data, view_name):
|
|||
data["rows"] = [remove_infinites(row) for row in data["rows"]]
|
||||
|
||||
# Deal with the _shape option
|
||||
shape = args.get("_shape", "arrays")
|
||||
shape = args.get("_shape", "objects")
|
||||
# if there's an error, ignore the shape entirely
|
||||
if data.get("error"):
|
||||
shape = "arrays"
|
||||
|
||||
next_url = data.get("next_url")
|
||||
data["ok"] = True
|
||||
if error:
|
||||
shape = "objects"
|
||||
status_code = 400
|
||||
data["error"] = error
|
||||
data["ok"] = False
|
||||
|
||||
if truncated is not None:
|
||||
data["truncated"] = truncated
|
||||
if shape == "arrayfirst":
|
||||
data = [row[0] for row in data["rows"]]
|
||||
if not data["rows"]:
|
||||
data = []
|
||||
elif isinstance(data["rows"][0], sqlite3.Row):
|
||||
data = [row[0] for row in data["rows"]]
|
||||
else:
|
||||
assert isinstance(data["rows"][0], dict)
|
||||
data = [next(iter(row.values())) for row in data["rows"]]
|
||||
elif shape in ("objects", "object", "array"):
|
||||
columns = data.get("columns")
|
||||
rows = data.get("rows")
|
||||
if rows and columns:
|
||||
if rows and columns and not isinstance(rows[0], dict):
|
||||
data["rows"] = [dict(zip(columns, row)) for row in rows]
|
||||
if shape == "object":
|
||||
error = None
|
||||
shape_error = None
|
||||
if "primary_keys" not in data:
|
||||
error = "_shape=object is only available on tables"
|
||||
shape_error = "_shape=object is only available on tables"
|
||||
else:
|
||||
pks = data["primary_keys"]
|
||||
if not pks:
|
||||
error = (
|
||||
shape_error = (
|
||||
"_shape=object not available for tables with no primary keys"
|
||||
)
|
||||
else:
|
||||
|
|
@ -74,13 +85,18 @@ def json_renderer(args, data, view_name):
|
|||
pk_string = path_from_row_pks(row, pks, not pks)
|
||||
object_rows[pk_string] = row
|
||||
data = object_rows
|
||||
if error:
|
||||
data = {"ok": False, "error": error}
|
||||
if shape_error:
|
||||
data = {"ok": False, "error": shape_error}
|
||||
elif shape == "array":
|
||||
data = data["rows"]
|
||||
|
||||
elif shape == "arrays":
|
||||
pass
|
||||
if not data["rows"]:
|
||||
pass
|
||||
elif isinstance(data["rows"][0], sqlite3.Row):
|
||||
data["rows"] = [list(row) for row in data["rows"]]
|
||||
else:
|
||||
data["rows"] = [list(row.values()) for row in data["rows"]]
|
||||
else:
|
||||
status_code = 400
|
||||
data = {
|
||||
|
|
@ -89,6 +105,12 @@ def json_renderer(args, data, view_name):
|
|||
"status": 400,
|
||||
"title": None,
|
||||
}
|
||||
|
||||
# Don't include "columns" in output
|
||||
# https://github.com/simonw/datasette/issues/2136
|
||||
if isinstance(data, dict) and "columns" not in request.args.getlist("_extra"):
|
||||
data.pop("columns", None)
|
||||
|
||||
# Handle _nl option for _shape=array
|
||||
nl = args.get("_nl", "")
|
||||
if nl and shape == "array":
|
||||
|
|
@ -98,8 +120,6 @@ def json_renderer(args, data, view_name):
|
|||
body = json.dumps(data, cls=CustomJSONEncoder)
|
||||
content_type = "application/json; charset=utf-8"
|
||||
headers = {}
|
||||
if next_url:
|
||||
headers["link"] = f'<{next_url}>; rel="next"'
|
||||
return Response(
|
||||
body, status=status_code, headers=headers, content_type=content_type
|
||||
)
|
||||
|
|
|
|||
90
datasette/resources.py
Normal file
90
datasette/resources.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Core resource types for Datasette's permission system."""
|
||||
|
||||
from datasette.permissions import Resource
|
||||
|
||||
|
||||
class DatabaseResource(Resource):
|
||||
"""A database in Datasette."""
|
||||
|
||||
name = "database"
|
||||
parent_class = None # Top of the resource hierarchy
|
||||
|
||||
def __init__(self, database: str):
|
||||
super().__init__(parent=database, child=None)
|
||||
|
||||
@classmethod
|
||||
async def resources_sql(cls, datasette) -> str:
|
||||
return """
|
||||
SELECT database_name AS parent, NULL AS child
|
||||
FROM catalog_databases
|
||||
"""
|
||||
|
||||
|
||||
class TableResource(Resource):
|
||||
"""A table in a database."""
|
||||
|
||||
name = "table"
|
||||
parent_class = DatabaseResource
|
||||
|
||||
def __init__(self, database: str, table: str):
|
||||
super().__init__(parent=database, child=table)
|
||||
|
||||
@classmethod
|
||||
async def resources_sql(cls, datasette) -> str:
|
||||
return """
|
||||
SELECT database_name AS parent, table_name AS child
|
||||
FROM catalog_tables
|
||||
UNION ALL
|
||||
SELECT database_name AS parent, view_name AS child
|
||||
FROM catalog_views
|
||||
"""
|
||||
|
||||
|
||||
class QueryResource(Resource):
|
||||
"""A canned query in a database."""
|
||||
|
||||
name = "query"
|
||||
parent_class = DatabaseResource
|
||||
|
||||
def __init__(self, database: str, query: str):
|
||||
super().__init__(parent=database, child=query)
|
||||
|
||||
@classmethod
|
||||
async def resources_sql(cls, datasette) -> str:
|
||||
from datasette.plugins import pm
|
||||
from datasette.utils import await_me_maybe
|
||||
|
||||
# Get all databases from catalog
|
||||
db = datasette.get_internal_database()
|
||||
result = await db.execute("SELECT database_name FROM catalog_databases")
|
||||
databases = [row[0] for row in result.rows]
|
||||
|
||||
# Gather all canned queries from all databases
|
||||
query_pairs = []
|
||||
for database_name in databases:
|
||||
# Call the hook to get queries (including from config via default plugin)
|
||||
for queries_result in pm.hook.canned_queries(
|
||||
datasette=datasette,
|
||||
database=database_name,
|
||||
actor=None, # Get ALL queries for resource enumeration
|
||||
):
|
||||
queries = await await_me_maybe(queries_result)
|
||||
if queries:
|
||||
for query_name in queries.keys():
|
||||
query_pairs.append((database_name, query_name))
|
||||
|
||||
# Build SQL
|
||||
if not query_pairs:
|
||||
return "SELECT NULL AS parent, NULL AS child WHERE 0"
|
||||
|
||||
# Generate UNION ALL query
|
||||
selects = []
|
||||
for db_name, query_name in query_pairs:
|
||||
# Escape single quotes by doubling them
|
||||
db_escaped = db_name.replace("'", "''")
|
||||
query_escaped = query_name.replace("'", "''")
|
||||
selects.append(
|
||||
f"SELECT '{db_escaped}' AS parent, '{query_escaped}' AS child"
|
||||
)
|
||||
|
||||
return " UNION ALL ".join(selects)
|
||||
|
|
@ -163,28 +163,22 @@ h6,
|
|||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 10px;
|
||||
border-left: 10px solid #666;
|
||||
margin-bottom: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.page-header h1 {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
padding-right: 0.2em;
|
||||
}
|
||||
.page-header details {
|
||||
display: inline-flex;
|
||||
}
|
||||
.page-header details > summary {
|
||||
|
||||
.page-action-menu details > summary {
|
||||
list-style: none;
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
}
|
||||
.page-header details > summary::-webkit-details-marker {
|
||||
.page-action-menu details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
|
@ -228,12 +222,6 @@ button.button-as-link:focus {
|
|||
color: #67C98D;
|
||||
}
|
||||
|
||||
a img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
font-family: monospace;
|
||||
|
|
@ -271,24 +259,28 @@ a.not-underlined {
|
|||
|
||||
/* Page Furniture ========================================================= */
|
||||
/* Header */
|
||||
header,
|
||||
footer {
|
||||
header.hd,
|
||||
footer.ft {
|
||||
padding: 0.6rem 1rem 0.5rem 1rem;
|
||||
background-color: #276890;
|
||||
background: linear-gradient(180deg, rgba(96,144,173,1) 0%, rgba(39,104,144,1) 50%);
|
||||
color: rgba(255,255,244,0.9);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
min-height: 2.6rem;
|
||||
}
|
||||
header p,
|
||||
footer p {
|
||||
footer.ft {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
header.hd p,
|
||||
footer.ft p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
header .crumbs {
|
||||
header.hd .crumbs {
|
||||
float: left;
|
||||
}
|
||||
header .actor {
|
||||
header.hd .actor {
|
||||
float: right;
|
||||
text-align: right;
|
||||
padding-left: 1rem;
|
||||
|
|
@ -297,32 +289,32 @@ header .actor {
|
|||
top: -3px;
|
||||
}
|
||||
|
||||
footer a:link,
|
||||
footer a:visited,
|
||||
footer a:hover,
|
||||
footer a:focus,
|
||||
footer a:active,
|
||||
footer button.button-as-link {
|
||||
footer.ft a:link,
|
||||
footer.ft a:visited,
|
||||
footer.ft a:hover,
|
||||
footer.ft a:focus,
|
||||
footer.ft a:active,
|
||||
footer.ft button.button-as-link {
|
||||
color: rgba(255,255,244,0.8);
|
||||
}
|
||||
header a:link,
|
||||
header a:visited,
|
||||
header a:hover,
|
||||
header a:focus,
|
||||
header a:active,
|
||||
header button.button-as-link {
|
||||
header.hd a:link,
|
||||
header.hd a:visited,
|
||||
header.hd a:hover,
|
||||
header.hd a:focus,
|
||||
header.hd a:active,
|
||||
header.hd button.button-as-link {
|
||||
color: rgba(255,255,244,0.8);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer a:hover,
|
||||
footer a:focus,
|
||||
footer a:active,
|
||||
footer.button-as-link:hover,
|
||||
footer.button-as-link:focus,
|
||||
header a:hover,
|
||||
header a:focus,
|
||||
header a:active,
|
||||
footer.ft a:hover,
|
||||
footer.ft a:focus,
|
||||
footer.ft a:active,
|
||||
footer.ft .button-as-link:hover,
|
||||
footer.ft .button-as-link:focus,
|
||||
header.hd a:hover,
|
||||
header.hd a:focus,
|
||||
header.hd a:active,
|
||||
button.button-as-link:hover,
|
||||
button.button-as-link:focus {
|
||||
color: rgba(255,255,244,1);
|
||||
|
|
@ -334,11 +326,6 @@ section.content {
|
|||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Navigation menu */
|
||||
details.nav-menu > summary {
|
||||
list-style: none;
|
||||
|
|
@ -352,25 +339,59 @@ details.nav-menu > summary::-webkit-details-marker {
|
|||
}
|
||||
details .nav-menu-inner {
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
top: 2.6rem;
|
||||
right: 10px;
|
||||
width: 180px;
|
||||
background-color: #276890;
|
||||
padding: 1rem;
|
||||
z-index: 1000;
|
||||
padding: 0;
|
||||
}
|
||||
.nav-menu-inner li,
|
||||
form.nav-menu-logout {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-top: 1px solid #ffffff69;
|
||||
}
|
||||
.nav-menu-inner a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Table/database actions menu */
|
||||
.page-header {
|
||||
.page-action-menu {
|
||||
position: relative;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.actions-menu-links {
|
||||
display: inline;
|
||||
}
|
||||
.actions-menu-links .dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
left: -10px;
|
||||
left: 0;
|
||||
z-index: 10000;
|
||||
}
|
||||
.page-action-menu .icon-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: .25rem;
|
||||
padding: 5px 12px 3px 7px;
|
||||
color: #fff;
|
||||
font-weight: 400;
|
||||
font-size: 0.8em;
|
||||
background: linear-gradient(180deg, #007bff 0%, #4E79C7 100%);
|
||||
border-color: #007bff;
|
||||
}
|
||||
.page-action-menu .icon-text span {
|
||||
/* Nudge text up a bit */
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
.page-action-menu .icon-text:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.page-action-menu .icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* Components ============================================================== */
|
||||
|
|
@ -423,36 +444,30 @@ h2 em {
|
|||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
table.rows-and-columns {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
td {
|
||||
table.rows-and-columns td {
|
||||
border-top: 1px solid #aaa;
|
||||
border-right: 1px solid #eee;
|
||||
padding: 4px;
|
||||
vertical-align: top;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
td.type-pk {
|
||||
table.rows-and-columns td.type-pk {
|
||||
font-weight: bold;
|
||||
}
|
||||
td em {
|
||||
table.rows-and-columns td em {
|
||||
font-style: normal;
|
||||
font-size: 0.8em;
|
||||
color: #aaa;
|
||||
}
|
||||
th {
|
||||
table.rows-and-columns th {
|
||||
padding-right: 1em;
|
||||
}
|
||||
table a:link {
|
||||
table.rows-and-columns a:link {
|
||||
text-decoration: none;
|
||||
}
|
||||
.rows-and-columns td:before {
|
||||
display: block;
|
||||
color: black;
|
||||
margin-left: -10%;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.rows-and-columns td ol,
|
||||
.rows-and-columns td ul {
|
||||
list-style: initial;
|
||||
|
|
@ -470,10 +485,8 @@ a.blob-download {
|
|||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
/* Forms =================================================================== */
|
||||
|
||||
|
||||
form.sql textarea {
|
||||
border: 1px solid #ccc;
|
||||
width: 70%;
|
||||
|
|
@ -482,27 +495,30 @@ form.sql textarea {
|
|||
font-family: monospace;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
form label {
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
form.sql label {
|
||||
width: 15%;
|
||||
}
|
||||
.advanced-export form label {
|
||||
width: auto;
|
||||
}
|
||||
.advanced-export input[type=submit] {
|
||||
font-size: 0.6em;
|
||||
margin-left: 1em;
|
||||
}
|
||||
label.sort_by_desc {
|
||||
width: auto;
|
||||
padding-right: 1em;
|
||||
}
|
||||
pre#sql-query {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
form input[type=text],
|
||||
form input[type=search] {
|
||||
|
||||
.core label,
|
||||
label.core {
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.core input[type=text],
|
||||
input.core[type=text],
|
||||
.core input[type=search],
|
||||
input.core[type=search] {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
width: 60%;
|
||||
|
|
@ -511,19 +527,27 @@ form input[type=search] {
|
|||
font-size: 1em;
|
||||
font-family: Helvetica, sans-serif;
|
||||
}
|
||||
/* Stop Webkit from styling search boxes in an inconsistent way */
|
||||
/* https://css-tricks.com/webkit-html5-search-inputs/ comments */
|
||||
input[type=search] {
|
||||
.core input[type=search],
|
||||
input.core[type=search] {
|
||||
/* Stop Webkit from styling search boxes in an inconsistent way */
|
||||
/* https://css-tricks.com/webkit-html5-search-inputs/ comments */
|
||||
-webkit-appearance: textfield;
|
||||
}
|
||||
input[type="search"]::-webkit-search-decoration,
|
||||
input[type="search"]::-webkit-search-cancel-button,
|
||||
input[type="search"]::-webkit-search-results-button,
|
||||
input[type="search"]::-webkit-search-results-decoration {
|
||||
.core input[type="search"]::-webkit-search-decoration,
|
||||
input.core[type="search"]::-webkit-search-decoration,
|
||||
.core input[type="search"]::-webkit-search-cancel-button,
|
||||
input.core[type="search"]::-webkit-search-cancel-button,
|
||||
.core input[type="search"]::-webkit-search-results-button,
|
||||
input.core[type="search"]::-webkit-search-results-button,
|
||||
.core input[type="search"]::-webkit-search-results-decoration,
|
||||
input.core[type="search"]::-webkit-search-results-decoration {
|
||||
display: none;
|
||||
}
|
||||
|
||||
form input[type=submit], form button[type=button] {
|
||||
.core input[type=submit],
|
||||
.core button[type=button],
|
||||
input.core[type=submit],
|
||||
button.core[type=button] {
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
|
|
@ -536,14 +560,16 @@ form input[type=submit], form button[type=button] {
|
|||
border-radius: .25rem;
|
||||
}
|
||||
|
||||
form input[type=submit] {
|
||||
.core input[type=submit],
|
||||
input.core[type=submit] {
|
||||
color: #fff;
|
||||
background-color: #007bff;
|
||||
background: linear-gradient(180deg, #007bff 0%, #4E79C7 100%);
|
||||
border-color: #007bff;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
form button[type=button] {
|
||||
.core button[type=button],
|
||||
button.core[type=button] {
|
||||
color: #007bff;
|
||||
background-color: #fff;
|
||||
border-color: #007bff;
|
||||
|
|
@ -573,6 +599,9 @@ form button[type=button] {
|
|||
display: inline-block;
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
.select-wrapper:focus-within {
|
||||
border: 1px solid black;
|
||||
}
|
||||
.select-wrapper.filter-op {
|
||||
width: 80px;
|
||||
}
|
||||
|
|
@ -730,7 +759,7 @@ p.zero-results {
|
|||
left: -9999px;
|
||||
}
|
||||
|
||||
.rows-and-columns tr {
|
||||
table.rows-and-columns tr {
|
||||
border: 1px solid #ccc;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 10px;
|
||||
|
|
@ -738,7 +767,7 @@ p.zero-results {
|
|||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
.rows-and-columns td {
|
||||
table.rows-and-columns td {
|
||||
/* Behave like a "row" */
|
||||
border: none;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
|
@ -746,7 +775,7 @@ p.zero-results {
|
|||
padding-left: 10%;
|
||||
}
|
||||
|
||||
.rows-and-columns td:before {
|
||||
table.rows-and-columns td:before {
|
||||
display: block;
|
||||
color: black;
|
||||
margin-left: -10%;
|
||||
|
|
@ -818,6 +847,13 @@ svg.dropdown-menu-icon {
|
|||
.dropdown-menu a:hover {
|
||||
background-color: #eee;
|
||||
}
|
||||
.dropdown-menu .dropdown-description {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.8em;
|
||||
max-width: 80vw;
|
||||
white-space: normal;
|
||||
}
|
||||
.dropdown-menu .hook {
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
|
|
|||
1
datasette/static/cm-editor-6.0.1.bundle.js
Normal file
1
datasette/static/cm-editor-6.0.1.bundle.js
Normal file
File diff suppressed because one or more lines are too long
74
datasette/static/cm-editor-6.0.1.js
Normal file
74
datasette/static/cm-editor-6.0.1.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { EditorView, basicSetup } from "codemirror";
|
||||
import { keymap } from "@codemirror/view";
|
||||
import { sql, SQLDialect } from "@codemirror/lang-sql";
|
||||
|
||||
// A variation of SQLite from lang-sql https://github.com/codemirror/lang-sql/blob/ebf115fffdbe07f91465ccbd82868c587f8182bc/src/sql.ts#L231
|
||||
const SQLite = SQLDialect.define({
|
||||
// Based on https://www.sqlite.org/lang_keywords.html based on likely keywords to be used in select queries
|
||||
// https://github.com/simonw/datasette/pull/1893#issuecomment-1316401895:
|
||||
keywords:
|
||||
"and as asc between by case cast count current_date current_time current_timestamp desc distinct each else escape except exists explain filter first for from full generated group having if in index inner intersect into isnull join last left like limit not null or order outer over pragma primary query raise range regexp right rollback row select set table then to union unique using values view virtual when where",
|
||||
// https://www.sqlite.org/datatype3.html
|
||||
types: "null integer real text blob",
|
||||
builtin: "",
|
||||
operatorChars: "*+-%<>!=&|/~",
|
||||
identifierQuotes: '`"',
|
||||
specialVar: "@:?$",
|
||||
});
|
||||
|
||||
// Utility function from https://codemirror.net/docs/migration/
|
||||
export function editorFromTextArea(textarea, conf = {}) {
|
||||
// This could also be configured with a set of tables and columns for better autocomplete:
|
||||
// https://github.com/codemirror/lang-sql#user-content-sqlconfig.tables
|
||||
let view = new EditorView({
|
||||
doc: textarea.value,
|
||||
extensions: [
|
||||
keymap.of([
|
||||
{
|
||||
key: "Shift-Enter",
|
||||
run: function () {
|
||||
textarea.value = view.state.doc.toString();
|
||||
textarea.form.submit();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Meta-Enter",
|
||||
run: function () {
|
||||
textarea.value = view.state.doc.toString();
|
||||
textarea.form.submit();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]),
|
||||
// This has to be after the keymap or else the basicSetup keys will prevent
|
||||
// Meta-Enter from running
|
||||
basicSetup,
|
||||
EditorView.lineWrapping,
|
||||
sql({
|
||||
dialect: SQLite,
|
||||
schema: conf.schema,
|
||||
tables: conf.tables,
|
||||
defaultTableName: conf.defaultTableName,
|
||||
defaultSchemaName: conf.defaultSchemaName,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Idea taken from https://discuss.codemirror.net/t/resizing-codemirror-6/3265.
|
||||
// Using CSS resize: both and scheduling a measurement when the element changes.
|
||||
let editorDOM = view.contentDOM.closest(".cm-editor");
|
||||
let observer = new ResizeObserver(function () {
|
||||
view.requestMeasure();
|
||||
});
|
||||
observer.observe(editorDOM, { attributes: true });
|
||||
|
||||
textarea.parentNode.insertBefore(view.dom, textarea);
|
||||
textarea.style.display = "none";
|
||||
if (textarea.form) {
|
||||
textarea.form.addEventListener("submit", () => {
|
||||
textarea.value = view.state.doc.toString();
|
||||
});
|
||||
}
|
||||
return view;
|
||||
}
|
||||
8
datasette/static/cm-resize-1.0.1.min.js
vendored
8
datasette/static/cm-resize-1.0.1.min.js
vendored
|
|
@ -1,8 +0,0 @@
|
|||
/*!
|
||||
* cm-resize v1.0.1
|
||||
* https://github.com/Sphinxxxx/cm-resize
|
||||
*
|
||||
* Copyright 2017-2018 Andreas Borgen (https://github.com/Sphinxxxx)
|
||||
* Released under the MIT license.
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.cmResize=t()}(this,function(){"use strict";return document.documentElement.firstElementChild.appendChild(document.createElement("style")).textContent=".cm-resize-handle{display:block;position:absolute;bottom:0;right:0;z-index:99;width:18px;height:18px;background:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0,0 16,16'%3E%3Cpath stroke='gray' stroke-width='2' d='M-1,12 l18,-18 M-1,18 l18,-18 M-1,24 l18,-18 M-1,30 l18,-18'/%3E%3C/svg%3E\") center/cover;box-shadow:inset -1px -1px 0 0 silver;cursor:nwse-resize}",function(r,e){var t,c=(e=e||{}).minWidth||200,l=e.minHeight||100,s=!1!==e.resizableWidth,d=!1!==e.resizableHeight,n=e.cssClass||"cm-resize-handle",o=r.display.wrapper,i=e.handle||((t=o.appendChild(document.createElement("div"))).className=n,t),a=o.querySelector(".CodeMirror-vscrollbar"),u=o.querySelector(".CodeMirror-hscrollbar");function h(){e.handle||(a.style.bottom="18px",u.style.right="18px")}r.on("update",h),h();var f=void 0,m=void 0;return function(e){var t=Element.prototype;t.matches||(t.matches=t.msMatchesSelector||t.webkitMatchesSelector),t.closest||(t.closest=function(e){var t=this;do{if(t.matches(e))return t;t="svg"===t.tagName?t.parentNode:t.parentElement}while(t);return null});var l=(e=e||{}).container||document.documentElement,n=e.selector,o=e.callback||console.log,i=e.callbackDragStart,a=e.callbackDragEnd,r=e.callbackClick,c=e.propagateEvents,s=!1!==e.roundCoords,d=!1!==e.dragOutside,u=e.handleOffset||!1!==e.handleOffset,h=null;switch(u){case"center":h=!0;break;case"topleft":case"top-left":h=!1}var f=void 0,m=void 0,p=void 0;function v(e,t,n,o){var i=e.clientX,a=e.clientY;function r(e,t,n){return Math.max(t,Math.min(e,n))}if(t){var c=t.getBoundingClientRect();i-=c.left,a-=c.top,n&&(i-=n[0],a-=n[1]),o&&(i=r(i,0,c.width),a=r(a,0,c.height)),t!==l&&(null!==h?h:"circle"===t.nodeName||"ellipse"===t.nodeName)&&(i-=c.width/2,a-=c.height/2)}return s?[Math.round(i),Math.round(a)]:[i,a]}function g(e){e.preventDefault(),c||e.stopPropagation()}function w(e){(f=n?n instanceof Element?n.contains(e.target)?n:null:e.target.closest(n):{})&&(g(e),m=n&&u?v(e,f):[0,0],p=v(e,l,m),s&&(p=p.map(Math.round)),i&&i(f,p))}function b(e){if(f){g(e);var t=v(e,l,m,!d);o(f,t,p)}}function E(e){if(f){if(a||r){var t=v(e,l,m,!d);r&&p[0]===t[0]&&p[1]===t[1]&&r(f,p),a&&a(f,t,p)}f=null}}function x(e){E(C(e))}function M(e){return void 0!==e.buttons?1===e.buttons:1===e.which}function k(e,t){1===e.touches.length?t(C(e)):E(e)}function C(e){var t=e.targetTouches[0];return t||(t=e.changedTouches[0]),t.preventDefault=e.preventDefault.bind(e),t.stopPropagation=e.stopPropagation.bind(e),t}l.addEventListener("mousedown",function(e){M(e)&&w(e)}),l.addEventListener("touchstart",function(e){k(e,w)}),window.addEventListener("mousemove",function(e){f&&(M(e)?b(e):E(e))}),window.addEventListener("touchmove",function(e){k(e,b)}),window.addEventListener("mouseup",function(e){f&&!M(e)&&E(e)}),l.addEventListener("touchend",x),l.addEventListener("touchcancel",x)}({container:i.offsetParent,selector:i,callbackDragStart:function(e,t){f=t,m=[o.clientWidth,o.clientHeight]},callback:function(e,t){var n=t[0]-f[0],o=t[1]-f[1],i=s?Math.max(c,m[0]+n):null,a=d?Math.max(l,m[1]+o):null;r.setSize(i,a)}}),i}});
|
||||
File diff suppressed because one or more lines are too long
1
datasette/static/codemirror-5.57.0.min.css
vendored
1
datasette/static/codemirror-5.57.0.min.css
vendored
File diff suppressed because one or more lines are too long
11
datasette/static/codemirror-5.57.0.min.js
vendored
11
datasette/static/codemirror-5.57.0.min.js
vendored
File diff suppressed because one or more lines are too long
210
datasette/static/datasette-manager.js
Normal file
210
datasette/static/datasette-manager.js
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
// Custom events for use with the native CustomEvent API
|
||||
const DATASETTE_EVENTS = {
|
||||
INIT: "datasette_init", // returns datasette manager instance in evt.detail
|
||||
};
|
||||
|
||||
// Datasette "core" -> Methods/APIs that are foundational
|
||||
// Plugins will have greater stability if they use the functional hooks- but if they do decide to hook into
|
||||
// literal DOM selectors, they'll have an easier time using these addresses.
|
||||
const DOM_SELECTORS = {
|
||||
/** Should have one match */
|
||||
jsonExportLink: ".export-links a[href*=json]",
|
||||
|
||||
/** Event listeners that go outside of the main table, e.g. existing scroll listener */
|
||||
tableWrapper: ".table-wrapper",
|
||||
table: "table.rows-and-columns",
|
||||
aboveTablePanel: ".above-table-panel",
|
||||
|
||||
// These could have multiple matches
|
||||
/** Used for selecting table headers. Use makeColumnActions if you want to add menu items. */
|
||||
tableHeaders: `table.rows-and-columns th`,
|
||||
|
||||
/** Used to add "where" clauses to query using direct manipulation */
|
||||
filterRows: ".filter-row",
|
||||
/** Used to show top available enum values for a column ("facets") */
|
||||
facetResults: ".facet-results [data-column]",
|
||||
};
|
||||
|
||||
/**
|
||||
* Monolith class for interacting with Datasette JS API
|
||||
* Imported with DEFER, runs after main document parsed
|
||||
* For now, manually synced with datasette/version.py
|
||||
*/
|
||||
const datasetteManager = {
|
||||
VERSION: window.datasetteVersion,
|
||||
|
||||
// TODO: Should order of registration matter more?
|
||||
|
||||
// Should plugins be allowed to clobber others or is it last-in takes priority?
|
||||
// Does pluginMetadata need to be serializable, or can we let it be stateful / have functions?
|
||||
plugins: new Map(),
|
||||
|
||||
registerPlugin: (name, pluginMetadata) => {
|
||||
if (datasetteManager.plugins.has(name)) {
|
||||
console.warn(`Warning -> plugin ${name} was redefined`);
|
||||
}
|
||||
datasetteManager.plugins.set(name, pluginMetadata);
|
||||
|
||||
// If the plugin participates in the panel... update the panel.
|
||||
if (pluginMetadata.makeAboveTablePanelConfigs) {
|
||||
datasetteManager.renderAboveTablePanel();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* New DOM elements are created on each click, so the data is not stale.
|
||||
*
|
||||
* Items
|
||||
* - must provide label (text)
|
||||
* - might provide href (string) or an onclick ((evt) => void)
|
||||
*
|
||||
* columnMeta is metadata stored on the column header (TH) as a DOMStringMap
|
||||
* - column: string
|
||||
* - columnNotNull: boolean
|
||||
* - columnType: sqlite datatype enum (text, number, etc)
|
||||
* - isPk: boolean
|
||||
*/
|
||||
makeColumnActions: (columnMeta) => {
|
||||
let columnActions = [];
|
||||
|
||||
// Accept function that returns list of columnActions with keys
|
||||
// Required: label (text)
|
||||
// Optional: onClick or href
|
||||
datasetteManager.plugins.forEach((plugin) => {
|
||||
if (plugin.makeColumnActions) {
|
||||
// Plugins can provide multiple columnActions if they want
|
||||
// If multiple try to create entry with same label, the last one deletes the others
|
||||
columnActions.push(...plugin.makeColumnActions(columnMeta));
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: Validate columnAction configs and give informative error message if missing keys.
|
||||
return columnActions;
|
||||
},
|
||||
|
||||
/**
|
||||
* In MVP, each plugin can only have 1 instance.
|
||||
* In future, panels could be repeated. We omit that for now since so many plugins depend on
|
||||
* shared URL state, so having multiple instances of plugin at same time is problematic.
|
||||
* Currently, we never destroy any panels, we just hide them.
|
||||
*
|
||||
* TODO: nicer panel css, show panel selection state.
|
||||
* TODO: does this hook need to take any arguments?
|
||||
*/
|
||||
renderAboveTablePanel: () => {
|
||||
const aboveTablePanel = document.querySelector(
|
||||
DOM_SELECTORS.aboveTablePanel,
|
||||
);
|
||||
|
||||
if (!aboveTablePanel) {
|
||||
console.warn(
|
||||
"This page does not have a table, the renderAboveTablePanel cannot be used.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let aboveTablePanelWrapper = aboveTablePanel.querySelector(".panels");
|
||||
|
||||
// First render: create wrappers. Otherwise, reuse previous.
|
||||
if (!aboveTablePanelWrapper) {
|
||||
aboveTablePanelWrapper = document.createElement("div");
|
||||
aboveTablePanelWrapper.classList.add("tab-contents");
|
||||
const panelNav = document.createElement("div");
|
||||
panelNav.classList.add("tab-controls");
|
||||
|
||||
// Temporary: css for minimal amount of breathing room.
|
||||
panelNav.style.display = "flex";
|
||||
panelNav.style.gap = "8px";
|
||||
panelNav.style.marginTop = "4px";
|
||||
panelNav.style.marginBottom = "20px";
|
||||
|
||||
aboveTablePanel.appendChild(panelNav);
|
||||
aboveTablePanel.appendChild(aboveTablePanelWrapper);
|
||||
}
|
||||
|
||||
datasetteManager.plugins.forEach((plugin, pluginName) => {
|
||||
const { makeAboveTablePanelConfigs } = plugin;
|
||||
|
||||
if (makeAboveTablePanelConfigs) {
|
||||
const controls = aboveTablePanel.querySelector(".tab-controls");
|
||||
const contents = aboveTablePanel.querySelector(".tab-contents");
|
||||
|
||||
// Each plugin can make multiple panels
|
||||
const configs = makeAboveTablePanelConfigs();
|
||||
|
||||
configs.forEach((config, i) => {
|
||||
const nodeContentId = `${pluginName}_${config.id}_panel-content`;
|
||||
|
||||
// quit if we've already registered this plugin
|
||||
// TODO: look into whether plugins should be allowed to ask
|
||||
// parent to re-render, or if they should manage that internally.
|
||||
if (document.getElementById(nodeContentId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add tab control button
|
||||
const pluginControl = document.createElement("button");
|
||||
pluginControl.textContent = config.label;
|
||||
pluginControl.onclick = () => {
|
||||
contents.childNodes.forEach((node) => {
|
||||
if (node.id === nodeContentId) {
|
||||
node.style.display = "block";
|
||||
} else {
|
||||
node.style.display = "none";
|
||||
}
|
||||
});
|
||||
};
|
||||
controls.appendChild(pluginControl);
|
||||
|
||||
// Add plugin content area
|
||||
const pluginNode = document.createElement("div");
|
||||
pluginNode.id = nodeContentId;
|
||||
config.render(pluginNode);
|
||||
pluginNode.style.display = "none"; // Default to hidden unless you're ifrst
|
||||
|
||||
contents.appendChild(pluginNode);
|
||||
});
|
||||
|
||||
// Let first node be selected by default
|
||||
if (contents.childNodes.length) {
|
||||
contents.childNodes[0].style.display = "block";
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/** Selectors for document (DOM) elements. Store identifier instead of immediate references in case they haven't loaded when Manager starts. */
|
||||
selectors: DOM_SELECTORS,
|
||||
|
||||
// Future API ideas
|
||||
// Fetch page's data in array, and cache so plugins could reuse it
|
||||
// Provide knowledge of what datasette JS or server-side via traditional console autocomplete
|
||||
// State helpers: URL params https://github.com/simonw/datasette/issues/1144 and localstorage
|
||||
// UI Hooks: command + k, tab manager hook
|
||||
// Should we notify plugins that have dependencies
|
||||
// when all dependencies were fulfilled? (leaflet, codemirror, etc)
|
||||
// https://github.com/simonw/datasette-leaflet -> this way
|
||||
// multiple plugins can all request the same copy of leaflet.
|
||||
};
|
||||
|
||||
const initializeDatasette = () => {
|
||||
// Hide the global behind __ prefix. Ideally they should be listening for the
|
||||
// DATASETTE_EVENTS.INIT event to avoid the habit of reading from the window.
|
||||
|
||||
window.__DATASETTE__ = datasetteManager;
|
||||
console.debug("Datasette Manager Created!");
|
||||
|
||||
const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, {
|
||||
detail: datasetteManager,
|
||||
});
|
||||
|
||||
document.dispatchEvent(initDatasetteEvent);
|
||||
};
|
||||
|
||||
/**
|
||||
* Main function
|
||||
* Fires AFTER the document has been parsed
|
||||
*/
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initializeDatasette();
|
||||
});
|
||||
56
datasette/static/json-format-highlight-1.0.1.js
Normal file
56
datasette/static/json-format-highlight-1.0.1.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
https://github.com/luyilin/json-format-highlight
|
||||
From https://unpkg.com/json-format-highlight@1.0.1/dist/json-format-highlight.js
|
||||
MIT Licensed
|
||||
*/
|
||||
(function (global, factory) {
|
||||
typeof exports === "object" && typeof module !== "undefined"
|
||||
? (module.exports = factory())
|
||||
: typeof define === "function" && define.amd
|
||||
? define(factory)
|
||||
: (global.jsonFormatHighlight = factory());
|
||||
})(this, function () {
|
||||
"use strict";
|
||||
|
||||
var defaultColors = {
|
||||
keyColor: "dimgray",
|
||||
numberColor: "lightskyblue",
|
||||
stringColor: "lightcoral",
|
||||
trueColor: "lightseagreen",
|
||||
falseColor: "#f66578",
|
||||
nullColor: "cornflowerblue",
|
||||
};
|
||||
|
||||
function index(json, colorOptions) {
|
||||
if (colorOptions === void 0) colorOptions = {};
|
||||
|
||||
if (!json) {
|
||||
return;
|
||||
}
|
||||
if (typeof json !== "string") {
|
||||
json = JSON.stringify(json, null, 2);
|
||||
}
|
||||
var colors = Object.assign({}, defaultColors, colorOptions);
|
||||
json = json.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
return json.replace(
|
||||
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+]?\d+)?)/g,
|
||||
function (match) {
|
||||
var color = colors.numberColor;
|
||||
if (/^"/.test(match)) {
|
||||
color = /:$/.test(match) ? colors.keyColor : colors.stringColor;
|
||||
} else {
|
||||
color = /true/.test(match)
|
||||
? colors.trueColor
|
||||
: /false/.test(match)
|
||||
? colors.falseColor
|
||||
: /null/.test(match)
|
||||
? colors.nullColor
|
||||
: color;
|
||||
}
|
||||
return '<span style="color: ' + color + '">' + match + "</span>";
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return index;
|
||||
});
|
||||
416
datasette/static/navigation-search.js
Normal file
416
datasette/static/navigation-search.js
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
class NavigationSearch extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.selectedIndex = -1;
|
||||
this.matches = [];
|
||||
this.debounceTimer = null;
|
||||
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
dialog {
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0;
|
||||
max-width: 90vw;
|
||||
width: 600px;
|
||||
max-height: 80vh;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
padding: 1.25rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.results-container {
|
||||
overflow-y: auto;
|
||||
height: calc(80vh - 180px);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 0.875rem 1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 0.5rem;
|
||||
transition: background-color 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.result-item.selected {
|
||||
background-color: #dbeafe;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.result-url {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hint-text kbd {
|
||||
background: #f3f4f6;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 640px) {
|
||||
dialog {
|
||||
width: 95vw;
|
||||
max-height: 85vh;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<dialog>
|
||||
<div class="search-container">
|
||||
<div class="search-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search..."
|
||||
aria-label="Search navigation"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
</div>
|
||||
<div class="results-container" role="listbox"></div>
|
||||
<div class="hint-text">
|
||||
<span><kbd>↑</kbd> <kbd>↓</kbd> Navigate</span>
|
||||
<span><kbd>Enter</kbd> Select</span>
|
||||
<span><kbd>Esc</kbd> Close</span>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const dialog = this.shadowRoot.querySelector("dialog");
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
const resultsContainer =
|
||||
this.shadowRoot.querySelector(".results-container");
|
||||
|
||||
// Global keyboard listener for "/"
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "/" && !this.isInputFocused() && !dialog.open) {
|
||||
e.preventDefault();
|
||||
this.openMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Input event
|
||||
input.addEventListener("input", (e) => {
|
||||
this.handleSearch(e.target.value);
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
this.moveSelection(1);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
this.moveSelection(-1);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
this.selectCurrentItem();
|
||||
} else if (e.key === "Escape") {
|
||||
this.closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Click on result item
|
||||
resultsContainer.addEventListener("click", (e) => {
|
||||
const item = e.target.closest(".result-item");
|
||||
if (item) {
|
||||
const index = parseInt(item.dataset.index);
|
||||
this.selectItem(index);
|
||||
}
|
||||
});
|
||||
|
||||
// Close on backdrop click
|
||||
dialog.addEventListener("click", (e) => {
|
||||
if (e.target === dialog) {
|
||||
this.closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Initial load
|
||||
this.loadInitialData();
|
||||
}
|
||||
|
||||
isInputFocused() {
|
||||
const activeElement = document.activeElement;
|
||||
return (
|
||||
activeElement &&
|
||||
(activeElement.tagName === "INPUT" ||
|
||||
activeElement.tagName === "TEXTAREA" ||
|
||||
activeElement.isContentEditable)
|
||||
);
|
||||
}
|
||||
|
||||
loadInitialData() {
|
||||
const itemsAttr = this.getAttribute("items");
|
||||
if (itemsAttr) {
|
||||
try {
|
||||
this.allItems = JSON.parse(itemsAttr);
|
||||
this.matches = this.allItems;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse items attribute:", e);
|
||||
this.allItems = [];
|
||||
this.matches = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleSearch(query) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
const url = this.getAttribute("url");
|
||||
|
||||
if (url) {
|
||||
// Fetch from API
|
||||
this.fetchResults(url, query);
|
||||
} else {
|
||||
// Filter local items
|
||||
this.filterLocalItems(query);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
async fetchResults(url, query) {
|
||||
try {
|
||||
const searchUrl = `${url}?q=${encodeURIComponent(query)}`;
|
||||
const response = await fetch(searchUrl);
|
||||
const data = await response.json();
|
||||
this.matches = data.matches || [];
|
||||
this.selectedIndex = this.matches.length > 0 ? 0 : -1;
|
||||
this.renderResults();
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch search results:", e);
|
||||
this.matches = [];
|
||||
this.renderResults();
|
||||
}
|
||||
}
|
||||
|
||||
filterLocalItems(query) {
|
||||
if (!query.trim()) {
|
||||
this.matches = [];
|
||||
} else {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
this.matches = (this.allItems || []).filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(lowerQuery) ||
|
||||
item.url.toLowerCase().includes(lowerQuery),
|
||||
);
|
||||
}
|
||||
this.selectedIndex = this.matches.length > 0 ? 0 : -1;
|
||||
this.renderResults();
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
const container = this.shadowRoot.querySelector(".results-container");
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
|
||||
if (this.matches.length === 0) {
|
||||
const message = input.value.trim()
|
||||
? "No results found"
|
||||
: "Start typing to search...";
|
||||
container.innerHTML = `<div class="no-results">${message}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = this.matches
|
||||
.map(
|
||||
(match, index) => `
|
||||
<div
|
||||
class="result-item ${
|
||||
index === this.selectedIndex ? "selected" : ""
|
||||
}"
|
||||
data-index="${index}"
|
||||
role="option"
|
||||
aria-selected="${index === this.selectedIndex}"
|
||||
>
|
||||
<div>
|
||||
<div class="result-name">${this.escapeHtml(
|
||||
match.name,
|
||||
)}</div>
|
||||
<div class="result-url">${this.escapeHtml(match.url)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
// Scroll selected item into view
|
||||
if (this.selectedIndex >= 0) {
|
||||
const selectedItem = container.children[this.selectedIndex];
|
||||
if (selectedItem) {
|
||||
selectedItem.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
moveSelection(direction) {
|
||||
const newIndex = this.selectedIndex + direction;
|
||||
if (newIndex >= 0 && newIndex < this.matches.length) {
|
||||
this.selectedIndex = newIndex;
|
||||
this.renderResults();
|
||||
}
|
||||
}
|
||||
|
||||
selectCurrentItem() {
|
||||
if (this.selectedIndex >= 0 && this.selectedIndex < this.matches.length) {
|
||||
this.selectItem(this.selectedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
selectItem(index) {
|
||||
const match = this.matches[index];
|
||||
if (match) {
|
||||
// Dispatch custom event
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("select", {
|
||||
detail: match,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Navigate to URL
|
||||
window.location.href = match.url;
|
||||
|
||||
this.closeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
openMenu() {
|
||||
const dialog = this.shadowRoot.querySelector("dialog");
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
|
||||
dialog.showModal();
|
||||
input.value = "";
|
||||
input.focus();
|
||||
|
||||
// Reset state - start with no items shown
|
||||
this.matches = [];
|
||||
this.selectedIndex = -1;
|
||||
this.renderResults();
|
||||
}
|
||||
|
||||
closeMenu() {
|
||||
const dialog = this.shadowRoot.querySelector("dialog");
|
||||
dialog.close();
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// Register the custom element
|
||||
customElements.define("navigation-search", NavigationSearch);
|
||||
|
|
@ -17,7 +17,8 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
|||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>`;
|
||||
|
||||
(function () {
|
||||
/** Main initialization function for Datasette Table interactions */
|
||||
const initDatasetteTable = function (manager) {
|
||||
// Feature detection
|
||||
if (!window.URLSearchParams) {
|
||||
return;
|
||||
|
|
@ -68,13 +69,11 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
|||
menu.style.display = "none";
|
||||
menu.classList.remove("anim-scale-in");
|
||||
}
|
||||
// When page loads, add scroll listener on .table-wrapper
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
var tableWrapper = document.querySelector(".table-wrapper");
|
||||
if (tableWrapper) {
|
||||
tableWrapper.addEventListener("scroll", closeMenu);
|
||||
}
|
||||
});
|
||||
|
||||
const tableWrapper = document.querySelector(manager.selectors.tableWrapper);
|
||||
if (tableWrapper) {
|
||||
tableWrapper.addEventListener("scroll", closeMenu);
|
||||
}
|
||||
document.body.addEventListener("click", (ev) => {
|
||||
/* was this click outside the menu? */
|
||||
var target = ev.target;
|
||||
|
|
@ -85,9 +84,11 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
|||
closeMenu();
|
||||
}
|
||||
});
|
||||
function iconClicked(ev) {
|
||||
|
||||
function onTableHeaderClick(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
menu.innerHTML = DROPDOWN_HTML;
|
||||
var th = ev.target;
|
||||
while (th.nodeName != "TH") {
|
||||
th = th.parentNode;
|
||||
|
|
@ -131,7 +132,7 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
|||
/* Only show "Facet by this" if it's not the first column, not selected,
|
||||
not a single PK and the Datasette allow_facet setting is True */
|
||||
var displayedFacets = Array.from(
|
||||
document.querySelectorAll(".facet-info")
|
||||
document.querySelectorAll(".facet-info"),
|
||||
).map((el) => el.dataset.column);
|
||||
var isFirstColumn =
|
||||
th.parentElement.querySelector("th:first-of-type") == th;
|
||||
|
|
@ -151,7 +152,7 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
|||
}
|
||||
/* Show notBlank option if not selected AND at least one visible blank value */
|
||||
var tdsForThisColumn = Array.from(
|
||||
th.closest("table").querySelectorAll("td." + th.className)
|
||||
th.closest("table").querySelectorAll("td." + th.className),
|
||||
);
|
||||
if (
|
||||
params.get(`${column}__notblank`) != "1" &&
|
||||
|
|
@ -185,7 +186,61 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
|||
menu.style.left = menuLeft + "px";
|
||||
menu.style.display = "block";
|
||||
menu.classList.add("anim-scale-in");
|
||||
|
||||
// Custom menu items on each render
|
||||
// Plugin hook: allow adding JS-based additional menu items
|
||||
const columnActionsPayload = {
|
||||
columnName: th.dataset.column,
|
||||
columnNotNull: th.dataset.columnNotNull === "1",
|
||||
columnType: th.dataset.columnType,
|
||||
isPk: th.dataset.isPk === "1",
|
||||
};
|
||||
const columnItemConfigs = manager.makeColumnActions(columnActionsPayload);
|
||||
|
||||
const menuList = menu.querySelector("ul");
|
||||
columnItemConfigs.forEach((itemConfig) => {
|
||||
// Remove items from previous render. We assume entries have unique labels.
|
||||
const existingItems = menuList.querySelectorAll(`li`);
|
||||
Array.from(existingItems)
|
||||
.filter((item) => item.innerText === itemConfig.label)
|
||||
.forEach((node) => {
|
||||
node.remove();
|
||||
});
|
||||
|
||||
const newLink = document.createElement("a");
|
||||
newLink.textContent = itemConfig.label;
|
||||
newLink.href = itemConfig.href ?? "#";
|
||||
if (itemConfig.onClick) {
|
||||
newLink.onclick = itemConfig.onClick;
|
||||
}
|
||||
|
||||
// Attach new elements to DOM
|
||||
const menuItem = document.createElement("li");
|
||||
menuItem.appendChild(newLink);
|
||||
menuList.appendChild(menuItem);
|
||||
});
|
||||
|
||||
// Measure width of menu and adjust position if too far right
|
||||
const menuWidth = menu.offsetWidth;
|
||||
const windowWidth = window.innerWidth;
|
||||
if (menuLeft + menuWidth > windowWidth) {
|
||||
menu.style.left = windowWidth - menuWidth - 20 + "px";
|
||||
}
|
||||
// Align menu .hook arrow with the column cog icon
|
||||
const hook = menu.querySelector(".hook");
|
||||
const icon = th.querySelector(".dropdown-menu-icon");
|
||||
const iconRect = icon.getBoundingClientRect();
|
||||
const hookLeft = iconRect.left - menuLeft + 1 + "px";
|
||||
hook.style.left = hookLeft;
|
||||
// Move the whole menu right if the hook is too far right
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
if (iconRect.right > menuRect.right) {
|
||||
menu.style.left = iconRect.right - menuWidth + "px";
|
||||
// And move hook tip as well
|
||||
hook.style.left = menuWidth - 13 + "px";
|
||||
}
|
||||
}
|
||||
|
||||
var svg = document.createElement("div");
|
||||
svg.innerHTML = DROPDOWN_ICON_SVG;
|
||||
svg = svg.querySelector("*");
|
||||
|
|
@ -197,23 +252,25 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
|||
menu.style.display = "none";
|
||||
document.body.appendChild(menu);
|
||||
|
||||
var ths = Array.from(document.querySelectorAll(".rows-and-columns th"));
|
||||
var ths = Array.from(
|
||||
document.querySelectorAll(manager.selectors.tableHeaders),
|
||||
);
|
||||
ths.forEach((th) => {
|
||||
if (!th.querySelector("a")) {
|
||||
return;
|
||||
}
|
||||
var icon = svg.cloneNode(true);
|
||||
icon.addEventListener("click", iconClicked);
|
||||
icon.addEventListener("click", onTableHeaderClick);
|
||||
th.appendChild(icon);
|
||||
});
|
||||
})();
|
||||
};
|
||||
|
||||
/* Add x buttons to the filter rows */
|
||||
(function () {
|
||||
function addButtonsToFilterRows(manager) {
|
||||
var x = "✖";
|
||||
var rows = Array.from(document.querySelectorAll(".filter-row")).filter((el) =>
|
||||
el.querySelector(".filter-op")
|
||||
);
|
||||
var rows = Array.from(
|
||||
document.querySelectorAll(manager.selectors.filterRow),
|
||||
).filter((el) => el.querySelector(".filter-op"));
|
||||
rows.forEach((row) => {
|
||||
var a = document.createElement("a");
|
||||
a.setAttribute("href", "#");
|
||||
|
|
@ -234,4 +291,53 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
|||
a.style.display = "none";
|
||||
}
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
/* Set up datalist autocomplete for filter values */
|
||||
function initAutocompleteForFilterValues(manager) {
|
||||
function createDataLists() {
|
||||
var facetResults = document.querySelectorAll(
|
||||
manager.selectors.facetResults,
|
||||
);
|
||||
Array.from(facetResults).forEach(function (facetResult) {
|
||||
// Use link text from all links in the facet result
|
||||
var links = Array.from(
|
||||
facetResult.querySelectorAll("li:not(.facet-truncated) a"),
|
||||
);
|
||||
// Create a datalist element
|
||||
var datalist = document.createElement("datalist");
|
||||
datalist.id = "datalist-" + facetResult.dataset.column;
|
||||
// Create an option element for each link text
|
||||
links.forEach(function (link) {
|
||||
var option = document.createElement("option");
|
||||
option.label = link.innerText;
|
||||
option.value = link.dataset.facetValue;
|
||||
datalist.appendChild(option);
|
||||
});
|
||||
// Add the datalist to the facet result
|
||||
facetResult.appendChild(datalist);
|
||||
});
|
||||
}
|
||||
createDataLists();
|
||||
// When any select with name=_filter_column changes, update the datalist
|
||||
document.body.addEventListener("change", function (event) {
|
||||
if (event.target.name === "_filter_column") {
|
||||
event.target
|
||||
.closest(manager.selectors.filterRow)
|
||||
.querySelector(".filter-value")
|
||||
.setAttribute("list", "datalist-" + event.target.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Ensures Table UI is initialized only after the Manager is ready.
|
||||
document.addEventListener("datasette_init", function (evt) {
|
||||
const { detail: manager } = evt;
|
||||
|
||||
// Main table
|
||||
initDatasetteTable(manager);
|
||||
|
||||
// Other UI functions with interactive JS needs
|
||||
addButtonsToFilterRows(manager);
|
||||
initAutocompleteForFilterValues(manager);
|
||||
});
|
||||
|
|
|
|||
28
datasette/templates/_action_menu.html
Normal file
28
datasette/templates/_action_menu.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{% if action_links %}
|
||||
<div class="page-action-menu">
|
||||
<details class="actions-menu-links details-menu">
|
||||
<summary>
|
||||
<div class="icon-text">
|
||||
<svg class="icon" aria-labelledby="actions-menu-links-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">{{ action_title }}</title>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
<span>{{ action_title }}</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="dropdown-menu">
|
||||
<div class="hook"></div>
|
||||
<ul>
|
||||
{% for link in action_links %}
|
||||
<li><a href="{{ link.href }}">{{ link.label }}
|
||||
{% if link.description %}
|
||||
<p class="dropdown-description">{{ link.description }}</p>
|
||||
{% endif %}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -9,7 +9,7 @@ document.body.addEventListener('click', (ev) => {
|
|||
if (target && target.tagName == 'DETAILS') {
|
||||
detailsClickedWithin = target;
|
||||
}
|
||||
Array.from(document.getElementsByTagName('details')).filter(
|
||||
Array.from(document.querySelectorAll('details.details-menu')).filter(
|
||||
(details) => details.open && details != detailsClickedWithin
|
||||
).forEach(details => details.open = false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
<script src="{{ base_url }}-/static/sql-formatter-2.3.3.min.js" defer></script>
|
||||
<script src="{{ base_url }}-/static/codemirror-5.57.0.min.js"></script>
|
||||
<link rel="stylesheet" href="{{ base_url }}-/static/codemirror-5.57.0.min.css" />
|
||||
<script src="{{ base_url }}-/static/codemirror-5.57.0-sql.min.js"></script>
|
||||
<script src="{{ base_url }}-/static/cm-resize-1.0.1.min.js"></script>
|
||||
<script src="{{ base_url }}-/static/cm-editor-6.0.1.bundle.js"></script>
|
||||
<style>
|
||||
.CodeMirror { height: auto; min-height: 70px; width: 80%; border: 1px solid #ddd; }
|
||||
.cm-resize-handle {
|
||||
background: url("data:image/svg+xml,%3Csvg%20aria-labelledby%3D%22cm-drag-to-resize%22%20role%3D%22img%22%20fill%3D%22%23ccc%22%20stroke%3D%22%23ccc%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20width%3D%2216%22%20height%3D%2216%22%3E%0A%20%20%3Ctitle%20id%3D%22cm-drag-to-resize%22%3EDrag%20to%20resize%3C%2Ftitle%3E%0A%20%20%3Cpath%20fill-rule%3D%22evenodd%22%20d%3D%22M1%202.75A.75.75%200%20011.75%202h12.5a.75.75%200%20110%201.5H1.75A.75.75%200%20011%202.75zm0%205A.75.75%200%20011.75%207h12.5a.75.75%200%20110%201.5H1.75A.75.75%200%20011%207.75zM1.75%2012a.75.75%200%20100%201.5h12.5a.75.75%200%20100-1.5H1.75z%22%3E%3C%2Fpath%3E%0A%3C%2Fsvg%3E");
|
||||
background-repeat: no-repeat;
|
||||
box-shadow: none;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
.cm-editor {
|
||||
resize: both;
|
||||
overflow: hidden;
|
||||
width: 80%;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
/* Fix autocomplete icon positioning. The icon element gets border-box sizing set due to
|
||||
the global reset, but this causes overlapping icon and text. Markup:
|
||||
`<div class="cm-completionIcon cm-completionIcon-keyword" aria-hidden="true"></div>` */
|
||||
.cm-completionIcon {
|
||||
box-sizing: content-box;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,38 +1,42 @@
|
|||
<script>
|
||||
window.onload = () => {
|
||||
{% if table_columns %}
|
||||
const schema = {{ table_columns|tojson(2) }};
|
||||
{% else %}
|
||||
const schema = {};
|
||||
{% endif %}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const sqlFormat = document.querySelector("button#sql-format");
|
||||
const readOnly = document.querySelector("pre#sql-query");
|
||||
const sqlInput = document.querySelector("textarea#sql-editor");
|
||||
if (sqlFormat && !readOnly) {
|
||||
sqlFormat.hidden = false;
|
||||
sqlFormat.hidden = false;
|
||||
}
|
||||
if (sqlInput) {
|
||||
var editor = CodeMirror.fromTextArea(sqlInput, {
|
||||
lineNumbers: true,
|
||||
mode: "text/x-sql",
|
||||
lineWrapping: true,
|
||||
var editor = (window.editor = cm.editorFromTextArea(sqlInput, {
|
||||
schema,
|
||||
}));
|
||||
if (sqlFormat) {
|
||||
sqlFormat.addEventListener("click", (ev) => {
|
||||
const formatted = sqlFormatter.format(editor.state.doc.toString());
|
||||
editor.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: editor.state.doc.length,
|
||||
insert: formatted,
|
||||
},
|
||||
});
|
||||
});
|
||||
editor.setOption("extraKeys", {
|
||||
"Shift-Enter": function() {
|
||||
document.getElementsByClassName("sql")[0].submit();
|
||||
},
|
||||
Tab: false
|
||||
});
|
||||
if (sqlFormat) {
|
||||
sqlFormat.addEventListener("click", ev => {
|
||||
editor.setValue(sqlFormatter.format(editor.getValue()));
|
||||
})
|
||||
}
|
||||
cmResize(editor, {resizableWidth: false});
|
||||
}
|
||||
}
|
||||
if (sqlFormat && readOnly) {
|
||||
const formatted = sqlFormatter.format(readOnly.innerHTML);
|
||||
if (formatted != readOnly.innerHTML) {
|
||||
sqlFormat.hidden = false;
|
||||
sqlFormat.addEventListener("click", ev => {
|
||||
readOnly.innerHTML = formatted;
|
||||
})
|
||||
}
|
||||
const formatted = sqlFormatter.format(readOnly.innerHTML);
|
||||
if (formatted != readOnly.innerHTML) {
|
||||
sqlFormat.hidden = false;
|
||||
sqlFormat.addEventListener("click", (ev) => {
|
||||
readOnly.innerHTML = formatted;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
50
datasette/templates/_debug_common_functions.html
Normal file
50
datasette/templates/_debug_common_functions.html
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<script>
|
||||
// Common utility functions for debug pages
|
||||
|
||||
// Populate form from URL parameters on page load
|
||||
function populateFormFromURL() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
const action = params.get('action');
|
||||
if (action) {
|
||||
const actionField = document.getElementById('action');
|
||||
if (actionField) {
|
||||
actionField.value = action;
|
||||
}
|
||||
}
|
||||
|
||||
const parent = params.get('parent');
|
||||
if (parent) {
|
||||
const parentField = document.getElementById('parent');
|
||||
if (parentField) {
|
||||
parentField.value = parent;
|
||||
}
|
||||
}
|
||||
|
||||
const child = params.get('child');
|
||||
if (child) {
|
||||
const childField = document.getElementById('child');
|
||||
if (childField) {
|
||||
childField.value = child;
|
||||
}
|
||||
}
|
||||
|
||||
const pageSize = params.get('page_size');
|
||||
if (pageSize) {
|
||||
const pageSizeField = document.getElementById('page_size');
|
||||
if (pageSizeField) {
|
||||
pageSizeField.value = pageSize;
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
// HTML escape function
|
||||
function escapeHtml(text) {
|
||||
if (text === null || text === undefined) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{% if metadata.description_html or metadata.description %}
|
||||
{% if metadata.get("description_html") or metadata.get("description") %}
|
||||
<div class="metadata-description">
|
||||
{% if metadata.description_html %}
|
||||
{% if metadata.get("description_html") %}
|
||||
{{ metadata.description_html|safe }}
|
||||
{% else %}
|
||||
{{ metadata.description }}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
<ul class="tight-bullets">
|
||||
{% for facet_value in facet_info.results %}
|
||||
{% if not facet_value.selected %}
|
||||
<li><a href="{{ facet_value.toggle_url }}">{{ (facet_value.label | string()) or "-" }}</a> {{ "{:,}".format(facet_value.count) }}</li>
|
||||
<li><a href="{{ facet_value.toggle_url }}" data-facet-value="{{ facet_value.value }}">{{ (facet_value.label | string()) or "-" }}</a> {{ "{:,}".format(facet_value.count) }}</li>
|
||||
{% else %}
|
||||
<li>{{ facet_value.label or "-" }} · {{ "{:,}".format(facet_value.count) }} <a href="{{ facet_value.toggle_url }}" class="cross">✖</a></li>
|
||||
{% endif %}
|
||||
|
|
|
|||
145
datasette/templates/_permission_ui_styles.html
Normal file
145
datasette/templates/_permission_ui_styles.html
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<style>
|
||||
.permission-form {
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
padding: 1.5em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.form-section {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.form-section label {
|
||||
display: block;
|
||||
margin-bottom: 0.3em;
|
||||
font-weight: bold;
|
||||
}
|
||||
.form-section input[type="text"],
|
||||
.form-section select {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
padding: 0.5em;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.form-section input[type="text"]:focus,
|
||||
.form-section select:focus {
|
||||
outline: 2px solid #0066cc;
|
||||
border-color: #0066cc;
|
||||
}
|
||||
.form-section small {
|
||||
display: block;
|
||||
margin-top: 0.3em;
|
||||
color: #666;
|
||||
}
|
||||
.form-actions {
|
||||
margin-top: 1em;
|
||||
}
|
||||
.submit-btn {
|
||||
padding: 0.6em 1.5em;
|
||||
font-size: 1em;
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.submit-btn:hover {
|
||||
background-color: #0052a3;
|
||||
}
|
||||
.submit-btn:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.results-container {
|
||||
margin-top: 2em;
|
||||
}
|
||||
.results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.results-count {
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
}
|
||||
.results-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background-color: white;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.results-table th {
|
||||
background-color: #f5f5f5;
|
||||
padding: 0.75em;
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
.results-table td {
|
||||
padding: 0.75em;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.results-table tr:hover {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.results-table tr.allow-row {
|
||||
background-color: #f1f8f4;
|
||||
}
|
||||
.results-table tr.allow-row:hover {
|
||||
background-color: #e8f5e9;
|
||||
}
|
||||
.results-table tr.deny-row {
|
||||
background-color: #fef5f5;
|
||||
}
|
||||
.results-table tr.deny-row:hover {
|
||||
background-color: #ffebee;
|
||||
}
|
||||
.resource-path {
|
||||
font-family: monospace;
|
||||
background-color: #f5f5f5;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.pagination {
|
||||
margin-top: 1.5em;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
align-items: center;
|
||||
}
|
||||
.pagination a {
|
||||
padding: 0.5em 1em;
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.pagination a:hover {
|
||||
background-color: #0052a3;
|
||||
}
|
||||
.pagination span {
|
||||
color: #666;
|
||||
}
|
||||
.no-results {
|
||||
padding: 2em;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.error-message {
|
||||
padding: 1em;
|
||||
background-color: #ffebee;
|
||||
border: 2px solid #f44336;
|
||||
border-radius: 5px;
|
||||
color: #c62828;
|
||||
}
|
||||
.loading {
|
||||
padding: 2em;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
54
datasette/templates/_permissions_debug_tabs.html
Normal file
54
datasette/templates/_permissions_debug_tabs.html
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
{% if has_debug_permission %}
|
||||
{% set query_string = '?' + request.query_string if request.query_string else '' %}
|
||||
|
||||
<style>
|
||||
.permissions-debug-tabs {
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
margin-bottom: 2em;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.permissions-debug-tabs a {
|
||||
padding: 0.75em 1.25em;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
border-bottom: 3px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
}
|
||||
.permissions-debug-tabs a:hover {
|
||||
background-color: #f5f5f5;
|
||||
border-bottom-color: #999;
|
||||
}
|
||||
.permissions-debug-tabs a.active {
|
||||
color: #0066cc;
|
||||
border-bottom-color: #0066cc;
|
||||
background-color: #f0f7ff;
|
||||
}
|
||||
@media only screen and (max-width: 576px) {
|
||||
.permissions-debug-tabs {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
.permissions-debug-tabs a {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.permissions-debug-tabs a.active {
|
||||
border-left: 3px solid #0066cc;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<nav class="permissions-debug-tabs">
|
||||
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Playground</a>
|
||||
<a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Check</a>
|
||||
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Allowed</a>
|
||||
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rules</a>
|
||||
<a href="{{ urls.path('-/actions') }}" {% if current_tab == "actions" %}class="active"{% endif %}>Actions</a>
|
||||
<a href="{{ urls.path('-/allow-debug') }}" {% if current_tab == "allow_debug" %}class="active"{% endif %}>Allow debug</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
<p class="suggested-facets">
|
||||
Suggested facets: {% for facet in suggested_facets %}<a href="{{ facet.toggle_url }}#facet-{{ facet.name|to_css_class }}">{{ facet.name }}</a>{% if facet.type %} ({{ facet.type }}){% endif %}{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
Suggested facets: {% for facet in suggested_facets %}<a href="{{ facet.toggle_url }}#facet-{{ facet.name|to_css_class }}">{{ facet.name }}</a>{% if facet.get("type") %} ({{ facet.type }}){% endif %}{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
<!-- above-table-panel is a hook node for plugins to attach to . Displays even if no data available -->
|
||||
<div class="above-table-panel"> </div>
|
||||
{% if display_rows %}
|
||||
<div class="table-wrapper">
|
||||
<table class="rows-and-columns">
|
||||
|
|
|
|||
|
|
@ -33,9 +33,12 @@ p.message-warning {
|
|||
|
||||
<h1>Debug allow rules</h1>
|
||||
|
||||
{% set current_tab = "allow_debug" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p>Use this tool to try out different actor and allow combinations. See <a href="https://docs.datasette.io/en/stable/authentication.html#defining-permissions-with-allow-blocks">Defining permissions with "allow" blocks</a> for documentation.</p>
|
||||
|
||||
<form action="{{ urls.path('-/allow-debug') }}" method="get">
|
||||
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get" style="margin-bottom: 1em">
|
||||
<div class="two-col">
|
||||
<p><label>Allow block</label></p>
|
||||
<textarea name="allow">{{ allow_input }}</textarea>
|
||||
|
|
|
|||
208
datasette/templates/api_explorer.html
Normal file
208
datasette/templates/api_explorer.html
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}API Explorer{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>API Explorer{% if private %} 🔒{% endif %}</h1>
|
||||
|
||||
<p>Use this tool to try out the
|
||||
{% if datasette_version %}
|
||||
<a href="https://docs.datasette.io/en/{{ datasette_version }}/json_api.html">Datasette API</a>.
|
||||
{% else %}
|
||||
Datasette API.
|
||||
{% endif %}
|
||||
</p>
|
||||
<details open style="border: 2px solid #ccc; border-bottom: none; padding: 0.5em">
|
||||
<summary style="cursor: pointer;">GET</summary>
|
||||
<form class="core" method="get" id="api-explorer-get" style="margin-top: 0.7em">
|
||||
<div>
|
||||
<label for="path">API path:</label>
|
||||
<input type="text" id="path" name="path" style="width: 60%">
|
||||
<input type="submit" value="GET">
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
<details style="border: 2px solid #ccc; padding: 0.5em">
|
||||
<summary style="cursor: pointer">POST</summary>
|
||||
<form class="core" method="post" id="api-explorer-post" style="margin-top: 0.7em">
|
||||
<div>
|
||||
<label for="path">API path:</label>
|
||||
<input type="text" id="path" name="path" style="width: 60%">
|
||||
</div>
|
||||
<div style="margin: 0.5em 0">
|
||||
<label for="apiJson" style="vertical-align: top">JSON:</label>
|
||||
<textarea id="apiJson" name="json" style="width: 60%; height: 200px; font-family: monospace; font-size: 0.8em;"></textarea>
|
||||
</div>
|
||||
<p><button id="json-format" type="button">Format JSON</button> <input type="submit" value="POST"></p>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<div id="output" style="display: none">
|
||||
<h2>API response: HTTP <span id="response-status"></span></h2>
|
||||
</h2>
|
||||
<ul class="errors message-error"></ul>
|
||||
<pre></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelector('#json-format').addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
let json = document.querySelector('textarea[name="json"]').value.trim();
|
||||
if (!json) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
document.querySelector('textarea[name="json"]').value = JSON.stringify(parsed, null, 2);
|
||||
} catch (e) {
|
||||
alert("Error parsing JSON: " + e);
|
||||
}
|
||||
});
|
||||
var postForm = document.getElementById('api-explorer-post');
|
||||
var getForm = document.getElementById('api-explorer-get');
|
||||
var output = document.getElementById('output');
|
||||
var errorList = output.querySelector('.errors');
|
||||
|
||||
// On first load or fragment change populate forms from # in URL, if present
|
||||
if (window.location.hash) {
|
||||
onFragmentChange();
|
||||
}
|
||||
function onFragmentChange() {
|
||||
var hash = window.location.hash.slice(1);
|
||||
// Treat hash as a foo=bar string and parse it:
|
||||
var params = new URLSearchParams(hash);
|
||||
var method = params.get('method');
|
||||
if (method == 'GET') {
|
||||
getForm.closest('details').open = true;
|
||||
postForm.closest('details').open = false;
|
||||
getForm.querySelector('input[name="path"]').value = params.get('path');
|
||||
} else if (method == 'POST') {
|
||||
postForm.closest('details').open = true;
|
||||
getForm.closest('details').open = false;
|
||||
postForm.querySelector('input[name="path"]').value = params.get('path');
|
||||
postForm.querySelector('textarea[name="json"]').value = params.get('json');
|
||||
}
|
||||
}
|
||||
window.addEventListener('hashchange', () => {
|
||||
onFragmentChange();
|
||||
// Animate scroll to top of page
|
||||
window.scrollTo({top: 0, behavior: 'smooth'});
|
||||
});
|
||||
|
||||
// Cause GET and POST regions to toggle each other
|
||||
var getDetails = getForm.closest('details');
|
||||
var postDetails = postForm.closest('details');
|
||||
getDetails.addEventListener('toggle', (ev) => {
|
||||
if (getDetails.open) {
|
||||
postDetails.open = false;
|
||||
}
|
||||
});
|
||||
postDetails.addEventListener('toggle', (ev) => {
|
||||
if (postDetails.open) {
|
||||
getDetails.open = false;
|
||||
}
|
||||
});
|
||||
|
||||
getForm.addEventListener("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
var formData = new FormData(getForm);
|
||||
// Update URL fragment hash
|
||||
var serialized = new URLSearchParams(formData).toString() + '&method=GET';
|
||||
window.history.pushState({}, "", location.pathname + '#' + serialized);
|
||||
// Send the request
|
||||
var path = formData.get('path');
|
||||
fetch(path, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
}).then((response) => {
|
||||
output.style.display = 'block';
|
||||
document.getElementById('response-status').textContent = response.status;
|
||||
return response.json();
|
||||
}).then((data) => {
|
||||
output.querySelector('pre').innerHTML = jsonFormatHighlight(data);
|
||||
errorList.style.display = 'none';
|
||||
}).catch((error) => {
|
||||
alert(error);
|
||||
});
|
||||
});
|
||||
|
||||
postForm.addEventListener("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
var formData = new FormData(postForm);
|
||||
// Update URL fragment hash
|
||||
var serialized = new URLSearchParams(formData).toString() + '&method=POST';
|
||||
window.history.pushState({}, "", location.pathname + '#' + serialized);
|
||||
// Send the request
|
||||
var json = formData.get('json');
|
||||
var path = formData.get('path');
|
||||
// Validate JSON
|
||||
if (!json.length) {
|
||||
json = '{}';
|
||||
}
|
||||
try {
|
||||
var data = JSON.parse(json);
|
||||
} catch (err) {
|
||||
alert("Invalid JSON: " + err);
|
||||
return;
|
||||
}
|
||||
// POST JSON to path with content-type application/json
|
||||
fetch(path, {
|
||||
method: 'POST',
|
||||
body: json,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}).then(r => {
|
||||
document.getElementById('response-status').textContent = r.status;
|
||||
return r.json();
|
||||
}).then(data => {
|
||||
if (data.errors) {
|
||||
errorList.style.display = 'block';
|
||||
errorList.innerHTML = '';
|
||||
data.errors.forEach(error => {
|
||||
var li = document.createElement('li');
|
||||
li.textContent = error;
|
||||
errorList.appendChild(li);
|
||||
});
|
||||
} else {
|
||||
errorList.style.display = 'none';
|
||||
}
|
||||
output.querySelector('pre').innerHTML = jsonFormatHighlight(data);
|
||||
output.style.display = 'block';
|
||||
}).catch(err => {
|
||||
alert("Error: " + err);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% if example_links %}
|
||||
<h2>API endpoints</h2>
|
||||
<ul class="bullets">
|
||||
{% for database in example_links %}
|
||||
<li>Database: <strong>{{ database.name }}</strong></li>
|
||||
<ul class="bullets">
|
||||
{% for link in database.links %}
|
||||
<li><a href="{{ api_path(link) }}">{{ link.path }}</a> - {{ link.label }} </li>
|
||||
{% endfor %}
|
||||
{% for table in database.tables %}
|
||||
<li><strong>{{ table.name }}</strong>
|
||||
<ul class="bullets">
|
||||
{% for link in table.links %}
|
||||
<li><a href="{{ api_path(link) }}">{{ link.path }}</a> - {{ link.label }} </li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
{% import "_crumbs.html" as crumbs with context %}<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ urls.static('app.css') }}?{{ app_css_hash }}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
{% for url in extra_css_urls %}
|
||||
<link rel="stylesheet" href="{{ url.url }}"{% if url.sri %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
|
||||
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
|
||||
{% endfor %}
|
||||
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
|
||||
<script src="{{ urls.static('datasette-manager.js') }}" defer></script>
|
||||
{% for url in extra_js_urls %}
|
||||
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.sri %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
|
||||
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
|
||||
{% endfor %}
|
||||
{%- if alternate_url_json -%}
|
||||
<link rel="alternate" type="application/json+datasette" href="{{ alternate_url_json }}">
|
||||
|
|
@ -17,9 +19,9 @@
|
|||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
<div class="not-footer">
|
||||
<header><nav>{% block nav %}{% block crumbs %}{{ crumbs.nav(request=request) }}{% endblock %}
|
||||
<header class="hd"><nav>{% block nav %}{% block crumbs %}{{ crumbs.nav(request=request) }}{% endblock %}
|
||||
{% set links = menu_links() %}{% if links or show_logout %}
|
||||
<details class="nav-menu">
|
||||
<details class="nav-menu details-menu">
|
||||
<summary><svg aria-labelledby="nav-menu-svg-title" role="img"
|
||||
fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16" width="16" height="16">
|
||||
|
|
@ -35,7 +37,7 @@
|
|||
</ul>
|
||||
{% endif %}
|
||||
{% if show_logout %}
|
||||
<form action="{{ urls.logout() }}" method="post">
|
||||
<form class="nav-menu-logout" action="{{ urls.logout() }}" method="post">
|
||||
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
|
||||
<button class="button-as-link">Log out</button>
|
||||
</form>{% endif %}
|
||||
|
|
@ -70,5 +72,7 @@
|
|||
{% endfor %}
|
||||
|
||||
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
|
||||
<script src="{{ urls.static('navigation-search.js') }}" defer></script>
|
||||
<navigation-search url="/-/tables"></navigation-search>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
124
datasette/templates/create_token.html
Normal file
124
datasette/templates/create_token.html
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create an API token{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style type="text/css">
|
||||
#restrict-permissions label {
|
||||
display: inline;
|
||||
width: 90%;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>Create an API token</h1>
|
||||
|
||||
<p>This token will allow API access with the same abilities as your current user, <strong>{{ request.actor.id }}</strong></p>
|
||||
|
||||
{% if token %}
|
||||
<div>
|
||||
<h2>Your API token</h2>
|
||||
<form>
|
||||
<input type="text" class="copyable" style="width: 40%" value="{{ token }}">
|
||||
<span class="copy-link-wrapper"></span>
|
||||
</form>
|
||||
<!--- show token in a <details> -->
|
||||
<details style="margin-top: 1em">
|
||||
<summary>Token details</summary>
|
||||
<pre>{{ token_bits|tojson(4) }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
<h2>Create another token</h2>
|
||||
{% endif %}
|
||||
|
||||
{% if errors %}
|
||||
{% for error in errors %}
|
||||
<p class="message-error">{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form class="core" action="{{ urls.path('-/create-token') }}" method="post">
|
||||
<div>
|
||||
<div class="select-wrapper" style="width: unset">
|
||||
<select name="expire_type">
|
||||
<option value="">Token never expires</option>
|
||||
<option value="minutes">Expires after X minutes</option>
|
||||
<option value="hours">Expires after X hours</option>
|
||||
<option value="days">Expires after X days</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="text" name="expire_duration" style="width: 10%">
|
||||
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
|
||||
<input type="submit" value="Create token">
|
||||
|
||||
<details style="margin-top: 1em" id="restrict-permissions">
|
||||
<summary style="cursor: pointer;">Restrict actions that can be performed using this token</summary>
|
||||
<h2>All databases and tables</h2>
|
||||
<ul>
|
||||
{% for permission in all_actions %}
|
||||
<li><label><input type="checkbox" name="all:{{ permission }}"> {{ permission }}</label></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% for database in database_with_tables %}
|
||||
<h2>All tables in "{{ database.name }}"</h2>
|
||||
<ul>
|
||||
{% for permission in database_actions %}
|
||||
<li><label><input type="checkbox" name="database:{{ database.encoded }}:{{ permission }}"> {{ permission }}</label></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
<h2>Specific tables</h2>
|
||||
{% for database in database_with_tables %}
|
||||
{% for table in database.tables %}
|
||||
<h3>{{ database.name }}: {{ table.name }}</h3>
|
||||
<ul>
|
||||
{% for permission in child_actions %}
|
||||
<li><label><input type="checkbox" name="resource:{{ database.encoded }}:{{ table.encoded }}:{{ permission }}"> {{ permission }}</label></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</details>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var expireDuration = document.querySelector('input[name="expire_duration"]');
|
||||
expireDuration.style.display = 'none';
|
||||
var expireType = document.querySelector('select[name="expire_type"]');
|
||||
function showHideExpireDuration() {
|
||||
if (expireType.value) {
|
||||
expireDuration.style.display = 'inline';
|
||||
expireDuration.setAttribute("placeholder", expireType.value.replace("Expires after X ", ""));
|
||||
} else {
|
||||
expireDuration.style.display = 'none';
|
||||
}
|
||||
}
|
||||
showHideExpireDuration();
|
||||
expireType.addEventListener('change', showHideExpireDuration);
|
||||
var copyInput = document.querySelector(".copyable");
|
||||
if (copyInput) {
|
||||
var wrapper = document.querySelector(".copy-link-wrapper");
|
||||
var button = document.createElement("button");
|
||||
button.className = "copyable-copy-button";
|
||||
button.setAttribute("type", "button");
|
||||
button.innerHTML = "Copy to clipboard";
|
||||
button.onclick = (ev) => {
|
||||
ev.preventDefault();
|
||||
copyInput.select();
|
||||
document.execCommand("copy");
|
||||
button.innerHTML = "Copied!";
|
||||
setTimeout(() => {
|
||||
button.innerHTML = "Copy to clipboard";
|
||||
}, 1500);
|
||||
};
|
||||
wrapper.appendChild(button);
|
||||
wrapper.insertAdjacentElement("afterbegin", button);
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
13
datasette/templates/csrf_error.html
Normal file
13
datasette/templates/csrf_error.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}CSRF check failed){% endblock %}
|
||||
{% block content %}
|
||||
<h1>Form origin check failed</h1>
|
||||
|
||||
<p>Your request's origin could not be validated. Please return to the form and submit it again.</p>
|
||||
|
||||
<details><summary>Technical details</summary>
|
||||
<p>Developers: consult Datasette's <a href="https://docs.datasette.io/en/latest/internals.html#csrf-protection">CSRF protection documentation</a>.</p>
|
||||
<p>Error code is {{ message_name }}.</p>
|
||||
</details>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -9,35 +9,23 @@
|
|||
|
||||
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
|
||||
|
||||
{% block crumbs %}
|
||||
{{ crumbs.nav(request=request, database=database) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header" style="border-color: #{{ database_color(database) }}">
|
||||
<div class="page-header" style="border-color: #{{ database_color }}">
|
||||
<h1>{{ metadata.title or database }}{% if private %} 🔒{% endif %}</h1>
|
||||
{% set links = database_actions() %}{% if links %}
|
||||
<details class="actions-menu-links">
|
||||
<summary><svg aria-labelledby="actions-menu-links-title" role="img"
|
||||
style="color: #666" xmlns="http://www.w3.org/2000/svg"
|
||||
width="28" height="28" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">Table actions</title>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg></summary>
|
||||
<div class="dropdown-menu">
|
||||
{% if links %}
|
||||
<ul>
|
||||
{% for link in links %}
|
||||
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% set action_links, action_title = database_actions(), "Database actions" %}
|
||||
{% include "_action_menu.html" %}
|
||||
|
||||
{{ top_database() }}
|
||||
|
||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
|
||||
{% if allow_execute_sql %}
|
||||
<form class="sql" action="{{ urls.database(database) }}" method="get">
|
||||
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get">
|
||||
<h3>Custom SQL query</h3>
|
||||
<p><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
|
||||
<p>
|
||||
|
|
@ -52,7 +40,7 @@
|
|||
<p>The following databases are attached to this connection, and can be used for cross-database joins:</p>
|
||||
<ul class="bullets">
|
||||
{% for db_name in attached_databases %}
|
||||
<li><strong>{{ db_name }}</strong> - <a href="?sql=select+*+from+[{{ db_name }}].sqlite_master+where+type='table'">tables</a></li>
|
||||
<li><strong>{{ db_name }}</strong> - <a href="{{ urls.database(db_name) }}/-/query?sql=select+*+from+[{{ db_name }}].sqlite_master+where+type='table'">tables</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -68,7 +56,7 @@
|
|||
{% endif %}
|
||||
|
||||
{% if tables %}
|
||||
<h2 id="tables">Tables</h2>
|
||||
<h2 id="tables">Tables <a style="font-weight: normal; font-size: 0.75em; padding-left: 0.5em;" href="{{ urls.database(database) }}/-/schema">schema</a></h2>
|
||||
{% endif %}
|
||||
|
||||
{% for table in tables %}
|
||||
|
|
@ -76,7 +64,7 @@
|
|||
<div class="db-table">
|
||||
<h3><a href="{{ urls.table(database, table.name) }}">{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if table.hidden %}<em> (hidden)</em>{% endif %}</h3>
|
||||
<p><em>{% for column in table.columns %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}</em></p>
|
||||
<p>{% if table.count is none %}Many rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
|
||||
<p>{% if table.count is none %}Many rows{% elif table.count == count_limit + 1 %}>{{ "{:,}".format(count_limit) }} rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
|
@ -95,7 +83,7 @@
|
|||
{% endif %}
|
||||
|
||||
{% if allow_download %}
|
||||
<p class="download-sqlite">Download SQLite DB: <a href="{{ urls.database(database) }}.db">{{ database }}.db</a> <em>{{ format_bytes(size) }}</em></p>
|
||||
<p class="download-sqlite">Download SQLite DB: <a href="{{ urls.database(database) }}.db" rel="nofollow">{{ database }}.db</a> <em>{{ format_bytes(size) }}</em></p>
|
||||
{% endif %}
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
|
|
|
|||
43
datasette/templates/debug_actions.html
Normal file
43
datasette/templates/debug_actions.html
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Registered Actions{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Registered actions</h1>
|
||||
|
||||
{% set current_tab = "actions" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p style="margin-bottom: 2em;">
|
||||
This Datasette instance has registered {{ data|length }} action{{ data|length != 1 and "s" or "" }}.
|
||||
Actions are used by the permission system to control access to different features.
|
||||
</p>
|
||||
|
||||
<table class="rows-and-columns">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Abbr</th>
|
||||
<th>Description</th>
|
||||
<th>Resource</th>
|
||||
<th>Takes Parent</th>
|
||||
<th>Takes Child</th>
|
||||
<th>Also Requires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for action in data %}
|
||||
<tr>
|
||||
<td><strong>{{ action.name }}</strong></td>
|
||||
<td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td>
|
||||
<td>{{ action.description or "" }}</td>
|
||||
<td>{% if action.resource_class %}<code>{{ action.resource_class }}</code>{% endif %}</td>
|
||||
<td>{% if action.takes_parent %}✓{% endif %}</td>
|
||||
<td>{% if action.takes_child %}✓{% endif %}</td>
|
||||
<td>{% if action.also_requires %}<code>{{ action.also_requires }}</code>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
229
datasette/templates/debug_allowed.html
Normal file
229
datasette/templates/debug_allowed.html
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Allowed Resources{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
{% include "_debug_common_functions.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Allowed resources</h1>
|
||||
|
||||
{% set current_tab = "allowed" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p>Use this tool to check which resources the current actor is allowed to access for a given permission action. It queries the <code>/-/allowed.json</code> API endpoint.</p>
|
||||
|
||||
{% if request.actor %}
|
||||
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
|
||||
{% else %}
|
||||
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
|
||||
{% endif %}
|
||||
|
||||
<div class="permission-form">
|
||||
<form id="allowed-form" method="get" action="{{ urls.path("-/allowed") }}">
|
||||
<div class="form-section">
|
||||
<label for="action">Action (permission name):</label>
|
||||
<select id="action" name="action" required>
|
||||
<option value="">Select an action...</option>
|
||||
{% for action_name in supported_actions %}
|
||||
<option value="{{ action_name }}">{{ action_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small>Only certain actions are supported by this endpoint</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="parent">Filter by parent (optional):</label>
|
||||
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
|
||||
<small>Filter results to a specific parent resource</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="child">Filter by child (optional):</label>
|
||||
<input type="text" id="child" name="child" placeholder="e.g., table name">
|
||||
<small>Filter results to a specific child resource (requires parent to be set)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="page_size">Page size:</label>
|
||||
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
|
||||
<small>Number of results per page (max 200)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn" id="submit-btn">Check Allowed Resources</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="results-container" style="display: none;">
|
||||
<div class="results-header">
|
||||
<h2>Results</h2>
|
||||
<div class="results-count" id="results-count"></div>
|
||||
</div>
|
||||
|
||||
<div id="results-content"></div>
|
||||
|
||||
<div id="pagination" class="pagination"></div>
|
||||
|
||||
<details style="margin-top: 2em;">
|
||||
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
||||
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('allowed-form');
|
||||
const resultsContainer = document.getElementById('results-container');
|
||||
const resultsContent = document.getElementById('results-content');
|
||||
const resultsCount = document.getElementById('results-count');
|
||||
const pagination = document.getElementById('pagination');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const hasDebugPermission = {{ 'true' if has_debug_permission else 'false' }};
|
||||
|
||||
// Populate form on initial load
|
||||
(function() {
|
||||
const params = populateFormFromURL();
|
||||
const action = params.get('action');
|
||||
const page = params.get('page');
|
||||
if (action) {
|
||||
fetchResults(page ? parseInt(page) : 1);
|
||||
}
|
||||
})();
|
||||
|
||||
async function fetchResults(page = 1) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Loading...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value && key !== 'page_size') {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const pageSize = document.getElementById('page_size').value || '50';
|
||||
params.append('page', page.toString());
|
||||
params.append('page_size', pageSize);
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ urls.path("-/allowed.json") }}?' + params.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
displayResults(data);
|
||||
} else {
|
||||
displayError(data);
|
||||
}
|
||||
} catch (error) {
|
||||
displayError({ error: error.message });
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Check Allowed Resources';
|
||||
}
|
||||
}
|
||||
|
||||
function displayResults(data) {
|
||||
resultsContainer.style.display = 'block';
|
||||
|
||||
// Update count
|
||||
resultsCount.textContent = `Showing ${data.items.length} of ${data.total} total resources (page ${data.page})`;
|
||||
|
||||
// Display results table
|
||||
if (data.items.length === 0) {
|
||||
resultsContent.innerHTML = '<div class="no-results">No allowed resources found for this action.</div>';
|
||||
} else {
|
||||
let html = '<table class="results-table">';
|
||||
html += '<thead><tr>';
|
||||
html += '<th>Resource Path</th>';
|
||||
html += '<th>Parent</th>';
|
||||
html += '<th>Child</th>';
|
||||
if (hasDebugPermission) {
|
||||
html += '<th>Reason</th>';
|
||||
}
|
||||
html += '</tr></thead>';
|
||||
html += '<tbody>';
|
||||
|
||||
for (const item of data.items) {
|
||||
html += '<tr>';
|
||||
html += `<td><span class="resource-path">${escapeHtml(item.resource || '/')}</span></td>`;
|
||||
html += `<td>${escapeHtml(item.parent || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.child || '—')}</td>`;
|
||||
if (hasDebugPermission) {
|
||||
// Display reason as JSON array
|
||||
let reasonHtml = '—';
|
||||
if (item.reason && Array.isArray(item.reason)) {
|
||||
reasonHtml = `<code>${escapeHtml(JSON.stringify(item.reason))}</code>`;
|
||||
}
|
||||
html += `<td>${reasonHtml}</td>`;
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
resultsContent.innerHTML = html;
|
||||
}
|
||||
|
||||
// Update pagination
|
||||
pagination.innerHTML = '';
|
||||
if (data.previous_url || data.next_url) {
|
||||
if (data.previous_url) {
|
||||
const prevLink = document.createElement('a');
|
||||
prevLink.href = data.previous_url;
|
||||
prevLink.textContent = '← Previous';
|
||||
pagination.appendChild(prevLink);
|
||||
}
|
||||
|
||||
const pageInfo = document.createElement('span');
|
||||
pageInfo.textContent = `Page ${data.page}`;
|
||||
pagination.appendChild(pageInfo);
|
||||
|
||||
if (data.next_url) {
|
||||
const nextLink = document.createElement('a');
|
||||
nextLink.href = data.next_url;
|
||||
nextLink.textContent = 'Next →';
|
||||
pagination.appendChild(nextLink);
|
||||
}
|
||||
}
|
||||
|
||||
// Update raw JSON
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
}
|
||||
|
||||
function displayError(data) {
|
||||
resultsContainer.style.display = 'block';
|
||||
resultsCount.textContent = '';
|
||||
pagination.innerHTML = '';
|
||||
|
||||
resultsContent.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;
|
||||
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
}
|
||||
|
||||
// Disable child input if parent is empty
|
||||
const parentInput = document.getElementById('parent');
|
||||
const childInput = document.getElementById('child');
|
||||
|
||||
parentInput.addEventListener('input', () => {
|
||||
childInput.disabled = !parentInput.value;
|
||||
if (!parentInput.value) {
|
||||
childInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize disabled state
|
||||
childInput.disabled = !parentInput.value;
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
270
datasette/templates/debug_check.html
Normal file
270
datasette/templates/debug_check.html
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Permission Check{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
{% include "_debug_common_functions.html" %}
|
||||
<style>
|
||||
#output {
|
||||
margin-top: 2em;
|
||||
padding: 1em;
|
||||
border-radius: 5px;
|
||||
}
|
||||
#output.allowed {
|
||||
background-color: #e8f5e9;
|
||||
border: 2px solid #4caf50;
|
||||
}
|
||||
#output.denied {
|
||||
background-color: #ffebee;
|
||||
border: 2px solid #f44336;
|
||||
}
|
||||
#output h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
#output .result-badge {
|
||||
display: inline-block;
|
||||
padding: 0.3em 0.8em;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
#output .allowed-badge {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
#output .denied-badge {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
}
|
||||
.details-section {
|
||||
margin-top: 1em;
|
||||
}
|
||||
.details-section dt {
|
||||
font-weight: bold;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.details-section dd {
|
||||
margin-left: 1em;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Permission check</h1>
|
||||
|
||||
{% set current_tab = "check" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p>Use this tool to test permission checks for the current actor. It queries the <code>/-/check.json</code> API endpoint.</p>
|
||||
|
||||
{% if request.actor %}
|
||||
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
|
||||
{% else %}
|
||||
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
|
||||
{% endif %}
|
||||
|
||||
<div class="permission-form">
|
||||
<form id="check-form" method="get" action="{{ urls.path("-/check") }}">
|
||||
<div class="form-section">
|
||||
<label for="action">Action (permission name):</label>
|
||||
<select id="action" name="action" required>
|
||||
<option value="">Select an action...</option>
|
||||
{% for action_name in sorted_actions %}
|
||||
<option value="{{ action_name }}">{{ action_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small>The permission action to check</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="parent">Parent resource (optional):</label>
|
||||
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
|
||||
<small>For database-level permissions, specify the database name</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="child">Child resource (optional):</label>
|
||||
<input type="text" id="child" name="child" placeholder="e.g., table name">
|
||||
<small>For table-level permissions, specify the table name (requires parent)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn" id="submit-btn">Check Permission</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="output" style="display: none;">
|
||||
<h2>Result: <span class="result-badge" id="result-badge"></span></h2>
|
||||
|
||||
<dl class="details-section">
|
||||
<dt>Action:</dt>
|
||||
<dd id="result-action"></dd>
|
||||
|
||||
<dt>Resource Path:</dt>
|
||||
<dd id="result-resource"></dd>
|
||||
|
||||
<dt>Actor ID:</dt>
|
||||
<dd id="result-actor"></dd>
|
||||
|
||||
<div id="additional-details"></div>
|
||||
</dl>
|
||||
|
||||
<details style="margin-top: 1em;">
|
||||
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
||||
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('check-form');
|
||||
const output = document.getElementById('output');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
|
||||
async function performCheck() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Checking...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ urls.path("-/check.json") }}?' + params.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
displayResult(data);
|
||||
} else {
|
||||
displayError(data);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Check Permission';
|
||||
}
|
||||
}
|
||||
|
||||
// Populate form on initial load
|
||||
(function() {
|
||||
const params = populateFormFromURL();
|
||||
const action = params.get('action');
|
||||
if (action) {
|
||||
performCheck();
|
||||
}
|
||||
})();
|
||||
|
||||
function displayResult(data) {
|
||||
output.style.display = 'block';
|
||||
|
||||
// Set badge and styling
|
||||
const resultBadge = document.getElementById('result-badge');
|
||||
if (data.allowed) {
|
||||
output.className = 'allowed';
|
||||
resultBadge.className = 'result-badge allowed-badge';
|
||||
resultBadge.textContent = 'ALLOWED ✓';
|
||||
} else {
|
||||
output.className = 'denied';
|
||||
resultBadge.className = 'result-badge denied-badge';
|
||||
resultBadge.textContent = 'DENIED ✗';
|
||||
}
|
||||
|
||||
// Basic details
|
||||
document.getElementById('result-action').textContent = data.action || 'N/A';
|
||||
document.getElementById('result-resource').textContent = data.resource?.path || '/';
|
||||
document.getElementById('result-actor').textContent = data.actor_id || 'anonymous';
|
||||
|
||||
// Additional details
|
||||
const additionalDetails = document.getElementById('additional-details');
|
||||
additionalDetails.innerHTML = '';
|
||||
|
||||
if (data.reason !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Reason:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.reason || 'N/A';
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
if (data.source_plugin !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Source Plugin:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.source_plugin || 'N/A';
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
if (data.used_default !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Used Default:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.used_default ? 'Yes' : 'No';
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
if (data.depth !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Depth:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.depth;
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
// Raw JSON
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
|
||||
// Scroll to output
|
||||
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function displayError(data) {
|
||||
output.style.display = 'block';
|
||||
output.className = 'denied';
|
||||
|
||||
const resultBadge = document.getElementById('result-badge');
|
||||
resultBadge.className = 'result-badge denied-badge';
|
||||
resultBadge.textContent = 'ERROR';
|
||||
|
||||
document.getElementById('result-action').textContent = 'N/A';
|
||||
document.getElementById('result-resource').textContent = 'N/A';
|
||||
document.getElementById('result-actor').textContent = 'N/A';
|
||||
|
||||
const additionalDetails = document.getElementById('additional-details');
|
||||
additionalDetails.innerHTML = '<dt>Error:</dt><dd>' + (data.error || 'Unknown error') + '</dd>';
|
||||
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
|
||||
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
// Disable child input if parent is empty
|
||||
const parentInput = document.getElementById('parent');
|
||||
const childInput = document.getElementById('child');
|
||||
|
||||
childInput.addEventListener('focus', () => {
|
||||
if (!parentInput.value) {
|
||||
alert('Please specify a parent resource first before adding a child resource.');
|
||||
parentInput.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
166
datasette/templates/debug_permissions_playground.html
Normal file
166
datasette/templates/debug_permissions_playground.html
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Debug permissions{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
<style type="text/css">
|
||||
.check-result-true {
|
||||
color: green;
|
||||
}
|
||||
.check-result-false {
|
||||
color: red;
|
||||
}
|
||||
.check-result-no-opinion {
|
||||
color: #aaa;
|
||||
}
|
||||
.check h2 {
|
||||
font-size: 1em
|
||||
}
|
||||
.check-action, .check-when, .check-result {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
textarea {
|
||||
height: 10em;
|
||||
width: 95%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5em;
|
||||
border: 2px dotted black;
|
||||
}
|
||||
.two-col {
|
||||
display: inline-block;
|
||||
width: 48%;
|
||||
}
|
||||
.two-col label {
|
||||
width: 48%;
|
||||
}
|
||||
@media only screen and (max-width: 576px) {
|
||||
.two-col {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Permission playground</h1>
|
||||
|
||||
{% set current_tab = "permissions" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p>This tool lets you simulate an actor and a permission check for that actor.</p>
|
||||
|
||||
<div class="permission-form">
|
||||
<form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post">
|
||||
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
|
||||
<div class="two-col">
|
||||
<div class="form-section">
|
||||
<label>Actor</label>
|
||||
<textarea name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="two-col" style="vertical-align: top">
|
||||
<div class="form-section">
|
||||
<label for="permission">Action</label>
|
||||
<select name="permission" id="permission">
|
||||
{% for permission in permissions %}
|
||||
<option value="{{ permission.name }}">{{ permission.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="resource_1">Parent</label>
|
||||
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="resource_2">Child</label>
|
||||
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn">Simulate permission check</button>
|
||||
</div>
|
||||
<pre style="margin-top: 1em" id="debugResult"></pre>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var rawPerms = {{ permissions|tojson }};
|
||||
var permissions = Object.fromEntries(rawPerms.map(p => [p.name, p]));
|
||||
var permissionSelect = document.getElementById('permission');
|
||||
var resource1 = document.getElementById('resource_1');
|
||||
var resource2 = document.getElementById('resource_2');
|
||||
var resource1Section = resource1.closest('.form-section');
|
||||
var resource2Section = resource2.closest('.form-section');
|
||||
function updateResourceVisibility() {
|
||||
var permission = permissionSelect.value;
|
||||
var {takes_parent, takes_child} = permissions[permission];
|
||||
resource1Section.style.display = takes_parent ? 'block' : 'none';
|
||||
resource2Section.style.display = takes_child ? 'block' : 'none';
|
||||
}
|
||||
permissionSelect.addEventListener('change', updateResourceVisibility);
|
||||
updateResourceVisibility();
|
||||
|
||||
// When #debug-post form is submitted, use fetch() to POST data
|
||||
var debugPost = document.getElementById('debug-post');
|
||||
var debugResult = document.getElementById('debugResult');
|
||||
debugPost.addEventListener('submit', function(ev) {
|
||||
ev.preventDefault();
|
||||
var formData = new FormData(debugPost);
|
||||
fetch(debugPost.action, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams(formData),
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}).then(function(response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Request failed with status ' + response.status);
|
||||
}
|
||||
return response.json();
|
||||
}).then(function(data) {
|
||||
debugResult.innerText = JSON.stringify(data, null, 4);
|
||||
}).catch(function(error) {
|
||||
debugResult.innerText = JSON.stringify({ error: error.message }, null, 4);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<h1>Recent permissions checks</h1>
|
||||
|
||||
<p>
|
||||
{% if filter != "all" %}<a href="?filter=all">All</a>{% else %}<strong>All</strong>{% endif %},
|
||||
{% if filter != "exclude-yours" %}<a href="?filter=exclude-yours">Exclude yours</a>{% else %}<strong>Exclude yours</strong>{% endif %},
|
||||
{% if filter != "only-yours" %}<a href="?filter=only-yours">Only yours</a>{% else %}<strong>Only yours</strong>{% endif %}
|
||||
</p>
|
||||
|
||||
{% if permission_checks %}
|
||||
<table class="rows-and-columns permission-checks-table" id="permission-checks-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Action</th>
|
||||
<th>Parent</th>
|
||||
<th>Child</th>
|
||||
<th>Actor</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for check in permission_checks %}
|
||||
<tr>
|
||||
<td><span style="font-size: 0.8em">{{ check.when.split('T', 1)[0] }}</span><br>{{ check.when.split('T', 1)[1].split('+', 1)[0].split('-', 1)[0].split('Z', 1)[0] }}</td>
|
||||
<td><code>{{ check.action }}</code></td>
|
||||
<td>{{ check.parent or '—' }}</td>
|
||||
<td>{{ check.child or '—' }}</td>
|
||||
<td>{% if check.actor %}<code>{{ check.actor|tojson }}</code>{% else %}<span class="check-actor-anon">anonymous</span>{% endif %}</td>
|
||||
<td>{% if check.result %}<span class="check-result check-result-true">Allowed</span>{% elif check.result is none %}<span class="check-result check-result-no-opinion">No opinion</span>{% else %}<span class="check-result check-result-false">Denied</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="no-results">No permission checks have been recorded yet.</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
203
datasette/templates/debug_rules.html
Normal file
203
datasette/templates/debug_rules.html
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Permission Rules{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
{% include "_debug_common_functions.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Permission rules</h1>
|
||||
|
||||
{% set current_tab = "rules" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p>Use this tool to view the permission rules that allow the current actor to access resources for a given permission action. It queries the <code>/-/rules.json</code> API endpoint.</p>
|
||||
|
||||
{% if request.actor %}
|
||||
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
|
||||
{% else %}
|
||||
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
|
||||
{% endif %}
|
||||
|
||||
<div class="permission-form">
|
||||
<form id="rules-form" method="get" action="{{ urls.path("-/rules") }}">
|
||||
<div class="form-section">
|
||||
<label for="action">Action (permission name):</label>
|
||||
<select id="action" name="action" required>
|
||||
<option value="">Select an action...</option>
|
||||
{% for action_name in sorted_actions %}
|
||||
<option value="{{ action_name }}">{{ action_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small>The permission action to check</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="page_size">Page size:</label>
|
||||
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
|
||||
<small>Number of results per page (max 200)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn" id="submit-btn">View Permission Rules</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="results-container" style="display: none;">
|
||||
<div class="results-header">
|
||||
<h2>Results</h2>
|
||||
<div class="results-count" id="results-count"></div>
|
||||
</div>
|
||||
|
||||
<div id="results-content"></div>
|
||||
|
||||
<div id="pagination" class="pagination"></div>
|
||||
|
||||
<details style="margin-top: 2em;">
|
||||
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
||||
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('rules-form');
|
||||
const resultsContainer = document.getElementById('results-container');
|
||||
const resultsContent = document.getElementById('results-content');
|
||||
const resultsCount = document.getElementById('results-count');
|
||||
const pagination = document.getElementById('pagination');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
|
||||
// Populate form on initial load
|
||||
(function() {
|
||||
const params = populateFormFromURL();
|
||||
const action = params.get('action');
|
||||
const page = params.get('page');
|
||||
if (action) {
|
||||
fetchResults(page ? parseInt(page) : 1);
|
||||
}
|
||||
})();
|
||||
|
||||
async function fetchResults(page = 1) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Loading...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value && key !== 'page_size') {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const pageSize = document.getElementById('page_size').value || '50';
|
||||
params.append('page', page.toString());
|
||||
params.append('page_size', pageSize);
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ urls.path("-/rules.json") }}?' + params.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
displayResults(data);
|
||||
} else {
|
||||
displayError(data);
|
||||
}
|
||||
} catch (error) {
|
||||
displayError({ error: error.message });
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'View Permission Rules';
|
||||
}
|
||||
}
|
||||
|
||||
function displayResults(data) {
|
||||
resultsContainer.style.display = 'block';
|
||||
|
||||
// Update count
|
||||
resultsCount.textContent = `Showing ${data.items.length} of ${data.total} total rules (page ${data.page})`;
|
||||
|
||||
// Display results table
|
||||
if (data.items.length === 0) {
|
||||
resultsContent.innerHTML = '<div class="no-results">No permission rules found for this action.</div>';
|
||||
} else {
|
||||
let html = '<table class="results-table">';
|
||||
html += '<thead><tr>';
|
||||
html += '<th>Effect</th>';
|
||||
html += '<th>Resource Path</th>';
|
||||
html += '<th>Parent</th>';
|
||||
html += '<th>Child</th>';
|
||||
html += '<th>Source Plugin</th>';
|
||||
html += '<th>Reason</th>';
|
||||
html += '</tr></thead>';
|
||||
html += '<tbody>';
|
||||
|
||||
for (const item of data.items) {
|
||||
const rowClass = item.allow ? 'allow-row' : 'deny-row';
|
||||
const effectBadge = item.allow
|
||||
? '<span style="background: #4caf50; color: white; padding: 0.2em 0.5em; border-radius: 3px; font-weight: bold;">ALLOW</span>'
|
||||
: '<span style="background: #f44336; color: white; padding: 0.2em 0.5em; border-radius: 3px; font-weight: bold;">DENY</span>';
|
||||
|
||||
html += `<tr class="${rowClass}">`;
|
||||
html += `<td>${effectBadge}</td>`;
|
||||
html += `<td><span class="resource-path">${escapeHtml(item.resource || '/')}</span></td>`;
|
||||
html += `<td>${escapeHtml(item.parent || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.child || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.source_plugin || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.reason || '—')}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
resultsContent.innerHTML = html;
|
||||
}
|
||||
|
||||
// Update pagination
|
||||
pagination.innerHTML = '';
|
||||
if (data.previous_url || data.next_url) {
|
||||
if (data.previous_url) {
|
||||
const prevLink = document.createElement('a');
|
||||
prevLink.href = data.previous_url;
|
||||
prevLink.textContent = '← Previous';
|
||||
pagination.appendChild(prevLink);
|
||||
}
|
||||
|
||||
const pageInfo = document.createElement('span');
|
||||
pageInfo.textContent = `Page ${data.page}`;
|
||||
pagination.appendChild(pageInfo);
|
||||
|
||||
if (data.next_url) {
|
||||
const nextLink = document.createElement('a');
|
||||
nextLink.href = data.next_url;
|
||||
nextLink.textContent = 'Next →';
|
||||
pagination.appendChild(nextLink);
|
||||
}
|
||||
}
|
||||
|
||||
// Update raw JSON
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
}
|
||||
|
||||
function displayError(data) {
|
||||
resultsContainer.style.display = 'block';
|
||||
resultsCount.textContent = '';
|
||||
pagination.innerHTML = '';
|
||||
|
||||
resultsContent.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;
|
||||
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -2,17 +2,26 @@
|
|||
|
||||
{% block title %}{{ metadata.title or "Datasette" }}: {% for database in databases %}{{ database.name }}{% if not loop.last %}, {% endif %}{% endfor %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% if noindex %}<meta name="robots" content="noindex">{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}index{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ metadata.title or "Datasette" }}{% if private %} 🔒{% endif %}</h1>
|
||||
|
||||
{% set action_links, action_title = homepage_actions, "Homepage actions" %}
|
||||
{% include "_action_menu.html" %}
|
||||
|
||||
{{ top_homepage() }}
|
||||
|
||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
|
||||
{% for database in databases %}
|
||||
<h2 style="padding-left: 10px; border-left: 10px solid #{{ database.color }}"><a href="{{ urls.database(database.name) }}">{{ database.name }}</a>{% if database.private %} 🔒{% endif %}</h2>
|
||||
<p>
|
||||
{% if database.show_table_row_counts %}{{ "{:,}".format(database.table_rows_sum) }} rows in {% endif %}{{ database.tables_count }} table{% if database.tables_count != 1 %}s{% endif %}{% if database.tables_count and database.hidden_tables_count %}, {% endif -%}
|
||||
{% if database.show_table_row_counts %}{{ "{:,}".format(database.table_rows_sum) }} rows in {% endif %}{{ database.tables_count }} table{% if database.tables_count != 1 %}s{% endif %}{% if database.hidden_tables_count %}, {% endif -%}
|
||||
{% if database.hidden_tables_count -%}
|
||||
{% if database.show_table_row_counts %}{{ "{:,}".format(database.hidden_table_rows_sum) }} rows in {% endif %}{{ database.hidden_tables_count }} hidden table{% if database.hidden_tables_count != 1 %}s{% endif -%}
|
||||
{% endif -%}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
<p>You are logged in as <strong>{{ display_actor(actor) }}</strong></p>
|
||||
|
||||
<form action="{{ urls.logout() }}" method="post">
|
||||
<form class="core" action="{{ urls.logout() }}" method="post">
|
||||
<div>
|
||||
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
|
||||
<input type="submit" value="Log out">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
<p>Set a message:</p>
|
||||
|
||||
<form action="{{ urls.path('-/messages') }}" method="post">
|
||||
<form class="core" action="{{ urls.path('-/messages') }}" method="post">
|
||||
<div>
|
||||
<input type="text" name="message" style="width: 40%">
|
||||
<div class="select-wrapper">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Datasette: Pattern Portfolio</title>
|
||||
<link rel="stylesheet" href="{{ base_url }}-/static/app.css?{{ app_css_hash }}">
|
||||
|
|
@ -9,11 +9,11 @@
|
|||
</head>
|
||||
<body>
|
||||
|
||||
<header><nav>
|
||||
<header class="hd"><nav>
|
||||
<p class="crumbs">
|
||||
<a href="/">home</a>
|
||||
</p>
|
||||
<details class="nav-menu">
|
||||
<details class="nav-menu details-menu">
|
||||
<summary><svg aria-labelledby="nav-menu-svg-title" role="img"
|
||||
fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16" width="16" height="16">
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
<li><a href="/-/plugins">Installed plugins</a></li>
|
||||
<li><a href="/-/versions">Version info</a></li>
|
||||
</ul>
|
||||
<form action="/-/logout" method="post">
|
||||
<form class="nav-menu-logout" action="/-/logout" method="post">
|
||||
<button class="button-as-link">Log out</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
|
||||
<h2 class="pattern-heading">Header for /database/table/row and Messages</h2>
|
||||
|
||||
<header>
|
||||
<header class="hd">
|
||||
<nav>
|
||||
<p class="crumbs">
|
||||
<a href="/">home</a> /
|
||||
|
|
@ -96,18 +96,24 @@
|
|||
<section class="content">
|
||||
<div class="page-header" style="border-color: #ff0000">
|
||||
<h1>fixtures</h1>
|
||||
<details class="actions-menu-links">
|
||||
<summary><svg aria-labelledby="actions-menu-links-title" role="img"
|
||||
style="color: #666" xmlns="http://www.w3.org/2000/svg"
|
||||
width="28" height="28" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">Table actions</title>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg></summary>
|
||||
</div>
|
||||
<div class="page-action-menu">
|
||||
<details class="actions-menu-links details-menu">
|
||||
<summary>
|
||||
<div class="icon-text">
|
||||
<svg class="icon" aria-labelledby="actions-menu-links-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">Database actions</title>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
<span>Database actions</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="dropdown-menu">
|
||||
<div class="hook"></div>
|
||||
<ul>
|
||||
<li><a href="#">Database action</a></li>
|
||||
<li><a href="#">Action one</a></li>
|
||||
<li><a href="#">Action two</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
|
|
@ -158,18 +164,24 @@
|
|||
<section class="content">
|
||||
<div class="page-header" style="border-color: #ff0000">
|
||||
<h1>roadside_attraction_characteristics</h1>
|
||||
<details class="actions-menu-links">
|
||||
<summary><svg aria-labelledby="actions-menu-links-title" role="img"
|
||||
style="color: #666" xmlns="http://www.w3.org/2000/svg"
|
||||
width="28" height="28" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">Table actions</title>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg></summary>
|
||||
</div>
|
||||
<div class="page-action-menu">
|
||||
<details class="actions-menu-links details-menu">
|
||||
<summary>
|
||||
<div class="icon-text">
|
||||
<svg class="icon" aria-labelledby="actions-menu-links-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">Database actions</title>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
<span>Table actions</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="dropdown-menu">
|
||||
<div class="hook"></div>
|
||||
<ul>
|
||||
<li><a href="#">Table action</a></li>
|
||||
<li><a href="#">Action one</a></li>
|
||||
<li><a href="#">Action two</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Debug permissions{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style type="text/css">
|
||||
.check-result-true {
|
||||
color: green;
|
||||
}
|
||||
.check-result-false {
|
||||
color: red;
|
||||
}
|
||||
.check-result-no-opinion {
|
||||
color: #aaa;
|
||||
}
|
||||
.check h2 {
|
||||
font-size: 1em
|
||||
}
|
||||
.check-action, .check-when, .check-result {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>Recent permissions checks</h1>
|
||||
|
||||
{% for check in permission_checks %}
|
||||
<div class="check">
|
||||
<h2>
|
||||
<span class="check-action">{{ check.action }}</span>
|
||||
checked at
|
||||
<span class="check-when">{{ check.when }}</span>
|
||||
{% if check.result %}
|
||||
<span class="check-result check-result-true">✓</span>
|
||||
{% elif check.result is none %}
|
||||
<span class="check-result check-result-no-opinion">none</span>
|
||||
{% else %}
|
||||
<span class="check-result check-result-false">✗</span>
|
||||
{% endif %}
|
||||
{% if check.used_default %}
|
||||
<span class="check-used-default">(used default)</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
<p><strong>Actor:</strong> {{ check.actor|tojson }}</p>
|
||||
{% if check.resource %}
|
||||
<p><strong>Resource:</strong> {{ check.resource }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -24,15 +24,19 @@
|
|||
|
||||
{% block content %}
|
||||
|
||||
{% if canned_write and db_is_immutable %}
|
||||
{% if canned_query_write and db_is_immutable %}
|
||||
<p class="message-error">This query cannot be executed because the database is immutable.</p>
|
||||
{% endif %}
|
||||
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color(database) }}">{{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>
|
||||
{% set action_links, action_title = query_actions(), "Query actions" %}
|
||||
{% include "_action_menu.html" %}
|
||||
|
||||
{% if canned_query %}{{ top_canned_query() }}{% else %}{{ top_query() }}{% endif %}
|
||||
|
||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
|
||||
<form class="sql" action="{{ urls.database(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="{% if canned_write %}post{% else %}get{% endif %}">
|
||||
<form class="sql core" action="{{ urls.database(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="{% if canned_query_write %}post{% else %}get{% endif %}">
|
||||
<h3>Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}{% if not query_error %}
|
||||
<span class="show-hide-sql">(<a href="{{ show_hide_link }}">{{ show_hide_text }}</a>)</span>
|
||||
{% endif %}</h3>
|
||||
|
|
@ -61,8 +65,8 @@
|
|||
{% endif %}
|
||||
<p>
|
||||
{% if not hide_sql %}<button id="sql-format" type="button" hidden>Format SQL</button>{% endif %}
|
||||
{% if canned_write %}<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">{% endif %}
|
||||
<input type="submit" value="Run SQL"{% if canned_write and db_is_immutable %} disabled{% endif %}>
|
||||
{% if canned_query_write %}<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">{% endif %}
|
||||
<input type="submit" value="Run SQL"{% if canned_query_write and db_is_immutable %} disabled{% endif %}>
|
||||
{{ show_hide_hidden }}
|
||||
{% if canned_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="canned-query-edit-sql">Edit SQL</a>{% endif %}
|
||||
</p>
|
||||
|
|
@ -87,7 +91,7 @@
|
|||
</tbody>
|
||||
</table></div>
|
||||
{% else %}
|
||||
{% if not canned_write and not error %}
|
||||
{% if not canned_query_write and not error %}
|
||||
<p class="zero-results">0 results</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,12 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color(database) }}">{{ table }}: {{ ', '.join(primary_key_values) }}{% if private %} 🔒{% endif %}</h1>
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ table }}: {{ ', '.join(primary_key_values) }}{% if private %} 🔒{% endif %}</h1>
|
||||
|
||||
{% set action_links, action_title = row_actions, "Row actions" %}
|
||||
{% include "_action_menu.html" %}
|
||||
|
||||
{{ top_row() }}
|
||||
|
||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
|
||||
|
|
|
|||
41
datasette/templates/schema.html
Normal file
41
datasette/templates/schema.html
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% if is_instance %}Schema for all databases{% elif table_name %}Schema for {{ schemas[0].database }}.{{ table_name }}{% else %}Schema for {{ schemas[0].database }}{% endif %}{% endblock %}
|
||||
|
||||
{% block body_class %}schema{% endblock %}
|
||||
|
||||
{% block crumbs %}
|
||||
{% if is_instance %}
|
||||
{{ crumbs.nav(request=request) }}
|
||||
{% elif table_name %}
|
||||
{{ crumbs.nav(request=request, database=schemas[0].database, table=table_name) }}
|
||||
{% else %}
|
||||
{{ crumbs.nav(request=request, database=schemas[0].database) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>{% if is_instance %}Schema for all databases{% elif table_name %}Schema for {{ table_name }}{% else %}Schema for {{ schemas[0].database }}{% endif %}</h1>
|
||||
</div>
|
||||
|
||||
{% for item in schemas %}
|
||||
{% if is_instance %}
|
||||
<h2>{{ item.database }}</h2>
|
||||
{% endif %}
|
||||
|
||||
{% if item.schema %}
|
||||
<pre style="background-color: #f5f5f5; padding: 1em; overflow-x: auto; border: 1px solid #ddd; border-radius: 4px;"><code>{{ item.schema }}</code></pre>
|
||||
{% else %}
|
||||
<p><em>No schema available for this database.</em></p>
|
||||
{% endif %}
|
||||
|
||||
{% if not loop.last %}
|
||||
<hr style="margin: 2em 0;">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if not schemas %}
|
||||
<p><em>No databases with viewable schemas found.</em></p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ database }}: {{ table }}: {% if filtered_table_rows_count or filtered_table_rows_count == 0 %}{{ "{:,}".format(filtered_table_rows_count) }} row{% if filtered_table_rows_count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
|
||||
{% block title %}{{ database }}: {{ table }}: {% if count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
|
|
@ -17,37 +17,21 @@
|
|||
{% block body_class %}table db-{{ database|to_css_class }} table-{{ table|to_css_class }}{% endblock %}
|
||||
|
||||
{% block crumbs %}
|
||||
{{ crumbs.nav(request=request, database=database) }}
|
||||
{{ crumbs.nav(request=request, database=database, table=table) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header" style="border-color: #{{ database_color(database) }}">
|
||||
<h1>{{ metadata.title or table }}{% if is_view %} (view){% endif %}{% if private %} 🔒{% endif %}</h1>
|
||||
{% set links = table_actions() %}{% if links %}
|
||||
<details class="actions-menu-links">
|
||||
<summary><svg aria-labelledby="actions-menu-links-title" role="img"
|
||||
style="color: #666" xmlns="http://www.w3.org/2000/svg"
|
||||
width="28" height="28" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">Table actions</title>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg></summary>
|
||||
<div class="dropdown-menu">
|
||||
{% if links %}
|
||||
<ul>
|
||||
{% for link in links %}
|
||||
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>{% endif %}
|
||||
<div class="page-header" style="border-color: #{{ database_color }}">
|
||||
<h1>{{ metadata.get("title") or table }}{% if is_view %} (view){% endif %}{% if private %} 🔒{% endif %}</h1>
|
||||
</div>
|
||||
{% set action_links, action_title = actions(), "View actions" if is_view else "Table actions" %}
|
||||
{% include "_action_menu.html" %}
|
||||
|
||||
{{ top_table() }}
|
||||
|
||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
|
||||
{% if metadata.columns %}
|
||||
{% if metadata.get("columns") %}
|
||||
<dl class="column-descriptions">
|
||||
{% for column_name, column_description in metadata.columns.items() %}
|
||||
<dt>{{ column_name }}</dt><dd>{{ column_description }}</dd>
|
||||
|
|
@ -55,13 +39,16 @@
|
|||
</dl>
|
||||
{% endif %}
|
||||
|
||||
{% if filtered_table_rows_count or human_description_en %}
|
||||
<h3>{% if filtered_table_rows_count or filtered_table_rows_count == 0 %}{{ "{:,}".format(filtered_table_rows_count) }} row{% if filtered_table_rows_count == 1 %}{% else %}s{% endif %}{% endif %}
|
||||
{% if count or human_description_en %}
|
||||
<h3>
|
||||
{% if count == count_limit + 1 %}>{{ "{:,}".format(count_limit) }} rows
|
||||
{% if allow_execute_sql and query.sql %} <a class="count-sql" style="font-size: 0.8em;" href="{{ urls.database_query(database, count_sql) }}">count all</a>{% endif %}
|
||||
{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}
|
||||
{% if human_description_en %}{{ human_description_en }}{% endif %}
|
||||
</h3>
|
||||
{% endif %}
|
||||
|
||||
<form class="filters" action="{{ urls.table(database, table) }}" method="get">
|
||||
<form class="core" class="filters" action="{{ urls.table(database, table) }}" method="get">
|
||||
{% if supports_search %}
|
||||
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value="{{ search }}"></div>
|
||||
{% endif %}
|
||||
|
|
@ -94,7 +81,7 @@
|
|||
</div><div class="select-wrapper filter-op">
|
||||
<select name="_filter_op">
|
||||
{% for key, display, no_argument in filters.lookups() %}
|
||||
<option value="{{ key }}{% if no_argument %}__1{% endif %}"{% if key == lookup %} selected{% endif %}>{{ display }}</option>
|
||||
<option value="{{ key }}{% if no_argument %}__1{% endif %}">{{ display }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div><input type="text" name="_filter_value" class="filter-value">
|
||||
|
|
@ -165,7 +152,7 @@
|
|||
<a href="{{ append_querystring(renderers['json'], '_shape=object') }}">object</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
<form action="{{ url_csv_path }}" method="get">
|
||||
<form class="core" action="{{ url_csv_path }}" method="get">
|
||||
<p>
|
||||
CSV options:
|
||||
<label><input type="checkbox" name="_dl"> download file</label>
|
||||
|
|
@ -188,4 +175,41 @@
|
|||
<pre class="wrapped-sql">{{ view_definition }}</pre>
|
||||
{% endif %}
|
||||
|
||||
{% if allow_execute_sql and query.sql %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const countLink = document.querySelector('a.count-sql');
|
||||
if (countLink) {
|
||||
countLink.addEventListener('click', async function(ev) {
|
||||
ev.preventDefault();
|
||||
// Replace countLink with span with same style attribute
|
||||
const span = document.createElement('span');
|
||||
span.textContent = 'counting...';
|
||||
span.setAttribute('style', countLink.getAttribute('style'));
|
||||
countLink.replaceWith(span);
|
||||
countLink.setAttribute('disabled', 'disabled');
|
||||
let url = countLink.href.replace(/(\?|$)/, '.json$1');
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
console.log({response});
|
||||
const data = await response.json();
|
||||
console.log({data});
|
||||
if (!response.ok) {
|
||||
console.log('throw error');
|
||||
throw new Error(data.title || data.error);
|
||||
}
|
||||
const count = data['rows'][0]['count(*)'];
|
||||
const formattedCount = count.toLocaleString();
|
||||
span.closest('h3').textContent = formattedCount + ' rows';
|
||||
} catch (error) {
|
||||
console.log('Update', span, 'with error message', error);
|
||||
span.textContent = error.message;
|
||||
span.style.color = 'red';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ def trace_child_tasks():
|
|||
|
||||
|
||||
@contextmanager
|
||||
def trace(type, **kwargs):
|
||||
def trace(trace_type, **kwargs):
|
||||
assert not TRACE_RESERVED_KEYS.intersection(
|
||||
kwargs.keys()
|
||||
), f".trace() keyword parameters cannot include {TRACE_RESERVED_KEYS}"
|
||||
|
|
@ -45,17 +45,24 @@ def trace(type, **kwargs):
|
|||
yield kwargs
|
||||
return
|
||||
start = time.perf_counter()
|
||||
yield kwargs
|
||||
end = time.perf_counter()
|
||||
trace_info = {
|
||||
"type": type,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"duration_ms": (end - start) * 1000,
|
||||
"traceback": traceback.format_list(traceback.extract_stack(limit=6)[:-3]),
|
||||
}
|
||||
trace_info.update(kwargs)
|
||||
tracer.append(trace_info)
|
||||
captured_error = None
|
||||
try:
|
||||
yield kwargs
|
||||
except Exception as ex:
|
||||
captured_error = ex
|
||||
raise
|
||||
finally:
|
||||
end = time.perf_counter()
|
||||
trace_info = {
|
||||
"type": trace_type,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"duration_ms": (end - start) * 1000,
|
||||
"traceback": traceback.format_list(traceback.extract_stack(limit=6)[:-3]),
|
||||
"error": str(captured_error) if captured_error else None,
|
||||
}
|
||||
trace_info.update(kwargs)
|
||||
tracer.append(trace_info)
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
|
@ -90,6 +97,7 @@ class AsgiTracer:
|
|||
|
||||
async def wrapped_send(message):
|
||||
nonlocal accumulated_body, size_limit_exceeded, response_headers
|
||||
|
||||
if message["type"] == "http.response.start":
|
||||
response_headers = message["headers"]
|
||||
await send(message)
|
||||
|
|
@ -102,11 +110,12 @@ class AsgiTracer:
|
|||
# Accumulate body until the end or until size is exceeded
|
||||
accumulated_body += message["body"]
|
||||
if len(accumulated_body) > self.max_body_bytes:
|
||||
# Send what we have accumulated so far
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.body",
|
||||
"body": accumulated_body,
|
||||
"more_body": True,
|
||||
"more_body": bool(message.get("more_body")),
|
||||
}
|
||||
)
|
||||
size_limit_exceeded = True
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from .utils import tilde_encode, path_with_format, HASH_LENGTH, PrefixedUrlString
|
||||
from .utils import tilde_encode, path_with_format, PrefixedUrlString
|
||||
import urllib
|
||||
|
||||
|
||||
|
|
@ -31,6 +31,12 @@ class Urls:
|
|||
db = self.ds.get_database(database)
|
||||
return self.path(tilde_encode(db.route), format=format)
|
||||
|
||||
def database_query(self, database, sql, format=None):
|
||||
path = f"{self.database(database)}/-/query?" + urllib.parse.urlencode(
|
||||
{"sql": sql}
|
||||
)
|
||||
return self.path(path, format=format)
|
||||
|
||||
def table(self, database, table, format=None):
|
||||
path = f"{self.database(database)}/{tilde_encode(table)}"
|
||||
if format is not None:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import asyncio
|
||||
from contextlib import contextmanager
|
||||
import aiofiles
|
||||
import click
|
||||
from collections import OrderedDict, namedtuple, Counter
|
||||
import copy
|
||||
import dataclasses
|
||||
import base64
|
||||
import hashlib
|
||||
import inspect
|
||||
|
|
@ -17,11 +20,66 @@ import time
|
|||
import types
|
||||
import secrets
|
||||
import shutil
|
||||
from typing import Iterable, List, Tuple
|
||||
import urllib
|
||||
import yaml
|
||||
from .shutil_backport import copytree
|
||||
from .sqlite import sqlite3, supports_table_xinfo
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from datasette.database import Database
|
||||
from datasette.permissions import Resource
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class PaginatedResources:
|
||||
"""Paginated results from allowed_resources query."""
|
||||
|
||||
resources: List["Resource"]
|
||||
next: str | None # Keyset token for next page (None if no more results)
|
||||
_datasette: typing.Any = dataclasses.field(default=None, repr=False)
|
||||
_action: str = dataclasses.field(default=None, repr=False)
|
||||
_actor: typing.Any = dataclasses.field(default=None, repr=False)
|
||||
_parent: str | None = dataclasses.field(default=None, repr=False)
|
||||
_include_is_private: bool = dataclasses.field(default=False, repr=False)
|
||||
_include_reasons: bool = dataclasses.field(default=False, repr=False)
|
||||
_limit: int = dataclasses.field(default=100, repr=False)
|
||||
|
||||
async def all(self):
|
||||
"""
|
||||
Async generator that yields all resources across all pages.
|
||||
|
||||
Automatically handles pagination under the hood. This is useful when you need
|
||||
to iterate through all results without manually managing pagination tokens.
|
||||
|
||||
Yields:
|
||||
Resource objects one at a time
|
||||
|
||||
Example:
|
||||
page = await datasette.allowed_resources("view-table", actor)
|
||||
async for table in page.all():
|
||||
print(f"{table.parent}/{table.child}")
|
||||
"""
|
||||
# Yield all resources from current page
|
||||
for resource in self.resources:
|
||||
yield resource
|
||||
|
||||
# Continue fetching subsequent pages if there are more
|
||||
next_token = self.next
|
||||
while next_token:
|
||||
page = await self._datasette.allowed_resources(
|
||||
self._action,
|
||||
self._actor,
|
||||
parent=self._parent,
|
||||
include_is_private=self._include_is_private,
|
||||
include_reasons=self._include_reasons,
|
||||
limit=self._limit,
|
||||
next=next_token,
|
||||
)
|
||||
for resource in page.resources:
|
||||
yield resource
|
||||
next_token = page.next
|
||||
|
||||
|
||||
# From https://www.sqlite.org/lang_keywords.html
|
||||
reserved_words = set(
|
||||
|
|
@ -242,6 +300,7 @@ allowed_pragmas = (
|
|||
"schema_version",
|
||||
"table_info",
|
||||
"table_xinfo",
|
||||
"table_list",
|
||||
)
|
||||
disallawed_sql_res = [
|
||||
(
|
||||
|
|
@ -402,9 +461,9 @@ def make_dockerfile(
|
|||
apt_get_extras = apt_get_extras_
|
||||
if spatialite:
|
||||
apt_get_extras.extend(["python3-dev", "gcc", "libsqlite3-mod-spatialite"])
|
||||
environment_variables[
|
||||
"SQLITE_EXTENSIONS"
|
||||
] = "/usr/lib/x86_64-linux-gnu/mod_spatialite.so"
|
||||
environment_variables["SQLITE_EXTENSIONS"] = (
|
||||
"/usr/lib/x86_64-linux-gnu/mod_spatialite.so"
|
||||
)
|
||||
return """
|
||||
FROM python:3.11.0-slim-bullseye
|
||||
COPY . /app
|
||||
|
|
@ -416,9 +475,11 @@ RUN datasette inspect {files} --inspect-file inspect-data.json
|
|||
ENV PORT {port}
|
||||
EXPOSE {port}
|
||||
CMD {cmd}""".format(
|
||||
apt_get_extras=APT_GET_DOCKERFILE_EXTRAS.format(" ".join(apt_get_extras))
|
||||
if apt_get_extras
|
||||
else "",
|
||||
apt_get_extras=(
|
||||
APT_GET_DOCKERFILE_EXTRAS.format(" ".join(apt_get_extras))
|
||||
if apt_get_extras
|
||||
else ""
|
||||
),
|
||||
environment_variables="\n".join(
|
||||
[
|
||||
"ENV {} '{}'".format(key, value)
|
||||
|
|
@ -709,7 +770,7 @@ def to_css_class(s):
|
|||
"""
|
||||
if css_class_re.match(s):
|
||||
return s
|
||||
md5_suffix = hashlib.md5(s.encode("utf8")).hexdigest()[:6]
|
||||
md5_suffix = md5_not_usedforsecurity(s)[:6]
|
||||
# Strip leading _, -
|
||||
s = s.lstrip("_").lstrip("-")
|
||||
# Replace any whitespace with hyphens
|
||||
|
|
@ -828,9 +889,18 @@ _infinities = {float("inf"), float("-inf")}
|
|||
|
||||
|
||||
def remove_infinites(row):
|
||||
if any((c in _infinities) if isinstance(c, float) else 0 for c in row):
|
||||
to_check = row
|
||||
if isinstance(row, dict):
|
||||
to_check = row.values()
|
||||
if not any((c in _infinities) if isinstance(c, float) else 0 for c in to_check):
|
||||
return row
|
||||
if isinstance(row, dict):
|
||||
return {
|
||||
k: (None if (isinstance(v, float) and v in _infinities) else v)
|
||||
for k, v in row.items()
|
||||
}
|
||||
else:
|
||||
return [None if (isinstance(c, float) and c in _infinities) else c for c in row]
|
||||
return row
|
||||
|
||||
|
||||
class StaticMount(click.ParamType):
|
||||
|
|
@ -1037,7 +1107,8 @@ def resolve_env_secrets(config, environ):
|
|||
if list(config.keys()) == ["$env"]:
|
||||
return environ.get(list(config.values())[0])
|
||||
elif list(config.keys()) == ["$file"]:
|
||||
return open(list(config.values())[0]).read()
|
||||
with open(list(config.values())[0]) as fp:
|
||||
return fp.read()
|
||||
else:
|
||||
return {
|
||||
key: resolve_env_secrets(value, environ)
|
||||
|
|
@ -1114,23 +1185,42 @@ class StartupError(Exception):
|
|||
pass
|
||||
|
||||
|
||||
_re_named_parameter = re.compile(":([a-zA-Z0-9_]+)")
|
||||
_single_line_comment_re = re.compile(r"--.*")
|
||||
_multi_line_comment_re = re.compile(r"/\*.*?\*/", re.DOTALL)
|
||||
_single_quote_re = re.compile(r"'(?:''|[^'])*'")
|
||||
_double_quote_re = re.compile(r'"(?:\"\"|[^"])*"')
|
||||
_named_param_re = re.compile(r":(\w+)")
|
||||
|
||||
|
||||
async def derive_named_parameters(db, sql):
|
||||
explain = "explain {}".format(sql.strip().rstrip(";"))
|
||||
possible_params = _re_named_parameter.findall(sql)
|
||||
try:
|
||||
results = await db.execute(explain, {p: None for p in possible_params})
|
||||
return [row["p4"].lstrip(":") for row in results if row["opcode"] == "Variable"]
|
||||
except sqlite3.DatabaseError:
|
||||
return possible_params
|
||||
@documented
|
||||
def named_parameters(sql: str) -> List[str]:
|
||||
"""
|
||||
Given a SQL statement, return a list of named parameters that are used in the statement
|
||||
|
||||
e.g. for ``select * from foo where id=:id`` this would return ``["id"]``
|
||||
"""
|
||||
sql = _single_line_comment_re.sub("", sql)
|
||||
sql = _multi_line_comment_re.sub("", sql)
|
||||
sql = _single_quote_re.sub("", sql)
|
||||
sql = _double_quote_re.sub("", sql)
|
||||
# Extract parameters from what is left
|
||||
return _named_param_re.findall(sql)
|
||||
|
||||
|
||||
async def derive_named_parameters(db: "Database", sql: str) -> List[str]:
|
||||
"""
|
||||
This undocumented but stable method exists for backwards compatibility
|
||||
with plugins that were using it before it switched to named_parameters()
|
||||
"""
|
||||
return named_parameters(sql)
|
||||
|
||||
|
||||
def add_cors_headers(headers):
|
||||
headers["Access-Control-Allow-Origin"] = "*"
|
||||
headers["Access-Control-Allow-Headers"] = "Authorization"
|
||||
headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
|
||||
headers["Access-Control-Expose-Headers"] = "Link"
|
||||
headers["Access-Control-Allow-Methods"] = "GET, POST, HEAD, OPTIONS"
|
||||
headers["Access-Control-Max-Age"] = "3600"
|
||||
|
||||
|
||||
_TILDE_ENCODING_SAFE = frozenset(
|
||||
|
|
@ -1193,3 +1283,233 @@ def truncate_url(url, length):
|
|||
rest, ext = bits
|
||||
return rest[: length - 1 - len(ext)] + "…." + ext
|
||||
return url[: length - 1] + "…"
|
||||
|
||||
|
||||
async def row_sql_params_pks(db, table, pk_values):
|
||||
pks = await db.primary_keys(table)
|
||||
use_rowid = not pks
|
||||
select = "*"
|
||||
if use_rowid:
|
||||
select = "rowid, *"
|
||||
pks = ["rowid"]
|
||||
wheres = [f'"{pk}"=:p{i}' for i, pk in enumerate(pks)]
|
||||
sql = f"select {select} from {escape_sqlite(table)} where {' AND '.join(wheres)}"
|
||||
params = {}
|
||||
for i, pk_value in enumerate(pk_values):
|
||||
params[f"p{i}"] = pk_value
|
||||
return sql, params, pks
|
||||
|
||||
|
||||
def _handle_pair(key: str, value: str) -> dict:
|
||||
"""
|
||||
Turn a key-value pair into a nested dictionary.
|
||||
foo, bar => {'foo': 'bar'}
|
||||
foo.bar, baz => {'foo': {'bar': 'baz'}}
|
||||
foo.bar, [1, 2, 3] => {'foo': {'bar': [1, 2, 3]}}
|
||||
foo.bar, "baz" => {'foo': {'bar': 'baz'}}
|
||||
foo.bar, '{"baz": "qux"}' => {'foo': {'bar': "{'baz': 'qux'}"}}
|
||||
"""
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
# If it doesn't parse as JSON, treat it as a string
|
||||
pass
|
||||
|
||||
keys = key.split(".")
|
||||
result = current_dict = {}
|
||||
|
||||
for k in keys[:-1]:
|
||||
current_dict[k] = {}
|
||||
current_dict = current_dict[k]
|
||||
|
||||
current_dict[keys[-1]] = value
|
||||
return result
|
||||
|
||||
|
||||
def _combine(base: dict, update: dict) -> dict:
|
||||
"""
|
||||
Recursively merge two dictionaries.
|
||||
"""
|
||||
for key, value in update.items():
|
||||
if isinstance(value, dict) and key in base and isinstance(base[key], dict):
|
||||
base[key] = _combine(base[key], value)
|
||||
else:
|
||||
base[key] = value
|
||||
return base
|
||||
|
||||
|
||||
def pairs_to_nested_config(pairs: typing.List[typing.Tuple[str, typing.Any]]) -> dict:
|
||||
"""
|
||||
Parse a list of key-value pairs into a nested dictionary.
|
||||
"""
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
parsed_pair = _handle_pair(key, value)
|
||||
result = _combine(result, parsed_pair)
|
||||
return result
|
||||
|
||||
|
||||
def make_slot_function(name, datasette, request, **kwargs):
|
||||
from datasette.plugins import pm
|
||||
|
||||
method = getattr(pm.hook, name, None)
|
||||
assert method is not None, "No hook found for {}".format(name)
|
||||
|
||||
async def inner():
|
||||
html_bits = []
|
||||
for hook in method(datasette=datasette, request=request, **kwargs):
|
||||
html = await await_me_maybe(hook)
|
||||
if html is not None:
|
||||
html_bits.append(html)
|
||||
return markupsafe.Markup("".join(html_bits))
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
def prune_empty_dicts(d: dict):
|
||||
"""
|
||||
Recursively prune all empty dictionaries from a given dictionary.
|
||||
"""
|
||||
for key, value in list(d.items()):
|
||||
if isinstance(value, dict):
|
||||
prune_empty_dicts(value)
|
||||
if value == {}:
|
||||
d.pop(key, None)
|
||||
|
||||
|
||||
def move_plugins_and_allow(source: dict, destination: dict) -> Tuple[dict, dict]:
|
||||
"""
|
||||
Move 'plugins' and 'allow' keys from source to destination dictionary. Creates
|
||||
hierarchy in destination if needed. After moving, recursively remove any keys
|
||||
in the source that are left empty.
|
||||
"""
|
||||
source = copy.deepcopy(source)
|
||||
destination = copy.deepcopy(destination)
|
||||
|
||||
def recursive_move(src, dest, path=None):
|
||||
if path is None:
|
||||
path = []
|
||||
for key, value in list(src.items()):
|
||||
new_path = path + [key]
|
||||
if key in ("plugins", "allow"):
|
||||
# Navigate and create the hierarchy in destination if needed
|
||||
d = dest
|
||||
for step in path:
|
||||
d = d.setdefault(step, {})
|
||||
# Move the plugins
|
||||
d[key] = value
|
||||
# Remove the plugins from source
|
||||
src.pop(key, None)
|
||||
elif isinstance(value, dict):
|
||||
recursive_move(value, dest, new_path)
|
||||
# After moving, check if the current dictionary is empty and remove it if so
|
||||
if not value:
|
||||
src.pop(key, None)
|
||||
|
||||
recursive_move(source, destination)
|
||||
prune_empty_dicts(source)
|
||||
return source, destination
|
||||
|
||||
|
||||
_table_config_keys = (
|
||||
"hidden",
|
||||
"sort",
|
||||
"sort_desc",
|
||||
"size",
|
||||
"sortable_columns",
|
||||
"label_column",
|
||||
"facets",
|
||||
"fts_table",
|
||||
"fts_pk",
|
||||
"searchmode",
|
||||
)
|
||||
|
||||
|
||||
def move_table_config(metadata: dict, config: dict):
|
||||
"""
|
||||
Move all known table configuration keys from metadata to config.
|
||||
"""
|
||||
if "databases" not in metadata:
|
||||
return metadata, config
|
||||
metadata = copy.deepcopy(metadata)
|
||||
config = copy.deepcopy(config)
|
||||
for database_name, database in metadata["databases"].items():
|
||||
if "tables" not in database:
|
||||
continue
|
||||
for table_name, table in database["tables"].items():
|
||||
for key in _table_config_keys:
|
||||
if key in table:
|
||||
config.setdefault("databases", {}).setdefault(
|
||||
database_name, {}
|
||||
).setdefault("tables", {}).setdefault(table_name, {})[
|
||||
key
|
||||
] = table.pop(
|
||||
key
|
||||
)
|
||||
prune_empty_dicts(metadata)
|
||||
return metadata, config
|
||||
|
||||
|
||||
def redact_keys(original: dict, key_patterns: Iterable) -> dict:
|
||||
"""
|
||||
Recursively redact sensitive keys in a dictionary based on given patterns
|
||||
|
||||
:param original: The original dictionary
|
||||
:param key_patterns: A list of substring patterns to redact
|
||||
:return: A copy of the original dictionary with sensitive values redacted
|
||||
"""
|
||||
|
||||
def redact(data):
|
||||
if isinstance(data, dict):
|
||||
return {
|
||||
k: (
|
||||
redact(v)
|
||||
if not any(pattern in k for pattern in key_patterns)
|
||||
else "***"
|
||||
)
|
||||
for k, v in data.items()
|
||||
}
|
||||
elif isinstance(data, list):
|
||||
return [redact(item) for item in data]
|
||||
else:
|
||||
return data
|
||||
|
||||
return redact(original)
|
||||
|
||||
|
||||
def md5_not_usedforsecurity(s):
|
||||
try:
|
||||
return hashlib.md5(s.encode("utf8"), usedforsecurity=False).hexdigest()
|
||||
except TypeError:
|
||||
# For Python 3.8 which does not support usedforsecurity=False
|
||||
return hashlib.md5(s.encode("utf8")).hexdigest()
|
||||
|
||||
|
||||
_etag_cache = {}
|
||||
|
||||
|
||||
async def calculate_etag(filepath, chunk_size=4096):
|
||||
if filepath in _etag_cache:
|
||||
return _etag_cache[filepath]
|
||||
|
||||
hasher = hashlib.md5()
|
||||
async with aiofiles.open(filepath, "rb") as f:
|
||||
while True:
|
||||
chunk = await f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
hasher.update(chunk)
|
||||
|
||||
etag = f'"{hasher.hexdigest()}"'
|
||||
_etag_cache[filepath] = etag
|
||||
|
||||
return etag
|
||||
|
||||
|
||||
def deep_dict_update(dict1, dict2):
|
||||
for key, value in dict2.items():
|
||||
if isinstance(value, dict):
|
||||
dict1[key] = deep_dict_update(dict1.get(key, type(value)()), value)
|
||||
else:
|
||||
dict1[key] = value
|
||||
return dict1
|
||||
|
|
|
|||
587
datasette/utils/actions_sql.py
Normal file
587
datasette/utils/actions_sql.py
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
"""
|
||||
SQL query builder for hierarchical permission checking.
|
||||
|
||||
This module implements a cascading permission system based on the pattern
|
||||
from https://github.com/simonw/research/tree/main/sqlite-permissions-poc
|
||||
|
||||
It builds SQL queries that:
|
||||
|
||||
1. Start with all resources of a given type (from resource_type.resources_sql())
|
||||
2. Gather permission rules from plugins (via permission_resources_sql hook)
|
||||
3. Apply cascading logic: child → parent → global
|
||||
4. Apply DENY-beats-ALLOW at each level
|
||||
|
||||
The core pattern is:
|
||||
- Resources are identified by (parent, child) tuples
|
||||
- Rules are evaluated at three levels:
|
||||
- child: exact match on (parent, child)
|
||||
- parent: match on (parent, NULL)
|
||||
- global: match on (NULL, NULL)
|
||||
- At the same level, DENY (allow=0) beats ALLOW (allow=1)
|
||||
- Across levels, child beats parent beats global
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from datasette.utils.permissions import gather_permission_sql_from_hooks
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
|
||||
async def build_allowed_resources_sql(
|
||||
datasette: "Datasette",
|
||||
actor: dict | None,
|
||||
action: str,
|
||||
*,
|
||||
parent: str | None = None,
|
||||
include_is_private: bool = False,
|
||||
) -> tuple[str, dict]:
|
||||
"""
|
||||
Build a SQL query that returns all resources the actor can access for this action.
|
||||
|
||||
Args:
|
||||
datasette: The Datasette instance
|
||||
actor: The actor dict (or None for unauthenticated)
|
||||
action: The action name (e.g., "view-table", "view-database")
|
||||
parent: Optional parent filter to limit results (e.g., database name)
|
||||
include_is_private: If True, add is_private column showing if anonymous cannot access
|
||||
|
||||
Returns:
|
||||
A tuple of (sql_query, params_dict)
|
||||
|
||||
The returned SQL query will have three columns (or four with include_is_private):
|
||||
- parent: The parent resource identifier (or NULL)
|
||||
- child: The child resource identifier (or NULL)
|
||||
- reason: The reason from the rule that granted access
|
||||
- is_private: (if include_is_private) 1 if anonymous cannot access, 0 otherwise
|
||||
|
||||
Example:
|
||||
For action="view-table", this might return:
|
||||
SELECT parent, child, reason FROM ... WHERE is_allowed = 1
|
||||
|
||||
Results would be like:
|
||||
('analytics', 'users', 'role-based: analysts can access analytics DB')
|
||||
('analytics', 'events', 'role-based: analysts can access analytics DB')
|
||||
('production', 'orders', 'business-exception: allow production.orders for carol')
|
||||
"""
|
||||
# Get the Action object
|
||||
action_obj = datasette.actions.get(action)
|
||||
if not action_obj:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
|
||||
# If this action also_requires another action, we need to combine the queries
|
||||
if action_obj.also_requires:
|
||||
# Build both queries
|
||||
main_sql, main_params = await _build_single_action_sql(
|
||||
datasette,
|
||||
actor,
|
||||
action,
|
||||
parent=parent,
|
||||
include_is_private=include_is_private,
|
||||
)
|
||||
required_sql, required_params = await _build_single_action_sql(
|
||||
datasette,
|
||||
actor,
|
||||
action_obj.also_requires,
|
||||
parent=parent,
|
||||
include_is_private=False,
|
||||
)
|
||||
|
||||
# Merge parameters - they should have identical values for :actor, :actor_id, etc.
|
||||
all_params = {**main_params, **required_params}
|
||||
if parent is not None:
|
||||
all_params["filter_parent"] = parent
|
||||
|
||||
# Combine with INNER JOIN - only resources allowed by both actions
|
||||
combined_sql = f"""
|
||||
WITH
|
||||
main_allowed AS (
|
||||
{main_sql}
|
||||
),
|
||||
required_allowed AS (
|
||||
{required_sql}
|
||||
)
|
||||
SELECT m.parent, m.child, m.reason"""
|
||||
|
||||
if include_is_private:
|
||||
combined_sql += ", m.is_private"
|
||||
|
||||
combined_sql += """
|
||||
FROM main_allowed m
|
||||
INNER JOIN required_allowed r
|
||||
ON ((m.parent = r.parent) OR (m.parent IS NULL AND r.parent IS NULL))
|
||||
AND ((m.child = r.child) OR (m.child IS NULL AND r.child IS NULL))
|
||||
"""
|
||||
|
||||
if parent is not None:
|
||||
combined_sql += "WHERE m.parent = :filter_parent\n"
|
||||
|
||||
combined_sql += "ORDER BY m.parent, m.child"
|
||||
|
||||
return combined_sql, all_params
|
||||
|
||||
# No also_requires, build single action query
|
||||
return await _build_single_action_sql(
|
||||
datasette, actor, action, parent=parent, include_is_private=include_is_private
|
||||
)
|
||||
|
||||
|
||||
async def _build_single_action_sql(
|
||||
datasette: "Datasette",
|
||||
actor: dict | None,
|
||||
action: str,
|
||||
*,
|
||||
parent: str | None = None,
|
||||
include_is_private: bool = False,
|
||||
) -> tuple[str, dict]:
|
||||
"""
|
||||
Build SQL for a single action (internal helper for build_allowed_resources_sql).
|
||||
|
||||
This contains the original logic from build_allowed_resources_sql, extracted
|
||||
to allow combining multiple actions when also_requires is used.
|
||||
"""
|
||||
# Get the Action object
|
||||
action_obj = datasette.actions.get(action)
|
||||
if not action_obj:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
|
||||
# Get base resources SQL from the resource class
|
||||
base_resources_sql = await action_obj.resource_class.resources_sql(datasette)
|
||||
|
||||
permission_sqls = await gather_permission_sql_from_hooks(
|
||||
datasette=datasette,
|
||||
actor=actor,
|
||||
action=action,
|
||||
)
|
||||
|
||||
# If permission_sqls is the sentinel, skip all permission checks
|
||||
# Return SQL that allows all resources
|
||||
from datasette.utils.permissions import SKIP_PERMISSION_CHECKS
|
||||
|
||||
if permission_sqls is SKIP_PERMISSION_CHECKS:
|
||||
cols = "parent, child, 'skip_permission_checks' AS reason"
|
||||
if include_is_private:
|
||||
cols += ", 0 AS is_private"
|
||||
return f"SELECT {cols} FROM ({base_resources_sql})", {}
|
||||
|
||||
all_params = {}
|
||||
rule_sqls = []
|
||||
restriction_sqls = []
|
||||
|
||||
for permission_sql in permission_sqls:
|
||||
# Always collect params (even from restriction-only plugins)
|
||||
all_params.update(permission_sql.params or {})
|
||||
|
||||
# Collect restriction SQL filters
|
||||
if permission_sql.restriction_sql:
|
||||
restriction_sqls.append(permission_sql.restriction_sql)
|
||||
|
||||
# Skip plugins that only provide restriction_sql (no permission rules)
|
||||
if permission_sql.sql is None:
|
||||
continue
|
||||
rule_sqls.append(
|
||||
f"""
|
||||
SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (
|
||||
{permission_sql.sql}
|
||||
)
|
||||
""".strip()
|
||||
)
|
||||
|
||||
# If no rules, return empty result (deny all)
|
||||
if not rule_sqls:
|
||||
empty_cols = "NULL AS parent, NULL AS child, NULL AS reason"
|
||||
if include_is_private:
|
||||
empty_cols += ", NULL AS is_private"
|
||||
return f"SELECT {empty_cols} WHERE 0", {}
|
||||
|
||||
# Build the cascading permission query
|
||||
rules_union = " UNION ALL ".join(rule_sqls)
|
||||
|
||||
# Build the main query
|
||||
query_parts = [
|
||||
"WITH",
|
||||
"base AS (",
|
||||
f" {base_resources_sql}",
|
||||
"),",
|
||||
"all_rules AS (",
|
||||
f" {rules_union}",
|
||||
"),",
|
||||
]
|
||||
|
||||
# If include_is_private, we need to build anonymous permissions too
|
||||
if include_is_private:
|
||||
anon_permission_sqls = await gather_permission_sql_from_hooks(
|
||||
datasette=datasette,
|
||||
actor=None,
|
||||
action=action,
|
||||
)
|
||||
anon_sqls_rewritten = []
|
||||
anon_params = {}
|
||||
|
||||
for permission_sql in anon_permission_sqls:
|
||||
# Skip plugins that only provide restriction_sql (no permission rules)
|
||||
if permission_sql.sql is None:
|
||||
continue
|
||||
rewritten_sql = permission_sql.sql
|
||||
for key, value in (permission_sql.params or {}).items():
|
||||
anon_key = f"anon_{key}"
|
||||
anon_params[anon_key] = value
|
||||
rewritten_sql = rewritten_sql.replace(f":{key}", f":{anon_key}")
|
||||
anon_sqls_rewritten.append(rewritten_sql)
|
||||
|
||||
all_params.update(anon_params)
|
||||
|
||||
if anon_sqls_rewritten:
|
||||
anon_rules_union = " UNION ALL ".join(anon_sqls_rewritten)
|
||||
query_parts.extend(
|
||||
[
|
||||
"anon_rules AS (",
|
||||
f" {anon_rules_union}",
|
||||
"),",
|
||||
]
|
||||
)
|
||||
|
||||
# Continue with the cascading logic
|
||||
query_parts.extend(
|
||||
[
|
||||
"child_lvl AS (",
|
||||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
|
||||
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
|
||||
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
|
||||
" FROM base b",
|
||||
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"parent_lvl AS (",
|
||||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
|
||||
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
|
||||
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
|
||||
" FROM base b",
|
||||
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"global_lvl AS (",
|
||||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
|
||||
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
|
||||
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
|
||||
" FROM base b",
|
||||
" LEFT JOIN all_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
]
|
||||
)
|
||||
|
||||
# Add anonymous decision logic if needed
|
||||
if include_is_private:
|
||||
query_parts.extend(
|
||||
[
|
||||
"anon_child_lvl AS (",
|
||||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
|
||||
" FROM base b",
|
||||
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child = b.child",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"anon_parent_lvl AS (",
|
||||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
|
||||
" FROM base b",
|
||||
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"anon_global_lvl AS (",
|
||||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
|
||||
" FROM base b",
|
||||
" LEFT JOIN anon_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"anon_decisions AS (",
|
||||
" SELECT",
|
||||
" b.parent, b.child,",
|
||||
" CASE",
|
||||
" WHEN acl.any_deny = 1 THEN 0",
|
||||
" WHEN acl.any_allow = 1 THEN 1",
|
||||
" WHEN apl.any_deny = 1 THEN 0",
|
||||
" WHEN apl.any_allow = 1 THEN 1",
|
||||
" WHEN agl.any_deny = 1 THEN 0",
|
||||
" WHEN agl.any_allow = 1 THEN 1",
|
||||
" ELSE 0",
|
||||
" END AS anon_is_allowed",
|
||||
" FROM base b",
|
||||
" JOIN anon_child_lvl acl ON b.parent = acl.parent AND (b.child = acl.child OR (b.child IS NULL AND acl.child IS NULL))",
|
||||
" JOIN anon_parent_lvl apl ON b.parent = apl.parent AND (b.child = apl.child OR (b.child IS NULL AND apl.child IS NULL))",
|
||||
" JOIN anon_global_lvl agl ON b.parent = agl.parent AND (b.child = agl.child OR (b.child IS NULL AND agl.child IS NULL))",
|
||||
"),",
|
||||
]
|
||||
)
|
||||
|
||||
# Final decisions
|
||||
query_parts.extend(
|
||||
[
|
||||
"decisions AS (",
|
||||
" SELECT",
|
||||
" b.parent, b.child,",
|
||||
" -- Cascading permission logic: child → parent → global, DENY beats ALLOW at each level",
|
||||
" -- Priority order:",
|
||||
" -- 1. Child-level deny (most specific, blocks access)",
|
||||
" -- 2. Child-level allow (most specific, grants access)",
|
||||
" -- 3. Parent-level deny (intermediate, blocks access)",
|
||||
" -- 4. Parent-level allow (intermediate, grants access)",
|
||||
" -- 5. Global-level deny (least specific, blocks access)",
|
||||
" -- 6. Global-level allow (least specific, grants access)",
|
||||
" -- 7. Default deny (no rules match)",
|
||||
" CASE",
|
||||
" WHEN cl.any_deny = 1 THEN 0",
|
||||
" WHEN cl.any_allow = 1 THEN 1",
|
||||
" WHEN pl.any_deny = 1 THEN 0",
|
||||
" WHEN pl.any_allow = 1 THEN 1",
|
||||
" WHEN gl.any_deny = 1 THEN 0",
|
||||
" WHEN gl.any_allow = 1 THEN 1",
|
||||
" ELSE 0",
|
||||
" END AS is_allowed,",
|
||||
" CASE",
|
||||
" WHEN cl.any_deny = 1 THEN cl.deny_reasons",
|
||||
" WHEN cl.any_allow = 1 THEN cl.allow_reasons",
|
||||
" WHEN pl.any_deny = 1 THEN pl.deny_reasons",
|
||||
" WHEN pl.any_allow = 1 THEN pl.allow_reasons",
|
||||
" WHEN gl.any_deny = 1 THEN gl.deny_reasons",
|
||||
" WHEN gl.any_allow = 1 THEN gl.allow_reasons",
|
||||
" ELSE '[]'",
|
||||
" END AS reason",
|
||||
]
|
||||
)
|
||||
|
||||
if include_is_private:
|
||||
query_parts.append(
|
||||
" , CASE WHEN ad.anon_is_allowed = 0 THEN 1 ELSE 0 END AS is_private"
|
||||
)
|
||||
|
||||
query_parts.extend(
|
||||
[
|
||||
" FROM base b",
|
||||
" JOIN child_lvl cl ON b.parent = cl.parent AND (b.child = cl.child OR (b.child IS NULL AND cl.child IS NULL))",
|
||||
" JOIN parent_lvl pl ON b.parent = pl.parent AND (b.child = pl.child OR (b.child IS NULL AND pl.child IS NULL))",
|
||||
" JOIN global_lvl gl ON b.parent = gl.parent AND (b.child = gl.child OR (b.child IS NULL AND gl.child IS NULL))",
|
||||
]
|
||||
)
|
||||
|
||||
if include_is_private:
|
||||
query_parts.append(
|
||||
" JOIN anon_decisions ad ON b.parent = ad.parent AND (b.child = ad.child OR (b.child IS NULL AND ad.child IS NULL))"
|
||||
)
|
||||
|
||||
query_parts.append(")")
|
||||
|
||||
# Add restriction list CTE if there are restrictions
|
||||
if restriction_sqls:
|
||||
# Wrap each restriction_sql in a subquery to avoid operator precedence issues
|
||||
# with UNION ALL inside the restriction SQL statements
|
||||
restriction_intersect = "\nINTERSECT\n".join(
|
||||
f"SELECT * FROM ({sql})" for sql in restriction_sqls
|
||||
)
|
||||
query_parts.extend(
|
||||
[",", "restriction_list AS (", f" {restriction_intersect}", ")"]
|
||||
)
|
||||
|
||||
# Final SELECT
|
||||
select_cols = "parent, child, reason"
|
||||
if include_is_private:
|
||||
select_cols += ", is_private"
|
||||
|
||||
query_parts.append(f"SELECT {select_cols}")
|
||||
query_parts.append("FROM decisions")
|
||||
query_parts.append("WHERE is_allowed = 1")
|
||||
|
||||
# Add restriction filter if there are restrictions
|
||||
if restriction_sqls:
|
||||
query_parts.append(
|
||||
"""
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM restriction_list r
|
||||
WHERE (r.parent = decisions.parent OR r.parent IS NULL)
|
||||
AND (r.child = decisions.child OR r.child IS NULL)
|
||||
)"""
|
||||
)
|
||||
|
||||
# Add parent filter if specified
|
||||
if parent is not None:
|
||||
query_parts.append(" AND parent = :filter_parent")
|
||||
all_params["filter_parent"] = parent
|
||||
|
||||
query_parts.append("ORDER BY parent, child")
|
||||
|
||||
query = "\n".join(query_parts)
|
||||
return query, all_params
|
||||
|
||||
|
||||
async def build_permission_rules_sql(
|
||||
datasette: "Datasette", actor: dict | None, action: str
|
||||
) -> tuple[str, dict]:
|
||||
"""
|
||||
Build the UNION SQL and params for all permission rules for a given actor and action.
|
||||
|
||||
Returns:
|
||||
A tuple of (sql, params) where sql is a UNION ALL query that returns
|
||||
(parent, child, allow, reason, source_plugin) rows.
|
||||
"""
|
||||
# Get the Action object
|
||||
action_obj = datasette.actions.get(action)
|
||||
if not action_obj:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
|
||||
permission_sqls = await gather_permission_sql_from_hooks(
|
||||
datasette=datasette,
|
||||
actor=actor,
|
||||
action=action,
|
||||
)
|
||||
|
||||
# If permission_sqls is the sentinel, skip all permission checks
|
||||
# Return SQL that allows everything
|
||||
from datasette.utils.permissions import SKIP_PERMISSION_CHECKS
|
||||
|
||||
if permission_sqls is SKIP_PERMISSION_CHECKS:
|
||||
return (
|
||||
"SELECT NULL AS parent, NULL AS child, 1 AS allow, 'skip_permission_checks' AS reason, 'skip' AS source_plugin",
|
||||
{},
|
||||
[],
|
||||
)
|
||||
|
||||
if not permission_sqls:
|
||||
return (
|
||||
"SELECT NULL AS parent, NULL AS child, 0 AS allow, NULL AS reason, NULL AS source_plugin WHERE 0",
|
||||
{},
|
||||
[],
|
||||
)
|
||||
|
||||
union_parts = []
|
||||
all_params = {}
|
||||
restriction_sqls = []
|
||||
|
||||
for permission_sql in permission_sqls:
|
||||
all_params.update(permission_sql.params or {})
|
||||
|
||||
# Collect restriction SQL filters
|
||||
if permission_sql.restriction_sql:
|
||||
restriction_sqls.append(permission_sql.restriction_sql)
|
||||
|
||||
# Skip plugins that only provide restriction_sql (no permission rules)
|
||||
if permission_sql.sql is None:
|
||||
continue
|
||||
|
||||
union_parts.append(
|
||||
f"""
|
||||
SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (
|
||||
{permission_sql.sql}
|
||||
)
|
||||
""".strip()
|
||||
)
|
||||
|
||||
rules_union = " UNION ALL ".join(union_parts)
|
||||
return rules_union, all_params, restriction_sqls
|
||||
|
||||
|
||||
async def check_permission_for_resource(
|
||||
*,
|
||||
datasette: "Datasette",
|
||||
actor: dict | None,
|
||||
action: str,
|
||||
parent: str | None,
|
||||
child: str | None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if an actor has permission for a specific action on a specific resource.
|
||||
|
||||
Args:
|
||||
datasette: The Datasette instance
|
||||
actor: The actor dict (or None)
|
||||
action: The action name
|
||||
parent: The parent resource identifier (e.g., database name, or None)
|
||||
child: The child resource identifier (e.g., table name, or None)
|
||||
|
||||
Returns:
|
||||
True if the actor is allowed, False otherwise
|
||||
|
||||
This builds the cascading permission query and checks if the specific
|
||||
resource is in the allowed set.
|
||||
"""
|
||||
rules_union, all_params, restriction_sqls = await build_permission_rules_sql(
|
||||
datasette, actor, action
|
||||
)
|
||||
|
||||
# If no rules (empty SQL), default deny
|
||||
if not rules_union:
|
||||
return False
|
||||
|
||||
# Add parameters for the resource we're checking
|
||||
all_params["_check_parent"] = parent
|
||||
all_params["_check_child"] = child
|
||||
|
||||
# If there are restriction filters, check if the resource passes them first
|
||||
if restriction_sqls:
|
||||
# Check if resource is in restriction allowlist
|
||||
# Database-level restrictions (parent, NULL) should match all children (parent, *)
|
||||
# Wrap each restriction_sql in a subquery to avoid operator precedence issues
|
||||
restriction_check = "\nINTERSECT\n".join(
|
||||
f"SELECT * FROM ({sql})" for sql in restriction_sqls
|
||||
)
|
||||
restriction_query = f"""
|
||||
WITH restriction_list AS (
|
||||
{restriction_check}
|
||||
)
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM restriction_list
|
||||
WHERE (parent = :_check_parent OR parent IS NULL)
|
||||
AND (child = :_check_child OR child IS NULL)
|
||||
) AS in_allowlist
|
||||
"""
|
||||
result = await datasette.get_internal_database().execute(
|
||||
restriction_query, all_params
|
||||
)
|
||||
if result.rows and not result.rows[0][0]:
|
||||
# Resource not in restriction allowlist - deny
|
||||
return False
|
||||
|
||||
query = f"""
|
||||
WITH
|
||||
all_rules AS (
|
||||
{rules_union}
|
||||
),
|
||||
matched_rules AS (
|
||||
SELECT ar.*,
|
||||
CASE
|
||||
WHEN ar.child IS NOT NULL THEN 2 -- child-level (most specific)
|
||||
WHEN ar.parent IS NOT NULL THEN 1 -- parent-level
|
||||
ELSE 0 -- root/global
|
||||
END AS depth
|
||||
FROM all_rules ar
|
||||
WHERE (ar.parent IS NULL OR ar.parent = :_check_parent)
|
||||
AND (ar.child IS NULL OR ar.child = :_check_child)
|
||||
),
|
||||
winner AS (
|
||||
SELECT *
|
||||
FROM matched_rules
|
||||
ORDER BY
|
||||
depth DESC, -- specificity first (higher depth wins)
|
||||
CASE WHEN allow=0 THEN 0 ELSE 1 END, -- then deny over allow
|
||||
source_plugin -- stable tie-break
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT COALESCE((SELECT allow FROM winner), 0) AS is_allowed
|
||||
"""
|
||||
|
||||
# Execute the query against the internal database
|
||||
result = await datasette.get_internal_database().execute(query, all_params)
|
||||
if result.rows:
|
||||
return bool(result.rows[0][0])
|
||||
return False
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import json
|
||||
from datasette.utils import MultiParams
|
||||
from datasette.utils import MultiParams, calculate_etag
|
||||
from mimetypes import guess_type
|
||||
from urllib.parse import parse_qs, urlunparse, parse_qsl
|
||||
from pathlib import Path
|
||||
from http.cookies import SimpleCookie, Morsel
|
||||
import aiofiles
|
||||
import aiofiles.os
|
||||
import re
|
||||
|
||||
# Workaround for adding samesite support to pre 3.8 python
|
||||
Morsel._reserved["samesite"] = "SameSite"
|
||||
|
|
@ -21,6 +22,27 @@ class NotFound(Base400):
|
|||
status = 404
|
||||
|
||||
|
||||
class DatabaseNotFound(NotFound):
|
||||
def __init__(self, database_name):
|
||||
self.database_name = database_name
|
||||
super().__init__("Database not found")
|
||||
|
||||
|
||||
class TableNotFound(NotFound):
|
||||
def __init__(self, database_name, table):
|
||||
super().__init__("Table not found")
|
||||
self.database_name = database_name
|
||||
self.table = table
|
||||
|
||||
|
||||
class RowNotFound(NotFound):
|
||||
def __init__(self, database_name, table, pk_values):
|
||||
super().__init__("Row not found")
|
||||
self.database_name = database_name
|
||||
self.table_name = table
|
||||
self.pk_values = pk_values
|
||||
|
||||
|
||||
class Forbidden(Base400):
|
||||
status = 403
|
||||
|
||||
|
|
@ -227,6 +249,9 @@ async def asgi_send_html(send, html, status=200, headers=None):
|
|||
|
||||
|
||||
async def asgi_send_redirect(send, location, status=302):
|
||||
# Prevent open redirect vulnerability: strip multiple leading slashes
|
||||
# //example.com would be interpreted as a protocol-relative URL (e.g., https://example.com/)
|
||||
location = re.sub(r"^/+", "/", location)
|
||||
await asgi_send(
|
||||
send,
|
||||
"",
|
||||
|
|
@ -264,6 +289,7 @@ async def asgi_send_file(
|
|||
headers = headers or {}
|
||||
if filename:
|
||||
headers["content-disposition"] = f'attachment; filename="{filename}"'
|
||||
|
||||
first = True
|
||||
headers["content-length"] = str((await aiofiles.os.stat(str(filepath))).st_size)
|
||||
async with aiofiles.open(str(filepath), mode="rb") as fp:
|
||||
|
|
@ -286,9 +312,14 @@ async def asgi_send_file(
|
|||
|
||||
def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
|
||||
root_path = Path(root_path)
|
||||
static_headers = {}
|
||||
|
||||
if headers:
|
||||
static_headers = headers.copy()
|
||||
|
||||
async def inner_static(request, send):
|
||||
path = request.scope["url_route"]["kwargs"]["path"]
|
||||
headers = static_headers.copy()
|
||||
try:
|
||||
full_path = (root_path / path).resolve().absolute()
|
||||
except FileNotFoundError:
|
||||
|
|
@ -304,7 +335,15 @@ def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
|
|||
await asgi_send_html(send, "404: Path not inside root path", 404)
|
||||
return
|
||||
try:
|
||||
await asgi_send_file(send, full_path, chunk_size=chunk_size)
|
||||
# Calculate ETag for filepath
|
||||
etag = await calculate_etag(full_path, chunk_size=chunk_size)
|
||||
headers["ETag"] = etag
|
||||
if_none_match = request.headers.get("if-none-match")
|
||||
if if_none_match and if_none_match == etag:
|
||||
return await asgi_send(send, "", 304)
|
||||
await asgi_send_file(
|
||||
send, full_path, chunk_size=chunk_size, headers=headers
|
||||
)
|
||||
except FileNotFoundError:
|
||||
await asgi_send_html(send, "404: File not found", 404)
|
||||
return
|
||||
|
|
@ -428,3 +467,18 @@ class AsgiFileDownload:
|
|||
content_type=self.content_type,
|
||||
headers=self.headers,
|
||||
)
|
||||
|
||||
|
||||
class AsgiRunOnFirstRequest:
|
||||
def __init__(self, asgi, on_startup):
|
||||
assert isinstance(on_startup, list)
|
||||
self.asgi = asgi
|
||||
self.on_startup = on_startup
|
||||
self._started = False
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if not self._started:
|
||||
self._started = True
|
||||
for hook in self.on_startup:
|
||||
await hook()
|
||||
return await self.asgi(scope, receive, send)
|
||||
|
|
|
|||
25
datasette/utils/check_callable.py
Normal file
25
datasette/utils/check_callable.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import inspect
|
||||
import types
|
||||
from typing import NamedTuple, Any
|
||||
|
||||
|
||||
class CallableStatus(NamedTuple):
|
||||
is_callable: bool
|
||||
is_async_callable: bool
|
||||
|
||||
|
||||
def check_callable(obj: Any) -> CallableStatus:
|
||||
if not callable(obj):
|
||||
return CallableStatus(False, False)
|
||||
|
||||
if isinstance(obj, type):
|
||||
# It's a class
|
||||
return CallableStatus(True, False)
|
||||
|
||||
if isinstance(obj, types.FunctionType):
|
||||
return CallableStatus(True, inspect.iscoroutinefunction(obj))
|
||||
|
||||
if hasattr(obj, "__call__"):
|
||||
return CallableStatus(True, inspect.iscoroutinefunction(obj.__call__))
|
||||
|
||||
assert False, "obj {} is somehow callable with no __call__ method".format(repr(obj))
|
||||
|
|
@ -5,21 +5,29 @@ from datasette.utils import table_column_details
|
|||
async def init_internal_db(db):
|
||||
create_tables_sql = textwrap.dedent(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS databases (
|
||||
CREATE TABLE IF NOT EXISTS catalog_databases (
|
||||
database_name TEXT PRIMARY KEY,
|
||||
path TEXT,
|
||||
is_memory INTEGER,
|
||||
schema_version INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tables (
|
||||
CREATE TABLE IF NOT EXISTS catalog_tables (
|
||||
database_name TEXT,
|
||||
table_name TEXT,
|
||||
rootpage INTEGER,
|
||||
sql TEXT,
|
||||
PRIMARY KEY (database_name, table_name),
|
||||
FOREIGN KEY (database_name) REFERENCES databases(database_name)
|
||||
FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS columns (
|
||||
CREATE TABLE IF NOT EXISTS catalog_views (
|
||||
database_name TEXT,
|
||||
view_name TEXT,
|
||||
rootpage INTEGER,
|
||||
sql TEXT,
|
||||
PRIMARY KEY (database_name, view_name),
|
||||
FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS catalog_columns (
|
||||
database_name TEXT,
|
||||
table_name TEXT,
|
||||
cid INTEGER,
|
||||
|
|
@ -30,10 +38,10 @@ async def init_internal_db(db):
|
|||
is_pk INTEGER, -- renamed from pk
|
||||
hidden INTEGER,
|
||||
PRIMARY KEY (database_name, table_name, name),
|
||||
FOREIGN KEY (database_name) REFERENCES databases(database_name),
|
||||
FOREIGN KEY (database_name, table_name) REFERENCES tables(database_name, table_name)
|
||||
FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name),
|
||||
FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS indexes (
|
||||
CREATE TABLE IF NOT EXISTS catalog_indexes (
|
||||
database_name TEXT,
|
||||
table_name TEXT,
|
||||
seq INTEGER,
|
||||
|
|
@ -42,10 +50,10 @@ async def init_internal_db(db):
|
|||
origin TEXT,
|
||||
partial INTEGER,
|
||||
PRIMARY KEY (database_name, table_name, name),
|
||||
FOREIGN KEY (database_name) REFERENCES databases(database_name),
|
||||
FOREIGN KEY (database_name, table_name) REFERENCES tables(database_name, table_name)
|
||||
FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name),
|
||||
FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS foreign_keys (
|
||||
CREATE TABLE IF NOT EXISTS catalog_foreign_keys (
|
||||
database_name TEXT,
|
||||
table_name TEXT,
|
||||
id INTEGER,
|
||||
|
|
@ -57,35 +65,92 @@ async def init_internal_db(db):
|
|||
on_delete TEXT,
|
||||
match TEXT,
|
||||
PRIMARY KEY (database_name, table_name, id, seq),
|
||||
FOREIGN KEY (database_name) REFERENCES databases(database_name),
|
||||
FOREIGN KEY (database_name, table_name) REFERENCES tables(database_name, table_name)
|
||||
FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name),
|
||||
FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name)
|
||||
);
|
||||
"""
|
||||
).strip()
|
||||
await db.execute_write_script(create_tables_sql)
|
||||
await initialize_metadata_tables(db)
|
||||
|
||||
|
||||
async def initialize_metadata_tables(db):
|
||||
await db.execute_write_script(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS metadata_instance (
|
||||
key text,
|
||||
value text,
|
||||
unique(key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_databases (
|
||||
database_name text,
|
||||
key text,
|
||||
value text,
|
||||
unique(database_name, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_resources (
|
||||
database_name text,
|
||||
resource_name text,
|
||||
key text,
|
||||
value text,
|
||||
unique(database_name, resource_name, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_columns (
|
||||
database_name text,
|
||||
resource_name text,
|
||||
column_name text,
|
||||
key text,
|
||||
value text,
|
||||
unique(database_name, resource_name, column_name, key)
|
||||
);
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def populate_schema_tables(internal_db, db):
|
||||
database_name = db.name
|
||||
|
||||
def delete_everything(conn):
|
||||
conn.execute("DELETE FROM tables WHERE database_name = ?", [database_name])
|
||||
conn.execute("DELETE FROM columns WHERE database_name = ?", [database_name])
|
||||
conn.execute(
|
||||
"DELETE FROM foreign_keys WHERE database_name = ?", [database_name]
|
||||
"DELETE FROM catalog_tables WHERE database_name = ?", [database_name]
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM catalog_views WHERE database_name = ?", [database_name]
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM catalog_columns WHERE database_name = ?", [database_name]
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM catalog_foreign_keys WHERE database_name = ?",
|
||||
[database_name],
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM catalog_indexes WHERE database_name = ?", [database_name]
|
||||
)
|
||||
conn.execute("DELETE FROM indexes WHERE database_name = ?", [database_name])
|
||||
|
||||
await internal_db.execute_write_fn(delete_everything)
|
||||
|
||||
tables = (await db.execute("select * from sqlite_master WHERE type = 'table'")).rows
|
||||
views = (await db.execute("select * from sqlite_master WHERE type = 'view'")).rows
|
||||
|
||||
def collect_info(conn):
|
||||
tables_to_insert = []
|
||||
views_to_insert = []
|
||||
columns_to_insert = []
|
||||
foreign_keys_to_insert = []
|
||||
indexes_to_insert = []
|
||||
|
||||
for view in views:
|
||||
view_name = view["name"]
|
||||
views_to_insert.append(
|
||||
(database_name, view_name, view["rootpage"], view["sql"])
|
||||
)
|
||||
|
||||
for table in tables:
|
||||
table_name = table["name"]
|
||||
tables_to_insert.append(
|
||||
|
|
@ -119,6 +184,7 @@ async def populate_schema_tables(internal_db, db):
|
|||
)
|
||||
return (
|
||||
tables_to_insert,
|
||||
views_to_insert,
|
||||
columns_to_insert,
|
||||
foreign_keys_to_insert,
|
||||
indexes_to_insert,
|
||||
|
|
@ -126,6 +192,7 @@ async def populate_schema_tables(internal_db, db):
|
|||
|
||||
(
|
||||
tables_to_insert,
|
||||
views_to_insert,
|
||||
columns_to_insert,
|
||||
foreign_keys_to_insert,
|
||||
indexes_to_insert,
|
||||
|
|
@ -133,14 +200,21 @@ async def populate_schema_tables(internal_db, db):
|
|||
|
||||
await internal_db.execute_write_many(
|
||||
"""
|
||||
INSERT INTO tables (database_name, table_name, rootpage, sql)
|
||||
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
|
||||
values (?, ?, ?, ?)
|
||||
""",
|
||||
tables_to_insert,
|
||||
)
|
||||
await internal_db.execute_write_many(
|
||||
"""
|
||||
INSERT INTO columns (
|
||||
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
|
||||
values (?, ?, ?, ?)
|
||||
""",
|
||||
views_to_insert,
|
||||
)
|
||||
await internal_db.execute_write_many(
|
||||
"""
|
||||
INSERT INTO catalog_columns (
|
||||
database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
|
||||
) VALUES (
|
||||
:database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden
|
||||
|
|
@ -150,7 +224,7 @@ async def populate_schema_tables(internal_db, db):
|
|||
)
|
||||
await internal_db.execute_write_many(
|
||||
"""
|
||||
INSERT INTO foreign_keys (
|
||||
INSERT INTO catalog_foreign_keys (
|
||||
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
|
||||
) VALUES (
|
||||
:database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match
|
||||
|
|
@ -160,7 +234,7 @@ async def populate_schema_tables(internal_db, db):
|
|||
)
|
||||
await internal_db.execute_write_many(
|
||||
"""
|
||||
INSERT INTO indexes (
|
||||
INSERT INTO catalog_indexes (
|
||||
database_name, table_name, seq, name, "unique", origin, partial
|
||||
) VALUES (
|
||||
:database_name, :table_name, :seq, :name, :unique, :origin, :partial
|
||||
|
|
|
|||
439
datasette/utils/permissions.py
Normal file
439
datasette/utils/permissions.py
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
# perm_utils.py
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, Iterable, List, Sequence, Tuple
|
||||
import sqlite3
|
||||
|
||||
from datasette.permissions import PermissionSQL
|
||||
from datasette.plugins import pm
|
||||
from datasette.utils import await_me_maybe
|
||||
|
||||
|
||||
# Sentinel object to indicate permission checks should be skipped
|
||||
SKIP_PERMISSION_CHECKS = object()
|
||||
|
||||
|
||||
async def gather_permission_sql_from_hooks(
|
||||
*, datasette, actor: dict | None, action: str
|
||||
) -> List[PermissionSQL] | object:
|
||||
"""Collect PermissionSQL objects from the permission_resources_sql hook.
|
||||
|
||||
Ensures that each returned PermissionSQL has a populated ``source``.
|
||||
|
||||
Returns SKIP_PERMISSION_CHECKS sentinel if skip_permission_checks context variable
|
||||
is set, signaling that all permission checks should be bypassed.
|
||||
"""
|
||||
from datasette.permissions import _skip_permission_checks
|
||||
|
||||
# Check if we should skip permission checks BEFORE calling hooks
|
||||
# This avoids creating unawaited coroutines
|
||||
if _skip_permission_checks.get():
|
||||
return SKIP_PERMISSION_CHECKS
|
||||
|
||||
hook_caller = pm.hook.permission_resources_sql
|
||||
hookimpls = hook_caller.get_hookimpls()
|
||||
hook_results = list(hook_caller(datasette=datasette, actor=actor, action=action))
|
||||
|
||||
collected: List[PermissionSQL] = []
|
||||
actor_json = json.dumps(actor) if actor is not None else None
|
||||
actor_id = actor.get("id") if isinstance(actor, dict) else None
|
||||
|
||||
for index, result in enumerate(hook_results):
|
||||
hookimpl = hookimpls[index]
|
||||
resolved = await await_me_maybe(result)
|
||||
default_source = _plugin_name_from_hookimpl(hookimpl)
|
||||
for permission_sql in _iter_permission_sql_from_result(resolved, action=action):
|
||||
if not permission_sql.source:
|
||||
permission_sql.source = default_source
|
||||
params = permission_sql.params or {}
|
||||
params.setdefault("action", action)
|
||||
params.setdefault("actor", actor_json)
|
||||
params.setdefault("actor_id", actor_id)
|
||||
collected.append(permission_sql)
|
||||
|
||||
return collected
|
||||
|
||||
|
||||
def _plugin_name_from_hookimpl(hookimpl) -> str:
|
||||
if getattr(hookimpl, "plugin_name", None):
|
||||
return hookimpl.plugin_name
|
||||
plugin = getattr(hookimpl, "plugin", None)
|
||||
if hasattr(plugin, "__name__"):
|
||||
return plugin.__name__
|
||||
return repr(plugin)
|
||||
|
||||
|
||||
def _iter_permission_sql_from_result(
|
||||
result: Any, *, action: str
|
||||
) -> Iterable[PermissionSQL]:
|
||||
if result is None:
|
||||
return []
|
||||
if isinstance(result, PermissionSQL):
|
||||
return [result]
|
||||
if isinstance(result, (list, tuple)):
|
||||
collected: List[PermissionSQL] = []
|
||||
for item in result:
|
||||
collected.extend(_iter_permission_sql_from_result(item, action=action))
|
||||
return collected
|
||||
if callable(result):
|
||||
permission_sql = result(action) # type: ignore[call-arg]
|
||||
return _iter_permission_sql_from_result(permission_sql, action=action)
|
||||
raise TypeError(
|
||||
"Plugin providers must return PermissionSQL instances, sequences, or callables"
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Plugin interface & utilities
|
||||
# -----------------------------
|
||||
|
||||
|
||||
def build_rules_union(
|
||||
actor: dict | None, plugins: Sequence[PermissionSQL]
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""
|
||||
Compose plugin SQL into a UNION ALL.
|
||||
|
||||
Returns:
|
||||
union_sql: a SELECT with columns (parent, child, allow, reason, source_plugin)
|
||||
params: dict of bound parameters including :actor (JSON), :actor_id, and plugin params
|
||||
|
||||
Note: Plugins are responsible for ensuring their parameter names don't conflict.
|
||||
The system reserves these parameter names: :actor, :actor_id, :action, :filter_parent
|
||||
Plugin parameters should be prefixed with a unique identifier (e.g., source name).
|
||||
"""
|
||||
parts: List[str] = []
|
||||
actor_json = json.dumps(actor) if actor else None
|
||||
actor_id = actor.get("id") if actor else None
|
||||
params: Dict[str, Any] = {"actor": actor_json, "actor_id": actor_id}
|
||||
|
||||
for p in plugins:
|
||||
# No namespacing - just use plugin params as-is
|
||||
params.update(p.params or {})
|
||||
|
||||
# Skip plugins that only provide restriction_sql (no permission rules)
|
||||
if p.sql is None:
|
||||
continue
|
||||
|
||||
parts.append(
|
||||
f"""
|
||||
SELECT parent, child, allow, reason, '{p.source}' AS source_plugin FROM (
|
||||
{p.sql}
|
||||
)
|
||||
""".strip()
|
||||
)
|
||||
|
||||
if not parts:
|
||||
# Empty UNION that returns no rows
|
||||
union_sql = "SELECT NULL parent, NULL child, NULL allow, NULL reason, 'none' source_plugin WHERE 0"
|
||||
else:
|
||||
union_sql = "\nUNION ALL\n".join(parts)
|
||||
|
||||
return union_sql, params
|
||||
|
||||
|
||||
# -----------------------------------------------
|
||||
# Core resolvers (no temp tables, no custom UDFs)
|
||||
# -----------------------------------------------
|
||||
|
||||
|
||||
async def resolve_permissions_from_catalog(
|
||||
db,
|
||||
actor: dict | None,
|
||||
plugins: Sequence[Any],
|
||||
action: str,
|
||||
candidate_sql: str,
|
||||
candidate_params: Dict[str, Any] | None = None,
|
||||
*,
|
||||
implicit_deny: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Resolve permissions by embedding the provided *candidate_sql* in a CTE.
|
||||
|
||||
Expectations:
|
||||
- candidate_sql SELECTs: parent TEXT, child TEXT
|
||||
(Use child=NULL for parent-scoped actions like "execute-sql".)
|
||||
- *db* exposes: rows = await db.execute(sql, params)
|
||||
where rows is an iterable of sqlite3.Row
|
||||
- plugins: hook results handled by await_me_maybe - can be sync/async,
|
||||
single PermissionSQL, list, or callable returning PermissionSQL
|
||||
- actor is the actor dict (or None), made available as :actor (JSON), :actor_id, and :action
|
||||
|
||||
Decision policy:
|
||||
1) Specificity first: child (depth=2) > parent (depth=1) > root (depth=0)
|
||||
2) Within the same depth: deny (0) beats allow (1)
|
||||
3) If no matching rule:
|
||||
- implicit_deny=True -> treat as allow=0, reason='implicit deny'
|
||||
- implicit_deny=False -> allow=None, reason=None
|
||||
|
||||
Returns: list of dict rows
|
||||
- parent, child, allow, reason, source_plugin, depth
|
||||
- resource (rendered "/parent/child" or "/parent" or "/")
|
||||
"""
|
||||
resolved_plugins: List[PermissionSQL] = []
|
||||
restriction_sqls: List[str] = []
|
||||
|
||||
for plugin in plugins:
|
||||
if callable(plugin) and not isinstance(plugin, PermissionSQL):
|
||||
resolved = plugin(action) # type: ignore[arg-type]
|
||||
else:
|
||||
resolved = plugin # type: ignore[assignment]
|
||||
if not isinstance(resolved, PermissionSQL):
|
||||
raise TypeError("Plugin providers must return PermissionSQL instances")
|
||||
resolved_plugins.append(resolved)
|
||||
|
||||
# Collect restriction SQL filters
|
||||
if resolved.restriction_sql:
|
||||
restriction_sqls.append(resolved.restriction_sql)
|
||||
|
||||
union_sql, rule_params = build_rules_union(actor, resolved_plugins)
|
||||
all_params = {
|
||||
**(candidate_params or {}),
|
||||
**rule_params,
|
||||
"action": action,
|
||||
}
|
||||
|
||||
sql = f"""
|
||||
WITH
|
||||
cands AS (
|
||||
{candidate_sql}
|
||||
),
|
||||
rules AS (
|
||||
{union_sql}
|
||||
),
|
||||
matched AS (
|
||||
SELECT
|
||||
c.parent, c.child,
|
||||
r.allow, r.reason, r.source_plugin,
|
||||
CASE
|
||||
WHEN r.child IS NOT NULL THEN 2 -- child-level (most specific)
|
||||
WHEN r.parent IS NOT NULL THEN 1 -- parent-level
|
||||
ELSE 0 -- root/global
|
||||
END AS depth
|
||||
FROM cands c
|
||||
JOIN rules r
|
||||
ON (r.parent IS NULL OR r.parent = c.parent)
|
||||
AND (r.child IS NULL OR r.child = c.child)
|
||||
),
|
||||
ranked AS (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY parent, child
|
||||
ORDER BY
|
||||
depth DESC, -- specificity first
|
||||
CASE WHEN allow=0 THEN 0 ELSE 1 END, -- then deny over allow at same depth
|
||||
source_plugin -- stable tie-break
|
||||
) AS rn
|
||||
FROM matched
|
||||
),
|
||||
winner AS (
|
||||
SELECT parent, child,
|
||||
allow, reason, source_plugin, depth
|
||||
FROM ranked WHERE rn = 1
|
||||
)
|
||||
SELECT
|
||||
c.parent, c.child,
|
||||
COALESCE(w.allow, CASE WHEN :implicit_deny THEN 0 ELSE NULL END) AS allow,
|
||||
COALESCE(w.reason, CASE WHEN :implicit_deny THEN 'implicit deny' ELSE NULL END) AS reason,
|
||||
w.source_plugin,
|
||||
COALESCE(w.depth, -1) AS depth,
|
||||
:action AS action,
|
||||
CASE
|
||||
WHEN c.parent IS NULL THEN '/'
|
||||
WHEN c.child IS NULL THEN '/' || c.parent
|
||||
ELSE '/' || c.parent || '/' || c.child
|
||||
END AS resource
|
||||
FROM cands c
|
||||
LEFT JOIN winner w
|
||||
ON ((w.parent = c.parent) OR (w.parent IS NULL AND c.parent IS NULL))
|
||||
AND ((w.child = c.child ) OR (w.child IS NULL AND c.child IS NULL))
|
||||
ORDER BY c.parent, c.child
|
||||
"""
|
||||
|
||||
# If there are restriction filters, wrap the query with INTERSECT
|
||||
# This ensures only resources in the restriction allowlist are returned
|
||||
if restriction_sqls:
|
||||
# Start with the main query, but select only parent/child for the INTERSECT
|
||||
main_query_for_intersect = f"""
|
||||
WITH
|
||||
cands AS (
|
||||
{candidate_sql}
|
||||
),
|
||||
rules AS (
|
||||
{union_sql}
|
||||
),
|
||||
matched AS (
|
||||
SELECT
|
||||
c.parent, c.child,
|
||||
r.allow, r.reason, r.source_plugin,
|
||||
CASE
|
||||
WHEN r.child IS NOT NULL THEN 2 -- child-level (most specific)
|
||||
WHEN r.parent IS NOT NULL THEN 1 -- parent-level
|
||||
ELSE 0 -- root/global
|
||||
END AS depth
|
||||
FROM cands c
|
||||
JOIN rules r
|
||||
ON (r.parent IS NULL OR r.parent = c.parent)
|
||||
AND (r.child IS NULL OR r.child = c.child)
|
||||
),
|
||||
ranked AS (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY parent, child
|
||||
ORDER BY
|
||||
depth DESC, -- specificity first
|
||||
CASE WHEN allow=0 THEN 0 ELSE 1 END, -- then deny over allow at same depth
|
||||
source_plugin -- stable tie-break
|
||||
) AS rn
|
||||
FROM matched
|
||||
),
|
||||
winner AS (
|
||||
SELECT parent, child,
|
||||
allow, reason, source_plugin, depth
|
||||
FROM ranked WHERE rn = 1
|
||||
),
|
||||
permitted_resources AS (
|
||||
SELECT c.parent, c.child
|
||||
FROM cands c
|
||||
LEFT JOIN winner w
|
||||
ON ((w.parent = c.parent) OR (w.parent IS NULL AND c.parent IS NULL))
|
||||
AND ((w.child = c.child ) OR (w.child IS NULL AND c.child IS NULL))
|
||||
WHERE COALESCE(w.allow, CASE WHEN :implicit_deny THEN 0 ELSE NULL END) = 1
|
||||
)
|
||||
SELECT parent, child FROM permitted_resources
|
||||
"""
|
||||
|
||||
# Build restriction list with INTERSECT (all must match)
|
||||
# Then filter to resources that match hierarchically
|
||||
# Wrap each restriction_sql in a subquery to avoid operator precedence issues
|
||||
# with UNION ALL inside the restriction SQL statements
|
||||
restriction_intersect = "\nINTERSECT\n".join(
|
||||
f"SELECT * FROM ({sql})" for sql in restriction_sqls
|
||||
)
|
||||
|
||||
# Combine: resources allowed by permissions AND in restriction allowlist
|
||||
# Database-level restrictions (parent, NULL) should match all children (parent, *)
|
||||
filtered_resources = f"""
|
||||
WITH restriction_list AS (
|
||||
{restriction_intersect}
|
||||
),
|
||||
permitted AS (
|
||||
{main_query_for_intersect}
|
||||
),
|
||||
filtered AS (
|
||||
SELECT p.parent, p.child
|
||||
FROM permitted p
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM restriction_list r
|
||||
WHERE (r.parent = p.parent OR r.parent IS NULL)
|
||||
AND (r.child = p.child OR r.child IS NULL)
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
# Now join back to get full results for only the filtered resources
|
||||
sql = f"""
|
||||
{filtered_resources}
|
||||
, cands AS (
|
||||
{candidate_sql}
|
||||
),
|
||||
rules AS (
|
||||
{union_sql}
|
||||
),
|
||||
matched AS (
|
||||
SELECT
|
||||
c.parent, c.child,
|
||||
r.allow, r.reason, r.source_plugin,
|
||||
CASE
|
||||
WHEN r.child IS NOT NULL THEN 2 -- child-level (most specific)
|
||||
WHEN r.parent IS NOT NULL THEN 1 -- parent-level
|
||||
ELSE 0 -- root/global
|
||||
END AS depth
|
||||
FROM cands c
|
||||
JOIN rules r
|
||||
ON (r.parent IS NULL OR r.parent = c.parent)
|
||||
AND (r.child IS NULL OR r.child = c.child)
|
||||
),
|
||||
ranked AS (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY parent, child
|
||||
ORDER BY
|
||||
depth DESC, -- specificity first
|
||||
CASE WHEN allow=0 THEN 0 ELSE 1 END, -- then deny over allow at same depth
|
||||
source_plugin -- stable tie-break
|
||||
) AS rn
|
||||
FROM matched
|
||||
),
|
||||
winner AS (
|
||||
SELECT parent, child,
|
||||
allow, reason, source_plugin, depth
|
||||
FROM ranked WHERE rn = 1
|
||||
)
|
||||
SELECT
|
||||
c.parent, c.child,
|
||||
COALESCE(w.allow, CASE WHEN :implicit_deny THEN 0 ELSE NULL END) AS allow,
|
||||
COALESCE(w.reason, CASE WHEN :implicit_deny THEN 'implicit deny' ELSE NULL END) AS reason,
|
||||
w.source_plugin,
|
||||
COALESCE(w.depth, -1) AS depth,
|
||||
:action AS action,
|
||||
CASE
|
||||
WHEN c.parent IS NULL THEN '/'
|
||||
WHEN c.child IS NULL THEN '/' || c.parent
|
||||
ELSE '/' || c.parent || '/' || c.child
|
||||
END AS resource
|
||||
FROM filtered c
|
||||
LEFT JOIN winner w
|
||||
ON ((w.parent = c.parent) OR (w.parent IS NULL AND c.parent IS NULL))
|
||||
AND ((w.child = c.child ) OR (w.child IS NULL AND c.child IS NULL))
|
||||
ORDER BY c.parent, c.child
|
||||
"""
|
||||
|
||||
rows_iter: Iterable[sqlite3.Row] = await db.execute(
|
||||
sql,
|
||||
{**all_params, "implicit_deny": 1 if implicit_deny else 0},
|
||||
)
|
||||
return [dict(r) for r in rows_iter]
|
||||
|
||||
|
||||
async def resolve_permissions_with_candidates(
|
||||
db,
|
||||
actor: dict | None,
|
||||
plugins: Sequence[Any],
|
||||
candidates: List[Tuple[str, str | None]],
|
||||
action: str,
|
||||
*,
|
||||
implicit_deny: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Resolve permissions without any external candidate table by embedding
|
||||
the candidates as a UNION of parameterized SELECTs in a CTE.
|
||||
|
||||
candidates: list of (parent, child) where child can be None for parent-scoped actions.
|
||||
actor: actor dict (or None), made available as :actor (JSON), :actor_id, and :action
|
||||
"""
|
||||
# Build a small CTE for candidates.
|
||||
cand_rows_sql: List[str] = []
|
||||
cand_params: Dict[str, Any] = {}
|
||||
for i, (parent, child) in enumerate(candidates):
|
||||
pkey = f"cand_p_{i}"
|
||||
ckey = f"cand_c_{i}"
|
||||
cand_params[pkey] = parent
|
||||
cand_params[ckey] = child
|
||||
cand_rows_sql.append(f"SELECT :{pkey} AS parent, :{ckey} AS child")
|
||||
candidate_sql = (
|
||||
"\nUNION ALL\n".join(cand_rows_sql)
|
||||
if cand_rows_sql
|
||||
else "SELECT NULL AS parent, NULL AS child WHERE 0"
|
||||
)
|
||||
|
||||
return await resolve_permissions_from_catalog(
|
||||
db,
|
||||
actor,
|
||||
plugins,
|
||||
action,
|
||||
candidate_sql=candidate_sql,
|
||||
candidate_params=cand_params,
|
||||
implicit_deny=implicit_deny,
|
||||
)
|
||||
|
|
@ -4,6 +4,7 @@ Backported from Python 3.8.
|
|||
This code is licensed under the Python License:
|
||||
https://github.com/python/cpython/blob/v3.8.3/LICENSE
|
||||
"""
|
||||
|
||||
import os
|
||||
from shutil import copy, copy2, copystat, Error
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ class TestResponse:
|
|||
def status(self):
|
||||
return self.httpx_response.status_code
|
||||
|
||||
# Supports both for test-writing convenience
|
||||
@property
|
||||
def status_code(self):
|
||||
return self.status
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
return self.httpx_response.headers
|
||||
|
|
@ -24,17 +29,14 @@ class TestResponse:
|
|||
def body(self):
|
||||
return self.httpx_response.content
|
||||
|
||||
@property
|
||||
def content(self):
|
||||
return self.body
|
||||
|
||||
@property
|
||||
def cookies(self):
|
||||
return dict(self.httpx_response.cookies)
|
||||
|
||||
def cookie_was_deleted(self, cookie):
|
||||
return any(
|
||||
h
|
||||
for h in self.httpx_response.headers.get_list("set-cookie")
|
||||
if h.startswith(f'{cookie}="";')
|
||||
)
|
||||
|
||||
@property
|
||||
def json(self):
|
||||
return json.loads(self.text)
|
||||
|
|
@ -60,9 +62,13 @@ class TestClient:
|
|||
follow_redirects=False,
|
||||
redirect_count=0,
|
||||
method="GET",
|
||||
params=None,
|
||||
cookies=None,
|
||||
if_none_match=None,
|
||||
headers=None,
|
||||
):
|
||||
if params:
|
||||
path += "?" + urlencode(params, doseq=True)
|
||||
return await self._request(
|
||||
path=path,
|
||||
follow_redirects=follow_redirects,
|
||||
|
|
@ -70,6 +76,7 @@ class TestClient:
|
|||
method=method,
|
||||
cookies=cookies,
|
||||
if_none_match=if_none_match,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@async_to_sync
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
__version__ = "0.63.1"
|
||||
__version__ = "1.0a23"
|
||||
__version_info__ = tuple(__version__.split("."))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
class Context:
|
||||
"Base class for all documented contexts"
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import asyncio
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
|
|
@ -8,9 +9,6 @@ import urllib
|
|||
from markupsafe import escape
|
||||
|
||||
|
||||
import pint
|
||||
|
||||
from datasette import __version__
|
||||
from datasette.database import QueryInterrupted
|
||||
from datasette.utils.asgi import Request
|
||||
from datasette.utils import (
|
||||
|
|
@ -20,7 +18,6 @@ from datasette.utils import (
|
|||
InvalidSql,
|
||||
LimitedWriter,
|
||||
call_with_supported_arguments,
|
||||
tilde_decode,
|
||||
path_from_row_pks,
|
||||
path_with_added_args,
|
||||
path_with_removed_args,
|
||||
|
|
@ -34,8 +31,6 @@ from datasette.utils.asgi import (
|
|||
BadRequest,
|
||||
)
|
||||
|
||||
ureg = pint.UnitRegistry()
|
||||
|
||||
|
||||
class DatasetteError(Exception):
|
||||
def __init__(
|
||||
|
|
@ -54,6 +49,43 @@ class DatasetteError(Exception):
|
|||
self.message_is_html = message_is_html
|
||||
|
||||
|
||||
class View:
|
||||
async def head(self, request, datasette):
|
||||
if not hasattr(self, "get"):
|
||||
return await self.method_not_allowed(request)
|
||||
response = await self.get(request, datasette)
|
||||
response.body = ""
|
||||
return response
|
||||
|
||||
async def method_not_allowed(self, request):
|
||||
if (
|
||||
request.path.endswith(".json")
|
||||
or request.headers.get("content-type") == "application/json"
|
||||
):
|
||||
response = Response.json(
|
||||
{"ok": False, "error": "Method not allowed"}, status=405
|
||||
)
|
||||
else:
|
||||
response = Response.text("Method not allowed", status=405)
|
||||
return response
|
||||
|
||||
async def options(self, request, datasette):
|
||||
response = Response.text("ok")
|
||||
response.headers["allow"] = ", ".join(
|
||||
method.upper()
|
||||
for method in ("head", "get", "post", "put", "patch", "delete")
|
||||
if hasattr(self, method)
|
||||
)
|
||||
return response
|
||||
|
||||
async def __call__(self, request, datasette):
|
||||
try:
|
||||
handler = getattr(self, request.method.lower())
|
||||
except AttributeError:
|
||||
return await self.method_not_allowed(request)
|
||||
return await handler(request, datasette)
|
||||
|
||||
|
||||
class BaseView:
|
||||
ds = None
|
||||
has_json_alternate = True
|
||||
|
|
@ -66,37 +98,52 @@ class BaseView:
|
|||
response.body = b""
|
||||
return response
|
||||
|
||||
def database_color(self, database):
|
||||
return "ff0000"
|
||||
async def method_not_allowed(self, request):
|
||||
if (
|
||||
request.path.endswith(".json")
|
||||
or request.headers.get("content-type") == "application/json"
|
||||
):
|
||||
response = Response.json(
|
||||
{"ok": False, "error": "Method not allowed"}, status=405
|
||||
)
|
||||
else:
|
||||
response = Response.text("Method not allowed", status=405)
|
||||
return response
|
||||
|
||||
async def options(self, request, *args, **kwargs):
|
||||
return Response.text("Method not allowed", status=405)
|
||||
return Response.text("ok")
|
||||
|
||||
async def get(self, request, *args, **kwargs):
|
||||
return await self.method_not_allowed(request)
|
||||
|
||||
async def post(self, request, *args, **kwargs):
|
||||
return Response.text("Method not allowed", status=405)
|
||||
return await self.method_not_allowed(request)
|
||||
|
||||
async def put(self, request, *args, **kwargs):
|
||||
return Response.text("Method not allowed", status=405)
|
||||
return await self.method_not_allowed(request)
|
||||
|
||||
async def patch(self, request, *args, **kwargs):
|
||||
return Response.text("Method not allowed", status=405)
|
||||
return await self.method_not_allowed(request)
|
||||
|
||||
async def delete(self, request, *args, **kwargs):
|
||||
return Response.text("Method not allowed", status=405)
|
||||
return await self.method_not_allowed(request)
|
||||
|
||||
async def dispatch_request(self, request):
|
||||
if self.ds:
|
||||
await self.ds.refresh_schemas()
|
||||
handler = getattr(self, request.method.lower(), None)
|
||||
return await handler(request)
|
||||
response = await handler(request)
|
||||
if self.ds.cors:
|
||||
add_cors_headers(response.headers)
|
||||
return response
|
||||
|
||||
async def render(self, templates, request, context=None):
|
||||
context = context or {}
|
||||
template = self.ds.jinja_env.select_template(templates)
|
||||
environment = self.ds.get_jinja_environment(request)
|
||||
template = environment.select_template(templates)
|
||||
template_context = {
|
||||
**context,
|
||||
**{
|
||||
"database_color": self.database_color,
|
||||
"select_templates": [
|
||||
f"{'*' if template_name == template.name else ''}{template_name}"
|
||||
for template_name in templates
|
||||
|
|
@ -112,7 +159,7 @@ class BaseView:
|
|||
template_context["alternate_url_json"] = alternate_url_json
|
||||
headers.update(
|
||||
{
|
||||
"Link": '{}; rel="alternate"; type="application/json+datasette"'.format(
|
||||
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
|
||||
alternate_url_json
|
||||
)
|
||||
}
|
||||
|
|
@ -143,12 +190,6 @@ class BaseView:
|
|||
class DataView(BaseView):
|
||||
name = ""
|
||||
|
||||
async def options(self, request, *args, **kwargs):
|
||||
r = Response.text("ok")
|
||||
if self.ds.cors:
|
||||
add_cors_headers(r.headers)
|
||||
return r
|
||||
|
||||
def redirect(self, request, path, forward_querystring=True, remove_args=None):
|
||||
if request.query_string and "?" not in path and forward_querystring:
|
||||
path = f"{path}?{request.query_string}"
|
||||
|
|
@ -163,185 +204,13 @@ class DataView(BaseView):
|
|||
async def data(self, request):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_templates(self, database, table=None):
|
||||
assert NotImplemented
|
||||
|
||||
async def as_csv(self, request, database):
|
||||
kwargs = {}
|
||||
stream = request.args.get("_stream")
|
||||
# Do not calculate facets or counts:
|
||||
extra_parameters = [
|
||||
"{}=1".format(key)
|
||||
for key in ("_nofacet", "_nocount")
|
||||
if not request.args.get(key)
|
||||
]
|
||||
if extra_parameters:
|
||||
# Replace request object with a new one with modified scope
|
||||
if not request.query_string:
|
||||
new_query_string = "&".join(extra_parameters)
|
||||
else:
|
||||
new_query_string = (
|
||||
request.query_string + "&" + "&".join(extra_parameters)
|
||||
)
|
||||
new_scope = dict(
|
||||
request.scope, query_string=new_query_string.encode("latin-1")
|
||||
)
|
||||
receive = request.receive
|
||||
request = Request(new_scope, receive)
|
||||
if stream:
|
||||
# Some quick soundness checks
|
||||
if not self.ds.setting("allow_csv_stream"):
|
||||
raise BadRequest("CSV streaming is disabled")
|
||||
if request.args.get("_next"):
|
||||
raise BadRequest("_next not allowed for CSV streaming")
|
||||
kwargs["_size"] = "max"
|
||||
# Fetch the first page
|
||||
try:
|
||||
response_or_template_contexts = await self.data(request)
|
||||
if isinstance(response_or_template_contexts, Response):
|
||||
return response_or_template_contexts
|
||||
elif len(response_or_template_contexts) == 4:
|
||||
data, _, _, _ = response_or_template_contexts
|
||||
else:
|
||||
data, _, _ = response_or_template_contexts
|
||||
except (sqlite3.OperationalError, InvalidSql) as e:
|
||||
raise DatasetteError(str(e), title="Invalid SQL", status=400)
|
||||
|
||||
except sqlite3.OperationalError as e:
|
||||
raise DatasetteError(str(e))
|
||||
|
||||
except DatasetteError:
|
||||
raise
|
||||
|
||||
# Convert rows and columns to CSV
|
||||
headings = data["columns"]
|
||||
# if there are expanded_columns we need to add additional headings
|
||||
expanded_columns = set(data.get("expanded_columns") or [])
|
||||
if expanded_columns:
|
||||
headings = []
|
||||
for column in data["columns"]:
|
||||
headings.append(column)
|
||||
if column in expanded_columns:
|
||||
headings.append(f"{column}_label")
|
||||
|
||||
content_type = "text/plain; charset=utf-8"
|
||||
preamble = ""
|
||||
postamble = ""
|
||||
|
||||
trace = request.args.get("_trace")
|
||||
if trace:
|
||||
content_type = "text/html; charset=utf-8"
|
||||
preamble = (
|
||||
"<html><head><title>CSV debug</title></head>"
|
||||
'<body><textarea style="width: 90%; height: 70vh">'
|
||||
)
|
||||
postamble = "</textarea></body></html>"
|
||||
|
||||
async def stream_fn(r):
|
||||
nonlocal data, trace
|
||||
limited_writer = LimitedWriter(r, self.ds.setting("max_csv_mb"))
|
||||
if trace:
|
||||
await limited_writer.write(preamble)
|
||||
writer = csv.writer(EscapeHtmlWriter(limited_writer))
|
||||
else:
|
||||
writer = csv.writer(limited_writer)
|
||||
first = True
|
||||
next = None
|
||||
while first or (next and stream):
|
||||
try:
|
||||
kwargs = {}
|
||||
if next:
|
||||
kwargs["_next"] = next
|
||||
if not first:
|
||||
data, _, _ = await self.data(request, **kwargs)
|
||||
if first:
|
||||
if request.args.get("_header") != "off":
|
||||
await writer.writerow(headings)
|
||||
first = False
|
||||
next = data.get("next")
|
||||
for row in data["rows"]:
|
||||
if any(isinstance(r, bytes) for r in row):
|
||||
new_row = []
|
||||
for column, cell in zip(headings, row):
|
||||
if isinstance(cell, bytes):
|
||||
# If this is a table page, use .urls.row_blob()
|
||||
if data.get("table"):
|
||||
pks = data.get("primary_keys") or []
|
||||
cell = self.ds.absolute_url(
|
||||
request,
|
||||
self.ds.urls.row_blob(
|
||||
database,
|
||||
data["table"],
|
||||
path_from_row_pks(row, pks, not pks),
|
||||
column,
|
||||
),
|
||||
)
|
||||
else:
|
||||
# Otherwise generate URL for this query
|
||||
url = self.ds.absolute_url(
|
||||
request,
|
||||
path_with_format(
|
||||
request=request,
|
||||
format="blob",
|
||||
extra_qs={
|
||||
"_blob_column": column,
|
||||
"_blob_hash": hashlib.sha256(
|
||||
cell
|
||||
).hexdigest(),
|
||||
},
|
||||
replace_format="csv",
|
||||
),
|
||||
)
|
||||
cell = url.replace("&_nocount=1", "").replace(
|
||||
"&_nofacet=1", ""
|
||||
)
|
||||
new_row.append(cell)
|
||||
row = new_row
|
||||
if not expanded_columns:
|
||||
# Simple path
|
||||
await writer.writerow(row)
|
||||
else:
|
||||
# Look for {"value": "label": } dicts and expand
|
||||
new_row = []
|
||||
for heading, cell in zip(data["columns"], row):
|
||||
if heading in expanded_columns:
|
||||
if cell is None:
|
||||
new_row.extend(("", ""))
|
||||
else:
|
||||
assert isinstance(cell, dict)
|
||||
new_row.append(cell["value"])
|
||||
new_row.append(cell["label"])
|
||||
else:
|
||||
new_row.append(cell)
|
||||
await writer.writerow(new_row)
|
||||
except Exception as e:
|
||||
sys.stderr.write("Caught this error: {}\n".format(e))
|
||||
sys.stderr.flush()
|
||||
await r.write(str(e))
|
||||
return
|
||||
await limited_writer.write(postamble)
|
||||
|
||||
headers = {}
|
||||
if self.ds.cors:
|
||||
add_cors_headers(headers)
|
||||
if request.args.get("_dl", None):
|
||||
if not trace:
|
||||
content_type = "text/csv; charset=utf-8"
|
||||
disposition = 'attachment; filename="{}.csv"'.format(
|
||||
request.url_vars.get("table", database)
|
||||
)
|
||||
headers["content-disposition"] = disposition
|
||||
|
||||
return AsgiStream(stream_fn, headers=headers, content_type=content_type)
|
||||
return await stream_csv(self.ds, self.data, request, database)
|
||||
|
||||
async def get(self, request):
|
||||
database_route = tilde_decode(request.url_vars["database"])
|
||||
|
||||
try:
|
||||
db = self.ds.get_database(route=database_route)
|
||||
except KeyError:
|
||||
raise NotFound("Database not found: {}".format(database_route))
|
||||
db = await self.ds.resolve_database(request)
|
||||
database = db.name
|
||||
database_route = db.route
|
||||
|
||||
_format = request.url_vars["format"]
|
||||
data_kwargs = {}
|
||||
|
|
@ -402,10 +271,6 @@ class DataView(BaseView):
|
|||
|
||||
end = time.perf_counter()
|
||||
data["query_ms"] = (end - start) * 1000
|
||||
for key in ("source", "source_url", "license", "license_url"):
|
||||
value = self.ds.metadata(key)
|
||||
if value:
|
||||
data[key] = value
|
||||
|
||||
# Special case for .jsono extension - redirect to _shape=objects
|
||||
if _format == "jsono":
|
||||
|
|
@ -433,6 +298,8 @@ class DataView(BaseView):
|
|||
table=data.get("table"),
|
||||
request=request,
|
||||
view_name=self.name,
|
||||
truncated=False, # TODO: support this
|
||||
error=data.get("error"),
|
||||
# These will be deprecated in Datasette 1.0:
|
||||
args=request.args,
|
||||
data=data,
|
||||
|
|
@ -507,12 +374,11 @@ class DataView(BaseView):
|
|||
if key not in ("_labels", "_facet", "_size")
|
||||
]
|
||||
+ [("_size", "max")],
|
||||
"datasette_version": __version__,
|
||||
"settings": self.ds.settings_dict(),
|
||||
},
|
||||
}
|
||||
if "metadata" not in context:
|
||||
context["metadata"] = self.ds.metadata
|
||||
context["metadata"] = await self.ds.get_instance_metadata()
|
||||
r = await self.render(templates, request=request, context=context)
|
||||
if status_code is not None:
|
||||
r.status = status_code
|
||||
|
|
@ -536,3 +402,174 @@ class DataView(BaseView):
|
|||
if self.ds.cors:
|
||||
add_cors_headers(response.headers)
|
||||
return response
|
||||
|
||||
|
||||
def _error(messages, status=400):
|
||||
return Response.json({"ok": False, "errors": messages}, status=status)
|
||||
|
||||
|
||||
async def stream_csv(datasette, fetch_data, request, database):
|
||||
kwargs = {}
|
||||
stream = request.args.get("_stream")
|
||||
# Do not calculate facets or counts:
|
||||
extra_parameters = [
|
||||
"{}=1".format(key)
|
||||
for key in ("_nofacet", "_nocount")
|
||||
if not request.args.get(key)
|
||||
]
|
||||
if extra_parameters:
|
||||
# Replace request object with a new one with modified scope
|
||||
if not request.query_string:
|
||||
new_query_string = "&".join(extra_parameters)
|
||||
else:
|
||||
new_query_string = request.query_string + "&" + "&".join(extra_parameters)
|
||||
new_scope = dict(request.scope, query_string=new_query_string.encode("latin-1"))
|
||||
receive = request.receive
|
||||
request = Request(new_scope, receive)
|
||||
if stream:
|
||||
# Some quick soundness checks
|
||||
if not datasette.setting("allow_csv_stream"):
|
||||
raise BadRequest("CSV streaming is disabled")
|
||||
if request.args.get("_next"):
|
||||
raise BadRequest("_next not allowed for CSV streaming")
|
||||
kwargs["_size"] = "max"
|
||||
# Fetch the first page
|
||||
try:
|
||||
response_or_template_contexts = await fetch_data(request)
|
||||
if isinstance(response_or_template_contexts, Response):
|
||||
return response_or_template_contexts
|
||||
elif len(response_or_template_contexts) == 4:
|
||||
data, _, _, _ = response_or_template_contexts
|
||||
else:
|
||||
data, _, _ = response_or_template_contexts
|
||||
except (sqlite3.OperationalError, InvalidSql) as e:
|
||||
raise DatasetteError(str(e), title="Invalid SQL", status=400)
|
||||
|
||||
except sqlite3.OperationalError as e:
|
||||
raise DatasetteError(str(e))
|
||||
|
||||
except DatasetteError:
|
||||
raise
|
||||
|
||||
# Convert rows and columns to CSV
|
||||
headings = data["columns"]
|
||||
# if there are expanded_columns we need to add additional headings
|
||||
expanded_columns = set(data.get("expanded_columns") or [])
|
||||
if expanded_columns:
|
||||
headings = []
|
||||
for column in data["columns"]:
|
||||
headings.append(column)
|
||||
if column in expanded_columns:
|
||||
headings.append(f"{column}_label")
|
||||
|
||||
content_type = "text/plain; charset=utf-8"
|
||||
preamble = ""
|
||||
postamble = ""
|
||||
|
||||
trace = request.args.get("_trace")
|
||||
if trace:
|
||||
content_type = "text/html; charset=utf-8"
|
||||
preamble = (
|
||||
"<html><head><title>CSV debug</title></head>"
|
||||
'<body><textarea style="width: 90%; height: 70vh">'
|
||||
)
|
||||
postamble = "</textarea></body></html>"
|
||||
|
||||
async def stream_fn(r):
|
||||
nonlocal data, trace
|
||||
limited_writer = LimitedWriter(r, datasette.setting("max_csv_mb"))
|
||||
if trace:
|
||||
await limited_writer.write(preamble)
|
||||
writer = csv.writer(EscapeHtmlWriter(limited_writer))
|
||||
else:
|
||||
writer = csv.writer(limited_writer)
|
||||
first = True
|
||||
next = None
|
||||
while first or (next and stream):
|
||||
try:
|
||||
kwargs = {}
|
||||
if next:
|
||||
kwargs["_next"] = next
|
||||
if not first:
|
||||
data, _, _ = await fetch_data(request, **kwargs)
|
||||
if first:
|
||||
if request.args.get("_header") != "off":
|
||||
await writer.writerow(headings)
|
||||
first = False
|
||||
next = data.get("next")
|
||||
for row in data["rows"]:
|
||||
if any(isinstance(r, bytes) for r in row):
|
||||
new_row = []
|
||||
for column, cell in zip(headings, row):
|
||||
if isinstance(cell, bytes):
|
||||
# If this is a table page, use .urls.row_blob()
|
||||
if data.get("table"):
|
||||
pks = data.get("primary_keys") or []
|
||||
cell = datasette.absolute_url(
|
||||
request,
|
||||
datasette.urls.row_blob(
|
||||
database,
|
||||
data["table"],
|
||||
path_from_row_pks(row, pks, not pks),
|
||||
column,
|
||||
),
|
||||
)
|
||||
else:
|
||||
# Otherwise generate URL for this query
|
||||
url = datasette.absolute_url(
|
||||
request,
|
||||
path_with_format(
|
||||
request=request,
|
||||
format="blob",
|
||||
extra_qs={
|
||||
"_blob_column": column,
|
||||
"_blob_hash": hashlib.sha256(
|
||||
cell
|
||||
).hexdigest(),
|
||||
},
|
||||
replace_format="csv",
|
||||
),
|
||||
)
|
||||
cell = url.replace("&_nocount=1", "").replace(
|
||||
"&_nofacet=1", ""
|
||||
)
|
||||
new_row.append(cell)
|
||||
row = new_row
|
||||
if not expanded_columns:
|
||||
# Simple path
|
||||
await writer.writerow(row)
|
||||
else:
|
||||
# Look for {"value": "label": } dicts and expand
|
||||
new_row = []
|
||||
for heading, cell in zip(data["columns"], row):
|
||||
if heading in expanded_columns:
|
||||
if cell is None:
|
||||
new_row.extend(("", ""))
|
||||
else:
|
||||
if not isinstance(cell, dict):
|
||||
new_row.extend((cell, ""))
|
||||
else:
|
||||
new_row.append(cell["value"])
|
||||
new_row.append(cell["label"])
|
||||
else:
|
||||
new_row.append(cell)
|
||||
await writer.writerow(new_row)
|
||||
except Exception as ex:
|
||||
sys.stderr.write("Caught this error: {}\n".format(ex))
|
||||
sys.stderr.flush()
|
||||
await r.write(str(ex))
|
||||
return
|
||||
await limited_writer.write(postamble)
|
||||
|
||||
headers = {}
|
||||
if datasette.cors:
|
||||
add_cors_headers(headers)
|
||||
if request.args.get("_dl", None):
|
||||
if not trace:
|
||||
content_type = "text/csv; charset=utf-8"
|
||||
disposition = 'attachment; filename="{}.csv"'.format(
|
||||
request.url_vars.get("table", database)
|
||||
)
|
||||
headers["content-disposition"] = disposition
|
||||
|
||||
return AsgiStream(stream_fn, headers=headers, content_type=content_type)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,12 @@
|
|||
import hashlib
|
||||
import json
|
||||
|
||||
from datasette.utils import add_cors_headers, CustomJSONEncoder
|
||||
from datasette.plugins import pm
|
||||
from datasette.utils import (
|
||||
add_cors_headers,
|
||||
await_me_maybe,
|
||||
make_slot_function,
|
||||
CustomJSONEncoder,
|
||||
)
|
||||
from datasette.utils.asgi import Response
|
||||
from datasette.version import __version__
|
||||
|
||||
|
|
@ -20,28 +25,49 @@ class IndexView(BaseView):
|
|||
|
||||
async def get(self, request):
|
||||
as_format = request.url_vars["format"]
|
||||
await self.ds.ensure_permissions(request.actor, ["view-instance"])
|
||||
await self.ds.ensure_permission(action="view-instance", actor=request.actor)
|
||||
|
||||
# Get all allowed databases and tables in bulk
|
||||
db_page = await self.ds.allowed_resources(
|
||||
"view-database", request.actor, include_is_private=True
|
||||
)
|
||||
allowed_databases = [r async for r in db_page.all()]
|
||||
allowed_db_dict = {r.parent: r for r in allowed_databases}
|
||||
|
||||
# Group tables by database
|
||||
tables_by_db = {}
|
||||
table_page = await self.ds.allowed_resources(
|
||||
"view-table", request.actor, include_is_private=True
|
||||
)
|
||||
async for t in table_page.all():
|
||||
if t.parent not in tables_by_db:
|
||||
tables_by_db[t.parent] = {}
|
||||
tables_by_db[t.parent][t.child] = t
|
||||
|
||||
databases = []
|
||||
for name, db in self.ds.databases.items():
|
||||
database_visible, database_private = await self.ds.check_visibility(
|
||||
request.actor,
|
||||
"view-database",
|
||||
name,
|
||||
)
|
||||
if not database_visible:
|
||||
continue
|
||||
table_names = await db.table_names()
|
||||
# Iterate over allowed databases instead of all databases
|
||||
for name in allowed_db_dict.keys():
|
||||
db = self.ds.databases[name]
|
||||
database_private = allowed_db_dict[name].private
|
||||
|
||||
# Get allowed tables/views for this database
|
||||
allowed_for_db = tables_by_db.get(name, {})
|
||||
|
||||
# Get table names from allowed set instead of db.table_names()
|
||||
table_names = [child_name for child_name in allowed_for_db.keys()]
|
||||
|
||||
hidden_table_names = set(await db.hidden_table_names())
|
||||
|
||||
views = []
|
||||
for view_name in await db.view_names():
|
||||
view_visible, view_private = await self.ds.check_visibility(
|
||||
request.actor,
|
||||
"view-table",
|
||||
(name, view_name),
|
||||
)
|
||||
if view_visible:
|
||||
views.append({"name": view_name, "private": view_private})
|
||||
# Determine which allowed items are views
|
||||
view_names_set = set(await db.view_names())
|
||||
views = [
|
||||
{"name": child_name, "private": resource.private}
|
||||
for child_name, resource in allowed_for_db.items()
|
||||
if child_name in view_names_set
|
||||
]
|
||||
|
||||
# Filter to just tables (not views) for table processing
|
||||
table_names = [name for name in table_names if name not in view_names_set]
|
||||
|
||||
# Perform counts only for immutable or DBS with <= COUNT_TABLE_LIMIT tables
|
||||
table_counts = {}
|
||||
|
|
@ -53,13 +79,10 @@ class IndexView(BaseView):
|
|||
|
||||
tables = {}
|
||||
for table in table_names:
|
||||
visible, private = await self.ds.check_visibility(
|
||||
request.actor,
|
||||
"view-table",
|
||||
(name, table),
|
||||
)
|
||||
if not visible:
|
||||
# Check if table is in allowed set
|
||||
if table not in allowed_for_db:
|
||||
continue
|
||||
|
||||
table_columns = await db.table_columns(table)
|
||||
tables[table] = {
|
||||
"name": table,
|
||||
|
|
@ -69,7 +92,7 @@ class IndexView(BaseView):
|
|||
"hidden": table in hidden_table_names,
|
||||
"fts_table": await db.fts_table(table),
|
||||
"num_relationships_for_sorting": 0,
|
||||
"private": private,
|
||||
"private": allowed_for_db[table].private,
|
||||
}
|
||||
|
||||
if request.args.get("_sort") == "relationships" or not table_counts:
|
||||
|
|
@ -105,9 +128,7 @@ class IndexView(BaseView):
|
|||
{
|
||||
"name": name,
|
||||
"hash": db.hash,
|
||||
"color": db.hash[:6]
|
||||
if db.hash
|
||||
else hashlib.md5(name.encode("utf8")).hexdigest()[:6],
|
||||
"color": db.color,
|
||||
"path": self.ds.urls.database(name),
|
||||
"tables_and_views_truncated": tables_and_views_truncated,
|
||||
"tables_and_views_more": (len(visible_tables) + len(views))
|
||||
|
|
@ -129,20 +150,41 @@ class IndexView(BaseView):
|
|||
if self.ds.cors:
|
||||
add_cors_headers(headers)
|
||||
return Response(
|
||||
json.dumps({db["name"]: db for db in databases}, cls=CustomJSONEncoder),
|
||||
json.dumps(
|
||||
{
|
||||
"databases": {db["name"]: db for db in databases},
|
||||
"metadata": await self.ds.get_instance_metadata(),
|
||||
},
|
||||
cls=CustomJSONEncoder,
|
||||
),
|
||||
content_type="application/json; charset=utf-8",
|
||||
headers=headers,
|
||||
)
|
||||
else:
|
||||
homepage_actions = []
|
||||
for hook in pm.hook.homepage_actions(
|
||||
datasette=self.ds,
|
||||
actor=request.actor,
|
||||
request=request,
|
||||
):
|
||||
extra_links = await await_me_maybe(hook)
|
||||
if extra_links:
|
||||
homepage_actions.extend(extra_links)
|
||||
alternative_homepage = request.path == "/-/"
|
||||
return await self.render(
|
||||
["index.html"],
|
||||
["default:index.html" if alternative_homepage else "index.html"],
|
||||
request=request,
|
||||
context={
|
||||
"databases": databases,
|
||||
"metadata": self.ds.metadata(),
|
||||
"metadata": await self.ds.get_instance_metadata(),
|
||||
"datasette_version": __version__,
|
||||
"private": not await self.ds.permission_allowed(
|
||||
None, "view-instance", default=True
|
||||
"private": not await self.ds.allowed(
|
||||
action="view-instance", actor=None
|
||||
),
|
||||
"top_homepage": make_slot_function(
|
||||
"top_homepage", self.ds, request
|
||||
),
|
||||
"homepage_actions": homepage_actions,
|
||||
"noindex": request.path == "/-/",
|
||||
},
|
||||
)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue