authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-11 00:20:47-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-11 01:38:21-07:00
log345d7576fee9ee12c5032dec7e5d0ac999e29880
tree50ee779247e95f298c8486096999645e1c922fcc
parent0afc7d74ea327a98280729334118af95fbc07a0c
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat: app list page on auth.paperclover.net/apps

closes #16 generate-app-list.py renders config/static/apps.html from the net.paperclover.list.* compose labels, grouped by access level; caddy serves it at auth.<domain>/apps. regenerate with: sh docker.sh compose config --format json | python3 generate-app-list.py > config/static/apps.html also fixes two wrong domain labels (sonarr jkt->snr, navidrome nd->music) so the generated links match the Caddyfile.

4 files changed, 197 insertions(+), 2 deletions(-)

compose.yaml+2-2
...@@ -336,7 +336,7 @@ services:...@@ -336,7 +336,7 @@ services:
336 ND_PORT: "80"336 ND_PORT: "80"
337 labels:337 labels:
338 net.paperclover.list.name: Navidrome Music338 net.paperclover.list.name: Navidrome Music
339 net.paperclover.list.domain: nd339 net.paperclover.list.domain: music
340 net.paperclover.list.priority: 109340 net.paperclover.list.priority: 109
341 net.paperclover.list.access: media341 net.paperclover.list.access: media
342 depends_on:342 depends_on:
...@@ -419,7 +419,7 @@ services:...@@ -419,7 +419,7 @@ services:
419 restart: unless-stopped419 restart: unless-stopped
420 labels:420 labels:
421 net.paperclover.list.name: Sonarr TV PVR421 net.paperclover.list.name: Sonarr TV PVR
422 net.paperclover.list.domain: jkt422 net.paperclover.list.domain: snr
423 net.paperclover.list.priority: 51423 net.paperclover.list.priority: 51
424 net.paperclover.list.access: media-manage424 net.paperclover.list.access: media-manage
425 radarr: # port 7878425 radarr: # port 7878
config/Caddyfile+6
...@@ -44,6 +44,12 @@...@@ -44,6 +44,12 @@
44auth.{$HOME_DOMAIN} {44auth.{$HOME_DOMAIN} {
45 handle / {45 handle / {
46 redir / /realms/master/account46 redir / /realms/master/account
47 }
48 handle /apps {
49 rewrite * /static/apps.html
50 file_server {
51 root /etc/caddy
52 }
47 }53 }
48 reverse_proxy "http://keycloak"54 reverse_proxy "http://keycloak"
49}55}
config/static/apps.html created+83
...@@ -0,0 +1,83 @@
1<!doctype html>
2<!-- generated by generate-app-list.py — do not edit by hand -->
3<html lang="en">
4<head>
5<meta charset="utf-8">
6<meta name="viewport" content="width=device-width, initial-scale=1">
7<title>apps (snow sign on)</title>
8<style>
9@import "https://file.paperclover.net/.static/font.css";
10:root { color-scheme: light dark; }
11body {
12 font-family: "national-medium", system-ui, sans-serif;
13 background-color: light-dark(#e8eefa, #2f4b67);
14 color: light-dark(black, white);
15 max-width: 34rem;
16 margin: 0 auto;
17 padding: 2rem 1rem 4rem;
18}
19h1, h2, a { color: light-dark(#1a46cd, #938cff); }
20h1 { text-align: center; font-weight: normal; }
21a { text-decoration: dotted underline; }
22a:hover { text-decoration: underline; }
23.who { opacity: 0.6; font-size: 0.9rem; margin-top: -0.5rem; }
24.domain { opacity: 0.5; font-size: 0.85rem; }
25ul { list-style: "\2744\FE0E "; padding-left: 1.5rem; }
26li { margin: 0.4rem 0; }
27footer { margin-top: 3rem; opacity: 0.7; font-size: 0.9rem; text-align: center; }
28</style>
29</head>
30<body>
31 <h1>&#x2744;&#xFE0E; apps on paperclover.net</h1>
32 <main>
33 <section>
34 <h2>for everyone</h2>
35 <p class="who">any snow sign on account</p>
36 <ul>
37 <li><a href="https://xmpp.paperclover.net">paper clover xmpp</a> <span class="domain">xmpp.paperclover.net</span></li>
38 <li><a href="https://spindle.paperclover.net">tangled spindle</a> <span class="domain">spindle.paperclover.net</span></li>
39 <li><a href="https://knot.paperclover.net">tangled knot</a> <span class="domain">knot.paperclover.net</span></li>
40 <li><a href="https://git.paperclover.net">clover git forge</a> <span class="domain">git.paperclover.net</span></li>
41 <li><a href="https://file.paperclover.net">copyparty file storage</a> <span class="domain">file.paperclover.net</span></li>
42 <li><a href="https://speedtest.paperclover.net">open speed test</a> <span class="domain">speedtest.paperclover.net</span></li>
43 </ul>
44 </section>
45 <section>
46 <h2>media</h2>
47 <p class="who">members of the media group</p>
48 <ul>
49 <li><a href="https://jelly.paperclover.net">cloverfin</a> <span class="domain">jelly.paperclover.net</span></li>
50 <li><a href="https://music.paperclover.net">navidrome music</a> <span class="domain">music.paperclover.net</span></li>
51 </ul>
52 </section>
53 <section>
54 <h2>media management</h2>
55 <p class="who">members of the media-manage group</p>
56 <ul>
57 <li><a href="https://qbt.paperclover.net">qbittorrent</a> <span class="domain">qbt.paperclover.net</span></li>
58 <li><a href="https://snr.paperclover.net">sonarr tv pvr</a> <span class="domain">snr.paperclover.net</span></li>
59 <li><a href="https://rdr.paperclover.net">radarr movie organizer</a> <span class="domain">rdr.paperclover.net</span></li>
60 <li><a href="https://jkt.paperclover.net">jackett api adapter</a> <span class="domain">jkt.paperclover.net</span></li>
61 </ul>
62 </section>
63 <section>
64 <h2>personal</h2>
65 <p class="who">just clover</p>
66 <ul>
67 <li><a href="https://opencode.paperclover.net">opencode</a> <span class="domain">opencode.paperclover.net</span></li>
68 <li><a href="https://sync.paperclover.net">syncthing</a> <span class="domain">sync.paperclover.net</span></li>
69 <li><a href="https://dawarich.paperclover.net">dawarich location history</a> <span class="domain">dawarich.paperclover.net</span></li>
70 <li><a href="https://pg.paperclover.net">pgadmin</a> <span class="domain">pg.paperclover.net</span></li>
71 <li><a href="https://redis.paperclover.net">redis insight</a> <span class="domain">redis.paperclover.net</span></li>
72 <li><a href="https://dns.paperclover.net">dns</a> <span class="domain">dns.paperclover.net</span></li>
73 <li>ddns <span class="domain">(no web ui)</span></li>
74 </ul>
75 </section>
76 </main>
77 <footer>
78 <p>signed in with <a href="https://auth.paperclover.net/realms/master/account">snow sign on</a> —
79 one account for everything here.</p>
80 <p>missing access? contact clover.</p>
81 </footer>
82</body>
83</html>
generate-app-list.py created+106
...@@ -0,0 +1,106 @@
1#!/usr/bin/env python3
2# generates config/static/apps.html from the net.paperclover.list.* labels
3# in compose.yaml. usage:
4# sh docker.sh compose config --format json | python3 generate-app-list.py > config/static/apps.html
5# (or local: docker compose config --format json | ...)
6import json
7import os
8import sys
9
10PFX = "net.paperclover.list."
11DOMAIN = os.environ.get("HOME_DOMAIN", "paperclover.net")
12
13ACCESS_GROUPS = [
14 (None, "for everyone", "any snow sign on account"),
15 ("media", "media", "members of the media group"),
16 ("media-manage", "media management", "members of the media-manage group"),
17 ("personal", "personal", "just clover"),
18]
19
20cfg = json.load(sys.stdin)
21apps = []
22for name, svc in cfg["services"].items():
23 labels = svc.get("labels") or {}
24 if isinstance(labels, list):
25 labels = dict(l.split("=", 1) for l in labels)
26 if PFX + "name" not in labels:
27 continue
28 apps.append({
29 "name": labels[PFX + "name"],
30 "domain": labels.get(PFX + "domain"),
31 "priority": int(labels.get(PFX + "priority", 0)),
32 "access": labels.get(PFX + "access"),
33 })
34
35apps.sort(key=lambda a: -a["priority"])
36
37def entries(access):
38 out = []
39 for a in apps:
40 if a["access"] != access:
41 continue
42 if a["domain"]:
43 out.append(
44 f' <li><a href="https://{a["domain"]}.{DOMAIN}">'
45 f'{a["name"].lower()}</a>'
46 f' <span class="domain">{a["domain"]}.{DOMAIN}</span></li>'
47 )
48 else:
49 out.append(f' <li>{a["name"].lower()} <span class="domain">(no web ui)</span></li>')
50 return out
51
52sections = []
53for access, title, who in ACCESS_GROUPS:
54 items = entries(access)
55 if not items:
56 continue
57 sections.append(
58 f' <section>\n <h2>{title}</h2>\n'
59 f' <p class="who">{who}</p>\n <ul>\n'
60 + "\n".join(" " + i for i in items)
61 + "\n </ul>\n </section>"
62 )
63
64body = "\n".join(sections)
65
66print(f"""<!doctype html>
67<!-- generated by generate-app-list.py — do not edit by hand -->
68<html lang="en">
69<head>
70<meta charset="utf-8">
71<meta name="viewport" content="width=device-width, initial-scale=1">
72<title>apps (snow sign on)</title>
73<style>
74@import "https://file.{DOMAIN}/.static/font.css";
75:root {{ color-scheme: light dark; }}
76body {{
77 font-family: "national-medium", system-ui, sans-serif;
78 background-color: light-dark(#e8eefa, #2f4b67);
79 color: light-dark(black, white);
80 max-width: 34rem;
81 margin: 0 auto;
82 padding: 2rem 1rem 4rem;
83}}
84h1, h2, a {{ color: light-dark(#1a46cd, #938cff); }}
85h1 {{ text-align: center; font-weight: normal; }}
86a {{ text-decoration: dotted underline; }}
87a:hover {{ text-decoration: underline; }}
88.who {{ opacity: 0.6; font-size: 0.9rem; margin-top: -0.5rem; }}
89.domain {{ opacity: 0.5; font-size: 0.85rem; }}
90ul {{ list-style: "\\2744\\FE0E "; padding-left: 1.5rem; }}
91li {{ margin: 0.4rem 0; }}
92footer {{ margin-top: 3rem; opacity: 0.7; font-size: 0.9rem; text-align: center; }}
93</style>
94</head>
95<body>
96 <h1>&#x2744;&#xFE0E; apps on {DOMAIN}</h1>
97 <main>
98{body}
99 </main>
100 <footer>
101 <p>signed in with <a href="https://auth.{DOMAIN}/realms/master/account">snow sign on</a> —
102 one account for everything here.</p>
103 <p>missing access? contact clover.</p>
104 </footer>
105</body>
106</html>""")