package oci import ( "net/http" "strings" "github.com/go-chi/chi/v5" ) // ociPathInfo holds the parsed components of an OCI API path. type ociPathInfo struct { name string // repository name (may contain slashes) kind string // "manifests", "blobs", or "tags" reference string // tag, digest, or "list" } // parseOCIPath extracts the repository name and operation from an OCI path. // The path should NOT include the /v2/ prefix. // Examples: // // "myrepo/manifests/latest" -> {name:"myrepo", kind:"manifests", reference:"latest"} // "org/team/app/blobs/sha256:abc" -> {name:"org/team/app", kind:"blobs", reference:"sha256:abc"} // "myrepo/tags/list" -> {name:"myrepo", kind:"tags", reference:"list"} // "myrepo/blobs/uploads/" -> {name:"myrepo", kind:"uploads", reference:""} // "myrepo/blobs/uploads/uuid-here" -> {name:"myrepo", kind:"uploads", reference:"uuid-here"} func parseOCIPath(path string) (ociPathInfo, bool) { // Check for /tags/list suffix. if strings.HasSuffix(path, "/tags/list") { name := path[:len(path)-len("/tags/list")] if name == "" { return ociPathInfo{}, false } return ociPathInfo{name: name, kind: "tags", reference: "list"}, true } // Check for /blobs/uploads/ (must come before /blobs/). if idx := strings.LastIndex(path, "/blobs/uploads/"); idx >= 0 { name := path[:idx] uuid := path[idx+len("/blobs/uploads/"):] if name == "" { return ociPathInfo{}, false } return ociPathInfo{name: name, kind: "uploads", reference: uuid}, true } // Check for /blobs/uploads (trailing-slash-trimmed form for POST initiate). if strings.HasSuffix(path, "/blobs/uploads") { name := path[:len(path)-len("/blobs/uploads")] if name == "" { return ociPathInfo{}, false } return ociPathInfo{name: name, kind: "uploads", reference: ""}, true } // Check for /manifests/. if idx := strings.LastIndex(path, "/manifests/"); idx >= 0 { name := path[:idx] ref := path[idx+len("/manifests/"):] if name == "" || ref == "" { return ociPathInfo{}, false } return ociPathInfo{name: name, kind: "manifests", reference: ref}, true } // Check for /blobs/. if idx := strings.LastIndex(path, "/blobs/"); idx >= 0 { name := path[:idx] ref := path[idx+len("/blobs/"):] if name == "" || ref == "" { return ociPathInfo{}, false } return ociPathInfo{name: name, kind: "blobs", reference: ref}, true } return ociPathInfo{}, false } // Router returns a chi router for OCI Distribution Spec endpoints. // It should be mounted at /v2 on the parent router. func (h *Handler) Router() chi.Router { r := chi.NewRouter() // Catalog endpoint: GET /v2/_catalog r.Get("/_catalog", h.handleCatalog) // All other OCI endpoints use a catch-all to support multi-segment repo names. r.HandleFunc("/*", h.dispatch) return r } // dispatch routes requests to the appropriate handler based on the parsed path. func (h *Handler) dispatch(w http.ResponseWriter, r *http.Request) { // Get the path after /v2/ path := chi.URLParam(r, "*") if path == "" { writeOCIError(w, "NAME_UNKNOWN", http.StatusNotFound, "repository name required") return } info, ok := parseOCIPath(path) if !ok { writeOCIError(w, "NAME_UNKNOWN", http.StatusNotFound, "invalid OCI path") return } switch info.kind { case "manifests": switch r.Method { case http.MethodGet: h.handleManifestGet(w, r, info.name, info.reference) case http.MethodHead: h.handleManifestHead(w, r, info.name, info.reference) case http.MethodPut: h.handleManifestPut(w, r, info.name, info.reference) case http.MethodDelete: h.handleManifestDelete(w, r, info.name, info.reference) default: w.Header().Set("Allow", "GET, HEAD, PUT, DELETE") writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed") } case "blobs": switch r.Method { case http.MethodGet: h.handleBlobGet(w, r, info.name, info.reference) case http.MethodHead: h.handleBlobHead(w, r, info.name, info.reference) case http.MethodDelete: h.handleBlobDelete(w, r, info.name, info.reference) default: w.Header().Set("Allow", "GET, HEAD, DELETE") writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed") } case "uploads": h.dispatchUpload(w, r, info.name, info.reference) case "tags": if r.Method != http.MethodGet { w.Header().Set("Allow", "GET") writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed") return } h.handleTagsList(w, r, info.name) default: writeOCIError(w, "NAME_UNKNOWN", http.StatusNotFound, "unknown operation") } } // dispatchUpload routes upload requests to the appropriate handler. func (h *Handler) dispatchUpload(w http.ResponseWriter, r *http.Request, repo, uuid string) { if uuid == "" { // POST /v2//blobs/uploads/ — initiate if r.Method != http.MethodPost { w.Header().Set("Allow", "POST") writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed") return } h.handleUploadInitiate(w, r, repo) return } // Operations on existing upload UUID. switch r.Method { case http.MethodPatch: h.handleUploadChunk(w, r, repo, uuid) case http.MethodPut: h.handleUploadComplete(w, r, repo, uuid) case http.MethodGet: h.handleUploadStatus(w, r, repo, uuid) case http.MethodDelete: h.handleUploadCancel(w, r, repo, uuid) default: w.Header().Set("Allow", "PATCH, PUT, GET, DELETE") writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed") } }