Integrate asset metadata/storage support, TypeScript client asset APIs, docs updates, and the Leaflet demo while resolving conflicts with recent challenge IP/login persistence changes on main. Made-with: Cursor
This commit is contained in:
+180
-3
@@ -1,10 +1,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"momswap/backend/internal/auth"
|
||||
@@ -22,23 +24,43 @@ var (
|
||||
ErrAlreadyUser = errors.New("user already registered")
|
||||
ErrCollectionMiss = errors.New("collection missing")
|
||||
ErrFeatureMiss = errors.New("feature missing")
|
||||
ErrAssetMiss = errors.New("asset missing")
|
||||
ErrStorageNotConfigured = errors.New("storage not configured")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ChallengeTTL time.Duration
|
||||
SessionTTL time.Duration
|
||||
UploadURLTTL time.Duration
|
||||
ReadURLTTL time.Duration
|
||||
}
|
||||
|
||||
type AssetURLSigner interface {
|
||||
SignedPutObjectURL(ctx context.Context, objectKey string, expiry time.Duration, contentType string) (string, error)
|
||||
SignedGetObjectURL(ctx context.Context, objectKey string, expiry time.Duration) (string, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store store.Store
|
||||
config Config
|
||||
servicePublicKey string
|
||||
assetSigner AssetURLSigner
|
||||
}
|
||||
|
||||
func NewService(st store.Store, cfg Config, servicePublicKey string) *Service {
|
||||
if cfg.UploadURLTTL <= 0 {
|
||||
cfg.UploadURLTTL = 15 * time.Minute
|
||||
}
|
||||
if cfg.ReadURLTTL <= 0 {
|
||||
cfg.ReadURLTTL = 10 * time.Minute
|
||||
}
|
||||
return &Service{store: st, config: cfg, servicePublicKey: servicePublicKey}
|
||||
}
|
||||
|
||||
func (s *Service) ConfigureAssetStorage(signer AssetURLSigner) {
|
||||
s.assetSigner = signer
|
||||
}
|
||||
|
||||
type InvitationPayload struct {
|
||||
JTI string `json:"jti"`
|
||||
InviterPublicKey string `json:"inviterPublicKey"`
|
||||
@@ -246,8 +268,8 @@ func validatePoint(point store.Point) error {
|
||||
if point.Type != "Point" {
|
||||
return fmt.Errorf("%w: geometry type must be Point", ErrBadRequest)
|
||||
}
|
||||
if len(point.Coordinates) != 2 {
|
||||
return fmt.Errorf("%w: coordinates must have lon/lat", ErrBadRequest)
|
||||
if len(point.Coordinates) != 2 && len(point.Coordinates) != 3 {
|
||||
return fmt.Errorf("%w: coordinates must have lon/lat[/alt]", ErrBadRequest)
|
||||
}
|
||||
lon, lat := point.Coordinates[0], point.Coordinates[1]
|
||||
if lon < -180 || lon > 180 {
|
||||
@@ -347,7 +369,28 @@ func (s *Service) ListFeatures(ownerKey, collectionID string) ([]store.Feature,
|
||||
if collection.OwnerKey != ownerKey {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
return s.store.ListFeaturesByCollection(collectionID), nil
|
||||
features := s.store.ListFeaturesByCollection(collectionID)
|
||||
for idx := range features {
|
||||
featureAssets := s.store.ListAssetsByFeature(features[idx].ID)
|
||||
assets := make([]map[string]interface{}, 0, len(featureAssets))
|
||||
for _, linkedAsset := range featureAssets {
|
||||
assets = append(assets, map[string]interface{}{
|
||||
"id": linkedAsset.ID,
|
||||
"kind": linkedAsset.Kind,
|
||||
"name": linkedAsset.Name,
|
||||
"description": linkedAsset.Description,
|
||||
"checksum": linkedAsset.Checksum,
|
||||
"ext": linkedAsset.Ext,
|
||||
"isPublic": linkedAsset.IsPublic,
|
||||
"link": "/v1/assets/" + linkedAsset.ID + "/download",
|
||||
})
|
||||
}
|
||||
if features[idx].Properties == nil {
|
||||
features[idx].Properties = map[string]interface{}{}
|
||||
}
|
||||
features[idx].Properties["assets"] = assets
|
||||
}
|
||||
return features, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteFeature(ownerKey, featureID string) error {
|
||||
@@ -360,3 +403,137 @@ func (s *Service) DeleteFeature(ownerKey, featureID string) error {
|
||||
}
|
||||
return s.store.DeleteFeature(featureID)
|
||||
}
|
||||
|
||||
type CreateAssetInput struct {
|
||||
FeatureID string
|
||||
Checksum string
|
||||
Ext string
|
||||
Kind string
|
||||
MimeType string
|
||||
SizeBytes int64
|
||||
Name string
|
||||
Description string
|
||||
Visibility *bool
|
||||
}
|
||||
|
||||
func normalizeExt(ext string) string {
|
||||
return strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".")
|
||||
}
|
||||
|
||||
func normalizeChecksum(checksum string) string {
|
||||
return strings.ToLower(strings.TrimSpace(checksum))
|
||||
}
|
||||
|
||||
func (s *Service) CreateOrLinkAsset(ownerKey string, in CreateAssetInput) (store.Asset, bool, error) {
|
||||
feature, err := s.store.GetFeature(in.FeatureID)
|
||||
if err != nil {
|
||||
return store.Asset{}, false, ErrFeatureMiss
|
||||
}
|
||||
if feature.OwnerKey != ownerKey {
|
||||
return store.Asset{}, false, ErrForbidden
|
||||
}
|
||||
|
||||
checksum := normalizeChecksum(in.Checksum)
|
||||
ext := normalizeExt(in.Ext)
|
||||
if checksum == "" || ext == "" {
|
||||
return store.Asset{}, false, fmt.Errorf("%w: checksum and ext required", ErrBadRequest)
|
||||
}
|
||||
switch ext {
|
||||
case "jpg", "jpeg", "png", "webp", "gltf", "glb":
|
||||
default:
|
||||
return store.Asset{}, false, fmt.Errorf("%w: unsupported extension", ErrBadRequest)
|
||||
}
|
||||
if in.Kind != "image" && in.Kind != "3d" {
|
||||
return store.Asset{}, false, fmt.Errorf("%w: kind must be image or 3d", ErrBadRequest)
|
||||
}
|
||||
if in.SizeBytes < 0 {
|
||||
return store.Asset{}, false, fmt.Errorf("%w: sizeBytes must be >= 0", ErrBadRequest)
|
||||
}
|
||||
|
||||
if existing, getErr := s.store.GetAssetByOwnerChecksumExt(ownerKey, checksum, ext); getErr == nil {
|
||||
if err := s.store.LinkAssetToFeature(in.FeatureID, existing.ID, in.Name, in.Description); err != nil {
|
||||
return store.Asset{}, false, err
|
||||
}
|
||||
return existing, false, nil
|
||||
}
|
||||
|
||||
id, err := auth.NewRandomToken(12)
|
||||
if err != nil {
|
||||
return store.Asset{}, false, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
isPublic := true
|
||||
if in.Visibility != nil {
|
||||
isPublic = *in.Visibility
|
||||
}
|
||||
asset := store.Asset{
|
||||
ID: id,
|
||||
OwnerKey: ownerKey,
|
||||
Checksum: checksum,
|
||||
Ext: ext,
|
||||
Kind: in.Kind,
|
||||
MimeType: in.MimeType,
|
||||
SizeBytes: in.SizeBytes,
|
||||
ObjectKey: ownerKey + "/" + checksum + "." + ext,
|
||||
IsPublic: isPublic,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
s.store.SaveAsset(asset)
|
||||
if err := s.store.LinkAssetToFeature(in.FeatureID, asset.ID, in.Name, in.Description); err != nil {
|
||||
return store.Asset{}, false, err
|
||||
}
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetAssetPublic(ownerKey, assetID string, isPublic bool) (store.Asset, error) {
|
||||
asset, err := s.store.GetAsset(assetID)
|
||||
if err != nil {
|
||||
return store.Asset{}, ErrAssetMiss
|
||||
}
|
||||
if asset.OwnerKey != ownerKey {
|
||||
return store.Asset{}, ErrForbidden
|
||||
}
|
||||
if err := s.store.SetAssetPublic(assetID, isPublic); err != nil {
|
||||
return store.Asset{}, err
|
||||
}
|
||||
asset.IsPublic = isPublic
|
||||
asset.UpdatedAt = time.Now().UTC()
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func (s *Service) SignedUploadURL(ownerKey, assetID, contentType string) (string, error) {
|
||||
if s.assetSigner == nil {
|
||||
return "", ErrStorageNotConfigured
|
||||
}
|
||||
asset, err := s.store.GetAsset(assetID)
|
||||
if err != nil {
|
||||
return "", ErrAssetMiss
|
||||
}
|
||||
if asset.OwnerKey != ownerKey {
|
||||
return "", ErrForbidden
|
||||
}
|
||||
url, err := s.assetSigner.SignedPutObjectURL(context.Background(), asset.ObjectKey, s.config.UploadURLTTL, contentType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func (s *Service) SignedDownloadURL(requesterKey, assetID string) (string, error) {
|
||||
if s.assetSigner == nil {
|
||||
return "", ErrStorageNotConfigured
|
||||
}
|
||||
asset, err := s.store.GetAsset(assetID)
|
||||
if err != nil {
|
||||
return "", ErrAssetMiss
|
||||
}
|
||||
if asset.OwnerKey != requesterKey && !asset.IsPublic {
|
||||
return "", ErrForbidden
|
||||
}
|
||||
url, err := s.assetSigner.SignedGetObjectURL(context.Background(), asset.ObjectKey, s.config.ReadURLTTL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return url, nil
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -23,10 +25,21 @@ func newTestServer(adminPublicKey string) *httptest.Server {
|
||||
SessionTTL: 24 * time.Hour,
|
||||
}, adminPublicKey)
|
||||
svc.BootstrapAdmin(adminPublicKey)
|
||||
svc.ConfigureAssetStorage(fakeSigner{})
|
||||
api := httpapi.NewAPI(svc)
|
||||
return httptest.NewServer(api.Routes())
|
||||
}
|
||||
|
||||
type fakeSigner struct{}
|
||||
|
||||
func (fakeSigner) SignedPutObjectURL(_ context.Context, objectKey string, _ time.Duration, _ string) (string, error) {
|
||||
return "http://files.local/upload/" + objectKey, nil
|
||||
}
|
||||
|
||||
func (fakeSigner) SignedGetObjectURL(_ context.Context, objectKey string, _ time.Duration) (string, error) {
|
||||
return "http://files.local/download/" + objectKey, nil
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, value interface{}) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(value)
|
||||
@@ -72,6 +85,26 @@ func postJSON(t *testing.T, client *http.Client, url string, body interface{}, t
|
||||
return resp, out
|
||||
}
|
||||
|
||||
func patchJSON(t *testing.T, client *http.Client, url string, body interface{}, token string) (*http.Response, map[string]interface{}) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPatch, url, bytes.NewReader(mustJSON(t, body)))
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out := map[string]interface{}{}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
return resp, out
|
||||
}
|
||||
|
||||
func loginUser(t *testing.T, client *http.Client, baseURL, pubB64 string, priv ed25519.PrivateKey) string {
|
||||
t.Helper()
|
||||
chResp, chData := postJSON(t, client, baseURL+"/v1/auth/challenge", map[string]string{"publicKey": pubB64}, "")
|
||||
@@ -251,3 +284,135 @@ func TestCollectionOwnershipIsolation(t *testing.T) {
|
||||
t.Fatalf("expected 403, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetLifecycleAndVisibility(t *testing.T) {
|
||||
adminPub, adminPriv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin key: %v", err)
|
||||
}
|
||||
adminPubB64 := base64.RawURLEncoding.EncodeToString(adminPub)
|
||||
server := newTestServer(adminPubB64)
|
||||
defer server.Close()
|
||||
client := server.Client()
|
||||
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
|
||||
|
||||
adminToken := loginUser(t, client, server.URL, adminPubB64, adminPriv)
|
||||
|
||||
user1Pub, user1Priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
user1PubB64 := base64.RawURLEncoding.EncodeToString(user1Pub)
|
||||
registerUserViaAdmin(t, client, server.URL, adminPubB64, adminPriv, adminToken, user1PubB64, user1Priv, "invite-asset-u1")
|
||||
user1Token := loginUser(t, client, server.URL, user1PubB64, user1Priv)
|
||||
|
||||
user2Pub, user2Priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
user2PubB64 := base64.RawURLEncoding.EncodeToString(user2Pub)
|
||||
registerUserViaAdmin(t, client, server.URL, adminPubB64, adminPriv, adminToken, user2PubB64, user2Priv, "invite-asset-u2")
|
||||
user2Token := loginUser(t, client, server.URL, user2PubB64, user2Priv)
|
||||
|
||||
createCollectionResp, createCollectionData := postJSON(t, client, server.URL+"/v1/collections", map[string]string{
|
||||
"name": "assets",
|
||||
}, user1Token)
|
||||
if createCollectionResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create collection status=%d body=%v", createCollectionResp.StatusCode, createCollectionData)
|
||||
}
|
||||
collectionID := createCollectionData["id"].(string)
|
||||
|
||||
createFeatureResp, createFeatureData := postJSON(t, client, server.URL+"/v1/collections/"+collectionID+"/features", map[string]interface{}{
|
||||
"geometry": map[string]interface{}{
|
||||
"type": "Point",
|
||||
"coordinates": []float64{-16.6291, 28.4636, 22},
|
||||
},
|
||||
"properties": map[string]interface{}{
|
||||
"name": "feature-a",
|
||||
},
|
||||
}, user1Token)
|
||||
if createFeatureResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create feature status=%d body=%v", createFeatureResp.StatusCode, createFeatureData)
|
||||
}
|
||||
featureID := createFeatureData["id"].(string)
|
||||
|
||||
createAssetResp, createAssetData := postJSON(t, client, server.URL+"/v1/assets", map[string]interface{}{
|
||||
"featureId": featureID,
|
||||
"checksum": "ABCDEF1234",
|
||||
"ext": "glb",
|
||||
"kind": "3d",
|
||||
"mimeType": "model/gltf-binary",
|
||||
"sizeBytes": 100,
|
||||
"name": "Tree",
|
||||
"description": "Public tree",
|
||||
"isPublic": true,
|
||||
}, user1Token)
|
||||
if createAssetResp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create asset status=%d body=%v", createAssetResp.StatusCode, createAssetData)
|
||||
}
|
||||
asset := createAssetData["asset"].(map[string]interface{})
|
||||
assetID := asset["id"].(string)
|
||||
|
||||
createAssetResp2, createAssetData2 := postJSON(t, client, server.URL+"/v1/assets", map[string]interface{}{
|
||||
"featureId": featureID,
|
||||
"checksum": "abcdef1234",
|
||||
"ext": "glb",
|
||||
"kind": "3d",
|
||||
"name": "Tree v2",
|
||||
}, user1Token)
|
||||
if createAssetResp2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("dedup create asset status=%d body=%v", createAssetResp2.StatusCode, createAssetData2)
|
||||
}
|
||||
asset2 := createAssetData2["asset"].(map[string]interface{})
|
||||
if asset2["id"].(string) != assetID {
|
||||
t.Fatalf("expected dedup asset id=%s got=%s", assetID, asset2["id"].(string))
|
||||
}
|
||||
|
||||
uploadResp, uploadData := postJSON(t, client, server.URL+"/v1/assets/"+assetID+"/signed-upload", map[string]interface{}{
|
||||
"contentType": "model/gltf-binary",
|
||||
}, user1Token)
|
||||
if uploadResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("signed upload status=%d body=%v", uploadResp.StatusCode, uploadData)
|
||||
}
|
||||
|
||||
featuresResp, featuresData := getJSON(t, client, server.URL+"/v1/collections/"+collectionID+"/features", user1Token)
|
||||
if featuresResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("list features status=%d body=%v", featuresResp.StatusCode, featuresData)
|
||||
}
|
||||
features := featuresData["features"].([]interface{})
|
||||
firstFeature := features[0].(map[string]interface{})
|
||||
properties := firstFeature["properties"].(map[string]interface{})
|
||||
assets := properties["assets"].([]interface{})
|
||||
if len(assets) != 1 {
|
||||
t.Fatalf("expected 1 linked asset, got %d", len(assets))
|
||||
}
|
||||
assetView := assets[0].(map[string]interface{})
|
||||
if assetView["link"] != "/v1/assets/"+assetID+"/download" {
|
||||
t.Fatalf("unexpected asset link: %v", assetView["link"])
|
||||
}
|
||||
|
||||
reqDownloadPublic, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/assets/"+assetID+"/download", nil)
|
||||
reqDownloadPublic.Header.Set("Authorization", "Bearer "+user2Token)
|
||||
downloadPublicResp, err := client.Do(reqDownloadPublic)
|
||||
if err != nil {
|
||||
t.Fatalf("download public request failed: %v", err)
|
||||
}
|
||||
if downloadPublicResp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("expected public asset redirect status, got %d", downloadPublicResp.StatusCode)
|
||||
}
|
||||
expectedLocation := fmt.Sprintf("http://files.local/download/%s/%s.%s", user1PubB64, "abcdef1234", "glb")
|
||||
if downloadPublicResp.Header.Get("Location") != expectedLocation {
|
||||
t.Fatalf("unexpected redirect location: %s", downloadPublicResp.Header.Get("Location"))
|
||||
}
|
||||
|
||||
patchResp, patchData := patchJSON(t, client, server.URL+"/v1/assets/"+assetID, map[string]interface{}{
|
||||
"isPublic": false,
|
||||
}, user1Token)
|
||||
if patchResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("patch asset status=%d body=%v", patchResp.StatusCode, patchData)
|
||||
}
|
||||
|
||||
reqDownloadPrivate, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/assets/"+assetID+"/download", nil)
|
||||
reqDownloadPrivate.Header.Set("Authorization", "Bearer "+user2Token)
|
||||
downloadPrivateResp, err := client.Do(reqDownloadPrivate)
|
||||
if err != nil {
|
||||
t.Fatalf("download private request failed: %v", err)
|
||||
}
|
||||
if downloadPrivateResp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for private asset, got %d", downloadPrivateResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
type S3Config struct {
|
||||
Endpoint string
|
||||
Region string
|
||||
Bucket string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
UseTLS bool
|
||||
PathStyle bool
|
||||
}
|
||||
|
||||
type S3Signer struct {
|
||||
client *minio.Client
|
||||
bucket string
|
||||
}
|
||||
|
||||
func NewS3Signer(cfg S3Config) (*S3Signer, error) {
|
||||
if cfg.Endpoint == "" || cfg.Bucket == "" {
|
||||
return nil, errors.New("s3 endpoint and bucket are required")
|
||||
}
|
||||
client, err := minio.New(cfg.Endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||
Secure: cfg.UseTLS,
|
||||
Region: cfg.Region,
|
||||
BucketLookup: bucketLookup(cfg.PathStyle),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &S3Signer{client: client, bucket: cfg.Bucket}, nil
|
||||
}
|
||||
|
||||
func bucketLookup(pathStyle bool) minio.BucketLookupType {
|
||||
if pathStyle {
|
||||
return minio.BucketLookupPath
|
||||
}
|
||||
return minio.BucketLookupAuto
|
||||
}
|
||||
|
||||
func (s *S3Signer) SignedPutObjectURL(ctx context.Context, objectKey string, expiry time.Duration, _ string) (string, error) {
|
||||
u, err := s.client.PresignedPutObject(ctx, s.bucket, objectKey, expiry)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (s *S3Signer) SignedGetObjectURL(ctx context.Context, objectKey string, expiry time.Duration) (string, error) {
|
||||
u, err := s.client.PresignedGetObject(ctx, s.bucket, objectKey, expiry, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
@@ -22,5 +22,12 @@ type Store interface {
|
||||
ListFeaturesByCollection(collectionID string) []Feature
|
||||
GetFeature(featureID string) (Feature, error)
|
||||
DeleteFeature(featureID string) error
|
||||
SaveAsset(a Asset)
|
||||
GetAsset(assetID string) (Asset, error)
|
||||
GetAssetByOwnerChecksumExt(ownerKey, checksum, ext string) (Asset, error)
|
||||
SetAssetPublic(assetID string, isPublic bool) error
|
||||
LinkAssetToFeature(featureID, assetID, name, description string) error
|
||||
UnlinkAssetFromFeature(featureID, assetID string) error
|
||||
ListAssetsByFeature(featureID string) []FeatureAsset
|
||||
PruneExpired(now time.Time)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ type MemoryStore struct {
|
||||
invitations map[string]Invitation
|
||||
collections map[string]Collection
|
||||
features map[string]Feature
|
||||
assets map[string]Asset
|
||||
featureRefs map[string]map[string]FeatureAsset
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
@@ -30,6 +32,8 @@ func NewMemoryStore() *MemoryStore {
|
||||
invitations: make(map[string]Invitation),
|
||||
collections: make(map[string]Collection),
|
||||
features: make(map[string]Feature),
|
||||
assets: make(map[string]Asset),
|
||||
featureRefs: make(map[string]map[string]FeatureAsset),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +170,7 @@ func (s *MemoryStore) DeleteCollection(id string) error {
|
||||
for fid, f := range s.features {
|
||||
if f.CollectionID == id {
|
||||
delete(s.features, fid)
|
||||
delete(s.featureRefs, fid)
|
||||
}
|
||||
}
|
||||
delete(s.collections, id)
|
||||
@@ -207,9 +212,110 @@ func (s *MemoryStore) DeleteFeature(featureID string) error {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(s.features, featureID)
|
||||
delete(s.featureRefs, featureID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) SaveAsset(a Asset) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.assets[a.ID] = a
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetAsset(assetID string) (Asset, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
a, ok := s.assets[assetID]
|
||||
if !ok {
|
||||
return Asset{}, ErrNotFound
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetAssetByOwnerChecksumExt(ownerKey, checksum, ext string) (Asset, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, a := range s.assets {
|
||||
if a.OwnerKey == ownerKey && a.Checksum == checksum && a.Ext == ext {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
return Asset{}, ErrNotFound
|
||||
}
|
||||
|
||||
func (s *MemoryStore) SetAssetPublic(assetID string, isPublic bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
a, ok := s.assets[assetID]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
a.IsPublic = isPublic
|
||||
a.UpdatedAt = time.Now().UTC()
|
||||
s.assets[assetID] = a
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) LinkAssetToFeature(featureID, assetID, name, description string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.features[featureID]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
a, ok := s.assets[assetID]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, ok := s.featureRefs[featureID]; !ok {
|
||||
s.featureRefs[featureID] = make(map[string]FeatureAsset)
|
||||
}
|
||||
if existing, exists := s.featureRefs[featureID][assetID]; exists {
|
||||
existing.Name = name
|
||||
existing.Description = description
|
||||
s.featureRefs[featureID][assetID] = existing
|
||||
return nil
|
||||
}
|
||||
s.featureRefs[featureID][assetID] = FeatureAsset{
|
||||
Asset: a,
|
||||
FeatureID: featureID,
|
||||
Name: name,
|
||||
Description: description,
|
||||
LinkedAt: time.Now().UTC(),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) UnlinkAssetFromFeature(featureID, assetID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
links, ok := s.featureRefs[featureID]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, exists := links[assetID]; !exists {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(links, assetID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListAssetsByFeature(featureID string) []FeatureAsset {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
links, ok := s.featureRefs[featureID]
|
||||
if !ok {
|
||||
return []FeatureAsset{}
|
||||
}
|
||||
result := make([]FeatureAsset, 0, len(links))
|
||||
for assetID, fa := range links {
|
||||
if updated, exists := s.assets[assetID]; exists {
|
||||
fa.Asset = updated
|
||||
}
|
||||
result = append(result, fa)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *MemoryStore) PruneExpired(now time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -3,8 +3,10 @@ package store
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
@@ -19,19 +21,25 @@ func Migrate(databaseURL string) error {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
entries, err := fs.Glob(migrationsFS, "migrations/*.sql")
|
||||
files, err := fs.ReadDir(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Strings(entries)
|
||||
for _, name := range entries {
|
||||
sql, err := migrationsFS.ReadFile(name)
|
||||
if err != nil {
|
||||
return err
|
||||
paths := make([]string, 0, len(files))
|
||||
for _, entry := range files {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".sql" {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Exec(string(sql)); err != nil {
|
||||
return err
|
||||
paths = append(paths, "migrations/"+entry.Name())
|
||||
}
|
||||
sort.Strings(paths)
|
||||
for _, path := range paths {
|
||||
sqlBytes, readErr := migrationsFS.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if _, execErr := db.Exec(string(sqlBytes)); execErr != nil {
|
||||
return fmt.Errorf("%s: %w", path, execErr)
|
||||
}
|
||||
}
|
||||
log.Printf("migrations applied")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_key TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
ext TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
mime_type TEXT,
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
object_key TEXT NOT NULL,
|
||||
is_public BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (owner_key, checksum, ext),
|
||||
UNIQUE (owner_key, object_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS feature_asset_links (
|
||||
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||
asset_id TEXT NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (feature_id, asset_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_owner ON assets(owner_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_owner_public ON assets(owner_key, is_public);
|
||||
CREATE INDEX IF NOT EXISTS idx_feature_asset_links_asset ON feature_asset_links(asset_id);
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
|
||||
ALTER TABLE features
|
||||
ADD COLUMN IF NOT EXISTS geom geometry(PointZ, 4326);
|
||||
|
||||
UPDATE features
|
||||
SET geom = ST_SetSRID(
|
||||
ST_MakePoint(
|
||||
(geometry->'coordinates'->>0)::double precision,
|
||||
(geometry->'coordinates'->>1)::double precision,
|
||||
CASE
|
||||
WHEN jsonb_array_length(geometry->'coordinates') >= 3 THEN (geometry->'coordinates'->>2)::double precision
|
||||
ELSE 0
|
||||
END
|
||||
),
|
||||
4326
|
||||
)
|
||||
WHERE geom IS NULL
|
||||
AND geometry ? 'coordinates';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_features_geom_gist ON features USING GIST (geom);
|
||||
+124
-3
@@ -219,11 +219,20 @@ func (s *PostgresStore) DeleteCollection(id string) error {
|
||||
func (s *PostgresStore) SaveFeature(f Feature) {
|
||||
geom, _ := json.Marshal(f.Geometry)
|
||||
props, _ := json.Marshal(f.Properties)
|
||||
z := 0.0
|
||||
if len(f.Geometry.Coordinates) >= 3 {
|
||||
z = f.Geometry.Coordinates[2]
|
||||
}
|
||||
_, _ = s.db.Exec(
|
||||
`INSERT INTO features (id, collection_id, owner_key, type, geometry, properties, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (id) DO UPDATE SET geometry = EXCLUDED.geometry, properties = EXCLUDED.properties, updated_at = EXCLUDED.updated_at`,
|
||||
`INSERT INTO features (id, collection_id, owner_key, type, geometry, properties, created_at, updated_at, geom)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, ST_SetSRID(ST_MakePoint($9, $10, $11), 4326))
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET geometry = EXCLUDED.geometry,
|
||||
properties = EXCLUDED.properties,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
geom = EXCLUDED.geom`,
|
||||
f.ID, f.CollectionID, f.OwnerKey, f.Type, geom, props, f.CreatedAt, f.UpdatedAt,
|
||||
f.Geometry.Coordinates[0], f.Geometry.Coordinates[1], z,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -289,6 +298,118 @@ func (s *PostgresStore) SaveUserLogin(ul UserLogin) {
|
||||
)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) SaveAsset(a Asset) {
|
||||
_, _ = s.db.Exec(
|
||||
`INSERT INTO assets (id, owner_key, checksum, ext, kind, mime_type, size_bytes, object_key, is_public, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET kind = EXCLUDED.kind,
|
||||
mime_type = EXCLUDED.mime_type,
|
||||
size_bytes = EXCLUDED.size_bytes,
|
||||
is_public = EXCLUDED.is_public,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
a.ID, a.OwnerKey, a.Checksum, a.Ext, a.Kind, nullStr(a.MimeType), a.SizeBytes, a.ObjectKey, a.IsPublic, a.CreatedAt, a.UpdatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetAsset(assetID string) (Asset, error) {
|
||||
var a Asset
|
||||
var mimeType sql.NullString
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, owner_key, checksum, ext, kind, mime_type, size_bytes, object_key, is_public, created_at, updated_at
|
||||
FROM assets WHERE id = $1`,
|
||||
assetID,
|
||||
).Scan(&a.ID, &a.OwnerKey, &a.Checksum, &a.Ext, &a.Kind, &mimeType, &a.SizeBytes, &a.ObjectKey, &a.IsPublic, &a.CreatedAt, &a.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Asset{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Asset{}, err
|
||||
}
|
||||
a.MimeType = mimeType.String
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetAssetByOwnerChecksumExt(ownerKey, checksum, ext string) (Asset, error) {
|
||||
var a Asset
|
||||
var mimeType sql.NullString
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, owner_key, checksum, ext, kind, mime_type, size_bytes, object_key, is_public, created_at, updated_at
|
||||
FROM assets WHERE owner_key = $1 AND checksum = $2 AND ext = $3`,
|
||||
ownerKey, checksum, ext,
|
||||
).Scan(&a.ID, &a.OwnerKey, &a.Checksum, &a.Ext, &a.Kind, &mimeType, &a.SizeBytes, &a.ObjectKey, &a.IsPublic, &a.CreatedAt, &a.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Asset{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Asset{}, err
|
||||
}
|
||||
a.MimeType = mimeType.String
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) SetAssetPublic(assetID string, isPublic bool) error {
|
||||
res, err := s.db.Exec(`UPDATE assets SET is_public = $2, updated_at = NOW() WHERE id = $1`, assetID, isPublic)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) LinkAssetToFeature(featureID, assetID, name, description string) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO feature_asset_links (feature_id, asset_id, name, description)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (feature_id, asset_id) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description`,
|
||||
featureID, assetID, nullStr(name), nullStr(description),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UnlinkAssetFromFeature(featureID, assetID string) error {
|
||||
res, err := s.db.Exec(`DELETE FROM feature_asset_links WHERE feature_id = $1 AND asset_id = $2`, featureID, assetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListAssetsByFeature(featureID string) []FeatureAsset {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT a.id, a.owner_key, a.checksum, a.ext, a.kind, COALESCE(a.mime_type, ''), a.size_bytes, a.object_key,
|
||||
a.is_public, a.created_at, a.updated_at,
|
||||
l.feature_id, COALESCE(l.name, ''), COALESCE(l.description, ''), l.created_at
|
||||
FROM feature_asset_links l
|
||||
JOIN assets a ON a.id = l.asset_id
|
||||
WHERE l.feature_id = $1
|
||||
ORDER BY l.created_at`,
|
||||
featureID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]FeatureAsset, 0)
|
||||
for rows.Next() {
|
||||
var fa FeatureAsset
|
||||
if err := rows.Scan(
|
||||
&fa.ID, &fa.OwnerKey, &fa.Checksum, &fa.Ext, &fa.Kind, &fa.MimeType, &fa.SizeBytes, &fa.ObjectKey,
|
||||
&fa.IsPublic, &fa.CreatedAt, &fa.UpdatedAt, &fa.FeatureID, &fa.Name, &fa.Description, &fa.LinkedAt,
|
||||
); err != nil {
|
||||
return result
|
||||
}
|
||||
result = append(result, fa)
|
||||
}
|
||||
return result
|
||||
}
|
||||
func (s *PostgresStore) PruneExpired(now time.Time) {
|
||||
_, _ = s.db.Exec(`DELETE FROM challenges WHERE expires_at < $1`, now)
|
||||
_, _ = s.db.Exec(`DELETE FROM sessions WHERE expires_at < $1`, now)
|
||||
|
||||
@@ -60,3 +60,25 @@ type Feature struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Asset struct {
|
||||
ID string `json:"id"`
|
||||
OwnerKey string `json:"ownerKey"`
|
||||
Checksum string `json:"checksum"`
|
||||
Ext string `json:"ext"`
|
||||
Kind string `json:"kind"`
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
ObjectKey string `json:"objectKey"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type FeatureAsset struct {
|
||||
Asset
|
||||
FeatureID string `json:"featureId"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
LinkedAt time.Time `json:"linkedAt"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user