forgejo-tickets/internal/forgejo/client.go

892 lines
24 KiB
Go

package forgejo
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
type Client struct {
baseURL string
apiToken string
httpClient *http.Client
BotLogin string
}
func NewClient(baseURL, apiToken string) *Client {
return &Client{
baseURL: baseURL,
apiToken: apiToken,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// BaseURL returns the Forgejo base URL for proxy download purposes.
func (c *Client) BaseURL() string {
return c.baseURL
}
// APIToken returns the API token for authenticated proxy requests.
func (c *Client) APIToken() string {
return c.apiToken
}
type CreateIssueRequest struct {
Title string `json:"title"`
Body string `json:"body"`
Labels []int64 `json:"labels,omitempty"`
}
type EditIssueRequest struct {
State string `json:"state,omitempty"`
}
type Label struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
}
type Attachment struct {
ID int64 `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
DownloadURL string `json:"browser_download_url"`
Created time.Time `json:"created_at"`
}
type Issue struct {
Number int64 `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
Labels []Label `json:"labels"`
Assignees []APIUser `json:"assignees"`
DueDate *time.Time `json:"due_date"`
PinOrder int `json:"pin_order"`
Assets []Attachment `json:"assets"`
CreatedAt time.Time `json:"created_at"`
}
type CreateCommentRequest struct {
Body string `json:"body"`
}
type Comment struct {
ID int64 `json:"id"`
Body string `json:"body"`
User APIUser `json:"user"`
Assets []Attachment `json:"assets"`
CreatedAt time.Time `json:"created_at"`
}
type APIUser struct {
Login string `json:"login"`
FullName string `json:"full_name"`
Email string `json:"email"`
}
// DisplayName returns the best human-readable name for the user.
func (u APIUser) DisplayName() string {
if u.FullName != "" {
return u.FullName
}
// Ignore Forgejo's privacy placeholder emails
if u.Email != "" && !strings.HasSuffix(u.Email, "@noreply.localhost") {
return u.Email
}
return u.Login
}
type CommentView struct {
Body string
AuthorName string
IsTeam bool
CreatedAt time.Time
Attachments []Attachment
}
// TimelineEvent represents a single event in the issue timeline from Forgejo.
type TimelineEvent struct {
ID int64 `json:"id"`
Type string `json:"type"`
CreatedAt time.Time `json:"created_at"`
User APIUser `json:"user"`
Body string `json:"body"`
Label *Label `json:"label"`
Assignee *APIUser `json:"assignee"`
OldRef string `json:"old_ref"`
Assets []Attachment `json:"assets"`
}
// TimelineView is the template-friendly representation of a timeline event.
type TimelineView struct {
Type string // "comment", "status_change", "assignment", "label"
Body string
AuthorName string
IsTeam bool
EventText string
CreatedAt time.Time
Attachments []Attachment
CommentID int64 // needed so templates can generate comment-asset download URLs
}
// RelatedIssue represents a cross-referenced issue with visibility info.
type RelatedIssue struct {
Number int64
Title string
IsVisible bool
DisplayText string
TicketID string // customer-facing UUID if visible
}
// DeriveStatus maps Forgejo issue state + labels to app status.
func DeriveStatus(issue *Issue) string {
if issue.State == "closed" {
return "closed"
}
for _, l := range issue.Labels {
if l.Name == "in_progress" {
return "in_progress"
}
}
return "open"
}
// DerivePriority returns "high", "medium", "low", or "" from issue labels.
func DerivePriority(issue *Issue) string {
for _, l := range issue.Labels {
switch l.Name {
case "priority/high":
return "high"
case "priority/medium":
return "medium"
case "priority/low":
return "low"
}
}
return ""
}
// StripCommentFooter removes the "---\n*...*" footer from bot-posted comments
// and returns the clean body and the attribution (email).
func StripCommentFooter(body string) (string, string) {
sep := "\n\n---\n*"
idx := strings.LastIndex(body, sep)
if idx == -1 {
return body, ""
}
footer := body[idx+len(sep)-1:] // starts at "*..."
cleanBody := body[:idx]
if len(footer) > 1 && footer[len(footer)-1] == '*' {
inner := footer[1 : len(footer)-1]
parts := strings.SplitN(inner, ": ", 2)
if len(parts) == 2 {
return cleanBody, parts[1]
}
}
return cleanBody, ""
}
// BuildCommentViews transforms Forgejo comments into view models,
// identifying customer vs team comments.
func BuildCommentViews(comments []Comment, botLogin string) []CommentView {
var views []CommentView
for _, c := range comments {
body, email := StripCommentFooter(c.Body)
isCustomer := email != "" || (botLogin != "" && c.User.Login == botLogin)
if isCustomer {
authorName := email
if authorName == "" {
authorName = "Customer"
}
views = append(views, CommentView{
Body: body,
AuthorName: authorName,
IsTeam: false,
CreatedAt: c.CreatedAt,
Attachments: c.Assets,
})
} else {
views = append(views, CommentView{
Body: c.Body,
AuthorName: c.User.DisplayName(),
IsTeam: true,
CreatedAt: c.CreatedAt,
Attachments: c.Assets,
})
}
}
return views
}
// BuildTimelineViews converts raw timeline events into template-friendly views.
func BuildTimelineViews(events []TimelineEvent, botLogin string, isAdmin bool) []TimelineView {
var views []TimelineView
for _, e := range events {
switch e.Type {
case "comment":
body, email := StripCommentFooter(e.Body)
isCustomer := email != "" || (botLogin != "" && e.User.Login == botLogin)
authorName := e.User.DisplayName()
if isCustomer {
if email != "" {
authorName = email
} else {
authorName = "Customer"
}
}
views = append(views, TimelineView{
Type: "comment",
Body: body,
AuthorName: authorName,
IsTeam: !isCustomer,
CreatedAt: e.CreatedAt,
Attachments: e.Assets,
CommentID: e.ID,
})
case "close":
views = append(views, TimelineView{
Type: "status_change",
AuthorName: e.User.DisplayName(),
IsTeam: true,
EventText: "closed this ticket",
CreatedAt: e.CreatedAt,
})
case "reopen":
views = append(views, TimelineView{
Type: "status_change",
AuthorName: e.User.DisplayName(),
IsTeam: true,
EventText: "reopened this ticket",
CreatedAt: e.CreatedAt,
})
case "label":
if !isAdmin {
continue
}
action := "added label"
labelName := ""
if e.Label != nil {
labelName = e.Label.Name
}
views = append(views, TimelineView{
Type: "label",
AuthorName: e.User.DisplayName(),
IsTeam: true,
EventText: action + " " + labelName,
CreatedAt: e.CreatedAt,
})
case "assignees":
if !isAdmin {
continue
}
assigneeName := ""
if e.Assignee != nil {
assigneeName = e.Assignee.DisplayName()
}
views = append(views, TimelineView{
Type: "assignment",
AuthorName: e.User.DisplayName(),
IsTeam: true,
EventText: "assigned " + assigneeName,
CreatedAt: e.CreatedAt,
})
}
}
return views
}
var issueRefRegex = regexp.MustCompile(`(?:^|[^\w])#(\d+)\b`)
// ExtractIssueReferences finds all #N references in text.
func ExtractIssueReferences(text string) []int64 {
matches := issueRefRegex.FindAllStringSubmatch(text, -1)
seen := map[int64]bool{}
var refs []int64
for _, m := range matches {
n, err := strconv.ParseInt(m[1], 10, 64)
if err != nil || n == 0 {
continue
}
if !seen[n] {
seen[n] = true
refs = append(refs, n)
}
}
sort.Slice(refs, func(i, j int) bool { return refs[i] < refs[j] })
return refs
}
// SortIssuesPinnedFirst sorts issues with pinned (PinOrder > 0) first, then by CreatedAt desc.
func SortIssuesPinnedFirst(issues []Issue) {
sort.SliceStable(issues, func(i, j int) bool {
iPinned := issues[i].PinOrder > 0
jPinned := issues[j].PinOrder > 0
if iPinned != jPinned {
return iPinned
}
if iPinned && jPinned {
return issues[i].PinOrder < issues[j].PinOrder
}
return issues[i].CreatedAt.After(issues[j].CreatedAt)
})
}
func GenerateWebhookSecret() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// InitBotLogin calls GetAuthenticatedUser and stores the login on the client.
func (c *Client) InitBotLogin() error {
user, err := c.GetAuthenticatedUser()
if err != nil {
return err
}
c.BotLogin = user.Login
return nil
}
func (c *Client) GetLabel(owner, repo, labelName string) (*Label, error) {
listURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels?limit=50", c.baseURL, owner, repo)
httpReq, err := http.NewRequest("GET", listURL, nil)
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", "token "+c.apiToken)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("forgejo API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody))
}
var labels []Label
if err := json.NewDecoder(resp.Body).Decode(&labels); err != nil {
return nil, err
}
for _, l := range labels {
if l.Name == labelName {
return &l, nil
}
}
return nil, fmt.Errorf("label %q not found in %s/%s", labelName, owner, repo)
}
func (c *Client) CreateLabel(owner, repo, labelName, color string) (*Label, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels", c.baseURL, owner, repo)
body, err := json.Marshal(map[string]string{"name": labelName, "color": color})
if err != nil {
return nil, err
}
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "token "+c.apiToken)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("forgejo API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody))
}
var label Label
if err := json.NewDecoder(resp.Body).Decode(&label); err != nil {
return nil, err
}
return &label, nil
}
// CheckRepoPermission verifies the bot user has write (push) access to the repo.
func (c *Client) CheckRepoPermission(owner, repo string) error {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s", c.baseURL, owner, repo)
httpReq, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return err
}
httpReq.Header.Set("Authorization", "token "+c.apiToken)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("forgejo API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
Permissions struct {
Admin bool `json:"admin"`
Push bool `json:"push"`
Pull bool `json:"pull"`
} `json:"permissions"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
if !result.Permissions.Push {
return fmt.Errorf("bot user does not have write access to %s/%s — add it as a collaborator with Write permission", owner, repo)
}
return nil
}
// GetOrCreateLabel looks up a label by name, creating it if it doesn't exist.
func (c *Client) GetOrCreateLabel(owner, repo, labelName, color string) (*Label, error) {
label, err := c.GetLabel(owner, repo, labelName)
if err == nil {
return label, nil
}
return c.CreateLabel(owner, repo, labelName, color)
}
func (c *Client) CreateIssue(owner, repo string, req CreateIssueRequest) (*Issue, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", c.baseURL, owner, repo)
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "token "+c.apiToken)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("forgejo API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody))
}
var issue Issue
if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil {
return nil, err
}
return &issue, nil
}
func (c *Client) CreateComment(owner, repo string, issueNumber int64, req CreateCommentRequest) (*Comment, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", c.baseURL, owner, repo, issueNumber)
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "token "+c.apiToken)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("forgejo API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody))
}
var comment Comment
if err := json.NewDecoder(resp.Body).Decode(&comment); err != nil {
return nil, err
}
return &comment, nil
}
func (c *Client) GetIssue(owner, repo string, number int64) (*Issue, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", c.baseURL, owner, repo, number)
httpReq, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", "token "+c.apiToken)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("forgejo API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody))
}
var issue Issue
if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil {
return nil, err
}
return &issue, nil
}
func (c *Client) ListIssueComments(owner, repo string, number int64) ([]Comment, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", c.baseURL, owner, repo, number)
httpReq, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", "token "+c.apiToken)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("forgejo API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody))
}
var comments []Comment
if err := json.NewDecoder(resp.Body).Decode(&comments); err != nil {
return nil, err
}
return comments, nil
}
func (c *Client) ListIssues(owner, repo, state, labels string) ([]Issue, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", c.baseURL, owner, repo)
params := url.Values{}
params.Set("type", "issues")
params.Set("limit", "50")
if state != "" {
params.Set("state", state)
}
if labels != "" {
params.Set("labels", labels)
}
reqURL += "?" + params.Encode()
httpReq, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", "token "+c.apiToken)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("forgejo API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody))
}
var issues []Issue
if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil {
return nil, err
}
return issues, nil
}
func (c *Client) EditIssue(owner, repo string, number int64, req EditIssueRequest) error {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", c.baseURL, owner, repo, number)
body, err := json.Marshal(req)
if err != nil {
return err
}
httpReq, err := http.NewRequest("PATCH", reqURL, bytes.NewReader(body))
if err != nil {
return err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "token "+c.apiToken)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("forgejo API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
func (c *Client) AddLabels(owner, repo string, number int64, labelIDs []int64) error {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/labels", c.baseURL, owner, repo, number)
body, err := json.Marshal(map[string][]int64{"labels": labelIDs})
if err != nil {
return err
}
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewReader(body))
if err != nil {
return err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "token "+c.apiToken)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("forgejo API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
func (c *Client) RemoveLabel(owner, repo string, number int64, labelID int64) error {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/labels/%d", c.baseURL, owner, repo, number, labelID)
httpReq, err := http.NewRequest("DELETE", reqURL, nil)
if err != nil {
return err
}
httpReq.Header.Set("Authorization", "token "+c.apiToken)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("forgejo API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
func (c *Client) GetAuthenticatedUser() (*APIUser, error) {
reqURL := fmt.Sprintf("%s/api/v1/user", c.baseURL)
httpReq, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", "token "+c.apiToken)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("forgejo API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody))
}
var user APIUser
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, err
}
return &user, nil
}
// GetUser looks up a Forgejo user by username.
func (c *Client) GetUser(username string) (*APIUser, error) {
reqURL := fmt.Sprintf("%s/api/v1/users/%s", c.baseURL, username)
httpReq, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", "token "+c.apiToken)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("forgejo API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody))
}
var user APIUser
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, err
}
return &user, nil
}
// ListIssueTimeline fetches the timeline events for an issue.
func (c *Client) ListIssueTimeline(owner, repo string, number int64) ([]TimelineEvent, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/timeline", c.baseURL, owner, repo, number)
httpReq, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", "token "+c.apiToken)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("forgejo API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody))
}
var events []TimelineEvent
if err := json.NewDecoder(resp.Body).Decode(&events); err != nil {
return nil, err
}
return events, nil
}
// CreateIssueAttachment uploads a file to a Forgejo issue.
func (c *Client) CreateIssueAttachment(owner, repo string, number int64, filename string, reader io.Reader) (*Attachment, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/assets", c.baseURL, owner, repo, number)
return c.uploadAttachment(reqURL, filename, reader)
}
// CreateCommentAttachment uploads a file to a Forgejo comment.
func (c *Client) CreateCommentAttachment(owner, repo string, commentID int64, filename string, reader io.Reader) (*Attachment, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/comments/%d/assets", c.baseURL, owner, repo, commentID)
return c.uploadAttachment(reqURL, filename, reader)
}
func (c *Client) uploadAttachment(reqURL, filename string, reader io.Reader) (*Attachment, error) {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, err := writer.CreateFormFile("attachment", filename)
if err != nil {
return nil, fmt.Errorf("create form file: %w", err)
}
if _, err := io.Copy(part, reader); err != nil {
return nil, fmt.Errorf("copy file data: %w", err)
}
writer.Close()
httpReq, err := http.NewRequest("POST", reqURL, &buf)
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", writer.FormDataContentType())
httpReq.Header.Set("Authorization", "token "+c.apiToken)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("forgejo API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody))
}
var attachment Attachment
if err := json.NewDecoder(resp.Body).Decode(&attachment); err != nil {
return nil, err
}
return &attachment, nil
}
// GetAttachmentURL fetches attachment metadata from the API and returns the browser_download_url.
func (c *Client) GetAttachmentURL(apiURL string) (string, error) {
resp, err := c.ProxyDownload(apiURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("forgejo API returned %d fetching attachment metadata", resp.StatusCode)
}
var attachment Attachment
if err := json.NewDecoder(resp.Body).Decode(&attachment); err != nil {
return "", fmt.Errorf("failed to decode attachment metadata: %w", err)
}
if attachment.DownloadURL == "" {
return "", fmt.Errorf("attachment metadata has no browser_download_url")
}
return attachment.DownloadURL, nil
}
// ProxyDownload fetches a file from the given Forgejo URL with authentication and streams it back.
// The URL host must match the configured Forgejo base URL to prevent SSRF.
func (c *Client) ProxyDownload(downloadURL string) (*http.Response, error) {
parsed, err := url.Parse(downloadURL)
if err != nil {
return nil, fmt.Errorf("invalid download URL: %w", err)
}
base, err := url.Parse(c.baseURL)
if err != nil {
return nil, fmt.Errorf("invalid base URL: %w", err)
}
if parsed.Host != base.Host {
return nil, fmt.Errorf("download URL host %q does not match Forgejo host %q", parsed.Host, base.Host)
}
httpReq, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", "token "+c.apiToken)
return c.httpClient.Do(httpReq)
}