Add asset metadata, sharing, and MinIO-backed signed links.
CI / test (pull_request) Successful in 4s

This introduces deduplicated per-user image/3D asset records linked into feature properties, adds visibility-controlled download routing, and wires local S3-compatible storage with automatic bucket bootstrap in Docker Compose.

Made-with: Cursor
This commit is contained in:
2026-03-02 21:03:08 +00:00
parent 184c5cb59f
commit f6f46f6db1
18 changed files with 1125 additions and 16 deletions
+112
View File
@@ -40,6 +40,10 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("POST /v1/collections/{id}/features", a.createFeature)
mux.HandleFunc("GET /v1/collections/{id}/features", a.listFeatures)
mux.HandleFunc("DELETE /v1/features/{id}", a.deleteFeature)
mux.HandleFunc("POST /v1/assets", a.createAsset)
mux.HandleFunc("PATCH /v1/assets/{id}", a.patchAsset)
mux.HandleFunc("POST /v1/assets/{id}/signed-upload", a.signedUpload)
mux.HandleFunc("GET /v1/assets/{id}/download", a.downloadAsset)
mux.Handle("/web/", http.StripPrefix("/web/", staticFiles))
mux.Handle("/libs/", http.StripPrefix("/libs/", libsFiles))
@@ -97,8 +101,11 @@ func statusFromErr(err error) int {
errors.Is(err, app.ErrInviteExhaust):
return http.StatusBadRequest
case errors.Is(err, app.ErrCollectionMiss), errors.Is(err, app.ErrFeatureMiss),
errors.Is(err, app.ErrAssetMiss),
errors.Is(err, store.ErrNotFound):
return http.StatusNotFound
case errors.Is(err, app.ErrStorageNotConfigured):
return http.StatusServiceUnavailable
default:
return http.StatusInternalServerError
}
@@ -361,3 +368,108 @@ func (a *API) deleteFeature(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusNoContent)
}
func (a *API) createAsset(w http.ResponseWriter, r *http.Request) {
user, err := a.authUser(r)
if err != nil {
writeErr(w, err)
return
}
var req struct {
FeatureID string `json:"featureId"`
Checksum string `json:"checksum"`
Ext string `json:"ext"`
Kind string `json:"kind"`
MimeType string `json:"mimeType"`
SizeBytes int64 `json:"sizeBytes"`
Name string `json:"name"`
Description string `json:"description"`
IsPublic *bool `json:"isPublic"`
}
if err := readJSON(r, &req); err != nil {
writeErr(w, app.ErrBadRequest)
return
}
asset, created, err := a.service.CreateOrLinkAsset(user, app.CreateAssetInput{
FeatureID: req.FeatureID,
Checksum: req.Checksum,
Ext: req.Ext,
Kind: req.Kind,
MimeType: req.MimeType,
SizeBytes: req.SizeBytes,
Name: req.Name,
Description: req.Description,
Visibility: req.IsPublic,
})
if err != nil {
writeErr(w, err)
return
}
status := http.StatusOK
if created {
status = http.StatusCreated
}
writeJSON(w, status, map[string]interface{}{
"asset": asset,
"link": "/v1/assets/" + asset.ID + "/download",
})
}
func (a *API) patchAsset(w http.ResponseWriter, r *http.Request) {
user, err := a.authUser(r)
if err != nil {
writeErr(w, err)
return
}
assetID := r.PathValue("id")
var req struct {
IsPublic bool `json:"isPublic"`
}
if err := readJSON(r, &req); err != nil {
writeErr(w, app.ErrBadRequest)
return
}
asset, err := a.service.SetAssetPublic(user, assetID, req.IsPublic)
if err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"asset": asset, "link": "/v1/assets/" + asset.ID + "/download"})
}
func (a *API) signedUpload(w http.ResponseWriter, r *http.Request) {
user, err := a.authUser(r)
if err != nil {
writeErr(w, err)
return
}
assetID := r.PathValue("id")
var req struct {
ContentType string `json:"contentType"`
}
if err := readJSON(r, &req); err != nil {
writeErr(w, app.ErrBadRequest)
return
}
url, err := a.service.SignedUploadURL(user, assetID, req.ContentType)
if err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"url": url, "method": http.MethodPut})
}
func (a *API) downloadAsset(w http.ResponseWriter, r *http.Request) {
user, err := a.authUser(r)
if err != nil {
writeErr(w, err)
return
}
assetID := r.PathValue("id")
url, err := a.service.SignedDownloadURL(user, assetID)
if err != nil {
writeErr(w, err)
return
}
http.Redirect(w, r, url, http.StatusFound)
}