diff --git a/internal/http/client_api.go b/internal/http/client_api.go index c30e279..871aa3b 100644 --- a/internal/http/client_api.go +++ b/internal/http/client_api.go @@ -12,6 +12,7 @@ import ( "update_server/internal/apikeys" "update_server/internal/db" + "update_server/internal/releases" ) var errInvalidReleaseID = errors.New("invalid release id") @@ -144,6 +145,71 @@ func (h *handler) apiReleaseDownload(w http.ResponseWriter, r *http.Request) { http.ServeContent(w, r, release.Filename, release.UpdatedAt, file) } +func (h *handler) apiUploadRelease(w http.ResponseWriter, r *http.Request) { + state, ok := apikeys.FromContext(r.Context()) + if !ok { + writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required") + return + } + + project, err := h.accessibleProjectBySlug(r, state.APIKey) + if err != nil { + h.writeClientResourceError(w, err, "project lookup failed") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxUploadRequestLimit(h.config.MaxUploadBytes)) + if err := r.ParseMultipartForm(16 << 20); err != nil { + if isMaxBytesError(err) { + writeJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": fmt.Sprintf("upload exceeds the configured %d MB limit", maxUploadMegabytes(h.config.MaxUploadBytes))}) + return + } + + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "upload form could not be read"}) + return + } + defer func() { + if r.MultipartForm != nil { + _ = r.MultipartForm.RemoveAll() + } + }() + + file, header, err := r.FormFile("artifact") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "artifact file is required"}) + return + } + defer file.Close() + + result, err := h.releases.Upload(r.Context(), releases.UploadParams{ + ProjectID: project.ID, + Version: r.FormValue("version"), + Build: r.FormValue("build"), + ReleaseNotes: r.FormValue("release_notes"), + OriginalFilename: header.Filename, + DeclaredType: header.Header.Get("Content-Type"), + Reader: file, + }) + if err != nil { + switch { + case errors.Is(err, db.ErrNotFound): + writeJSON(w, http.StatusNotFound, map[string]any{"error": "resource not found"}) + case errors.Is(err, db.ErrConflict): + writeJSON(w, http.StatusConflict, map[string]any{"error": "release version and build already exist for this project"}) + default: + writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + } + return + } + + releasePayload := apiReleasePayload(*result.Release) + w.Header().Set("Location", releasePayload.MetadataURL) + writeJSON(w, http.StatusCreated, map[string]any{ + "project": apiProjectPayload(*result.Project), + "release": releasePayload, + }) +} + func (h *handler) accessibleProjectBySlug(r *http.Request, apiKey db.APIKey) (*db.Project, error) { projectSlug := strings.TrimSpace(chi.URLParam(r, "projectSlug")) if projectSlug == "" { diff --git a/internal/http/client_api_integration_test.go b/internal/http/client_api_integration_test.go index f05f172..b6b2a37 100644 --- a/internal/http/client_api_integration_test.go +++ b/internal/http/client_api_integration_test.go @@ -1,10 +1,17 @@ package httpserver_test import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" "encoding/json" + "mime/multipart" "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" "strings" "testing" "time" @@ -192,6 +199,134 @@ func TestClientAPIListsAuthorizedProjectsReturnsLatestMetadataAndStreamsDownload } } +func TestClientAPIUploadsReleaseWithUploadPermission(t *testing.T) { + t.Parallel() + + router, cfg, store := newTestRouterWithStore(t) + sessionCookie := loginAsAdmin(t, router) + + desktopProjectID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{ + "name": {"Desktop App"}, + "slug": {"desktop-app"}, + }, http.StatusSeeOther, sessionCookie), "/admin/projects/") + + extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{ + "name": {"Mobile App"}, + "slug": {"mobile-app"}, + }, http.StatusSeeOther, sessionCookie), "/admin/projects/") + + desktopProjectIDInt := mustParseInt64(t, desktopProjectID) + apiKeyService := apikeys.NewService(store) + uploadKey, err := apiKeyService.Create(t.Context(), apikeys.CreateParams{ + Name: "Release Uploader", + ScopeMode: db.ScopeModeProjectAllowList, + CanUpload: true, + ProjectIDs: []int64{desktopProjectIDInt}, + }) + if err != nil { + t.Fatalf("create upload api key: %v", err) + } + + downloadOnlyKey, err := apiKeyService.Create(t.Context(), apikeys.CreateParams{ + Name: "Download Only", + ScopeMode: db.ScopeModeAllProjects, + CanDownload: true, + }) + if err != nil { + t.Fatalf("create download-only api key: %v", err) + } + + artifactBody := []byte("api-release-payload-1.2.3") + uploadRecorder := performAPIMultipartRequest(t, router, "/api/v1/projects/desktop-app/releases", map[string]string{ + "version": "1.2.3", + "build": "api-build-7", + "release_notes": "Uploaded through bearer API.", + }, "artifact", "..\\Desktop API 1.2.3.ZIP", artifactBody, "Bearer "+uploadKey.RawKey) + if uploadRecorder.Code != http.StatusCreated { + t.Fatalf("expected API upload to return 201, got %d with body %s", uploadRecorder.Code, uploadRecorder.Body.String()) + } + + var uploadPayload struct { + Project struct { + Slug string `json:"slug"` + } `json:"project"` + Release struct { + ID int64 `json:"id"` + Version string `json:"version"` + Build string `json:"build"` + Filename string `json:"filename"` + ChecksumSHA256 string `json:"checksum_sha256"` + MetadataURL string `json:"metadata_url"` + DownloadURL string `json:"download_url"` + } `json:"release"` + } + decodeJSONBody(t, uploadRecorder, &uploadPayload) + + if uploadPayload.Project.Slug != "desktop-app" { + t.Fatalf("expected desktop-app project payload, got %+v", uploadPayload.Project) + } + if uploadPayload.Release.Version != "1.2.3" || uploadPayload.Release.Build != "api-build-7" { + t.Fatalf("unexpected uploaded release payload %+v", uploadPayload.Release) + } + if uploadPayload.Release.Filename != "desktop-api-1.2.3.zip" { + t.Fatalf("expected sanitized filename, got %q", uploadPayload.Release.Filename) + } + if uploadRecorder.Header().Get("Location") != uploadPayload.Release.MetadataURL { + t.Fatalf("expected Location to point at metadata URL, got %q", uploadRecorder.Header().Get("Location")) + } + + expectedChecksum := sha256.Sum256(artifactBody) + if uploadPayload.Release.ChecksumSHA256 != hex.EncodeToString(expectedChecksum[:]) { + t.Fatalf("expected checksum %s, got %s", hex.EncodeToString(expectedChecksum[:]), uploadPayload.Release.ChecksumSHA256) + } + + releases, err := store.Releases.ListByProjectID(context.Background(), desktopProjectIDInt) + if err != nil { + t.Fatalf("load releases: %v", err) + } + if len(releases) != 1 { + t.Fatalf("expected one release, got %d", len(releases)) + } + + release := releases[0].Release + if release.UploadedByUserID != nil { + t.Fatalf("expected API-uploaded release to have no admin uploader, got %d", *release.UploadedByUserID) + } + if release.StoragePath != "desktop-app/1.2.3/api-build-7/desktop-api-1.2.3.zip" { + t.Fatalf("unexpected storage path %q", release.StoragePath) + } + + storedArtifact, err := os.ReadFile(filepath.Join(cfg.ArtifactsDir, filepath.FromSlash(release.StoragePath))) + if err != nil { + t.Fatalf("read stored artifact: %v", err) + } + if !bytes.Equal(storedArtifact, artifactBody) { + t.Fatal("stored artifact body did not match uploaded payload") + } + + duplicateRecorder := performAPIMultipartRequest(t, router, "/api/v1/projects/desktop-app/releases", map[string]string{ + "version": "1.2.3", + "build": "api-build-7", + }, "artifact", "desktop-api-alt.zip", []byte("duplicate"), "Bearer "+uploadKey.RawKey) + if duplicateRecorder.Code != http.StatusConflict { + t.Fatalf("expected duplicate version/build to return 409, got %d with body %s", duplicateRecorder.Code, duplicateRecorder.Body.String()) + } + + forbiddenRecorder := performAPIMultipartRequest(t, router, "/api/v1/projects/desktop-app/releases", map[string]string{ + "version": "2.0.0", + }, "artifact", "desktop-api-2.0.0.zip", []byte("forbidden"), "Bearer "+downloadOnlyKey.RawKey) + if forbiddenRecorder.Code != http.StatusForbidden { + t.Fatalf("expected key without upload permission to return 403, got %d with body %s", forbiddenRecorder.Code, forbiddenRecorder.Body.String()) + } + + blockedProjectRecorder := performAPIMultipartRequest(t, router, "/api/v1/projects/mobile-app/releases", map[string]string{ + "version": "2.0.0", + }, "artifact", "mobile-api-2.0.0.apk", []byte("blocked"), "Bearer "+uploadKey.RawKey) + if blockedProjectRecorder.Code != http.StatusNotFound { + t.Fatalf("expected out-of-scope project upload to return 404, got %d with body %s", blockedProjectRecorder.Code, blockedProjectRecorder.Body.String()) + } +} + func TestClientAPIRejectsMissingPermissionDisabledAndExpiredKeys(t *testing.T) { t.Parallel() @@ -281,6 +416,40 @@ func performAPIRequest(t *testing.T, handler http.Handler, method, target, autho return recorder } +func performAPIMultipartRequest(t *testing.T, handler http.Handler, target string, fields map[string]string, fileField, filename string, body []byte, authorization string) *httptest.ResponseRecorder { + t.Helper() + + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + for key, value := range fields { + if err := writer.WriteField(key, value); err != nil { + t.Fatalf("write multipart field %s: %v", key, err) + } + } + + part, err := writer.CreateFormFile(fileField, filename) + if err != nil { + t.Fatalf("create multipart file part: %v", err) + } + if _, err := part.Write(body); err != nil { + t.Fatalf("write multipart file body: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, target, &requestBody) + req.RemoteAddr = "127.0.0.1:12345" + req.Header.Set("Content-Type", writer.FormDataContentType()) + if authorization != "" { + req.Header.Set("Authorization", authorization) + } + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + return recorder +} + func decodeJSONBody(t *testing.T, recorder *httptest.ResponseRecorder, target any) { t.Helper() diff --git a/internal/http/handlers.go b/internal/http/handlers.go index 8fa7b1b..f44a3eb 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -124,6 +124,11 @@ func (h *handler) apiIndex(w http.ResponseWriter, r *http.Request) { "path": "/api/v1/projects/{projectSlug}/releases/latest", "description": "Get the latest active release metadata for an accessible project.", }, + { + "method": http.MethodPost, + "path": "/api/v1/projects/{projectSlug}/releases", + "description": "Upload a release artifact for an accessible project.", + }, { "method": http.MethodGet, "path": "/api/v1/releases/{releaseID}", diff --git a/internal/http/router.go b/internal/http/router.go index cf68f02..084d8e9 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -106,6 +106,15 @@ func NewRouter(cfg config.Config, logger *slog.Logger, renderer *Renderer, store r.Get("/releases/{releaseID}", h.apiReleaseMetadata) r.Get("/releases/{releaseID}/download", h.apiReleaseDownload) }) + + r.Group(func(r chi.Router) { + r.Use(protectedAPIResponseHeaders) + r.Use(h.clientAPIRateLimit) + r.Use(h.requireAPIKey) + r.Use(h.requireAPIKeyPermission(apikeys.PermissionUpload)) + + r.Post("/projects/{projectSlug}/releases", h.apiUploadRelease) + }) }) })