264 lines
7.2 KiB
Go
264 lines
7.2 KiB
Go
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
|
|
}
|