4c321a089d
- 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
149 lines
4.8 KiB
Go
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})
|
|
}
|
|
}
|