Commit 5dbc280895

5dbc28089538479b7ba44f910c1b7f91e14663ae

parent: d0c63c4365

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-30 02:09 UTC

httpd: serve apple-app-site-association

[web] apple_app_ids lists iOS app IDs; when set,
/.well-known/apple-app-site-association names the owner, repository
and repository pages the iOS app routes, and excludes settings, forms,
downloads, raw files, feeds and the API. Unset, the route is not
registered.

Ref #310

Layout: unified · split

internal/config/config.go +4
@@ -130,6 +130,10 @@ type Web struct {
130130 // PrivacyNotice is operator-provided text shown on /privacy under the
131131 // fixed project-level statement. Plain text; blank paragraphs split.
132132 PrivacyNotice string `toml:"privacy_notice"`
133 // AppleAppIDs are the iOS apps (TEAMID.bundle.id) that may open this
134 // instance's links, served in /.well-known/apple-app-site-association.
135 // Empty leaves the route unregistered.
136 AppleAppIDs []string `toml:"apple_app_ids"`
133137}
134138
135139type Registration struct {
internal/httpd/aasa.go added +69
@@ -0,0 +1,69 @@
1package httpd
2
3import (
4 "encoding/json"
5 "net/http"
6)
7
8// aasaComponent is one applinks path pattern. iOS takes the first
9// component that matches; * matches any run of characters, slashes
10// included, and ? matches one.
11type aasaComponent struct {
12 Path string `json:"/"`
13 Exclude bool `json:"exclude,omitempty"`
14}
15
16// aasaComponents lists the pages the iOS app opens. Web-only pages come
17// first as exclusions, then the repository sections the app has screens
18// for; anything else of three or more segments stays in the browser,
19// and what is left is an owner or a repository.
20var aasaComponents = func() []aasaComponent {
21 var c []aasaComponent
22 for _, p := range []string{
23 "/.well-known/*", "/static/*", "/api/*", "/admin", "/admin/*",
24 "/settings", "/settings/*", "/login", "/logout", "/register",
25 "/new", "/explore", "/search", "/healthz", "/privacy", "/favicon.svg",
26 "/*/activity.atom", "/*/*/releases.atom", "/*/*/log.atom", "/*/*/log.atom/*",
27 "/*/-/queries", "/*/-/queries/*", "/*/-/snippets/new", "/*/-/snippets/*/raw/*",
28 "/*/*/settings", "/*/*/fork", "/*/*/search", "/*/*/symbols",
29 "/*/*/issues/new", "/*/*/mrs/new", "/*/*/mrs/*/range-diff",
30 "/*/*/edit/*", "/*/*/raw/*", "/*/*/archive/*", "/*/*/releases/download/*",
31 "/*/*/badge/*", "/*/*/wiki/_raw/*", "/*/*/compare/*", "/*/*/info/*",
32 } {
33 c = append(c, aasaComponent{Path: p, Exclude: true})
34 }
35 for _, p := range []string{
36 "/bookmarks", "/notifications",
37 "/*/-/repositories", "/*/-/bookmarks", "/*/-/people", "/*/-/labels",
38 "/*/-/milestones", "/*/-/snippets", "/*/-/snippets/*",
39 "/*/*/issues", "/*/*/issues/*", "/*/*/mrs", "/*/*/mrs/*",
40 "/*/*/builds", "/*/*/builds/*", "/*/*/tree/*", "/*/*/blob/*",
41 "/*/*/blame/*", "/*/*/commit/*", "/*/*/log", "/*/*/log/*",
42 "/*/*/refs", "/*/*/compare", "/*/*/releases", "/*/*/milestones",
43 "/*/*/labels", "/*/*/wiki", "/*/*/wiki/*",
44 } {
45 c = append(c, aasaComponent{Path: p})
46 }
47 return append(c,
48 aasaComponent{Path: "/*/*/?*", Exclude: true},
49 aasaComponent{Path: "/?*"},
50 )
51}()
52
53// appleAppSiteAssociation serves the file iOS fetches to decide which
54// links open the app ([web] apple_app_ids). Apple requires JSON at this
55// exact path with no redirect.
56func (s *Server) appleAppSiteAssociation(w http.ResponseWriter, r *http.Request) {
57 type detail struct {
58 AppIDs []string `json:"appIDs"`
59 Components []aasaComponent `json:"components"`
60 }
61 var doc struct {
62 Applinks struct {
63 Details []detail `json:"details"`
64 } `json:"applinks"`
65 }
66 doc.Applinks.Details = []detail{{AppIDs: s.cfg.Web.AppleAppIDs, Components: aasaComponents}}
67 w.Header().Set("Content-Type", "application/json")
68 json.NewEncoder(w).Encode(doc)
69}
internal/httpd/aasa_test.go added +206
@@ -0,0 +1,206 @@
1package httpd
2
3import (
4 "encoding/json"
5 "regexp"
6 "strings"
7 "testing"
8
9 "gitbay.org/gitbay/internal/config"
10)
11
12// aasaGlob matches one applinks pattern: * is any run of characters,
13// slashes included, and ? is one character.
14func aasaGlob(pat, s string) bool {
15 if pat == "" {
16 return s == ""
17 }
18 switch pat[0] {
19 case '*':
20 for i := 0; i <= len(s); i++ {
21 if aasaGlob(pat[1:], s[i:]) {
22 return true
23 }
24 }
25 return false
26 case '?':
27 return s != "" && aasaGlob(pat[1:], s[1:])
28 }
29 return s != "" && s[0] == pat[0] && aasaGlob(pat[1:], s[1:])
30}
31
32// opensApp applies a component list the way iOS does: the first match
33// decides, and a path nothing matches stays in the browser.
34func opensApp(comps []aasaComponent, p string) bool {
35 for _, c := range comps {
36 if aasaGlob(c.Path, p) {
37 return !c.Exclude
38 }
39 }
40 return false
41}
42
43func TestAppleAppSiteAssociation(t *testing.T) {
44 s := plainServer()
45 s.cfg.Web.AppleAppIDs = []string{"ZCNAX3VL9D.org.gitbay.gitbay"}
46 w := get(t, s.Handler(), "/.well-known/apple-app-site-association", nil)
47 if w.Code != 200 {
48 t.Fatalf("status %d", w.Code)
49 }
50 if ct := w.Header().Get("Content-Type"); ct != "application/json" {
51 t.Errorf("content type %q", ct)
52 }
53 var doc struct {
54 Applinks struct {
55 Details []struct {
56 AppIDs []string `json:"appIDs"`
57 Components []aasaComponent `json:"components"`
58 } `json:"details"`
59 } `json:"applinks"`
60 }
61 if err := json.Unmarshal(w.Body.Bytes(), &doc); err != nil {
62 t.Fatalf("body is not JSON: %v\n%s", err, w.Body.String())
63 }
64 if len(doc.Applinks.Details) != 1 || len(doc.Applinks.Details[0].AppIDs) != 1 ||
65 doc.Applinks.Details[0].AppIDs[0] != "ZCNAX3VL9D.org.gitbay.gitbay" {
66 t.Fatalf("details: %+v", doc.Applinks.Details)
67 }
68 comps := doc.Applinks.Details[0].Components
69 for _, p := range []string{
70 "/krz",
71 "/krz/-/repositories",
72 "/krz/-/snippets",
73 "/krz/-/snippets/abc123",
74 "/krz/-/labels",
75 "/krz/-/milestones",
76 "/krz/gitbay",
77 "/krz/gitbay/issues",
78 "/krz/gitbay/issues/12",
79 "/krz/gitbay/mrs",
80 "/krz/gitbay/mrs/7",
81 "/krz/gitbay/builds",
82 "/krz/gitbay/builds/1510",
83 "/krz/gitbay/tree/main/internal/httpd",
84 "/krz/gitbay/blob/main/README.org",
85 "/krz/gitbay/blame/main/README.org",
86 "/krz/gitbay/commit/d0c63c4",
87 "/krz/gitbay/log",
88 "/krz/gitbay/log/main",
89 "/krz/gitbay/refs",
90 "/krz/gitbay/compare",
91 "/krz/gitbay/releases",
92 "/krz/gitbay/milestones",
93 "/krz/gitbay/labels",
94 "/krz/gitbay/wiki",
95 "/krz/gitbay/wiki/Architecture/00-Overview",
96 "/krz/gitbay/blob/main/feed.atom",
97 "/bookmarks",
98 "/notifications",
99 } {
100 if !opensApp(comps, p) {
101 t.Errorf("%s does not open the app", p)
102 }
103 }
104 for _, p := range []string{
105 "/",
106 "/.well-known/apple-app-site-association",
107 "/static/style.css",
108 "/static/fonts/x.woff2",
109 "/favicon.svg",
110 "/api/v1/read",
111 "/admin",
112 "/admin/users",
113 "/settings",
114 "/settings/export",
115 "/login",
116 "/logout",
117 "/register",
118 "/new",
119 "/explore",
120 "/search",
121 "/healthz",
122 "/privacy",
123 "/krz/activity.atom",
124 "/krz/-/queries",
125 "/krz/-/snippets/new",
126 "/krz/-/snippets/abc123/raw/a.txt",
127 "/krz/gitbay/settings",
128 "/krz/gitbay/fork",
129 "/krz/gitbay/search",
130 "/krz/gitbay/symbols",
131 "/krz/gitbay/issues/new",
132 "/krz/gitbay/mrs/new",
133 "/krz/gitbay/mrs/7/range-diff",
134 "/krz/gitbay/edit/main/README.org",
135 "/krz/gitbay/raw/main/README.org",
136 "/krz/gitbay/archive/main.tar.gz",
137 "/krz/gitbay/releases/download/v1.0.0/gitbay.tar.gz",
138 "/krz/gitbay/releases.atom",
139 "/krz/gitbay/log.atom",
140 "/krz/gitbay/badge/build.svg",
141 "/krz/gitbay/wiki/_raw/diagram.svg",
142 "/krz/gitbay/compare/main...aasa",
143 "/krz/gitbay/info/refs",
144 "/krz/gitbay/info/lfs/objects/batch",
145 "/krz/gitbay/raw/main/docs/tree/a",
146 "/krz/gitbay/log.atom/main",
147 } {
148 if opensApp(comps, p) {
149 t.Errorf("%s opens the app", p)
150 }
151 }
152}
153
154// With no app configured the file does not exist.
155func TestAppleAppSiteAssociationOff(t *testing.T) {
156 for _, r := range plainServer().Routes() {
157 if r.Pattern == "/.well-known/apple-app-site-association" {
158 t.Fatal("route registered with no apple_app_ids")
159 }
160 }
161}
162
163// appRoutes are the GET patterns the iOS app has a screen for. Every
164// other page stays in the browser, so a new route is classified here or
165// this test fails.
166var appRoutes = map[string]bool{
167 "/bookmarks": true, "/notifications": true,
168 "/{owner}": true, "/{owner}/-/repositories": true, "/{owner}/-/bookmarks": true,
169 "/{owner}/-/people": true, "/{owner}/-/labels": true, "/{owner}/-/milestones": true,
170 "/{owner}/-/snippets": true, "/{owner}/-/snippets/{id}": true,
171 "/{owner}/{repo}": true,
172 "/{owner}/{repo}/issues": true, "/{owner}/{repo}/issues/{n}": true,
173 "/{owner}/{repo}/mrs": true, "/{owner}/{repo}/mrs/{n}": true,
174 "/{owner}/{repo}/builds": true, "/{owner}/{repo}/builds/{n}": true,
175 "/{owner}/{repo}/tree/{ref}/{path...}": true, "/{owner}/{repo}/blob/{ref}/{path...}": true,
176 "/{owner}/{repo}/blame/{ref}/{path...}": true, "/{owner}/{repo}/commit/{sha}": true,
177 "/{owner}/{repo}/log": true, "/{owner}/{repo}/log/{ref}": true,
178 "/{owner}/{repo}/refs": true, "/{owner}/{repo}/compare": true,
179 "/{owner}/{repo}/releases": true, "/{owner}/{repo}/milestones": true,
180 "/{owner}/{repo}/labels": true, "/{owner}/{repo}/wiki": true,
181 "/{owner}/{repo}/wiki/{page...}": true,
182}
183
184func TestAppleAppSiteAssociationCoversEveryRoute(t *testing.T) {
185 cfg := config.Default()
186 cfg.Web.Mode = "accounts"
187 cfg.Registration.Mode = "open"
188 cfg.API.Enabled = true
189 s := New(cfg, nil, nil)
190 wild := regexp.MustCompile(`\{[a-z]+(\.\.\.)?\}`)
191 for _, r := range s.Routes() {
192 if r.Method != "GET" {
193 continue
194 }
195 pat := strings.TrimSuffix(r.Pattern, "{$}")
196 p := wild.ReplaceAllStringFunc(pat, func(w string) string {
197 if strings.HasSuffix(w, "...}") {
198 return "a/b"
199 }
200 return "x"
201 })
202 if got := opensApp(aasaComponents, p); got != appRoutes[r.Pattern] {
203 t.Errorf("%s (%s): opens app = %v", r.Pattern, p, got)
204 }
205 }
206}
internal/httpd/routes.go +3
@@ -57,6 +57,9 @@ func (s *Server) Routes() []Route {
5757 for _, f := range images {
5858 routes = append(routes, Route{Method: "GET", Pattern: "/static/img/" + f.Name(), Handler: s.image})
5959 }
60 if len(s.cfg.Web.AppleAppIDs) > 0 {
61 routes = append(routes, Route{Method: "GET", Pattern: "/.well-known/apple-app-site-association", Handler: s.appleAppSiteAssociation})
62 }
6063 routes = append(routes,
6164 Route{Method: "GET", Pattern: "/favicon.svg", Handler: s.favicon},
6265 Route{Method: "GET", Pattern: "/{owner}", Handler: s.ownerProfile},