Files
ipcr-forge/forge/gitea.go
T
Worph c961d74e0e IPCR Forge: org-root image names and per-repository push credentials
Short names: the public repositories of one Gitea organisation (IPCR_ROOT_ORG,
then the admin page; metadec on our boxes) are also published at the root of
the forge's name, so ipcr.localhost:4767/ipns/metadec.eth/ipcr-hello:1.0.0
works. Collisions (a user/org of that name, another owner's folder) are
skipped and explained.

Push credentials: the staging registry now sits behind forge-gate
(ipcr-forge-bridge gate), in the CI daemon's network namespace, with the
registry on a unix socket and no network. Each publishing repository logs in
as <owner>/<repo> with an IPCR_PUSH_TOKEN Actions secret the bridge sets, and
may push its own name only; IPCR logs in with a credential it generates.
Workflows need a docker/login-action step (example and page updated).

ipcr 1.4.0: allow.aliases with per-pass alias sync; unpublish/retag on every
path; IMPORT_AUTH_GENERATE; names this node publishes resolve via
name/resolve --nocache (Kubo's resolve cache lagged, and the generic resolve
ignores nocache).

bridge 0.3.0: gate, push credentials (rotate in the admin page), root
organisation setting, setup creates the org and the example in it; the
Radicle description carries the Gitea path (identical documents gave the same
RID); OAuth2 app updated only when hosts change, keeping the new secret.
Admin page: tabs, one-line keys, name-check feedback, per-tag docker pull
commands (IPFS, IPNS, ENS, short path first).

Verified on holyhorse (docs/forge.md).
2026-10-05 11:37:33 +00:00

153 lines
4.5 KiB
Go

package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// gitea is a minimal Gitea API client. The daemon authenticates with the token in tokenFile (read
// on every call: the setup step may write it after the service starts); the setup step with the
// admin's password instead (Gitea's token API accepts nothing else).
type gitea struct {
base string
owner string
tokenFile string
password string // setup only
http *http.Client
}
func newGitea(base, owner, tokenFile string) *gitea {
return &gitea{base: strings.TrimRight(base, "/"), owner: owner, tokenFile: tokenFile,
http: &http.Client{Timeout: 30 * time.Second}}
}
func (g *gitea) token() string {
b, _ := os.ReadFile(g.tokenFile)
return strings.TrimSpace(string(b))
}
func (g *gitea) hasToken() bool { return g.token() != "" }
// gitAuth is the header git sends to Gitea's HTTP endpoint: basic auth with the token as password,
// passed with http.extraHeader so it never appears in a URL, `ps` or a log.
func (g *gitea) gitAuth() string {
return "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(g.owner+":"+g.token()))
}
// call sends a request and decodes a JSON answer into out (when non-nil and the status is 2xx).
// It returns the status; a transport error is returned as an error with status 0.
func (g *gitea) call(method, path string, body, out any) (int, error) {
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return 0, err
}
rd = bytes.NewReader(b)
}
req, err := http.NewRequest(method, g.base+"/api/v1"+path, rd)
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if g.password != "" {
req.SetBasicAuth(g.owner, g.password)
} else {
req.Header.Set("Authorization", "token "+g.token())
}
resp, err := g.http.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if resp.StatusCode/100 != 2 {
msg := strings.TrimSpace(string(data))
if len(msg) > 300 {
msg = msg[:300]
}
return resp.StatusCode, fmt.Errorf("%s %s: %s %s", method, path, resp.Status, msg)
}
if out != nil && len(data) > 0 {
if err := json.Unmarshal(data, out); err != nil {
return resp.StatusCode, fmt.Errorf("%s %s: %w", method, path, err)
}
}
return resp.StatusCode, nil
}
type repo struct {
ID int64 `json:"id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
Private bool `json:"private"`
Internal bool `json:"internal"`
Fork bool `json:"fork"`
Mirror bool `json:"mirror"`
Empty bool `json:"empty"`
DefaultBranch string `json:"default_branch"`
Owner struct {
Login string `json:"login"`
// public | limited | private. Only a public owner's repositories are public.
Visibility string `json:"visibility"`
} `json:"owner"`
}
// public reports whether the repository may leave this server. The token is `public-only`, so
// Gitea already hides everything else; this is the second lock on the same door.
func (r *repo) public() bool {
return !r.Private && !r.Internal && (r.Owner.Visibility == "" || r.Owner.Visibility == "public")
}
// publicRepos lists every repository the token can see.
func (g *gitea) publicRepos() ([]repo, error) {
var all []repo
for page := 1; ; page++ {
var res struct {
OK bool `json:"ok"`
Data []repo `json:"data"`
}
q := url.Values{"limit": {"50"}, "page": {fmt.Sprint(page)}}
if _, err := g.call("GET", "/repos/search?"+q.Encode(), nil, &res); err != nil {
return nil, err
}
all = append(all, res.Data...)
if len(res.Data) < 50 {
return all, nil
}
}
}
// repoByID returns the repository, or nil when the token cannot see it (private, deleted).
func (g *gitea) repoByID(id int64) (*repo, error) {
var r repo
code, err := g.call("GET", fmt.Sprintf("/repositories/%d", id), nil, &r)
if code == http.StatusNotFound || code == http.StatusForbidden {
return nil, nil
}
if err != nil {
return nil, err
}
return &r, nil
}
// exists reports whether a Gitea user or organisation has this name (an admin-scoped token sees
// private ones too). On doubt (Gitea unreachable) it answers yes: a short path is then not used.
func (g *gitea) exists(name string) bool {
if g == nil {
return false
}
code, err := g.call("GET", "/users/"+url.PathEscape(name), nil, nil)
return code == http.StatusOK || (err != nil && code != http.StatusNotFound)
}