313 lines
9.1 KiB
Go
313 lines
9.1 KiB
Go
package httpserver
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"mime"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"update_server/internal/apikeys"
|
|
"update_server/internal/db"
|
|
"update_server/internal/releases"
|
|
)
|
|
|
|
var errInvalidReleaseID = errors.New("invalid release id")
|
|
|
|
type apiProjectResponse struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Description string `json:"description"`
|
|
LatestReleaseURL string `json:"latest_release_url"`
|
|
}
|
|
|
|
type apiReleaseResponse struct {
|
|
ID int64 `json:"id"`
|
|
Version string `json:"version"`
|
|
Build string `json:"build"`
|
|
Filename string `json:"filename"`
|
|
ChecksumSHA256 string `json:"checksum_sha256"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
ContentType string `json:"content_type"`
|
|
ReleaseNotes string `json:"release_notes"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
MetadataURL string `json:"metadata_url"`
|
|
DownloadURL string `json:"download_url"`
|
|
}
|
|
|
|
func (h *handler) apiAccessibleProjects(w http.ResponseWriter, r *http.Request) {
|
|
state, ok := apikeys.FromContext(r.Context())
|
|
if !ok {
|
|
writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required")
|
|
return
|
|
}
|
|
|
|
projects, err := h.apiKeys.ListAccessibleProjects(r.Context(), state.APIKey)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "project listing failed"})
|
|
return
|
|
}
|
|
|
|
items := make([]apiProjectResponse, 0, len(projects))
|
|
for _, project := range projects {
|
|
items = append(items, apiProjectPayload(project))
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"projects": items})
|
|
}
|
|
|
|
func (h *handler) apiLatestRelease(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
|
|
}
|
|
|
|
release, err := h.store.Releases.GetLatestByProjectID(r.Context(), project.ID)
|
|
if err != nil {
|
|
if errors.Is(err, db.ErrNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]any{"error": "release not found"})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "release lookup failed"})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"project": apiProjectPayload(*project),
|
|
"release": apiReleasePayload(*release),
|
|
})
|
|
}
|
|
|
|
func (h *handler) apiReleaseMetadata(w http.ResponseWriter, r *http.Request) {
|
|
state, ok := apikeys.FromContext(r.Context())
|
|
if !ok {
|
|
writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required")
|
|
return
|
|
}
|
|
|
|
project, release, err := h.accessibleReleaseByID(r, state.APIKey)
|
|
if err != nil {
|
|
h.writeClientResourceError(w, err, "release lookup failed")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"project": apiProjectPayload(*project),
|
|
"release": apiReleasePayload(*release),
|
|
})
|
|
}
|
|
|
|
func (h *handler) apiReleaseDownload(w http.ResponseWriter, r *http.Request) {
|
|
state, ok := apikeys.FromContext(r.Context())
|
|
if !ok {
|
|
writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required")
|
|
return
|
|
}
|
|
|
|
_, release, err := h.accessibleReleaseByID(r, state.APIKey)
|
|
if err != nil {
|
|
h.writeClientResourceError(w, err, "release lookup failed")
|
|
return
|
|
}
|
|
|
|
file, err := h.releases.Artifact(release.StoragePath)
|
|
if err != nil {
|
|
if errors.Is(err, db.ErrNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]any{"error": "release not found"})
|
|
return
|
|
}
|
|
|
|
h.logger.Error("artifact open failed", "release_id", release.ID, "storage_path", release.StoragePath, "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "artifact download failed"})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
disposition := mime.FormatMediaType("attachment", map[string]string{"filename": release.Filename})
|
|
if disposition != "" {
|
|
w.Header().Set("Content-Disposition", disposition)
|
|
}
|
|
w.Header().Set("Content-Type", release.ContentType)
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
|
|
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 == "" {
|
|
return nil, db.ErrNotFound
|
|
}
|
|
|
|
project, err := h.store.Projects.GetBySlug(r.Context(), projectSlug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !project.IsActive {
|
|
return nil, db.ErrNotFound
|
|
}
|
|
|
|
allowed, err := h.apiKeys.CanAccessProject(r.Context(), apiKey, project.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("check project access: %w", err)
|
|
}
|
|
|
|
if !allowed {
|
|
return nil, db.ErrNotFound
|
|
}
|
|
|
|
return project, nil
|
|
}
|
|
|
|
func (h *handler) accessibleReleaseByID(r *http.Request, apiKey db.APIKey) (*db.Project, *db.Release, error) {
|
|
releaseID, err := routeID(r, "releaseID")
|
|
if err != nil {
|
|
return nil, nil, errInvalidReleaseID
|
|
}
|
|
|
|
release, err := h.store.Releases.GetByID(r.Context(), releaseID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if !release.IsActive {
|
|
return nil, nil, db.ErrNotFound
|
|
}
|
|
|
|
project, err := h.store.Projects.GetByID(r.Context(), release.ProjectID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if !project.IsActive {
|
|
return nil, nil, db.ErrNotFound
|
|
}
|
|
|
|
allowed, err := h.apiKeys.CanAccessProject(r.Context(), apiKey, project.ID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("check release project access: %w", err)
|
|
}
|
|
|
|
if !allowed {
|
|
return nil, nil, db.ErrNotFound
|
|
}
|
|
|
|
return project, release, nil
|
|
}
|
|
|
|
func (h *handler) writeClientResourceError(w http.ResponseWriter, err error, message string) {
|
|
switch {
|
|
case err == nil:
|
|
return
|
|
case errors.Is(err, db.ErrNotFound):
|
|
writeJSON(w, http.StatusNotFound, map[string]any{"error": "resource not found"})
|
|
case errors.Is(err, errInvalidReleaseID):
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid release id"})
|
|
default:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": message})
|
|
}
|
|
}
|
|
|
|
func apiProjectPayload(project db.Project) apiProjectResponse {
|
|
return apiProjectResponse{
|
|
ID: project.ID,
|
|
Name: project.Name,
|
|
Slug: project.Slug,
|
|
Description: project.Description,
|
|
LatestReleaseURL: fmt.Sprintf("/api/v1/projects/%s/releases/latest", project.Slug),
|
|
}
|
|
}
|
|
|
|
func apiReleasePayload(release db.Release) apiReleaseResponse {
|
|
return apiReleaseResponse{
|
|
ID: release.ID,
|
|
Version: release.Version,
|
|
Build: release.Build,
|
|
Filename: release.Filename,
|
|
ChecksumSHA256: release.ChecksumSHA256,
|
|
SizeBytes: release.SizeBytes,
|
|
ContentType: release.ContentType,
|
|
ReleaseNotes: release.ReleaseNotes,
|
|
CreatedAt: release.CreatedAt,
|
|
MetadataURL: fmt.Sprintf("/api/v1/releases/%d", release.ID),
|
|
DownloadURL: fmt.Sprintf("/api/v1/releases/%d/download", release.ID),
|
|
}
|
|
}
|