package forgejo import ( "bytes" "context" "crypto/rand" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "strings" ) // Client talks to the Forgejo admin API for user management. type Client struct { baseURL string adminUser string adminPassword string httpClient *http.Client } // NewClient creates a Forgejo API client. func NewClient(baseURL, adminUser, adminPassword string) *Client { return &Client{ baseURL: baseURL, adminUser: adminUser, adminPassword: adminPassword, httpClient: &http.Client{}, } } // CreateForgejoUser creates a user in Forgejo and returns a personal access token. func (c *Client) CreateForgejoUser(ctx context.Context, username string) (string, error) { password, err := generatePassword(16) if err != nil { return "", fmt.Errorf("generate password: %w", err) } email := username + "@spinoff.dev" if err := c.createUser(ctx, username, email, password); err != nil { return "", fmt.Errorf("create forgejo user: %w", err) } token, err := c.createAccessToken(ctx, username, password, "dev-pod-access") if err != nil { _ = c.DeleteForgejoUser(ctx, username) return "", fmt.Errorf("create forgejo token: %w", err) } return token, nil } // DeleteForgejoUser removes a user from Forgejo including all their repos. func (c *Client) DeleteForgejoUser(ctx context.Context, username string) error { req, err := http.NewRequestWithContext(ctx, http.MethodDelete, fmt.Sprintf("%s/api/v1/admin/users/%s?purge=true", c.baseURL, username), nil) if err != nil { return fmt.Errorf("build request: %w", err) } req.SetBasicAuth(c.adminUser, c.adminPassword) resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("delete user request: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil } if resp.StatusCode != http.StatusNoContent { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("delete user: status %d: %s", resp.StatusCode, body) } return nil } func (c *Client) createUser(ctx context.Context, username, email, password string) error { payload := map[string]interface{}{ "username": username, "email": email, "password": password, "must_change_password": false, "visibility": "private", } body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/admin/users", bytes.NewReader(body)) if err != nil { return fmt.Errorf("build request: %w", err) } req.Header.Set("Content-Type", "application/json") req.SetBasicAuth(c.adminUser, c.adminPassword) resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("request: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusUnprocessableEntity { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("user already exists or invalid: %s", respBody) } if resp.StatusCode != http.StatusCreated { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("status %d: %s", resp.StatusCode, respBody) } return nil } func (c *Client) createAccessToken(ctx context.Context, username, password, tokenName string) (string, error) { payload := map[string]interface{}{ "name": tokenName, "scopes": []string{ "write:repository", "write:issue", "write:organization", "write:user", "write:notification", "write:misc", "write:activitypub", "write:package", "read:repository", "read:issue", "read:organization", "read:user", "read:notification", "read:misc", "read:activitypub", "read:package", }, } body, err := json.Marshal(payload) if err != nil { return "", fmt.Errorf("marshal: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("%s/api/v1/users/%s/tokens", c.baseURL, username), bytes.NewReader(body)) if err != nil { return "", fmt.Errorf("build request: %w", err) } req.Header.Set("Content-Type", "application/json") req.SetBasicAuth(username, password) resp, err := c.httpClient.Do(req) if err != nil { return "", fmt.Errorf("request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { respBody, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("status %d: %s", resp.StatusCode, respBody) } var result struct { SHA1 string `json:"sha1"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", fmt.Errorf("decode response: %w", err) } if result.SHA1 == "" { return "", fmt.Errorf("empty token in response") } return result.SHA1, nil } // GetRepoFileContent reads a file from a Forgejo repository. // Returns the raw file content. Returns nil with no error if the file does not exist. func (c *Client) GetRepoFileContent(ctx context.Context, owner, repo, filepath, ref string) ([]byte, error) { url := fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s", c.baseURL, owner, repo, filepath) if ref != "" { url += "?ref=" + ref } req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("build request: %w", err) } req.SetBasicAuth(c.adminUser, c.adminPassword) resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("request: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, nil } if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("status %d: %s", resp.StatusCode, body) } var result struct { Content string `json:"content"` Encoding string `json:"encoding"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("decode response: %w", err) } if result.Encoding != "base64" { return []byte(result.Content), nil } decoded, err := base64.StdEncoding.DecodeString(result.Content) if err != nil { // Forgejo sometimes uses standard base64 with newlines cleaned := strings.ReplaceAll(result.Content, "\n", "") decoded, err = base64.StdEncoding.DecodeString(cleaned) if err != nil { return nil, fmt.Errorf("decode base64 content: %w", err) } } return decoded, nil } // CreateIssueComment posts a comment on a Forgejo issue. func (c *Client) CreateIssueComment(ctx context.Context, owner, repo string, issueNumber int, body string) error { payload := map[string]string{"body": body} payloadBytes, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal: %w", err) } url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", c.baseURL, owner, repo, issueNumber) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payloadBytes)) if err != nil { return fmt.Errorf("build request: %w", err) } req.Header.Set("Content-Type", "application/json") req.SetBasicAuth(c.adminUser, c.adminPassword) resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("status %d: %s", resp.StatusCode, respBody) } return nil } func generatePassword(length int) (string, error) { b := make([]byte, length) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil }