package docker import "encoding/json" import "errors" import "fmt" import "io" import "net/http" import "os" import "path/filepath" import "strconv" import "strings" import "sync" import "time" import "codit/internal/db" import "codit/internal/models" import "codit/internal/util" type AuthFunc func(username, password string) (bool, error) type HTTPServer struct { store *db.Store baseDir string auth AuthFunc logger *util.Logger } func NewHTTPServer(store *db.Store, baseDir string, auth AuthFunc, logger *util.Logger) *HTTPServer { return &HTTPServer{ store: store, baseDir: baseDir, auth: auth, logger: logger, } } func (s *HTTPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { var ok bool var userLabel string var username string var password string var status int var recorder *statusRecorder if s.auth != nil { username, password, ok = r.BasicAuth() if !ok { w.Header().Set("WWW-Authenticate", `Basic realm="docker"`) w.WriteHeader(http.StatusUnauthorized) return } ok, _ = s.auth(username, password) if !ok { w.Header().Set("WWW-Authenticate", `Basic realm="docker"`) w.WriteHeader(http.StatusUnauthorized) return } } w.Header().Set("Docker-Distribution-Api-Version", "registry/2.0") recorder = &statusRecorder{ResponseWriter: w, status: http.StatusOK} s.handle(recorder, r) status = recorder.status if s.logger != nil { userLabel = "-" if username != "" { userLabel = username } s.logger.Write("docker", util.LOG_INFO, "method=%s path=%s remote=%s user=%s status=%d", r.Method, r.URL.Path, r.RemoteAddr, userLabel, status) } } func (s *HTTPServer) handle(w http.ResponseWriter, r *http.Request) { var path string var action string var repoName string var rest string var repo models.Repo var project models.Project var imageName string var imagePath string var err error path = r.URL.Path if path == "/v2" || path == "/v2/" { w.WriteHeader(http.StatusOK) return } repoName, action, rest = parseV2Path(path) if action == "" { w.WriteHeader(http.StatusNotFound) return } if action == "catalog" { s.handleCatalog(w, r) return } repo, project, imageName, err = s.resolveRepo(repoName) _ = project if err != nil { w.WriteHeader(http.StatusNotFound) return } imagePath = ImagePath(repo.Path, imageName) switch action { case "tags": s.handleTags(w, r, repo, repoName, imagePath) case "manifest": s.handleManifest(w, r, repo, repoName, rest, imagePath) case "blob": s.handleBlob(w, r, repo, repoName, rest, imagePath) case "upload_start": s.handleUploadStart(w, r, repo, repoName, imagePath) case "upload": s.handleUpload(w, r, repo, repoName, rest, imagePath) default: w.WriteHeader(http.StatusNotFound) } } func parseV2Path(path string) (string, string, string) { var p string var idx int p = strings.TrimPrefix(path, "/v2/") if p == "" || p == "/v2" { return "", "", "" } if strings.HasPrefix(p, "_catalog") { return "", "catalog", "" } if strings.HasSuffix(p, "/tags/list") { p = strings.TrimSuffix(p, "/tags/list") p = strings.TrimSuffix(p, "/") if p == "" { return "", "", "" } return p, "tags", "" } idx = strings.Index(p, "/manifests/") if idx >= 0 { return p[:idx], "manifest", p[idx+len("/manifests/"):] } idx = strings.Index(p, "/blobs/uploads/") if idx >= 0 { if strings.HasSuffix(p, "/blobs/uploads/") { return p[:idx], "upload_start", "" } return p[:idx], "upload", p[idx+len("/blobs/uploads/"):] } if strings.HasSuffix(p, "/blobs/uploads") { p = strings.TrimSuffix(p, "/blobs/uploads") p = strings.TrimSuffix(p, "/") return p, "upload_start", "" } idx = strings.Index(p, "/blobs/") if idx >= 0 { return p[:idx], "blob", p[idx+len("/blobs/"):] } return "", "", "" } func (s *HTTPServer) resolveRepo(repoName string) (models.Repo, models.Project, string, error) { var parts []string var project models.Project var repo models.Repo var projectStorageID int64 var repoStorageID int64 var err error var slug string var name string var image string repoName = strings.Trim(repoName, "/") parts = strings.Split(repoName, "/") if len(parts) < 2 { return repo, project, "", errors.New("invalid repo name") } slug = parts[0] name = parts[1] if len(parts) > 2 { image = strings.Join(parts[2:], "/") } if IsReservedImagePath(image) { return repo, project, "", errors.New("invalid image name") } project, err = s.store.GetProjectBySlug(slug) if err != nil { return repo, project, "", err } repo, err = s.store.GetRepoByProjectNameType(project.ID, name, "docker") if err != nil { return repo, project, "", err } projectStorageID, repoStorageID, err = s.store.GetRepoStorageIDs(repo.ID) if err != nil { return repo, project, "", err } repo.Path = filepath.Join(s.baseDir, storageIDSegment(projectStorageID), storageIDSegment(repoStorageID)) return repo, project, image, nil } func storageIDSegment(id int64) string { return fmt.Sprintf("%016x", id) } func IsReservedImagePath(image string) bool { var cleaned string var parts []string var i int var part string if image == "" { return false } cleaned = strings.Trim(image, "/") if cleaned == "" { return false } parts = strings.Split(cleaned, "/") for i = 0; i < len(parts); i++ { part = parts[i] if part == ".root" || part == "blobs" || part == "uploads" || part == "oci-layout" || part == "index.json" { return true } } return false } func (s *HTTPServer) handleCatalog(w http.ResponseWriter, r *http.Request) { var repos []models.Repo var err error var names []string var i int var repo models.Repo var project models.Project var data []byte var response map[string][]string var images []string var j int var image string repos, err = s.store.ListAllRepos() if err != nil { w.WriteHeader(http.StatusInternalServerError) return } names = []string{} for i = 0; i < len(repos); i++ { repo = repos[i] if repo.Type != "docker" { continue } project, err = s.store.GetProject(repo.ProjectID) if err != nil { continue } images, err = ListImages(repo.Path) if err != nil { continue } if len(images) == 0 { names = append(names, project.Slug+"/"+repo.Name) continue } for j = 0; j < len(images); j++ { image = images[j] if image == "" { names = append(names, project.Slug+"/"+repo.Name) } else { names = append(names, project.Slug+"/"+repo.Name+"/"+image) } } } response = map[string][]string{"repositories": names} data, err = json.Marshal(response) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(data) } func (s *HTTPServer) handleTags(w http.ResponseWriter, r *http.Request, repo models.Repo, name string, imagePath string) { var tags []string var err error var response map[string]interface{} var data []byte tags, err = listTags(imagePath) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } response = map[string]interface{}{ "name": name, "tags": tags, } data, err = json.Marshal(response) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(data) } func (s *HTTPServer) handleManifest(w http.ResponseWriter, r *http.Request, repo models.Repo, name string, reference string, imagePath string) { var desc ociDescriptor var err error var digest string var data []byte var mediaType string var ok bool if reference == "" { w.WriteHeader(http.StatusBadRequest) return } if r.Method == http.MethodPut { data, err = io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } if r.Header.Get("Content-Type") != "" { mediaType = r.Header.Get("Content-Type") } else { mediaType = detectManifestMediaType(data) } digest = ComputeDigest(data) err = EnsureLayout(imagePath) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } ok, err = HasBlob(imagePath, digest) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } if !ok { err = WriteBlob(imagePath, digest, data) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } } desc = ociDescriptor{MediaType: mediaType, Digest: digest, Size: int64(len(data))} if !isDigestRef(reference) { err = withRepoLock(imagePath, func() error { return updateTag(imagePath, reference, desc) }) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } } w.Header().Set("Docker-Content-Digest", digest) w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, digest)) w.WriteHeader(http.StatusCreated) return } if r.Method != http.MethodGet && r.Method != http.MethodHead { w.WriteHeader(http.StatusMethodNotAllowed) return } desc, err = resolveManifest(imagePath, reference) if err != nil { if err == ErrNotFound { w.WriteHeader(http.StatusNotFound) return } w.WriteHeader(http.StatusInternalServerError) return } data, err = ReadBlob(imagePath, desc.Digest) if err != nil { if err == ErrNotFound { w.WriteHeader(http.StatusNotFound) return } w.WriteHeader(http.StatusInternalServerError) return } if desc.MediaType != "" { w.Header().Set("Content-Type", desc.MediaType) } w.Header().Set("Docker-Content-Digest", desc.Digest) if r.Method == http.MethodHead { w.Header().Set("Content-Length", strconv.FormatInt(int64(len(data)), 10)) w.WriteHeader(http.StatusOK) return } _, _ = w.Write(data) } func (s *HTTPServer) handleBlob(w http.ResponseWriter, r *http.Request, repo models.Repo, name string, digest string, imagePath string) { var data []byte var err error if digest == "" { w.WriteHeader(http.StatusBadRequest) return } if r.Method != http.MethodGet && r.Method != http.MethodHead { w.WriteHeader(http.StatusMethodNotAllowed) return } data, err = ReadBlob(imagePath, digest) if err != nil { if err == ErrNotFound { w.WriteHeader(http.StatusNotFound) return } w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set("Docker-Content-Digest", digest) w.Header().Set("Content-Type", "application/octet-stream") if r.Method == http.MethodHead { w.Header().Set("Content-Length", strconv.FormatInt(int64(len(data)), 10)) w.WriteHeader(http.StatusOK) return } _, _ = w.Write(data) } func (s *HTTPServer) handleUploadStart(w http.ResponseWriter, r *http.Request, repo models.Repo, name string, imagePath string) { var id string var err error var uploadPath string var uploadDir string var created *os.File if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } id, err = util.NewID() if err != nil { w.WriteHeader(http.StatusInternalServerError) return } uploadDir = filepath.Join(imagePath, "uploads") err = os.MkdirAll(uploadDir, 0o755) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } uploadPath = filepath.Join(uploadDir, id) _, err = os.Stat(uploadPath) if err == nil { w.WriteHeader(http.StatusConflict) return } created, err = os.Create(uploadPath) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } _ = created.Close() w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, id)) w.Header().Set("Docker-Upload-UUID", id) w.Header().Set("Range", "0-0") w.WriteHeader(http.StatusAccepted) } func (s *HTTPServer) handleUpload(w http.ResponseWriter, r *http.Request, repo models.Repo, name string, uploadID string, imagePath string) { var uploadDir string var uploadPath string var err error var file *os.File var size int64 var digest string var computed string var ok bool var data []byte var info os.FileInfo var appendFile *os.File var appendSize int64 if uploadID == "" { w.WriteHeader(http.StatusBadRequest) return } uploadDir = filepath.Join(imagePath, "uploads") uploadPath = filepath.Join(uploadDir, uploadID) if r.Method == http.MethodDelete { _ = os.Remove(uploadPath) w.WriteHeader(http.StatusNoContent) return } if r.Method == http.MethodPatch { file, err = os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { if os.IsNotExist(err) { w.WriteHeader(http.StatusNotFound) return } w.WriteHeader(http.StatusInternalServerError) return } defer file.Close() _, err = io.Copy(file, r.Body) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } info, err = file.Stat() if err == nil { size = info.Size() } w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uploadID)) w.Header().Set("Docker-Upload-UUID", uploadID) if size > 0 { w.Header().Set("Range", fmt.Sprintf("0-%d", size-1)) } w.WriteHeader(http.StatusAccepted) return } if r.Method == http.MethodPut { digest = r.URL.Query().Get("digest") if digest == "" { w.WriteHeader(http.StatusBadRequest) return } appendFile, err = os.OpenFile(uploadPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } appendSize, err = io.Copy(appendFile, r.Body) _ = appendSize _ = appendFile.Close() if err != nil { w.WriteHeader(http.StatusInternalServerError) return } file, err = os.Open(uploadPath) if err != nil { if os.IsNotExist(err) { w.WriteHeader(http.StatusNotFound) return } w.WriteHeader(http.StatusInternalServerError) return } defer file.Close() computed, size, err = computeDigestFromReader(file) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } if computed != digest { w.WriteHeader(http.StatusBadRequest) return } data, err = os.ReadFile(uploadPath) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } err = EnsureLayout(imagePath) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } ok, err = HasBlob(imagePath, digest) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } if !ok { err = WriteBlob(imagePath, digest, data) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } } _ = os.Remove(uploadPath) w.Header().Set("Docker-Content-Digest", digest) w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest)) w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) w.WriteHeader(http.StatusCreated) return } w.WriteHeader(http.StatusMethodNotAllowed) } func resolveManifest(repoPath string, reference string) (ociDescriptor, error) { var desc ociDescriptor var err error var ok bool var data []byte if isDigestRef(reference) { ok, err = HasBlob(repoPath, reference) if err != nil { return desc, err } if !ok { return desc, ErrNotFound } desc = ociDescriptor{Digest: reference} data, err = ReadBlob(repoPath, reference) if err == nil { desc.Size = int64(len(data)) desc.MediaType = detectManifestMediaType(data) } return desc, nil } desc, err = resolveTag(repoPath, reference) if err != nil { return desc, err } return desc, nil } func isDigestRef(ref string) bool { return strings.HasPrefix(ref, "sha256:") } func detectManifestMediaType(data []byte) string { var tmp map[string]interface{} var value interface{} var s string _ = json.Unmarshal(data, &tmp) if tmp == nil { return "" } value = tmp["mediaType"] if value == nil { return "" } s, _ = value.(string) return s } type statusRecorder struct { http.ResponseWriter status int } func (r *statusRecorder) WriteHeader(code int) { r.status = code r.ResponseWriter.WriteHeader(code) } func (r *statusRecorder) Flush() { var flusher http.Flusher var ok bool flusher, ok = r.ResponseWriter.(http.Flusher) if ok { flusher.Flush() } } var repoLocksMu sync.Mutex var repoLocks map[string]*sync.Mutex = map[string]*sync.Mutex{} func repoLock(path string) *sync.Mutex { var lock *sync.Mutex var ok bool repoLocksMu.Lock() lock, ok = repoLocks[path] if !ok { lock = &sync.Mutex{} repoLocks[path] = lock } repoLocksMu.Unlock() return lock } func withRepoLock(path string, fn func() error) error { var lock *sync.Mutex lock = repoLock(path) lock.Lock() defer lock.Unlock() return fn() } func init() { _ = time.Now() }