// Package assets owns asset visibility, metadata, and blob lifecycle policy. package assets import ( "bytes" "context" "encoding/json" "errors" "fmt" "html" "io" "path" "regexp" "strings" "time" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/publicapi" ) const PublicJobLookupLimit = 200 var ( ErrNotFound = errors.New("asset not found") ErrBlobNotFound = errors.New("asset blob not found") ErrUnsafeBlobKey = errors.New("unsafe blob key") ErrRemoteProtocol = errors.New("remote asset protocol is not allowed") ErrRemoteTooLarge = errors.New("remote asset exceeds size limit") ) type Kind string const ( KindImage Kind = "image" KindVideo Kind = "video" KindMask Kind = "mask" KindReference Kind = "reference" KindOther Kind = "other" ) type Source string const ( SourceUpload Source = "upload" SourceGenerated Source = "generated" SourceEdited Source = "edited" SourceUpscaled Source = "upscaled" SourceExternal Source = "external" SourceSeed Source = "seed" ) type Asset struct { ID string `json:"id"` OwnerID string `json:"ownerId"` Kind Kind `json:"kind"` Name string `json:"name"` URL string `json:"url"` StoragePath string `json:"storagePath,omitempty"` Source Source `json:"source"` Tags []string `json:"tags"` Metadata map[string]any `json:"metadata"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } // MarshalJSON preserves JavaScript Date.toISOString() millisecond precision. func (a Asset) MarshalJSON() ([]byte, error) { type wireAsset struct { ID string `json:"id"` OwnerID string `json:"ownerId"` Kind Kind `json:"kind"` Name string `json:"name"` URL string `json:"url"` StoragePath string `json:"storagePath,omitempty"` Source Source `json:"source"` Tags []string `json:"tags"` Metadata map[string]any `json:"metadata"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } return json.Marshal(wireAsset{ ID: a.ID, OwnerID: a.OwnerID, Kind: a.Kind, Name: a.Name, URL: a.URL, StoragePath: a.StoragePath, Source: a.Source, Tags: a.Tags, Metadata: a.Metadata, CreatedAt: a.CreatedAt.UTC().Format("2006-01-02T15:04:05.000Z"), UpdatedAt: a.UpdatedAt.UTC().Format("2006-01-02T15:04:05.000Z"), }) } type scopeKind uint8 const ( platformScope scopeKind = iota publicScope ) type Scope struct { kind scopeKind ownerID, clientID string } func PlatformScope(ownerID string) Scope { return Scope{kind: platformScope, ownerID: ownerID} } func PublicScope(clientID string) Scope { return Scope{kind: publicScope, ownerID: publicapi.OwnerID(clientID), clientID: clientID} } func ClientTag(clientID string) string { return "api-client:" + clientID } type Catalog interface { ListOwner(context.Context, string) ([]Asset, error) GetOwner(context.Context, string, string) (Asset, bool, error) ListPublic(context.Context, string, string, int) ([]Asset, error) GetPublic(context.Context, string, string, string, int) (Asset, bool, error) Create(context.Context, Asset) (Asset, error) DeleteOwner(context.Context, string, string) (Asset, bool, error) } type storagePathCatalog interface { GetOwnerByStoragePath(context.Context, string, string) (Asset, bool, error) } type StoredObject struct{ Key, URL string } type Blob struct { Body io.ReadCloser ContentType string Size int64 } type BlobStore interface { Put(context.Context, string, io.Reader, int64, string) (StoredObject, error) Read(context.Context, string) (Blob, error) Delete(context.Context, string) error } type RemoteFetcher interface { Fetch(context.Context, string) (Blob, error) } type Service struct { catalog Catalog blobs BlobStore remote RemoteFetcher now func() time.Time id func(string) string } func NewService(c Catalog, b BlobStore, r RemoteFetcher, now func() time.Time, id func(string) string) *Service { if now == nil { now = time.Now } if id == nil { id = func(prefix string) string { return fmt.Sprintf("%s-%d", prefix, now().UnixNano()) } } return &Service{catalog: c, blobs: b, remote: r, now: now, id: id} } func (s *Service) List(ctx context.Context, scope Scope) ([]Asset, error) { if err := validScope(scope); err != nil { return nil, err } if scope.kind == publicScope { return s.catalog.ListPublic(ctx, scope.ownerID, scope.clientID, PublicJobLookupLimit) } return s.catalog.ListOwner(ctx, scope.ownerID) } func (s *Service) Get(ctx context.Context, scope Scope, id string) (Asset, error) { if err := validScope(scope); err != nil { return Asset{}, err } var a Asset var found bool var err error if scope.kind == publicScope { a, found, err = s.catalog.GetPublic(ctx, scope.ownerID, scope.clientID, id, PublicJobLookupLimit) } else { a, found, err = s.catalog.GetOwner(ctx, scope.ownerID, id) } if err != nil { return Asset{}, err } if !found { return Asset{}, ErrNotFound } return a, nil } type CreateExternalCommand struct { URL, Name string Kind Kind Source Source Tags []string } func (s *Service) CreateExternal(ctx context.Context, scope Scope, cmd CreateExternalCommand) (Asset, error) { if err := validScope(scope); err != nil { return Asset{}, err } if strings.TrimSpace(cmd.URL) == "" { return Asset{}, errors.New("url is required") } now := s.now().UTC() kind := cmd.Kind if kind == "" { kind = KindImage } source := cmd.Source if source == "" { source = SourceExternal } name := cmd.Name tags := cloneStrings(cmd.Tags) metadata := map[string]any{} if scope.kind == publicScope { name = defaultString(name, "外部素材") tags = appendUnique(tags, "external", "public-api", ClientTag(scope.clientID)) metadata["registeredFrom"] = "public-api" metadata["externalClientId"] = scope.clientID } else { name = defaultString(name, "外部图片") if cmd.Tags == nil { tags = []string{"external"} } metadata["registeredFrom"] = "api" } a := Asset{ID: s.id("asset"), OwnerID: scope.ownerID, Kind: kind, Name: name, URL: cmd.URL, Source: source, Tags: tags, Metadata: metadata, CreatedAt: now, UpdatedAt: now} return s.catalog.Create(ctx, a) } type UploadCommand struct { Bytes []byte FileName, ContentType, Origin string Kind Kind Tags []string } type ImportGeneratedCommand struct { URL, Name, Capability, JobID string Kind Kind Source Source Tags []string Metadata map[string]any } // ImportMockCommand describes a locally generated development result. Unlike // ImportGeneratedCommand it carries no URL, so mock output never enters the // remote-fetch/SSRF boundary. type ImportMockCommand struct { Name, Capability, JobID string Kind Kind Tags []string Metadata map[string]any } func (s *Service) Upload(ctx context.Context, scope Scope, cmd UploadCommand) (Asset, error) { if err := validScope(scope); err != nil { return Asset{}, err } if s.blobs == nil { return Asset{}, errors.New("blob store is unavailable") } name := sanitizeFileName(cmd.FileName) key := path.Join("uploads", s.now().UTC().Format("2006-01-02"), s.id("file")+"-"+name) stored, err := s.blobs.Put(ctx, key, bytes.NewReader(cmd.Bytes), int64(len(cmd.Bytes)), cmd.ContentType) if err != nil { return Asset{}, err } kind := cmd.Kind if kind == "" { kind = inferKind(cmd.ContentType) } tags := cloneStrings(cmd.Tags) if scope.kind == publicScope { tags = appendUnique(tags, "upload", "public-api", ClientTag(scope.clientID)) } else if cmd.Tags == nil { tags = []string{"upload"} } now := s.now().UTC() a := Asset{ID: s.id("asset"), OwnerID: scope.ownerID, Kind: kind, Name: cmd.FileName, URL: stored.URL, StoragePath: stored.Key, Source: SourceUpload, Tags: tags, Metadata: map[string]any{"contentType": cmd.ContentType, "size": len(cmd.Bytes)}, CreatedAt: now, UpdatedAt: now} created, err := s.catalog.Create(ctx, a) if err != nil { _ = s.blobs.Delete(context.WithoutCancel(ctx), stored.Key) return Asset{}, err } return created, nil } // ImportGenerated fetches a provider result through the bounded RemoteFetcher, // stores it in the configured BlobStore, and only then creates relational // metadata. Upload already owns blob compensation when catalog persistence // fails, so provider outputs share the same crash-safe write ordering. func (s *Service) ImportGenerated(ctx context.Context, scope Scope, cmd ImportGeneratedCommand) (Asset, error) { if strings.TrimSpace(cmd.URL) == "" || s.remote == nil { return Asset{}, ErrBlobNotFound } blob, err := s.remote.Fetch(ctx, cmd.URL) if err != nil { return Asset{}, err } defer blob.Body.Close() maximum := blob.Size if maximum < 0 { maximum = 64 << 20 } content, err := io.ReadAll(io.LimitReader(blob.Body, maximum+1)) if err != nil || int64(len(content)) > maximum { return Asset{}, ErrRemoteTooLarge } name := cmd.Name if strings.TrimSpace(name) == "" { name = path.Base(strings.SplitN(cmd.URL, "?", 2)[0]) } if s.blobs == nil { return Asset{}, errors.New("blob store is unavailable") } cleanName := sanitizeFileName(name) key := path.Join("generated-results", s.now().UTC().Format("2006-01-02"), s.id("file")+"-"+cleanName) stored, err := s.blobs.Put(ctx, key, bytes.NewReader(content), int64(len(content)), blob.ContentType) if err != nil { return Asset{}, err } kind := cmd.Kind if kind == "" { kind = inferKind(blob.ContentType) } now := s.now().UTC() source := cmd.Source if source == "" { source = SourceGenerated } metadata := cloneMetadata(cmd.Metadata) metadata["contentType"] = blob.ContentType metadata["size"] = len(content) metadata["capability"] = cmd.Capability metadata["jobId"] = cmd.JobID metadata["importedFrom"] = cmd.URL asset := Asset{ID: s.id("asset"), OwnerID: scope.ownerID, Kind: kind, Name: name, URL: stored.URL, StoragePath: stored.Key, Source: source, Tags: cloneStrings(cmd.Tags), Metadata: metadata, CreatedAt: now, UpdatedAt: now} created, err := s.catalog.Create(ctx, asset) if err != nil { _ = s.blobs.Delete(context.WithoutCancel(ctx), stored.Key) return Asset{}, err } return created, nil } // ImportMock stores a small deterministic placeholder in the configured blob // store and registers it as a generated asset. It is intentionally a separate // path from remote imports: relative mock provider URLs are never interpreted // as fetch destinations. func (s *Service) ImportMock(ctx context.Context, scope Scope, cmd ImportMockCommand) (Asset, error) { if err := validScope(scope); err != nil { return Asset{}, err } if s.blobs == nil { return Asset{}, errors.New("blob store is unavailable") } kind := cmd.Kind if kind == "" { kind = KindImage } name, contentType, content := mockOutput(cmd.Name, kind, cmd.JobID) key := path.Join("generated-results", s.now().UTC().Format("2006-01-02"), s.id("file")+"-"+sanitizeFileName(name)) stored, err := s.blobs.Put(ctx, key, bytes.NewReader(content), int64(len(content)), contentType) if err != nil { return Asset{}, err } now := s.now().UTC() metadata := cloneMetadata(cmd.Metadata) metadata["contentType"] = contentType metadata["size"] = len(content) metadata["capability"] = cmd.Capability metadata["jobId"] = cmd.JobID metadata["mock"] = true asset := Asset{ID: s.id("asset"), OwnerID: scope.ownerID, Kind: kind, Name: name, URL: stored.URL, StoragePath: stored.Key, Source: SourceGenerated, Tags: cloneStrings(cmd.Tags), Metadata: metadata, CreatedAt: now, UpdatedAt: now} created, err := s.catalog.Create(ctx, asset) if err != nil { _ = s.blobs.Delete(context.WithoutCancel(ctx), stored.Key) return Asset{}, err } return created, nil } func mockOutput(name string, kind Kind, jobID string) (string, string, []byte) { if kind == KindVideo { return defaultString(name, "mock-video.mp4"), "video/mp4", []byte("mock video result for " + jobID + "\n") } name = defaultString(name, "mock-image.svg") content := `Mock image` + html.EscapeString(jobID) + `` return name, "image/svg+xml", []byte(content) } func (s *Service) Delete(ctx context.Context, scope Scope, id string) (Asset, error) { a, err := s.Get(ctx, scope, id) if err != nil { return Asset{}, err } if a.StoragePath != "" && s.blobs != nil { if err := s.blobs.Delete(ctx, a.StoragePath); err != nil && !errors.Is(err, ErrBlobNotFound) { return Asset{}, err } } deleted, found, err := s.catalog.DeleteOwner(ctx, scope.ownerID, id) if err != nil { return Asset{}, err } if !found { return Asset{}, ErrNotFound } return deleted, nil } func (s *Service) Download(ctx context.Context, scope Scope, id string) (Blob, error) { a, err := s.Get(ctx, scope, id) if err != nil { return Blob{}, err } if a.StoragePath != "" && s.blobs != nil { b, e := s.blobs.Read(ctx, a.StoragePath) if e == nil { return b, nil } if !errors.Is(e, ErrBlobNotFound) { return Blob{}, e } } if s.remote == nil { return Blob{}, ErrBlobNotFound } return s.remote.Fetch(ctx, a.URL) } // DownloadPath serves a stored object only after its metadata has been found // in the requesting owner's catalog. Catalogs that support HTTP file serving // implement the optional storage-path lookup without widening other callers. func (s *Service) DownloadPath(ctx context.Context, scope Scope, storagePath string) (Blob, error) { if err := validScope(scope); err != nil { return Blob{}, err } lookup, ok := s.catalog.(storagePathCatalog) if !ok { return Blob{}, ErrNotFound } a, found, err := lookup.GetOwnerByStoragePath(ctx, scope.ownerID, storagePath) if err != nil { return Blob{}, err } if !found || a.StoragePath == "" { return Blob{}, ErrNotFound } if s.blobs == nil { return Blob{}, ErrBlobNotFound } return s.blobs.Read(ctx, a.StoragePath) } func validScope(s Scope) error { if strings.TrimSpace(s.ownerID) == "" { return errors.New("asset owner is required") } if s.kind == publicScope && strings.TrimSpace(s.clientID) == "" { return errors.New("public client is required") } return nil } func defaultString(v, d string) string { if strings.TrimSpace(v) == "" { return d } return v } func appendUnique(values []string, add ...string) []string { for _, v := range add { if !contains(values, v) { values = append(values, v) } } return values } func cloneStrings(values []string) []string { if values == nil { return nil } return append([]string{}, values...) } func cloneMetadata(values map[string]any) map[string]any { result := make(map[string]any, len(values)+5) for key, value := range values { result[key] = value } return result } func contains(values []string, want string) bool { for _, v := range values { if v == want { return true } } return false } var unsafeName = regexp.MustCompile(`[^A-Za-z0-9._-]+`) func sanitizeFileName(v string) string { v = strings.Trim(unsafeName.ReplaceAllString(v, "-"), "-") if v == "" { return "asset.bin" } return v } func inferKind(contentType string) Kind { if strings.HasPrefix(contentType, "image/") { return KindImage } if strings.HasPrefix(contentType, "video/") { return KindVideo } return KindOther }