Files
artifactapi/internal/api/v1/cargo.go
T
unkin-agent 4c321a089d
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Add local cargo registry type
- serve sparse index and config.json for local cargo repos from a cargo_index table
- add Web API publish, yank and unyank under the local repo api root
- drop index lines when the crate file is deleted
2026-10-16 23:35:35 +11:00

149 lines
4.8 KiB
Go

package v1
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/internal/provider/cargo"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// This file implements the cargo registry Web API (publish, yank, unyank) for
// local cargo repositories. config.json points api at /api/v1/local/<repo>, so
// cargo appends its own /api/v1/crates/... below that.
const maxCratePublish = 64 << 20
func (h *ProxyHandler) localCargoRemote(r *http.Request) (*models.Remote, bool) {
remote, err := h.db.GetRemote(r.Context(), chi.URLParam(r, "localName"))
if err != nil {
return nil, false
}
return remote, remote.RepoType == models.RepoTypeLocal && remote.PackageType == models.PackageCargo
}
func cargoError(w http.ResponseWriter, status int, format string, args ...any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{
"errors": []map[string]string{{"detail": fmt.Sprintf(format, args...)}},
})
}
func cargoOK(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(v)
}
func (h *ProxyHandler) cargoPublish(w http.ResponseWriter, r *http.Request) {
remote, ok := h.localCargoRemote(r)
if !ok {
cargoError(w, http.StatusNotFound, "no local cargo registry %q", chi.URLParam(r, "localName"))
return
}
ctx := r.Context()
body, err := io.ReadAll(io.LimitReader(r.Body, maxCratePublish+1))
if err != nil {
cargoError(w, http.StatusBadRequest, "read body: %v", err)
return
}
if len(body) > maxCratePublish {
cargoError(w, http.StatusRequestEntityTooLarge, "publish body exceeds %d bytes", maxCratePublish)
return
}
meta, crate, err := cargo.ParsePublish(body)
if err != nil {
cargoError(w, http.StatusBadRequest, "%v", err)
return
}
line, err := cargo.NewIndexLine(meta, crate)
if err != nil {
cargoError(w, http.StatusBadRequest, "%v", err)
return
}
existing, err := h.db.ListCargoIndexEntries(ctx, remote.Name, meta.Name)
if err != nil {
cargoError(w, http.StatusInternalServerError, "%v", err)
return
}
for _, e := range existing {
if e.Name != meta.Name {
cargoError(w, http.StatusBadRequest, "crate %q already exists as %q", meta.Name, e.Name)
return
}
if e.Version == meta.Vers {
cargoError(w, http.StatusConflict, "crate version `%s@%s` already exists", meta.Name, meta.Vers)
return
}
}
lineJSON, err := json.Marshal(line)
if err != nil {
cargoError(w, http.StatusInternalServerError, "%v", err)
return
}
path := cargo.CratePath(meta.Name, meta.Vers)
result, err := h.cas.Store(ctx, bytes.NewReader(crate), "application/gzip")
if err != nil {
cargoError(w, http.StatusInternalServerError, "store failed: %v", err)
return
}
if err := h.db.UpsertBlob(ctx, result.ContentHash, result.S3Key, result.SizeBytes, "application/gzip"); err != nil {
cargoError(w, http.StatusInternalServerError, "record blob: %v", err)
return
}
if err := h.db.CreateLocalFile(ctx, remote.Name, path, result.ContentHash); err != nil {
if errors.Is(err, database.ErrAlreadyExists) {
cargoError(w, http.StatusConflict, "crate version `%s@%s` already exists", meta.Name, meta.Vers)
return
}
cargoError(w, http.StatusInternalServerError, "record file: %v", err)
return
}
entry := &provider.CargoIndexEntry{RepoName: remote.Name, FilePath: path, Name: meta.Name, Version: meta.Vers, Line: string(lineJSON)}
if err := h.db.InsertCargoIndexEntry(ctx, entry); err != nil {
// Drop the file so a retry is not rejected as a duplicate.
_ = h.db.DeleteLocalFile(ctx, remote.Name, path)
cargoError(w, http.StatusInternalServerError, "record index: %v", err)
return
}
slog.Info("cargo: published", "repo", remote.Name, "crate", meta.Name, "version", meta.Vers)
cargoOK(w, map[string]any{"warnings": map[string][]string{"invalid_categories": {}, "invalid_badges": {}, "other": {}}})
}
func (h *ProxyHandler) cargoYank(yanked bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
remote, ok := h.localCargoRemote(r)
if !ok {
cargoError(w, http.StatusNotFound, "no local cargo registry %q", chi.URLParam(r, "localName"))
return
}
name, version := chi.URLParam(r, "crate"), chi.URLParam(r, "version")
found, err := h.db.SetCargoYanked(r.Context(), remote.Name, name, version, yanked)
if err != nil {
cargoError(w, http.StatusInternalServerError, "%v", err)
return
}
if !found {
cargoError(w, http.StatusNotFound, "crate version `%s@%s` not found", name, version)
return
}
slog.Info("cargo: yank updated", "repo", remote.Name, "crate", name, "version", version, "yanked", yanked)
cargoOK(w, map[string]bool{"ok": true})
}
}