add upload api
This commit is contained in:
parent
a90dd9bc50
commit
77da1ffd94
4 changed files with 249 additions and 0 deletions
|
|
@ -12,6 +12,7 @@ import (
|
||||||
|
|
||||||
"update_server/internal/apikeys"
|
"update_server/internal/apikeys"
|
||||||
"update_server/internal/db"
|
"update_server/internal/db"
|
||||||
|
"update_server/internal/releases"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errInvalidReleaseID = errors.New("invalid release id")
|
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)
|
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) {
|
func (h *handler) accessibleProjectBySlug(r *http.Request, apiKey db.APIKey) (*db.Project, error) {
|
||||||
projectSlug := strings.TrimSpace(chi.URLParam(r, "projectSlug"))
|
projectSlug := strings.TrimSpace(chi.URLParam(r, "projectSlug"))
|
||||||
if projectSlug == "" {
|
if projectSlug == "" {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
package httpserver_test
|
package httpserver_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestClientAPIRejectsMissingPermissionDisabledAndExpiredKeys(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|
@ -281,6 +416,40 @@ func performAPIRequest(t *testing.T, handler http.Handler, method, target, autho
|
||||||
return recorder
|
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) {
|
func decodeJSONBody(t *testing.T, recorder *httptest.ResponseRecorder, target any) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,11 @@ func (h *handler) apiIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
"path": "/api/v1/projects/{projectSlug}/releases/latest",
|
"path": "/api/v1/projects/{projectSlug}/releases/latest",
|
||||||
"description": "Get the latest active release metadata for an accessible project.",
|
"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,
|
"method": http.MethodGet,
|
||||||
"path": "/api/v1/releases/{releaseID}",
|
"path": "/api/v1/releases/{releaseID}",
|
||||||
|
|
|
||||||
|
|
@ -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}", h.apiReleaseMetadata)
|
||||||
r.Get("/releases/{releaseID}/download", h.apiReleaseDownload)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue