package forge import ( "encoding/json" "errors" "io" "log" "net/http" "net/url" "strconv" "strings" "time" ) const ( needNone = "none" needUser = "user" needWrite = "write" needGist = "gist" needAdmin = "admin" needPassword = "password" ) type apiRoute struct { Method string `json:"method"` Path string `json:"path"` Needs string `json:"needs"` About string `json:"about"` handle func(c *call) pattern []string } type call struct { s *Server w http.ResponseWriter r *http.Request user *User scope string via string args map[string]string } func (s *Server) api() []apiRoute { routes := []apiRoute{ {"GET", "/", needNone, "this index: every route, the scope it needs and what it does", nil, nil}, } routes = append(routes, s.peopleRoutes()...) routes = append(routes, s.repoRoutes()...) routes = append(routes, s.threadRoutes()...) routes = append(routes, s.gistRoutes()...) for i := range routes { routes[i].pattern = strings.Split(strings.Trim(routes[i].Path, "/"), "/") } routes[0].handle = func(c *call) { c.s.apiIndex(c, routes) } return routes } func (s *Server) apiIndex(c *call, routes []apiRoute) { if strings.Contains(c.r.Header.Get("Accept"), "text/html") { groups := map[string][]apiRoute{} var order []string for _, rt := range routes[1:] { key := apiGroup(rt.Path) if _, ok := groups[key]; !ok { order = append(order, key) } groups[key] = append(groups[key], rt) } type group struct { Name string Routes []apiRoute } var out []group for _, k := range order { out = append(out, group{k, groups[k]}) } s.render(c.w, c.r, "api", page{Title: "API", User: s.current(c.r), Data: map[string]any{"Groups": out, "Base": "https://" + s.host + "/api"}}) return } c.reply(200, map[string]any{ "site": s.name, "base": "https://" + s.host + "/api", "auth": "send a token as `Authorization: Bearer gp_…` or as the password of HTTP basic auth, tokens are made under /settings/tokens or with POST /api/me/tokens", "scopes": map[string]string{scopeAll: "everything the account may do", scopeRead: "reads only", scopeGist: "reads, plus writes to your own gists"}, "needs": map[string]string{needNone: "anyone, private data appears when authenticated", needUser: "any authenticated caller", needWrite: "a token scoped all, a password or a session", needGist: "a token scoped all or gist", needAdmin: "a site administrator with a token scoped all", needPassword: "a password or a session, never a token"}, "paging": "lists answer {items, total, next}, follow next or pass ?from= and ?limit= (at most 100)", "routes": routes, }) } func apiGroup(p string) string { parts := strings.Split(strings.Trim(p, "/"), "/") switch { case parts[0] == "~:owner" && len(parts) > 2 && (parts[2] == "issues" || parts[2] == "pulls"): return parts[2] case parts[0] == "~:owner" && len(parts) > 1 && parts[1] == ":repo": return "repositories" case parts[0] == "~:owner": return "accounts" } return parts[0] } func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("Access-Control-Allow-Origin", "*") h.Set("Access-Control-Expose-Headers", "Link") h.Set("Cache-Control", "no-store") if r.Method == "OPTIONS" { h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE") h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type") h.Set("Access-Control-Max-Age", "86400") w.WriteHeader(204) return } c := &call{s: s, w: w, r: r, args: map[string]string{}} rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api"), "/") var segs []string if rest != "" { segs = strings.Split(rest, "/") } method := r.Method if method == "HEAD" { method = "GET" } var allowed []string for _, rt := range s.routes { args, ok := matchRoute(rt.pattern, segs) if !ok { continue } if rt.Method != method { allowed = append(allowed, rt.Method) continue } c.args = args if !c.authenticate() || !c.permit(rt.Needs) { return } rt.handle(c) return } if len(allowed) > 0 { h.Set("Allow", strings.Join(allowed, ", ")) c.fail(405, "Use "+strings.Join(allowed, " or ")+" here") return } c.fail(404, "No such route, see /api for the list") } func matchRoute(pattern, segs []string) (map[string]string, bool) { if len(pattern) == 1 && pattern[0] == "" { return nil, len(segs) == 0 } args := map[string]string{} for i, p := range pattern { if strings.HasPrefix(p, "*") { args[p[1:]] = strings.Join(segs[min(i, len(segs)):], "/") return args, true } if i >= len(segs) { return nil, false } prefix, name, wild := strings.Cut(p, ":") if !wild { if segs[i] != p { return nil, false } continue } if !strings.HasPrefix(segs[i], prefix) || len(segs[i]) == len(prefix) { return nil, false } v, err := url.PathUnescape(segs[i][len(prefix):]) if err != nil { return nil, false } args[name] = v } return args, len(segs) == len(pattern) } func (c *call) sameOrigin() bool { switch c.r.Header.Get("Sec-Fetch-Site") { case "same-origin": return true case "": o := c.r.Header.Get("Origin") return o == "" || o == "https://"+c.s.host } return false } func (c *call) authenticate() bool { ip := clientIP(c.r) auth := c.r.Header.Get("Authorization") if auth == "" { if c.sameOrigin() { if u := c.s.current(c.r); u != nil { c.user, c.scope, c.via = u, scopeAll, "session" } } return true } if c.s.limiter.blocked(ip) { c.fail(429, "Too many failed attempts, try again later") return false } kind, value, _ := strings.Cut(auth, " ") switch strings.ToLower(kind) { case "bearer", "token": if u, scope, ok := c.s.store.UserByBearer(strings.TrimSpace(value)); ok { c.user, c.scope, c.via = &u, scope, "token" return true } case "basic": name, secret, _ := c.r.BasicAuth() if u, scope, ok := c.s.store.UserByToken(name, secret); ok { c.user, c.scope, c.via = &u, scope, "token" return true } if u, ok := c.s.store.CheckPassword(name, secret); ok { if c.s.store.Account(u.ID).TOTP != "" { c.fail(401, "This account uses two-factor authentication, use a token instead of the password") return false } c.user, c.scope, c.via = &u, scopeAll, "password" return true } } c.s.limiter.fail(ip) c.w.Header().Set("WWW-Authenticate", `Bearer realm="`+c.s.host+`"`) c.fail(401, "Those credentials are not valid") return false } func (c *call) permit(need string) bool { if need == needNone { return true } if c.user == nil { c.w.Header().Set("WWW-Authenticate", `Bearer realm="`+c.s.host+`"`) c.fail(401, "Authenticate with a token first") return false } switch need { case needWrite, needAdmin: if c.scope != scopeAll { c.fail(403, "This token is scoped to "+c.scope+" and cannot do that") return false } case needGist: if c.scope != scopeAll && c.scope != scopeGist { c.fail(403, "This token is scoped to "+c.scope+" and cannot do that") return false } case needPassword: if c.via == "token" { c.fail(403, "Tokens cannot manage tokens, use your password or the settings page") return false } } if need == needAdmin && !c.s.store.IsAdmin(c.user.ID) { c.fail(403, "Only administrators can do that") return false } if need != needUser && !c.s.writes.allow(strconv.FormatInt(c.user.ID, 10)) { c.w.Header().Set("Retry-After", "10") c.fail(429, "Too many changes, slow down") return false } return true } func (c *call) arg(name string) string { return c.args[name] } func (c *call) query(name string) string { return c.r.URL.Query().Get(name) } func (c *call) reply(code int, v any) { c.w.Header().Set("Content-Type", "application/json; charset=utf-8") c.w.WriteHeader(code) enc := json.NewEncoder(c.w) enc.SetIndent("", " ") enc.SetEscapeHTML(false) enc.Encode(v) } func (c *call) done() { c.w.WriteHeader(204) } func (c *call) fail(code int, msg string) { c.reply(code, map[string]any{"status": code, "error": msg}) } func (c *call) err(err error) { if errors.Is(err, ErrNotFound) { c.fail(404, "Not found") return } code := problemCode(err) msg := err.Error() if code >= 500 { c.s.logFailure(c.r, msg) msg = "Something went wrong" } c.fail(code, msg) } func (s *Server) logFailure(r *http.Request, msg string) { log.Printf("%s %s: %s", r.Method, r.URL.Path, msg) } func (c *call) decode(v any) bool { body := http.MaxBytesReader(c.w, c.r.Body, 8<<20) dec := json.NewDecoder(body) dec.DisallowUnknownFields() if err := dec.Decode(v); err != nil && !errors.Is(err, io.EOF) { var tooBig *http.MaxBytesError if errors.As(err, &tooBig) { c.fail(413, "The body is larger than 8 MiB") return false } c.fail(400, "The body is not the JSON this route expects: "+err.Error()) return false } return true } type listing struct { Items any `json:"items"` Total int `json:"total"` Next *string `json:"next"` } func paged[T any](c *call, items []T) { limit, err := strconv.Atoi(c.query("limit")) if err != nil || limit <= 0 { limit = 30 } limit = min(limit, 100) from, _ := strconv.Atoi(c.query("from")) from = max(from, 0) total := len(items) page := items[min(from, total):min(from+limit, total)] if page == nil { page = []T{} } out := listing{Items: page, Total: total} if from+limit < total { q := c.r.URL.Query() q.Set("from", strconv.Itoa(from+limit)) q.Set("limit", strconv.Itoa(limit)) next := "https://" + c.s.host + c.r.URL.Path + "?" + q.Encode() out.Next = &next c.w.Header().Set("Link", "<"+next+`>; rel="next"`) } c.reply(200, out) } func mapped[T, U any](items []T, f func(T) U) []U { out := make([]U, 0, len(items)) for _, it := range items { out = append(out, f(it)) } return out } func (c *call) account() (User, bool) { name := c.arg("owner") u, err := c.s.store.UserByName(name) if err != nil { if renamed, ok := c.s.store.Renamed(name); ok { c.fail(404, "~"+name+" is now ~"+renamed.Name) return User{}, false } c.fail(404, "No such account") return User{}, false } return u, true } func (c *call) repo() (Repo, bool) { owner, ok := c.account() if !ok { return Repo{}, false } repo, err := c.s.store.RepoByPath(owner.Name, c.arg("repo")) if err != nil { if moved, ok := c.s.store.Redirect(owner.Name, c.arg("repo")); ok && c.s.store.CanRead(moved, c.user) { c.fail(404, owner.Name+"/"+c.arg("repo")+" moved to "+moved.Path()) return Repo{}, false } c.fail(404, "No such repository") return Repo{}, false } if !c.s.store.CanRead(repo, c.user) { c.fail(404, "No such repository") return Repo{}, false } return repo, true } func (c *call) number() (int64, bool) { n, err := strconv.ParseInt(c.arg("number"), 10, 64) if err != nil || n <= 0 { c.fail(404, "Not found") return 0, false } return n, true } func (c *call) id(name string) (int64, bool) { n, err := strconv.ParseInt(c.arg(name), 10, 64) if err != nil || n <= 0 { c.fail(404, "Not found") return 0, false } return n, true } func iso(t time.Time) *string { if t.IsZero() { return nil } v := t.UTC().Format(time.RFC3339) return &v } func handle(name string) string { return "~" + name } func handles(users []User) []string { out := []string{} for _, u := range users { out = append(out, handle(u.Name)) } return out } func nonNil[T any](v []T) []T { if v == nil { return []T{} } return v }