httpd: serve apple-app-site-association !544
6 files changed, +297 −0
Layout: unified · split
.gitbay/wiki/Admin.org +6
| @@ -114,6 +114,12 @@ build page's live log arrives only when the build ends; | |||
| 114 | gitbay itself. | 114 | gitbay itself. |
| 115 | - =privacy_notice= — operator text shown on =/privacy= under the fixed | 115 | - =privacy_notice= — operator text shown on =/privacy= under the fixed |
| 116 | statement. | 116 | statement. |
| 117 | - =apple_app_ids= (empty) — iOS app IDs (=TEAMID.bundle.id=) allowed to | ||
| 118 | open this instance's links, served at | ||
| 119 | =/.well-known/apple-app-site-association=. Empty leaves the path a | ||
| 120 | 404. The gitbay iOS app is =ZCNAX3VL9D.org.gitbay.gitbay=; its | ||
| 121 | entitlement names gitbay.org only, so listing it elsewhere does | ||
| 122 | nothing. | ||
| 117 | 123 | ||
| 118 | ** [registration] | 124 | ** [registration] |
| 119 | - =mode= — =closed= (default) | =invite= | =open=. invite/open require | 125 | - =mode= — =closed= (default) | =invite= | =open=. invite/open require |
CHANGELOG.org +9
| @@ -4,6 +4,15 @@ Versioning follows semver from v0.1.0. Database migrations run | |||
| 4 | automatically on daemon start; upgrade notes appear per release when | 4 | automatically on daemon start; upgrade notes appear per release when |
| 5 | anything beyond "replace the binary and restart" is needed. | 5 | anything beyond "replace the binary and restart" is needed. |
| 6 | 6 | ||
| 7 | * Unreleased | ||
| 8 | |||
| 9 | - =[web] apple_app_ids= serves =/.well-known/apple-app-site-association= | ||
| 10 | as JSON, so links to this instance open in the listed iOS apps | ||
| 11 | (=TEAMID.bundle.id=). The file names owners, repositories and the | ||
| 12 | repository pages the gitbay iOS app has screens for; settings, forms, | ||
| 13 | downloads, raw files, feeds and the API stay in the browser. Empty, | ||
| 14 | the default, leaves the path a 404. (#310) | ||
| 15 | |||
| 7 | * v1.40.1 — 2026-09-29 | 16 | * v1.40.1 — 2026-09-29 |
| 8 | 17 | ||
| 9 | A concurrency limit for pushes, and =repo download= under the pack | 18 | A concurrency limit for pushes, and =repo download= under the pack |
internal/config/config.go +4
| @@ -130,6 +130,10 @@ type Web struct { | |||
| 130 | // PrivacyNotice is operator-provided text shown on /privacy under the | 130 | // PrivacyNotice is operator-provided text shown on /privacy under the |
| 131 | // fixed project-level statement. Plain text; blank paragraphs split. | 131 | // fixed project-level statement. Plain text; blank paragraphs split. |
| 132 | PrivacyNotice string `toml:"privacy_notice"` | 132 | 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"` | ||
| 133 | } | 137 | } |
| 134 | 138 | ||
| 135 | type Registration struct { | 139 | type Registration struct { |
internal/httpd/aasa.go added +69
| @@ -0,0 +1,69 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 11 | type 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. | ||
| 20 | var 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. | ||
| 56 | func (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 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 14 | func 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. | ||
| 34 | func 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 | |||
| 43 | func 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. | ||
| 155 | func 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. | ||
| 166 | var 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 | |||
| 184 | func 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 { | |||
| 57 | for _, f := range images { | 57 | for _, f := range images { |
| 58 | routes = append(routes, Route{Method: "GET", Pattern: "/static/img/" + f.Name(), Handler: s.image}) | 58 | routes = append(routes, Route{Method: "GET", Pattern: "/static/img/" + f.Name(), Handler: s.image}) |
| 59 | } | 59 | } |
| 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 | } | ||
| 60 | routes = append(routes, | 63 | routes = append(routes, |
| 61 | Route{Method: "GET", Pattern: "/favicon.svg", Handler: s.favicon}, | 64 | Route{Method: "GET", Pattern: "/favicon.svg", Handler: s.favicon}, |
| 62 | Route{Method: "GET", Pattern: "/{owner}", Handler: s.ownerProfile}, | 65 | Route{Method: "GET", Pattern: "/{owner}", Handler: s.ownerProfile}, |