diff --git a/config/Caddyfile b/config/Caddyfile index 1604bdc69ea3bd61f7566de83fd0ea66cb2e6d9e..895a617f87c02c8daf910a71c94f78fc720e996d 100644 --- a/config/Caddyfile +++ b/config/Caddyfile @@ -43,10 +43,26 @@ # services are sorted alphabetically auth.{$HOME_DOMAIN} { handle / { - redir / /realms/master/account + redir / /apps + } + handle /snow.oauth2/* { + reverse_proxy "http://forward-auth" { + header_up X-Real-IP {remote_host} + header_up X-Forwarded-Uri {uri} + } + } + forward_auth /apps "http://forward-auth" { + uri /snow.oauth2/auth + header_up X-Real-IP {remote_host} + copy_headers X-Auth-Request-Groups>User-Groups X-Auth-Request-Preferred-Username>User-Name + @error status 401 + handle_response @error { + redir * /snow.oauth2/sign_in?rd={scheme}://{host}{uri} + } } handle /apps { rewrite * /static/apps.html + templates file_server { root /etc/caddy } diff --git a/config/static/apps.html b/config/static/apps.html index 8990e80d3b86b22c3065e1bab900b1420871976c..98a0c8c0950f45a051de12e13b2eca787b5dd438 100644 --- a/config/static/apps.html +++ b/config/static/apps.html @@ -1,5 +1,9 @@ - + +{{ $groups := .Req.Header.Get "User-Groups" }} +{{ $name := .Req.Header.Get "User-Name" }} @@ -10,74 +14,101 @@ :root { color-scheme: light dark; } body { font-family: "national-medium", system-ui, sans-serif; - background-color: light-dark(#e8eefa, #2f4b67); + background-color: light-dark(#e8eefa, #152c42); color: light-dark(black, white); max-width: 34rem; margin: 0 auto; padding: 2rem 1rem 4rem; } -h1, h2, a { color: light-dark(#1a46cd, #938cff); } +h1, a { color: light-dark(#1a46cd, #938cff); } h1 { text-align: center; font-weight: normal; } a { text-decoration: dotted underline; } a:hover { text-decoration: underline; } -.who { opacity: 0.6; font-size: 0.9rem; margin-top: -0.5rem; } +header { text-align: center; } +header svg { display: block; margin: 0 auto; } +#logout { + position: absolute; + top: 1rem; + right: 1.25rem; + font-size: 0.9rem; + opacity: 0.75; +} +#logout:hover { opacity: 1; } .domain { opacity: 0.5; font-size: 0.85rem; } -ul { list-style: "\2744\FE0E "; padding-left: 1.5rem; } -li { margin: 0.4rem 0; } -footer { margin-top: 3rem; opacity: 0.7; font-size: 0.9rem; text-align: center; } +ul { list-style: none; padding-left: 0.25rem; } +li { margin: 0.55rem 0; } +li::before { + content: ""; + display: inline-block; + width: 1em; + height: 1em; + margin-right: 0.55em; + vertical-align: -0.12em; + 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"); + background-size: contain; + background-repeat: no-repeat; +} -

❄︎ apps on paperclover.net

+ log out +
+ + + + + + + + + + + + +

hi {{ $name }}

+
-
-

for everyone

-

any snow sign on account

- -
-
-

media

-

members of the media group

- -
-
-

media management

-

members of the media-manage group

- -
-
-

personal

-

just clover

