authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-11 00:26:10-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-11 01:38:21-07:00
log7724489e4f16f750a945aef25084e7b30bbde95b
tree00e35a228374f5b30e25dbb49600628d1113fb73
parent345d7576fee9ee12c5032dec7e5d0ac999e29880
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat: app list filters by signed-in user, drops non-web services

#16 /apps now sits behind forward-auth; the page is a caddy template that shows only entries whose role (media / media-manage / admin) appears in the User-Groups header forward-auth sets. flat list, no section headers.

3 files changed, 189 insertions(+), 104 deletions(-)

config/Caddyfile+17-1
......@@ -43,10 +43,26 @@
4343# services are sorted alphabetically
4444auth.{$HOME_DOMAIN} {
4545 handle / {
46 redir / /realms/master/account
46 redir / /apps
47 }
48 handle /snow.oauth2/* {
49 reverse_proxy "http://forward-auth" {
50 header_up X-Real-IP {remote_host}
51 header_up X-Forwarded-Uri {uri}
52 }
53 }
54 forward_auth /apps "http://forward-auth" {
55 uri /snow.oauth2/auth
56 header_up X-Real-IP {remote_host}
57 copy_headers X-Auth-Request-Groups>User-Groups X-Auth-Request-Preferred-Username>User-Name
58 @error status 401
59 handle_response @error {
60 redir * /snow.oauth2/sign_in?rd={scheme}://{host}{uri}
61 }
4762 }
4863 handle /apps {
4964 rewrite * /static/apps.html
65 templates
5066 file_server {
5167 root /etc/caddy
5268 }
config/static/apps.html+87-56
......@@ -1,5 +1,9 @@
11<!doctype html>
2<!-- generated by generate-app-list.py — do not edit by hand -->
2<!-- generated by generate-app-list.py — do not edit by hand.
3 rendered by caddy's templates directive; entries are filtered by the
4 User-Groups header that forward-auth sets for the signed-in user. -->
5{{ $groups := .Req.Header.Get "User-Groups" }}
6{{ $name := .Req.Header.Get "User-Name" }}
37<html lang="en">
48<head>
59<meta charset="utf-8">
......@@ -10,74 +14,101 @@
1014:root { color-scheme: light dark; }
1115body {
1216 font-family: "national-medium", system-ui, sans-serif;
13 background-color: light-dark(#e8eefa, #2f4b67);
17 background-color: light-dark(#e8eefa, #152c42);
1418 color: light-dark(black, white);
1519 max-width: 34rem;
1620 margin: 0 auto;
1721 padding: 2rem 1rem 4rem;
1822}
19h1, h2, a { color: light-dark(#1a46cd, #938cff); }
23h1, a { color: light-dark(#1a46cd, #938cff); }
2024h1 { text-align: center; font-weight: normal; }
2125a { text-decoration: dotted underline; }
2226a:hover { text-decoration: underline; }
23.who { opacity: 0.6; font-size: 0.9rem; margin-top: -0.5rem; }
27header { text-align: center; }
28header svg { display: block; margin: 0 auto; }
29#logout {
30 position: absolute;
31 top: 1rem;
32 right: 1.25rem;
33 font-size: 0.9rem;
34 opacity: 0.75;
35}
36#logout:hover { opacity: 1; }
2437.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; }
38ul { list-style: none; padding-left: 0.25rem; }
39li { margin: 0.55rem 0; }
40li::before {
41 content: "";
42 display: inline-block;
43 width: 1em;
44 height: 1em;
45 margin-right: 0.55em;
46 vertical-align: -0.12em;
47 background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%22-2%20-2%2028%2028%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M10%2020L8.75%2017.5L6%2018M10%204L8.75%206.5L6%206M14%2020L15.25%2017.5L18%2018M14%204L15.25%206.5L18%206M17%2021L14%2015M14%2015H10M14%2015L15.5%2012M10%2015L7%2021M10%2015L8.5%2012M17%203L14%209M14%209L15.5%2012M14%209H10M15.5%2012H22M2%2012H8.5M8.5%2012L10%209M10%209L7%203M20%2010L18.5%2012L20%2014M4%2010L5.5%2012L4%2014%22%20stroke%3D%22url%28%23sfo%29%22%20stroke-width%3D%223%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%0A%3Cpath%20d%3D%22M10%2020L8.75%2017.5L6%2018M10%204L8.75%206.5L6%206M14%2020L15.25%2017.5L18%2018M14%204L15.25%206.5L18%206M17%2021L14%2015M14%2015H10M14%2015L15.5%2012M10%2015L7%2021M10%2015L8.5%2012M17%203L14%209M14%209L15.5%2012M14%209H10M15.5%2012H22M2%2012H8.5M8.5%2012L10%209M10%209L7%203M20%2010L18.5%2012L20%2014M4%2010L5.5%2012L4%2014%22%20stroke%3D%22url%28%23sfi%29%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%0A%3Cdefs%3E%0A%3ClinearGradient%20id%3D%22sfo%22%20x1%3D%224%22%20y1%3D%22-2%22%20x2%3D%2218.5%22%20y2%3D%2225%22%20gradientUnits%3D%22userSpaceOnUse%22%3E%0A%3Cstop%20stop-color%3D%22%23223D99%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23154392%22%2F%3E%0A%3C%2FlinearGradient%3E%0A%3ClinearGradient%20id%3D%22sfi%22%20x1%3D%2210%22%20y1%3D%22-3%22%20x2%3D%2218%22%20y2%3D%2227%22%20gradientUnits%3D%22userSpaceOnUse%22%3E%0A%3Cstop%20stop-color%3D%22%23F2E3FF%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23F2F8FF%22%2F%3E%0A%3C%2FlinearGradient%3E%0A%3C%2Fdefs%3E%0A%3C%2Fsvg%3E");
48 background-size: contain;
49 background-repeat: no-repeat;
50}
2851</style>
2952</head>
3053<body>
31 <h1>&#x2744;&#xFE0E; apps on paperclover.net</h1>
54 <a id="logout" href="/snow.oauth2/sign_out?rd=https://auth.paperclover.net/apps">log out</a>
55 <header>
56<svg width="88" height="88" viewBox="-2 -2 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
57<path d="M10 20L8.75 17.5L6 18M10 4L8.75 6.5L6 6M14 20L15.25 17.5L18 18M14 4L15.25 6.5L18 6M17 21L14 15M14 15H10M14 15L15.5 12M10 15L7 21M10 15L8.5 12M17 3L14 9M14 9L15.5 12M14 9H10M15.5 12H22M2 12H8.5M8.5 12L10 9M10 9L7 3M20 10L18.5 12L20 14M4 10L5.5 12L4 14" stroke="url(#sfo)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
58<path d="M10 20L8.75 17.5L6 18M10 4L8.75 6.5L6 6M14 20L15.25 17.5L18 18M14 4L15.25 6.5L18 6M17 21L14 15M14 15H10M14 15L15.5 12M10 15L7 21M10 15L8.5 12M17 3L14 9M14 9L15.5 12M14 9H10M15.5 12H22M2 12H8.5M8.5 12L10 9M10 9L7 3M20 10L18.5 12L20 14M4 10L5.5 12L4 14" stroke="url(#sfi)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
59<defs>
60<linearGradient id="sfo" x1="4" y1="-2" x2="18.5" y2="25" gradientUnits="userSpaceOnUse">
61<stop stop-color="#223D99"/><stop offset="1" stop-color="#154392"/>
62</linearGradient>
63<linearGradient id="sfi" x1="10" y1="-3" x2="18" y2="27" gradientUnits="userSpaceOnUse">
64<stop stop-color="#F2E3FF"/><stop offset="1" stop-color="#F2F8FF"/>
65</linearGradient>
66</defs>
67</svg>
68 <h1>hi {{ $name }}</h1>
69 </header>
3270 <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>
71 <ul>
72 {{ if contains "role:media" $groups }}
73 <li><a href="https://jelly.paperclover.net">cloverfin</a> <span class="domain">jelly.paperclover.net</span></li>
74 {{ end }}
75 {{ if contains "role:media" $groups }}
76 <li><a href="https://music.paperclover.net">navidrome music</a> <span class="domain">music.paperclover.net</span></li>
77 {{ end }}
78 <li><a href="https://git.paperclover.net">clover git forge</a> <span class="domain">git.paperclover.net</span></li>
79 <li><a href="https://file.paperclover.net">copyparty file storage</a> <span class="domain">file.paperclover.net</span></li>
80 {{ if contains "role:admin" $groups }}
81 <li><a href="https://opencode.paperclover.net">opencode</a> <span class="domain">opencode.paperclover.net</span></li>
82 {{ end }}
83 {{ if contains "role:media-manage" $groups }}
84 <li><a href="https://qbt.paperclover.net">qbittorrent</a> <span class="domain">qbt.paperclover.net</span></li>
85 {{ end }}
86 {{ if contains "role:media-manage" $groups }}
87 <li><a href="https://snr.paperclover.net">sonarr tv pvr</a> <span class="domain">snr.paperclover.net</span></li>
88 {{ end }}
89 {{ if contains "role:media-manage" $groups }}
90 <li><a href="https://rdr.paperclover.net">radarr movie organizer</a> <span class="domain">rdr.paperclover.net</span></li>
91 {{ end }}
92 {{ if contains "role:media-manage" $groups }}
93 <li><a href="https://jkt.paperclover.net">jackett api adapter</a> <span class="domain">jkt.paperclover.net</span></li>
94 {{ end }}
95 {{ if contains "role:admin" $groups }}
96 <li><a href="https://sync.paperclover.net">syncthing</a> <span class="domain">sync.paperclover.net</span></li>
97 {{ end }}
98 {{ if contains "role:admin" $groups }}
99 <li><a href="https://dawarich.paperclover.net">dawarich location history</a> <span class="domain">dawarich.paperclover.net</span></li>
100 {{ end }}
101 {{ if contains "role:admin" $groups }}
102 <li><a href="https://pg.paperclover.net">pgadmin</a> <span class="domain">pg.paperclover.net</span></li>
103 {{ end }}
104 {{ if contains "role:admin" $groups }}
105 <li><a href="https://redis.paperclover.net">redis insight</a> <span class="domain">redis.paperclover.net</span></li>
106 {{ end }}
107 <li><a href="https://speedtest.paperclover.net">open speed test</a> <span class="domain">speedtest.paperclover.net</span></li>
108 {{ if contains "role:admin" $groups }}
109 <li><a href="https://dns.paperclover.net">dns</a> <span class="domain">dns.paperclover.net</span></li>
110 {{ end }}
111 </ul>
76112 </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>
82113</body>
83114</html>
generate-app-list.py+85-47
......@@ -1,21 +1,48 @@
11#!/usr/bin/env python3
22# generates config/static/apps.html from the net.paperclover.list.* labels
3# in compose.yaml. usage:
3# in compose.yaml. the output is a caddy template: caddy's `templates`
4# directive renders it per-request, filtering entries by the User-Groups
5# header and greeting the User-Name that forward-auth populates. usage:
46# sh docker.sh compose config --format json | python3 generate-app-list.py > config/static/apps.html
57# (or local: docker compose config --format json | ...)
68import json
79import os
810import sys
11import urllib.parse
912
1013PFX = "net.paperclover.list."
1114DOMAIN = os.environ.get("HOME_DOMAIN", "paperclover.net")
1215
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]
16# access label -> keycloak role required to see the entry (None = everyone)
17ACCESS_ROLE = {
18 None: None,
19 "media": "role:media",
20 "media-manage": "role:media-manage",
21 "personal": "role:admin",
22}
23
24# the snow sign on snowflake (same artwork as the login theme, viewBox
25# pre-padded so the round caps don't clip)
26SNOWFLAKE_PATH = ("M10 20L8.75 17.5L6 18M10 4L8.75 6.5L6 6M14 20L15.25 17.5L18 18"
27 "M14 4L15.25 6.5L18 6M17 21L14 15M14 15H10M14 15L15.5 12M10 15L7 21M10 15L8.5 12"
28 "M17 3L14 9M14 9L15.5 12M14 9H10M15.5 12H22M2 12H8.5M8.5 12L10 9M10 9L7 3"
29 "M20 10L18.5 12L20 14M4 10L5.5 12L4 14")
30
31def snowflake_svg(size):
32 return f'''<svg width="{size}" height="{size}" viewBox="-2 -2 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
33<path d="{SNOWFLAKE_PATH}" stroke="url(#sfo)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
34<path d="{SNOWFLAKE_PATH}" stroke="url(#sfi)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
35<defs>
36<linearGradient id="sfo" x1="4" y1="-2" x2="18.5" y2="25" gradientUnits="userSpaceOnUse">
37<stop stop-color="#223D99"/><stop offset="1" stop-color="#154392"/>
38</linearGradient>
39<linearGradient id="sfi" x1="10" y1="-3" x2="18" y2="27" gradientUnits="userSpaceOnUse">
40<stop stop-color="#F2E3FF"/><stop offset="1" stop-color="#F2F8FF"/>
41</linearGradient>
42</defs>
43</svg>'''
44
45BULLET_URI = "data:image/svg+xml," + urllib.parse.quote(snowflake_svg(16), safe="")
1946
2047cfg = json.load(sys.stdin)
2148apps = []
......@@ -25,46 +52,37 @@ for name, svc in cfg["services"].items():
2552 labels = dict(l.split("=", 1) for l in labels)
2653 if PFX + "name" not in labels:
2754 continue
55 if labels.get(PFX + "web") == "false" or not labels.get(PFX + "domain"):
56 continue
2857 apps.append({
2958 "name": labels[PFX + "name"],
30 "domain": labels.get(PFX + "domain"),
59 "domain": labels[PFX + "domain"],
3160 "priority": int(labels.get(PFX + "priority", 0)),
3261 "access": labels.get(PFX + "access"),
3362 })
3463
3564apps.sort(key=lambda a: -a["priority"])
3665
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>"
66items = []
67for a in apps:
68 role = ACCESS_ROLE.get(a["access"], "role:admin")
69 li = (
70 f' <li><a href="https://{a["domain"]}.{DOMAIN}">'
71 f'{a["name"].lower()}</a>'
72 f' <span class="domain">{a["domain"]}.{DOMAIN}</span></li>'
6273 )
74 if role:
75 li = f' {{{{ if contains "{role}" $groups }}}}\n{li}\n {{{{ end }}}}'
76 items.append(li)
6377
64body = "\n".join(sections)
78body = "\n".join(items)
6579
6680print(f"""<!doctype html>
67<!-- generated by generate-app-list.py — do not edit by hand -->
81<!-- generated by generate-app-list.py — do not edit by hand.
82 rendered by caddy's templates directive; entries are filtered by the
83 User-Groups header that forward-auth sets for the signed-in user. -->
84{{{{ $groups := .Req.Header.Get "User-Groups" }}}}
85{{{{ $name := .Req.Header.Get "User-Name" }}}}
6886<html lang="en">
6987<head>
7088<meta charset="utf-8">
......@@ -75,32 +93,52 @@ print(f"""<!doctype html>
7593:root {{ color-scheme: light dark; }}
7694body {{
7795 font-family: "national-medium", system-ui, sans-serif;
78 background-color: light-dark(#e8eefa, #2f4b67);
96 background-color: light-dark(#e8eefa, #152c42);
7997 color: light-dark(black, white);
8098 max-width: 34rem;
8199 margin: 0 auto;
82100 padding: 2rem 1rem 4rem;
83101}}
84h1, h2, a {{ color: light-dark(#1a46cd, #938cff); }}
102h1, a {{ color: light-dark(#1a46cd, #938cff); }}
85103h1 {{ text-align: center; font-weight: normal; }}
86104a {{ text-decoration: dotted underline; }}
87105a:hover {{ text-decoration: underline; }}
88.who {{ opacity: 0.6; font-size: 0.9rem; margin-top: -0.5rem; }}
106header {{ text-align: center; }}
107header svg {{ display: block; margin: 0 auto; }}
108#logout {{
109 position: absolute;
110 top: 1rem;
111 right: 1.25rem;
112 font-size: 0.9rem;
113 opacity: 0.75;
114}}
115#logout:hover {{ opacity: 1; }}
89116.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; }}
117ul {{ list-style: none; padding-left: 0.25rem; }}
118li {{ margin: 0.55rem 0; }}
119li::before {{
120 content: "";
121 display: inline-block;
122 width: 1em;
123 height: 1em;
124 margin-right: 0.55em;
125 vertical-align: -0.12em;
126 background-image: url("{BULLET_URI}");
127 background-size: contain;
128 background-repeat: no-repeat;
129}}
93130</style>
94131</head>
95132<body>
96 <h1>&#x2744;&#xFE0E; apps on {DOMAIN}</h1>
133 <a id="logout" href="/snow.oauth2/sign_out?rd=https://auth.{DOMAIN}/apps">log out</a>
134 <header>
135{snowflake_svg(88)}
136 <h1>hi {{{{ $name }}}}</h1>
137 </header>
97138 <main>
139 <ul>
98140{body}
141 </ul>
99142 </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>
105143</body>
106144</html>""")