From 1e91a5fb7294dd0a4d6cac2d9c9ef5eac5a76fc1 Mon Sep 17 00:00:00 2001 From: BenVincent Date: Mon, 22 Jun 2026 23:52:20 +1000 Subject: [PATCH] feat: add local repository type with repo_type field (#49) Introduces repo_type (remote/local) as a separate axis from package_type so that any package type can be hosted locally. A terraform local repo is package_type=terraform + repo_type=local. - Remote model gains RepoType field (defaults to "remote") - Database schema adds repo_type column with migration for existing DBs - V1 proxy adds /api/v1/local/{name}/* route for serving local files - V2 upload via PUT /api/v2/remotes/{name}/files/{ns}/{type}/{file}.zip validates filename matches terraform-provider-{type}_{ver}_{os}_{arch}.zip and returns 409 on duplicate (no overwrites) - index.json and {version}.json are computed on-the-fly from uploaded zips rather than stored as separate files - V2 create validates repo_type and requires base_url only for remotes --------- Co-authored-by: Ben Vincent Reviewed-on: https://git.unkin.net/unkin/artifactapi/pulls/49 --- internal/api/v1/proxy.go | 86 ++++++++- internal/api/v2/local.go | 296 +++++++++++++++++++++++++++++++ internal/api/v2/remotes.go | 11 ++ internal/database/local_files.go | 105 +++++++++++ internal/database/postgres.go | 5 +- internal/database/remotes.go | 24 +-- internal/server/server.go | 10 +- pkg/models/remote.go | 34 +++- ui/src/api/types.ts | 1 + 9 files changed, 555 insertions(+), 17 deletions(-) create mode 100644 internal/api/v2/local.go create mode 100644 internal/database/local_files.go diff --git a/internal/api/v1/proxy.go b/internal/api/v1/proxy.go index e1d3e1e..5f0593b 100644 --- a/internal/api/v1/proxy.go +++ b/internal/api/v1/proxy.go @@ -6,28 +6,38 @@ import ( "io" "log/slog" "net/http" + "regexp" + "strings" "github.com/go-chi/chi/v5" + v2 "git.unkin.net/unkin/artifactapi/internal/api/v2" "git.unkin.net/unkin/artifactapi/internal/database" "git.unkin.net/unkin/artifactapi/internal/provider" "git.unkin.net/unkin/artifactapi/internal/proxy" + "git.unkin.net/unkin/artifactapi/internal/storage" "git.unkin.net/unkin/artifactapi/internal/virtual" + "git.unkin.net/unkin/artifactapi/pkg/models" ) +var semverRe = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.]+)?$`) + type ProxyHandler struct { engine *proxy.Engine virtualEngine *virtual.Engine db *database.DB + store *storage.S3 + local *v2.LocalHandler } -func NewProxyHandler(engine *proxy.Engine, virtualEngine *virtual.Engine, db *database.DB) *ProxyHandler { - return &ProxyHandler{engine: engine, virtualEngine: virtualEngine, db: db} +func NewProxyHandler(engine *proxy.Engine, virtualEngine *virtual.Engine, db *database.DB, store *storage.S3, local *v2.LocalHandler) *ProxyHandler { + return &ProxyHandler{engine: engine, virtualEngine: virtualEngine, db: db, store: store, local: local} } func (h *ProxyHandler) Routes() chi.Router { r := chi.NewRouter() r.Get("/remote/{remoteName}/*", h.handleProxy) + r.Get("/local/{localName}/*", h.handleLocal) r.Get("/virtual/{virtualName}/*", h.handleVirtual) return r } @@ -95,6 +105,78 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) { w.Write(body) } +func (h *ProxyHandler) handleLocal(w http.ResponseWriter, r *http.Request) { + localName := chi.URLParam(r, "localName") + path := chi.URLParam(r, "*") + + remote, err := h.db.GetRemote(r.Context(), localName) + if err != nil { + http.Error(w, fmt.Sprintf("local %q not found", localName), http.StatusNotFound) + return + } + + if remote.PackageType == models.PackageTerraform { + if h.serveTerraformMirror(w, r, remote, path) { + return + } + } + + h.serveLocalFile(w, r, localName, path) +} + +func (h *ProxyHandler) serveTerraformMirror(w http.ResponseWriter, r *http.Request, remote *models.Remote, path string) bool { + parts := strings.Split(path, "/") + if len(parts) < 3 { + return false + } + + namespace, typeName := parts[0], parts[1] + tail := parts[2] + + if tail == "index.json" { + h.local.ServeTerraformIndex(w, r, remote.Name, namespace, typeName) + return true + } + + if strings.HasSuffix(tail, ".json") { + version := strings.TrimSuffix(tail, ".json") + if semverRe.MatchString(version) { + h.local.ServeTerraformVersionDoc(w, r, remote.Name, namespace, typeName, version) + return true + } + } + + return false +} + +func (h *ProxyHandler) serveLocalFile(w http.ResponseWriter, r *http.Request, repoName, path string) { + file, err := h.db.GetLocalFile(r.Context(), repoName, path) + if err != nil { + slog.Error("local file lookup failed", "repo", repoName, "path", path, "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if file == nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + + s3Key := storage.BlobKey(file.ContentHash[len("sha256:"):]) + reader, info, err := h.store.Download(r.Context(), s3Key) + if err != nil { + slog.Error("local file download failed", "repo", repoName, "path", path, "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + defer reader.Close() + + w.Header().Set("Content-Type", info.ContentType) + w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size)) + w.Header().Set("X-Artifact-Source", "local") + w.WriteHeader(http.StatusOK) + io.Copy(w, reader) +} + func scheme(r *http.Request) string { if r.TLS != nil { return "https" diff --git a/internal/api/v2/local.go b/internal/api/v2/local.go new file mode 100644 index 0000000..d0b96eb --- /dev/null +++ b/internal/api/v2/local.go @@ -0,0 +1,296 @@ +package v2 + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strings" + + "github.com/go-chi/chi/v5" + + "git.unkin.net/unkin/artifactapi/internal/database" + "git.unkin.net/unkin/artifactapi/internal/storage" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +var providerZipRe = regexp.MustCompile( + `^terraform-provider-([a-zA-Z0-9_-]+)_([0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.]+)?)_([a-z0-9]+)_([a-z0-9]+)\.zip$`, +) + +type LocalHandler struct { + db *database.DB + store *storage.S3 + cas *storage.CAS +} + +func NewLocalHandler(db *database.DB, store *storage.S3) *LocalHandler { + return &LocalHandler{ + db: db, + store: store, + cas: storage.NewCAS(store), + } +} + +func (h *LocalHandler) Routes() chi.Router { + r := chi.NewRouter() + r.Put("/*", h.upload) + r.Get("/*", h.download) + r.Delete("/*", h.remove) + return r +} + +func (h *LocalHandler) upload(w http.ResponseWriter, r *http.Request) { + repoName := chi.URLParam(r, "name") + filePath := chi.URLParam(r, "*") + + if filePath == "" { + http.Error(w, "file path required", http.StatusBadRequest) + return + } + + remote, err := h.db.GetRemote(r.Context(), repoName) + if err != nil { + http.Error(w, fmt.Sprintf("remote %q not found", repoName), http.StatusNotFound) + return + } + if remote.RepoType != models.RepoTypeLocal { + http.Error(w, "upload only allowed for local repository types", http.StatusBadRequest) + return + } + + if remote.PackageType == models.PackageTerraform { + h.uploadTerraformProvider(w, r, remote, filePath) + return + } + + h.uploadGeneric(w, r, remote, filePath) +} + +func (h *LocalHandler) uploadTerraformProvider(w http.ResponseWriter, r *http.Request, remote *models.Remote, filePath string) { + parts := strings.Split(filePath, "/") + if len(parts) != 3 { + http.Error(w, "path must be {namespace}/{type}/{filename}.zip", http.StatusBadRequest) + return + } + namespace, typeName, filename := parts[0], parts[1], parts[2] + + m := providerZipRe.FindStringSubmatch(filename) + if m == nil { + http.Error(w, fmt.Sprintf( + "filename %q does not match terraform-provider-{type}_{version}_{os}_{arch}.zip", + filename, + ), http.StatusBadRequest) + return + } + fileType, version, os, arch := m[1], m[2], m[3], m[4] + + if fileType != typeName { + http.Error(w, fmt.Sprintf( + "provider type in filename %q does not match path type %q", + fileType, typeName, + ), http.StatusBadRequest) + return + } + + storagePath := fmt.Sprintf("%s/%s/%s", namespace, typeName, filename) + + existing, err := h.db.GetLocalFile(r.Context(), remote.Name, storagePath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if existing != nil { + http.Error(w, fmt.Sprintf( + "provider %s/%s version %s for %s_%s already exists; overwrites are not allowed", + namespace, typeName, version, os, arch, + ), http.StatusConflict) + return + } + + result, err := h.cas.Store(r.Context(), r.Body, "application/zip") + if err != nil { + http.Error(w, fmt.Sprintf("store failed: %v", err), http.StatusInternalServerError) + return + } + + if err := h.db.UpsertBlob(r.Context(), result.ContentHash, result.S3Key, result.SizeBytes, "application/zip"); err != nil { + http.Error(w, fmt.Sprintf("record blob: %v", err), http.StatusInternalServerError) + return + } + + if err := h.db.CreateLocalFile(r.Context(), remote.Name, storagePath, result.ContentHash); err != nil { + if errors.Is(err, database.ErrAlreadyExists) { + http.Error(w, fmt.Sprintf("file %q already exists; overwrites are not allowed", storagePath), http.StatusConflict) + return + } + http.Error(w, fmt.Sprintf("record file: %v", err), http.StatusInternalServerError) + return + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "namespace": namespace, + "type": typeName, + "version": version, + "os": os, + "arch": arch, + "content_hash": result.ContentHash, + "size_bytes": result.SizeBytes, + }) +} + +func (h *LocalHandler) uploadGeneric(w http.ResponseWriter, r *http.Request, remote *models.Remote, filePath string) { + existing, err := h.db.GetLocalFile(r.Context(), remote.Name, filePath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if existing != nil { + http.Error(w, fmt.Sprintf("file %q already exists; overwrites are not allowed", filePath), http.StatusConflict) + return + } + + contentType := "application/octet-stream" + if ct := r.Header.Get("Content-Type"); ct != "" && ct != "application/octet-stream" { + contentType = ct + } + + result, err := h.cas.Store(r.Context(), r.Body, contentType) + if err != nil { + http.Error(w, fmt.Sprintf("store failed: %v", err), http.StatusInternalServerError) + return + } + + if err := h.db.UpsertBlob(r.Context(), result.ContentHash, result.S3Key, result.SizeBytes, contentType); err != nil { + http.Error(w, fmt.Sprintf("record blob: %v", err), http.StatusInternalServerError) + return + } + + if err := h.db.CreateLocalFile(r.Context(), remote.Name, filePath, result.ContentHash); err != nil { + if errors.Is(err, database.ErrAlreadyExists) { + http.Error(w, fmt.Sprintf("file %q already exists; overwrites are not allowed", filePath), http.StatusConflict) + return + } + http.Error(w, fmt.Sprintf("record file: %v", err), http.StatusInternalServerError) + return + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "path": filePath, + "content_hash": result.ContentHash, + "size_bytes": result.SizeBytes, + }) +} + +func (h *LocalHandler) download(w http.ResponseWriter, r *http.Request) { + repoName := chi.URLParam(r, "name") + filePath := chi.URLParam(r, "*") + + file, err := h.db.GetLocalFile(r.Context(), repoName, filePath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if file == nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + + s3Key := storage.BlobKey(file.ContentHash[len("sha256:"):]) + reader, info, err := h.store.Download(r.Context(), s3Key) + if err != nil { + http.Error(w, fmt.Sprintf("download failed: %v", err), http.StatusInternalServerError) + return + } + defer reader.Close() + + w.Header().Set("Content-Type", info.ContentType) + w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size)) + w.WriteHeader(http.StatusOK) + io.Copy(w, reader) +} + +func (h *LocalHandler) remove(w http.ResponseWriter, r *http.Request) { + repoName := chi.URLParam(r, "name") + filePath := chi.URLParam(r, "*") + + if err := h.db.DeleteLocalFile(r.Context(), repoName, filePath); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +type terraformIndex struct { + Versions map[string]json.RawMessage `json:"versions"` +} + +type terraformVersionDoc struct { + Archives map[string]terraformArchive `json:"archives"` +} + +type terraformArchive struct { + URL string `json:"url"` + Hashes []string `json:"hashes,omitempty"` +} + +func (h *LocalHandler) ServeTerraformIndex(w http.ResponseWriter, r *http.Request, repoName, namespace, typeName string) { + prefix := fmt.Sprintf("%s/%s/", namespace, typeName) + files, err := h.db.ListLocalFilesByPrefix(r.Context(), repoName, prefix) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + versions := map[string]json.RawMessage{} + for _, f := range files { + filename := strings.TrimPrefix(f.FilePath, prefix) + m := providerZipRe.FindStringSubmatch(filename) + if m == nil { + continue + } + versions[m[2]] = json.RawMessage(`{}`) + } + + if len(versions) == 0 { + http.Error(w, "not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(terraformIndex{Versions: versions}) +} + +func (h *LocalHandler) ServeTerraformVersionDoc(w http.ResponseWriter, r *http.Request, repoName, namespace, typeName, version string) { + prefix := fmt.Sprintf("%s/%s/terraform-provider-%s_%s_", namespace, typeName, typeName, version) + files, err := h.db.ListLocalFilesByPrefix(r.Context(), repoName, prefix) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + archives := map[string]terraformArchive{} + for _, f := range files { + filename := strings.TrimPrefix(f.FilePath, fmt.Sprintf("%s/%s/", namespace, typeName)) + m := providerZipRe.FindStringSubmatch(filename) + if m == nil || m[2] != version { + continue + } + platform := m[3] + "_" + m[4] + archive := terraformArchive{URL: filename} + if f.ContentHash != "" { + archive.Hashes = []string{"zh:" + strings.TrimPrefix(f.ContentHash, "sha256:")} + } + archives[platform] = archive + } + + if len(archives) == 0 { + http.Error(w, "not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(terraformVersionDoc{Archives: archives}) +} diff --git a/internal/api/v2/remotes.go b/internal/api/v2/remotes.go index 6e767da..7b927ad 100644 --- a/internal/api/v2/remotes.go +++ b/internal/api/v2/remotes.go @@ -58,6 +58,17 @@ func (h *RemotesHandler) create(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("invalid package type: %q", remote.PackageType), http.StatusBadRequest) return } + if remote.RepoType == "" { + remote.RepoType = models.RepoTypeRemote + } + if !remote.RepoType.Valid() { + http.Error(w, fmt.Sprintf("invalid repo type: %q", remote.RepoType), http.StatusBadRequest) + return + } + if remote.RepoType == models.RepoTypeRemote && remote.BaseURL == "" { + http.Error(w, "base_url is required for remote repositories", http.StatusBadRequest) + return + } if err := h.db.CreateRemote(r.Context(), &remote); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/internal/database/local_files.go b/internal/database/local_files.go new file mode 100644 index 0000000..b5ba26a --- /dev/null +++ b/internal/database/local_files.go @@ -0,0 +1,105 @@ +package database + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type LocalFile struct { + ID int64 `json:"id"` + RepoName string `json:"repo_name"` + FilePath string `json:"file_path"` + ContentHash string `json:"content_hash"` + CreatedAt time.Time `json:"created_at"` +} + +var ErrAlreadyExists = fmt.Errorf("file already exists") + +func (db *DB) CreateLocalFile(ctx context.Context, repoName, filePath, contentHash string) error { + _, err := db.Pool.Exec(ctx, ` + INSERT INTO local_files (repo_name, file_path, content_hash) + VALUES ($1, $2, $3) + `, repoName, filePath, contentHash) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return ErrAlreadyExists + } + return err + } + return nil +} + +func (db *DB) GetLocalFile(ctx context.Context, repoName, filePath string) (*LocalFile, error) { + row := db.Pool.QueryRow(ctx, ` + SELECT id, repo_name, file_path, content_hash, created_at + FROM local_files + WHERE repo_name = $1 AND file_path = $2 + `, repoName, filePath) + + var f LocalFile + if err := row.Scan(&f.ID, &f.RepoName, &f.FilePath, &f.ContentHash, &f.CreatedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &f, nil +} + +func (db *DB) ListLocalFiles(ctx context.Context, repoName string, limit, offset int) ([]LocalFile, error) { + rows, err := db.Pool.Query(ctx, ` + SELECT id, repo_name, file_path, content_hash, created_at + FROM local_files + WHERE repo_name = $1 + ORDER BY file_path + LIMIT $2 OFFSET $3 + `, repoName, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var files []LocalFile + for rows.Next() { + var f LocalFile + if err := rows.Scan(&f.ID, &f.RepoName, &f.FilePath, &f.ContentHash, &f.CreatedAt); err != nil { + return nil, err + } + files = append(files, f) + } + return files, rows.Err() +} + +func (db *DB) ListLocalFilesByPrefix(ctx context.Context, repoName, prefix string) ([]LocalFile, error) { + rows, err := db.Pool.Query(ctx, ` + SELECT id, repo_name, file_path, content_hash, created_at + FROM local_files + WHERE repo_name = $1 AND file_path LIKE $2 + ORDER BY file_path + `, repoName, prefix+"%") + if err != nil { + return nil, err + } + defer rows.Close() + + var files []LocalFile + for rows.Next() { + var f LocalFile + if err := rows.Scan(&f.ID, &f.RepoName, &f.FilePath, &f.ContentHash, &f.CreatedAt); err != nil { + return nil, err + } + files = append(files, f) + } + return files, rows.Err() +} + +func (db *DB) DeleteLocalFile(ctx context.Context, repoName, filePath string) error { + _, err := db.Pool.Exec(ctx, `DELETE FROM local_files WHERE repo_name = $1 AND file_path = $2`, repoName, filePath) + return err +} diff --git a/internal/database/postgres.go b/internal/database/postgres.go index 94ea7be..54e0b67 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -42,7 +42,8 @@ func (db *DB) migrate() error { CREATE TABLE IF NOT EXISTS remotes ( name TEXT PRIMARY KEY, package_type TEXT NOT NULL, - base_url TEXT NOT NULL, + repo_type TEXT DEFAULT 'remote', + base_url TEXT NOT NULL DEFAULT '', description TEXT DEFAULT '', username TEXT DEFAULT '', password TEXT DEFAULT '', @@ -121,6 +122,8 @@ func (db *DB) migrate() error { ); CREATE INDEX IF NOT EXISTS idx_access_log_remote_time ON access_log(remote_name, created_at); + + ALTER TABLE remotes ADD COLUMN IF NOT EXISTS repo_type TEXT DEFAULT 'remote'; `) return err } diff --git a/internal/database/remotes.go b/internal/database/remotes.go index 4704c27..8cca8ba 100644 --- a/internal/database/remotes.go +++ b/internal/database/remotes.go @@ -6,7 +6,7 @@ import ( "git.unkin.net/unkin/artifactapi/pkg/models" ) -const remoteCols = `name, package_type, base_url, description, username, password, +const remoteCols = `name, package_type, repo_type, base_url, description, username, password, immutable_ttl, mutable_ttl, check_mutable, patterns, blocklist, mutable_patterns, immutable_patterns, ban_tags_enabled, ban_tags, @@ -15,7 +15,7 @@ const remoteCols = `name, package_type, base_url, description, username, passwor func scanRemote(scanner interface{ Scan(...any) error }, r *models.Remote) error { return scanner.Scan( - &r.Name, &r.PackageType, &r.BaseURL, &r.Description, &r.Username, &r.Password, + &r.Name, &r.PackageType, &r.RepoType, &r.BaseURL, &r.Description, &r.Username, &r.Password, &r.ImmutableTTL, &r.MutableTTL, &r.CheckMutable, &r.Patterns, &r.Blocklist, &r.MutablePatterns, &r.ImmutablePatterns, &r.BanTagsEnabled, &r.BanTags, @@ -54,15 +54,15 @@ func (db *DB) ListRemotes(ctx context.Context) ([]models.Remote, error) { func (db *DB) CreateRemote(ctx context.Context, r *models.Remote) error { _, err := db.Pool.Exec(ctx, ` INSERT INTO remotes ( - name, package_type, base_url, description, username, password, + name, package_type, repo_type, base_url, description, username, password, immutable_ttl, mutable_ttl, check_mutable, patterns, blocklist, mutable_patterns, immutable_patterns, ban_tags_enabled, ban_tags, quarantine_enabled, quarantine_days, stale_on_error, releases_remote, managed_by - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20) + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21) `, - r.Name, r.PackageType, r.BaseURL, r.Description, r.Username, r.Password, + r.Name, r.PackageType, r.RepoType, r.BaseURL, r.Description, r.Username, r.Password, r.ImmutableTTL, r.MutableTTL, r.CheckMutable, r.Patterns, r.Blocklist, r.MutablePatterns, r.ImmutablePatterns, r.BanTagsEnabled, r.BanTags, @@ -75,15 +75,15 @@ func (db *DB) CreateRemote(ctx context.Context, r *models.Remote) error { func (db *DB) UpdateRemote(ctx context.Context, r *models.Remote) error { _, err := db.Pool.Exec(ctx, ` UPDATE remotes SET - package_type=$2, base_url=$3, description=$4, username=$5, password=$6, - immutable_ttl=$7, mutable_ttl=$8, check_mutable=$9, - patterns=$10, blocklist=$11, mutable_patterns=$12, immutable_patterns=$13, - ban_tags_enabled=$14, ban_tags=$15, - quarantine_enabled=$16, quarantine_days=$17, stale_on_error=$18, - releases_remote=$19, managed_by=$20, updated_at=NOW() + package_type=$2, repo_type=$3, base_url=$4, description=$5, username=$6, password=$7, + immutable_ttl=$8, mutable_ttl=$9, check_mutable=$10, + patterns=$11, blocklist=$12, mutable_patterns=$13, immutable_patterns=$14, + ban_tags_enabled=$15, ban_tags=$16, + quarantine_enabled=$17, quarantine_days=$18, stale_on_error=$19, + releases_remote=$20, managed_by=$21, updated_at=NOW() WHERE name=$1 `, - r.Name, r.PackageType, r.BaseURL, r.Description, r.Username, r.Password, + r.Name, r.PackageType, r.RepoType, r.BaseURL, r.Description, r.Username, r.Password, r.ImmutableTTL, r.MutableTTL, r.CheckMutable, r.Patterns, r.Blocklist, r.MutablePatterns, r.ImmutablePatterns, r.BanTagsEnabled, r.BanTags, diff --git a/internal/server/server.go b/internal/server/server.go index f79da71..6690dab 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -91,7 +91,9 @@ func (s *Server) routes() chi.Router { r.Get("/health", s.handleHealth) r.Get("/", s.handleRoot) - proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db) + localHandler := v2.NewLocalHandler(s.db, s.store) + + proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, localHandler) r.Mount("/api/v1", proxyHandler.Routes()) remotesHandler := v2.NewRemotesHandler(s.db) @@ -114,6 +116,12 @@ func (s *Server) routes() chi.Router { r.Get("/", objHandler.Routes().ServeHTTP) r.Delete("/*", objHandler.Routes().ServeHTTP) }) + + r.Route("/remotes/{name}/files", func(r chi.Router) { + r.Put("/*", localHandler.Routes().ServeHTTP) + r.Get("/*", localHandler.Routes().ServeHTTP) + r.Delete("/*", localHandler.Routes().ServeHTTP) + }) }) return r diff --git a/pkg/models/remote.go b/pkg/models/remote.go index a8eeebc..6e380e8 100644 --- a/pkg/models/remote.go +++ b/pkg/models/remote.go @@ -1,10 +1,42 @@ package models -import "time" +import ( + "fmt" + "time" +) + +type RepoType string + +const ( + RepoTypeRemote RepoType = "remote" + RepoTypeLocal RepoType = "local" +) + +var validRepoTypes = map[RepoType]bool{ + RepoTypeRemote: true, + RepoTypeLocal: true, +} + +func (r RepoType) Valid() bool { + return validRepoTypes[r] +} + +func (r RepoType) String() string { + return string(r) +} + +func ParseRepoType(s string) (RepoType, error) { + rt := RepoType(s) + if !rt.Valid() { + return "", fmt.Errorf("unknown repo type: %q", s) + } + return rt, nil +} type Remote struct { Name string `json:"name"` PackageType PackageType `json:"package_type"` + RepoType RepoType `json:"repo_type"` BaseURL string `json:"base_url"` Description string `json:"description,omitempty"` Username string `json:"-"` diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 1ad6f74..e3efbd8 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -1,6 +1,7 @@ export interface Remote { name: string; package_type: string; + repo_type: string; base_url: string; description: string; username?: string;