| 1 | package attachment |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/rand" |
| 7 | "encoding/hex" |
| 8 | "io" |
| 9 | "maps" |
| 10 | "sync" |
| 11 | |
| 12 | "reasonix/internal/sessioncontent" |
| 13 | ) |
| 14 | |
| 15 | // Service validates images and publishes originals into a session content store. |
| 16 | type Service struct { |
| 17 | store *sessioncontent.Store |
| 18 | policy Policy |
| 19 | cache *VariantCache |
| 20 | drafts *DraftStore |
| 21 | } |
| 22 | |
| 23 | func NewService(store *sessioncontent.Store, cache *VariantCache) *Service { |
| 24 | if cache == nil { |
| 25 | cache = NewVariantCache(DefaultCacheBytes, DefaultTransforms) |
| 26 | } |
| 27 | return &Service{store: store, policy: DefaultPolicy(), cache: cache, drafts: NewDraftStore()} |
| 28 | } |
| 29 | |
| 30 | func (s *Service) WithPolicy(policy Policy) *Service { |
| 31 | if s == nil { |
| 32 | return nil |
| 33 | } |
| 34 | out := *s |
| 35 | out.policy = policy.withDefaults() |
| 36 | return &out |
| 37 | } |
| 38 | |
| 39 | func (s *Service) Store() *sessioncontent.Store { |
| 40 | if s == nil { |
| 41 | return nil |
| 42 | } |
| 43 | return s.store |
| 44 | } |
| 45 | |
| 46 | func (s *Service) Drafts() *DraftStore { |
| 47 | if s == nil { |
| 48 | return nil |
| 49 | } |
| 50 | return s.drafts |
| 51 | } |
| 52 | |
| 53 | func (s *Service) Cache() *VariantCache { |
| 54 | if s == nil { |
| 55 | return nil |
| 56 | } |
| 57 | return s.cache |
| 58 | } |
| 59 | |
| 60 | func (s *Service) PrepareBatch(ctx context.Context, sources []Source) (PreparedImages, error) { |
| 61 | if s == nil { |
| 62 | return PreparedImages{}, Error{Code: CodeUnreadable, Message: "attachment service unavailable"} |
| 63 | } |
| 64 | if err := ctx.Err(); err != nil { |
| 65 | return PreparedImages{}, canceledError(err) |
| 66 | } |
| 67 | policy := s.policy.withDefaults() |
| 68 | if len(sources) > policy.MaxCount { |
| 69 | return PreparedImages{}, Error{Code: CodeTooMany, Message: defaultDetail(CodeTooMany)} |
| 70 | } |
| 71 | out := PreparedImages{Items: make([]PreparedImage, 0, len(sources))} |
| 72 | var failures BatchError |
| 73 | var total int64 |
| 74 | for i, source := range sources { |
| 75 | if err := ctx.Err(); err != nil { |
| 76 | return PreparedImages{}, canceledError(err) |
| 77 | } |
| 78 | item, err := s.prepareSource(ctx, source, policy) |
| 79 | if err != nil { |
| 80 | itemErr := asError(err) |
| 81 | itemErr.Index = i + 1 |
| 82 | if itemErr.Name == "" { |
| 83 | itemErr.Name = source.displayName() |
| 84 | } |
| 85 | failures = append(failures, itemErr) |
| 86 | continue |
| 87 | } |
| 88 | size := int64(len(item.Bytes)) |
| 89 | if item.Existing != nil { |
| 90 | size = item.Existing.Content.Bytes |
| 91 | } |
| 92 | total += size |
| 93 | if total > policy.MaxBatchBytes { |
| 94 | failures = append(failures, Error{Code: CodeBatchSize, Name: item.DisplayName, Index: i + 1, Message: defaultDetail(CodeBatchSize)}) |
| 95 | continue |
| 96 | } |
| 97 | out.Items = append(out.Items, item) |
| 98 | } |
| 99 | if len(failures) > 0 { |
| 100 | return PreparedImages{}, failures |
| 101 | } |
| 102 | return out, nil |
| 103 | } |
| 104 | |
| 105 | func (s *Service) prepareSource(ctx context.Context, source Source, policy Policy) (PreparedImage, error) { |
| 106 | select { |
| 107 | case s.cache.transforms <- struct{}{}: |
| 108 | defer func() { <-s.cache.transforms }() |
| 109 | case <-ctx.Done(): |
| 110 | return PreparedImage{}, canceledError(ctx.Err()) |
| 111 | } |
| 112 | if err := ctx.Err(); err != nil { |
| 113 | return PreparedImage{}, canceledError(err) |
| 114 | } |
| 115 | if source.Existing == nil { |
| 116 | return source.Read(ctx, policy) |
| 117 | } |
| 118 | ref := source.Existing |
| 119 | if ref.Content.Bytes > policy.MaxBytes { |
| 120 | return PreparedImage{}, Error{Code: CodeSize, Message: defaultDetail(CodeSize)} |
| 121 | } |
| 122 | raw, err := s.ReadVerified(ctx, *ref) |
| 123 | if err != nil { |
| 124 | return PreparedImage{}, err |
| 125 | } |
| 126 | item, err := (Source{Bytes: raw, DeclaredMIME: ref.MIME(), DisplayName: source.displayName()}).Read(ctx, policy) |
| 127 | if err != nil { |
| 128 | return PreparedImage{}, err |
| 129 | } |
| 130 | if item.Width != ref.Width || item.Height != ref.Height { |
| 131 | return PreparedImage{}, Error{Code: CodeCorrupt, Message: defaultDetail(CodeCorrupt)} |
| 132 | } |
| 133 | item.Existing = ref |
| 134 | item.Bytes = nil |
| 135 | return item, nil |
| 136 | } |
| 137 | |
| 138 | func (s *Service) CommitBatch(ctx context.Context, prepared PreparedImages) ([]AttachmentRef, error) { |
| 139 | if s == nil || s.store == nil { |
| 140 | return nil, Error{Code: CodeUnreadable, Message: "attachment store unavailable"} |
| 141 | } |
| 142 | if err := ctx.Err(); err != nil { |
| 143 | return nil, canceledError(err) |
| 144 | } |
| 145 | refs := make([]AttachmentRef, 0, len(prepared.Items)) |
| 146 | for i, item := range prepared.Items { |
| 147 | if err := ctx.Err(); err != nil { |
| 148 | return nil, canceledError(err) |
| 149 | } |
| 150 | if item.Existing != nil { |
| 151 | if err := s.store.Verify(ctx, item.Existing.Content); err != nil { |
| 152 | return nil, Error{Code: CodeCorrupt, Name: item.DisplayName, Index: i + 1, Message: defaultDetail(CodeCorrupt), Cause: err} |
| 153 | } |
| 154 | refs = append(refs, *item.Existing) |
| 155 | continue |
| 156 | } |
| 157 | ref, err := s.store.Put(ctx, bytes.NewReader(item.Bytes), sessioncontent.Metadata{MediaType: item.MIME, Name: item.DisplayName}) |
| 158 | if err != nil { |
| 159 | return nil, Error{Code: CodeUnreadable, Name: item.DisplayName, Index: i + 1, Message: "could not persist the image", Cause: err, Retry: true} |
| 160 | } |
| 161 | refs = append(refs, AttachmentRef{ |
| 162 | Version: RefVersion, |
| 163 | Content: ref, |
| 164 | Width: item.Width, |
| 165 | Height: item.Height, |
| 166 | DisplayName: item.DisplayName, |
| 167 | }) |
| 168 | } |
| 169 | return refs, nil |
| 170 | } |
| 171 | |
| 172 | func (s *Service) ReadVerified(ctx context.Context, ref AttachmentRef) ([]byte, error) { |
| 173 | if s == nil || s.store == nil { |
| 174 | return nil, Error{Code: CodeUnreadable, Message: "attachment store unavailable"} |
| 175 | } |
| 176 | if err := ref.Validate(); err != nil { |
| 177 | return nil, err |
| 178 | } |
| 179 | f, err := s.store.Open(ctx, ref.Content) |
| 180 | if err != nil { |
| 181 | return nil, Error{Code: CodeUnreadable, Name: ref.DisplayName, Message: defaultDetail(CodeUnreadable), Cause: err} |
| 182 | } |
| 183 | defer f.Close() |
| 184 | raw, err := io.ReadAll(f) |
| 185 | if err != nil { |
| 186 | return nil, Error{Code: CodeUnreadable, Name: ref.DisplayName, Message: defaultDetail(CodeUnreadable), Cause: err} |
| 187 | } |
| 188 | if int64(len(raw)) != ref.Content.Bytes { |
| 189 | return nil, Error{Code: CodeCorrupt, Name: ref.DisplayName, Message: defaultDetail(CodeCorrupt)} |
| 190 | } |
| 191 | return raw, nil |
| 192 | } |
| 193 | |
| 194 | func (s *Service) InputsFromRefs(refs []AttachmentRef) []ImageInput { |
| 195 | out := make([]ImageInput, 0, len(refs)) |
| 196 | for i := range refs { |
| 197 | ref := refs[i] |
| 198 | out = append(out, ImageInput{Kind: KindAttachment, Attachment: &ref}) |
| 199 | } |
| 200 | return out |
| 201 | } |
| 202 | |
| 203 | func asError(err error) Error { |
| 204 | var item Error |
| 205 | if err != nil && errorsAs(err, &item) { |
| 206 | return item |
| 207 | } |
| 208 | return Error{Code: CodeUnreadable, Message: defaultDetail(CodeUnreadable), Cause: err, Retry: true} |
| 209 | } |
| 210 | |
| 211 | func newID() string { |
| 212 | var b [16]byte |
| 213 | if _, err := rand.Read(b[:]); err != nil { |
| 214 | panic("attachment: random id: " + err.Error()) |
| 215 | } |
| 216 | return hex.EncodeToString(b[:]) |
| 217 | } |
| 218 | |
| 219 | // DraftStore issues opaque draft credentials bound to a caller-chosen key. |
| 220 | type DraftStore struct { |
| 221 | mu sync.Mutex |
| 222 | items map[string]draftRecord |
| 223 | } |
| 224 | |
| 225 | type draftRecord struct { |
| 226 | scope string |
| 227 | draft DraftCredential |
| 228 | family string |
| 229 | needsRebind bool |
| 230 | } |
| 231 | |
| 232 | func NewDraftStore() *DraftStore { |
| 233 | return &DraftStore{items: map[string]draftRecord{}} |
| 234 | } |
| 235 | |
| 236 | func (d *DraftStore) Issue(scope string, ref AttachmentRef) DraftCredential { |
| 237 | if d == nil { |
| 238 | d = NewDraftStore() |
| 239 | } |
| 240 | draft := DraftCredential{ |
| 241 | ID: newID(), |
| 242 | Ref: ref, |
| 243 | DisplayName: ref.DisplayName, |
| 244 | MIME: ref.MIME(), |
| 245 | Width: ref.Width, |
| 246 | Height: ref.Height, |
| 247 | Bytes: ref.Content.Bytes, |
| 248 | } |
| 249 | d.mu.Lock() |
| 250 | d.items[draft.ID] = draftRecord{scope: scope, draft: draft, family: draft.ID} |
| 251 | d.mu.Unlock() |
| 252 | return draft |
| 253 | } |
| 254 | |
| 255 | func (d *DraftStore) Lookup(scope, id string) (DraftCredential, bool) { |
| 256 | if d == nil { |
| 257 | return DraftCredential{}, false |
| 258 | } |
| 259 | d.mu.Lock() |
| 260 | defer d.mu.Unlock() |
| 261 | item, ok := d.items[id] |
| 262 | if !ok || item.scope != scope { |
| 263 | return DraftCredential{}, false |
| 264 | } |
| 265 | return item.draft, true |
| 266 | } |
| 267 | |
| 268 | func (d *DraftStore) Resolve(scope string, ids []string) ([]AttachmentRef, error) { |
| 269 | refs := make([]AttachmentRef, 0, len(ids)) |
| 270 | for i, id := range ids { |
| 271 | draft, ok := d.Lookup(scope, id) |
| 272 | if !ok { |
| 273 | return nil, Error{Code: CodeMissing, Name: "image", Index: i + 1, Message: "draft credential is not valid", Retry: true} |
| 274 | } |
| 275 | refs = append(refs, draft.Ref) |
| 276 | } |
| 277 | return refs, nil |
| 278 | } |
| 279 | |
| 280 | func (d *DraftStore) Release(scope, id string) { |
| 281 | if d == nil { |
| 282 | return |
| 283 | } |
| 284 | d.mu.Lock() |
| 285 | defer d.mu.Unlock() |
| 286 | item, ok := d.items[id] |
| 287 | if ok && item.scope == scope { |
| 288 | for key, related := range d.items { |
| 289 | if related.scope == scope && related.family == item.family { |
| 290 | delete(d.items, key) |
| 291 | } |
| 292 | } |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | func (d *DraftStore) ReleaseScope(scope string) { |
| 297 | if d == nil { |
| 298 | return |
| 299 | } |
| 300 | d.mu.Lock() |
| 301 | defer d.mu.Unlock() |
| 302 | for id, item := range d.items { |
| 303 | if item.scope == scope { |
| 304 | delete(d.items, id) |
| 305 | } |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | // CopyScopeTo transfers credentials during an owner-authorized replacement. |
| 310 | // It cannot grant access to another session or storage generation. |
| 311 | func (d *DraftStore) CopyScopeTo(scope string, target *DraftStore) { |
| 312 | if d == nil || target == nil || d == target { |
| 313 | return |
| 314 | } |
| 315 | d.mu.Lock() |
| 316 | items := make(map[string]draftRecord) |
| 317 | for id, item := range d.items { |
| 318 | if item.scope == scope { |
| 319 | item.needsRebind = true |
| 320 | items[id] = item |
| 321 | } |
| 322 | } |
| 323 | d.mu.Unlock() |
| 324 | target.mu.Lock() |
| 325 | defer target.mu.Unlock() |
| 326 | maps.Copy(target.items, items) |
| 327 | } |
| 328 |