forgejo-mcp/pkg/forgejo/rawhttp.go
Byte Flavour bd08d6147d
merge: resolve conflicts with upstream/main
Synchronized with latest upstream changes, including style fixes and
    CI hardening. Resolved a conflict in operation/issue/issue.go by
    maintaining the context-aware client pattern.
2026-05-24 14:18:59 +02:00

316 lines
9.6 KiB
Go

package forgejo
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"strings"
"time"
"codeberg.org/goern/forgejo-mcp/v2/pkg/flag"
"codeberg.org/goern/forgejo-mcp/v2/pkg/log"
)
// MaxInlineDownloadBytes caps inline base64 attachment payloads.
// Files at or above this size return metadata only; the caller is expected
// to fetch browser_download_url directly with the same auth token.
// See docs/plans/issue-attachments.md and Codeberg issue #106.
const MaxInlineDownloadBytes = 1 * 1024 * 1024
// Error sentinels callers can match with errors.Is.
var (
ErrUnauthorized = errors.New("unauthorized")
ErrNotFound = errors.New("not found")
ErrPayloadTooLarge = errors.New("payload exceeds inline cap")
)
// HTTPError carries the response status and a sanitised body snippet.
// It wraps one of the sentinels above when the status maps to one.
type HTTPError struct {
StatusCode int
Status string
Body string
Method string
URL string
wrapped error
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("%s %s: %s: %s", e.Method, e.URL, e.Status, e.Body)
}
func (e *HTTPError) Unwrap() error { return e.wrapped }
// rawHTTPClient is package-level so tests can swap timeouts; a single
// shared client lets keep-alives work across tool calls.
var rawHTTPClient = &http.Client{Timeout: 60 * time.Second}
// userAgent returns the configured UA, falling back to forgejo-mcp/<version>.
func userAgent() string {
if flag.UserAgent != "" {
return flag.UserAgent
}
return "forgejo-mcp/" + flag.Version
}
// resolveURL turns a path or absolute URL into an absolute URL string.
// API paths (e.g. "/repos/x/y/issues/1/assets") are prefixed with
// flag.URL + "/api/v1". Absolute URLs are returned verbatim.
func resolveURL(pathOrURL string) (string, error) {
if strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://") {
return pathOrURL, nil
}
base := strings.TrimRight(flag.URL, "/")
if base == "" {
return "", fmt.Errorf("flag.URL is empty; raw-HTTP helper needs a configured base URL")
}
if !strings.HasPrefix(pathOrURL, "/") {
pathOrURL = "/" + pathOrURL
}
// Forgejo REST API root.
return base + "/api/v1" + pathOrURL, nil
}
// resolveSameOriginURL is like resolveURL but for asset/download URLs that
// live outside the /api/v1 prefix (e.g. /attachments/{uuid}). Absolute URLs
// pass through; relative URLs hang off flag.URL with no /api/v1.
func resolveSameOriginURL(pathOrURL string) (string, error) {
if strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://") {
return pathOrURL, nil
}
base := strings.TrimRight(flag.URL, "/")
if base == "" {
return "", fmt.Errorf("flag.URL is empty; raw-HTTP helper needs a configured base URL")
}
if !strings.HasPrefix(pathOrURL, "/") {
pathOrURL = "/" + pathOrURL
}
return base + pathOrURL, nil
}
func setCommonHeaders(ctx context.Context, req *http.Request) {
token, ok := ctx.Value(TokenContextKey).(string)
if !ok || token == "" {
token = flag.Token
}
req.Header.Set("Authorization", "token "+token)
req.Header.Set("User-Agent", userAgent())
req.Header.Set("Accept", "application/json")
}
// doRequest sends req, returns the response, mapping common HTTP errors to
// the sentinels above. Caller owns response body close.
func doRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
start := time.Now()
resp, err := rawHTTPClient.Do(req)
duration := time.Since(start)
endpoint := req.URL.Path
if req.URL.RawQuery != "" {
endpoint += "?" + req.URL.RawQuery
}
if err != nil {
LogAPICall(ctx, req.Method, endpoint, duration, 0, err)
return nil, fmt.Errorf("%s %s: %w", req.Method, req.URL.String(), err)
}
LogAPICall(ctx, req.Method, endpoint, duration, resp.StatusCode, nil)
return resp, nil
}
// readBodySnippet reads up to 1 KiB of the body for inclusion in errors.
// It does not close the body.
func readBodySnippet(r io.Reader) string {
buf := make([]byte, 1024)
n, _ := io.ReadFull(io.LimitReader(r, 1024), buf)
return string(buf[:n])
}
// httpErrorFromResponse builds an HTTPError mapping status to a sentinel.
func httpErrorFromResponse(req *http.Request, resp *http.Response) *HTTPError {
body := readBodySnippet(resp.Body)
e := &HTTPError{
StatusCode: resp.StatusCode,
Status: resp.Status,
Body: body,
Method: req.Method,
URL: req.URL.String(),
}
switch {
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
e.wrapped = ErrUnauthorized
case resp.StatusCode == http.StatusNotFound:
e.wrapped = ErrNotFound
}
return e
}
// DoJSON performs an authenticated JSON request. Encodes body as JSON if
// non-nil; decodes 2xx response into out if non-nil. 204 responses are
// always success-with-no-body. 4xx/5xx return *HTTPError.
//
// The boolean isList signals "list endpoint": for those, 404 is treated
// as an empty list (not an error), matching Forgejo's habit of 404ing
// list endpoints when the parent entity has no children.
func DoJSON(ctx context.Context, method, pathOrURL string, body, out any) error {
full, err := resolveURL(pathOrURL)
if err != nil {
return err
}
var bodyReader io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal body: %w", err)
}
bodyReader = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, full, bodyReader)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
setCommonHeaders(ctx, req)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := doRequest(ctx, req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return httpErrorFromResponse(req, resp)
}
if resp.StatusCode == http.StatusNoContent || out == nil {
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("decode response: %w", err)
}
return nil
}
// DoJSONList is like DoJSON but treats 404 as "empty list" (no error,
// out left at its zero value).
func DoJSONList(ctx context.Context, method, pathOrURL string, out any) error {
err := DoJSON(ctx, method, pathOrURL, nil, out)
var he *HTTPError
if errors.As(err, &he) && he.StatusCode == http.StatusNotFound {
return nil
}
return err
}
// quoteEscaper mirrors mime/multipart's internal escaper for filenames.
var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
// DoMultipart uploads a single file part via multipart/form-data and
// decodes the JSON response into out (if non-nil).
func DoMultipart(ctx context.Context, method, pathOrURL, fieldName, filename, mimeType string, r io.Reader, out any) error {
full, err := resolveURL(pathOrURL)
if err != nil {
return err
}
body := &bytes.Buffer{}
mw := multipart.NewWriter(body)
h := textproto.MIMEHeader{}
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`,
quoteEscaper.Replace(fieldName), quoteEscaper.Replace(filename)))
if mimeType != "" {
h.Set("Content-Type", mimeType)
} else {
h.Set("Content-Type", "application/octet-stream")
}
part, err := mw.CreatePart(h)
if err != nil {
return fmt.Errorf("create multipart part: %w", err)
}
if _, err := io.Copy(part, r); err != nil {
return fmt.Errorf("copy file into part: %w", err)
}
if err := mw.Close(); err != nil {
return fmt.Errorf("close multipart writer: %w", err)
}
req, err := http.NewRequestWithContext(ctx, method, full, body)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
setCommonHeaders(ctx, req)
req.Header.Set("Content-Type", mw.FormDataContentType())
resp, err := doRequest(ctx, req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return httpErrorFromResponse(req, resp)
}
if resp.StatusCode == http.StatusNoContent || out == nil {
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("decode response: %w", err)
}
return nil
}
// DoRaw fetches bytes from a URL (absolute or relative-to-flag.URL with no
// /api/v1 prefix), adding the configured auth header. Caps the response at
// MaxInlineDownloadBytes; ErrPayloadTooLarge is returned if the body would
// exceed the cap. Returns body bytes + content type.
func DoRaw(ctx context.Context, pathOrURL string) ([]byte, string, error) {
full, err := resolveSameOriginURL(pathOrURL)
if err != nil {
return nil, "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
if err != nil {
return nil, "", fmt.Errorf("build request: %w", err)
}
setCommonHeaders(ctx, req)
// Don't constrain Accept here — the asset endpoint is binary.
resp, err := doRequest(ctx, req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, "", httpErrorFromResponse(req, resp)
}
// Read up to cap+1 to detect overflow.
limited := io.LimitReader(resp.Body, MaxInlineDownloadBytes+1)
buf, err := io.ReadAll(limited)
if err != nil {
return nil, "", fmt.Errorf("read body: %w", err)
}
if int64(len(buf)) > MaxInlineDownloadBytes {
return nil, "", ErrPayloadTooLarge
}
ct := resp.Header.Get("Content-Type")
return buf, ct, nil
}
// helper used by tests to validate URL construction directly.
func init() {
// Validate at init that net/url accepts our base format if set.
if flag.URL != "" {
if _, err := url.Parse(flag.URL); err != nil {
log.Errorf("flag.URL is not a valid URL: %v", err)
}
}
}