- -
+
- diff --git a/generate-app-list.py b/generate-app-list.py index 2275c51c5e9694d5833e6db98435f61ae96a634a..76eb0302b9cde62966d1bf1ba8e8067d6a6dfa3b 100644 --- a/generate-app-list.py +++ b/generate-app-list.py @@ -1,21 +1,48 @@ #!/usr/bin/env python3 # generates config/static/apps.html from the net.paperclover.list.* labels -# in compose.yaml. usage: +# in compose.yaml. the output is a caddy template: caddy's `templates` +# directive renders it per-request, filtering entries by the User-Groups +# header and greeting the User-Name that forward-auth populates. usage: # sh docker.sh compose config --format json | python3 generate-app-list.py > config/static/apps.html # (or local: docker compose config --format json | ...) import json import os import sys +import urllib.parse PFX = "net.paperclover.list." DOMAIN = os.environ.get("HOME_DOMAIN", "paperclover.net") -ACCESS_GROUPS = [ - (None, "for everyone", "any snow sign on account"), - ("media", "media", "members of the media group"), - ("media-manage", "media management", "members of the media-manage group"), - ("personal", "personal", "just clover"), -] +# access label -> keycloak role required to see the entry (None = everyone) +ACCESS_ROLE = { + None: None, + "media": "role:media", + "media-manage": "role:media-manage", + "personal": "role:admin", +} + +# the snow sign on snowflake (same artwork as the login theme, viewBox +# pre-padded so the round caps don't clip) +SNOWFLAKE_PATH = ("M10 20L8.75 17.5L6 18M10 4L8.75 6.5L6 6M14 20L15.25 17.5L18 18" + "M14 4L15.25 6.5L18 6M17 21L14 15M14 15H10M14 15L15.5 12M10 15L7 21M10 15L8.5 12" + "M17 3L14 9M14 9L15.5 12M14 9H10M15.5 12H22M2 12H8.5M8.5 12L10 9M10 9L7 3" + "M20 10L18.5 12L20 14M4 10L5.5 12L4 14") + +def snowflake_svg(size): + return f''' + + + + + + + + + + +''' + +BULLET_URI = "data:image/svg+xml," + urllib.parse.quote(snowflake_svg(16), safe="") cfg = json.load(sys.stdin) apps = [] @@ -25,46 +52,37 @@ for name, svc in cfg["services"].items(): labels = dict(l.split("=", 1) for l in labels) if PFX + "name" not in labels: continue + if labels.get(PFX + "web") == "false" or not labels.get(PFX + "domain"): + continue apps.append({ "name": labels[PFX + "name"], - "domain": labels.get(PFX + "domain"), + "domain": labels[PFX + "domain"], "priority": int(labels.get(PFX + "priority", 0)), "access": labels.get(PFX + "access"), }) apps.sort(key=lambda a: -a["priority"]) -def entries(access): - out = [] - for a in apps: - if a["access"] != access: - continue - if a["domain"]: - out.append( - f'
  • ' - f'{a["name"].lower()}' - f' {a["domain"]}.{DOMAIN}
  • ' - ) - else: - out.append(f'
  • {a["name"].lower()} (no web ui)
  • ') - return out - -sections = [] -for access, title, who in ACCESS_GROUPS: - items = entries(access) - if not items: - continue - sections.append( - f'
    \n

    {title}

    \n' - f'

    {who}

    \n \n
    " +items = [] +for a in apps: + role = ACCESS_ROLE.get(a["access"], "role:admin") + li = ( + f'
  • ' + f'{a["name"].lower()}' + f' {a["domain"]}.{DOMAIN}
  • ' ) + if role: + li = f' {{{{ if contains "{role}" $groups }}}}\n{li}\n {{{{ end }}}}' + items.append(li) -body = "\n".join(sections) +body = "\n".join(items) print(f""" - + +{{{{ $groups := .Req.Header.Get "User-Groups" }}}} +{{{{ $name := .Req.Header.Get "User-Name" }}}} @@ -75,32 +93,52 @@ print(f""" :root {{ color-scheme: light dark; }} body {{ font-family: "national-medium", system-ui, sans-serif; - background-color: light-dark(#e8eefa, #2f4b67); + background-color: light-dark(#e8eefa, #152c42); color: light-dark(black, white); max-width: 34rem; margin: 0 auto; padding: 2rem 1rem 4rem; }} -h1, h2, a {{ color: light-dark(#1a46cd, #938cff); }} +h1, a {{ color: light-dark(#1a46cd, #938cff); }} h1 {{ text-align: center; font-weight: normal; }} a {{ text-decoration: dotted underline; }} a:hover {{ text-decoration: underline; }} -.who {{ opacity: 0.6; font-size: 0.9rem; margin-top: -0.5rem; }} +header {{ text-align: center; }} +header svg {{ display: block; margin: 0 auto; }} +#logout {{ + position: absolute; + top: 1rem; + right: 1.25rem; + font-size: 0.9rem; + opacity: 0.75; +}} +#logout:hover {{ opacity: 1; }} .domain {{ opacity: 0.5; font-size: 0.85rem; }} -ul {{ list-style: "\\2744\\FE0E "; padding-left: 1.5rem; }} -li {{ margin: 0.4rem 0; }} -footer {{ margin-top: 3rem; opacity: 0.7; font-size: 0.9rem; text-align: center; }} +ul {{ list-style: none; padding-left: 0.25rem; }} +li {{ margin: 0.55rem 0; }} +li::before {{ + content: ""; + display: inline-block; + width: 1em; + height: 1em; + margin-right: 0.55em; + vertical-align: -0.12em; + background-image: url("{BULLET_URI}"); + background-size: contain; + background-repeat: no-repeat; +}} -

    ❄︎ apps on {DOMAIN}

    + log out +
    +{snowflake_svg(88)} +

    hi {{{{ $name }}}}

    +
    +
    - """)