551 lines
19 KiB
Go
551 lines
19 KiB
Go
// Package evidence stores INTERNAL_RAW PNG assets outside the public web tree.
|
|
package evidence
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"image/png"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"cmbuyer/admin/internal/deviceauth"
|
|
core "cmbuyer/admin/internal/evidence"
|
|
)
|
|
|
|
var pngSignature = []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}
|
|
|
|
type Store struct {
|
|
database *sql.DB
|
|
root string
|
|
now func() time.Time
|
|
random io.Reader
|
|
syncDirectory func(string) error
|
|
syncFile func(*os.File) error
|
|
renameFile func(string, string) error
|
|
commitTx func(*sql.Tx) error
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func NewStore(database *sql.DB, root string) (*Store, error) {
|
|
return newStore(database, root, syncDirectory)
|
|
}
|
|
|
|
func newStore(database *sql.DB, root string, directorySync func(string) error) (*Store, error) {
|
|
if database == nil {
|
|
return nil, errors.New("evidence database is required")
|
|
}
|
|
if directorySync == nil {
|
|
return nil, errors.New("evidence directory sync is required")
|
|
}
|
|
if root == "" || !filepath.IsAbs(root) {
|
|
return nil, errors.New("evidence root must be an absolute path")
|
|
}
|
|
absolute, err := filepath.Abs(filepath.Clean(root))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve evidence root: %w", err)
|
|
}
|
|
if filepath.Dir(absolute) == absolute {
|
|
return nil, errors.New("evidence root cannot be a filesystem root")
|
|
}
|
|
if err := ensureDurableDirectory(absolute, 0o700, directorySync); err != nil {
|
|
return nil, fmt.Errorf("create evidence root: %w", err)
|
|
}
|
|
// A prior startup may have created the root and then failed its parent sync.
|
|
// Existence is therefore never accepted as proof that the directory entry is durable.
|
|
if err := directorySync(filepath.Dir(absolute)); err != nil {
|
|
return nil, fmt.Errorf("persist evidence root directory: %w", err)
|
|
}
|
|
if err := os.Chmod(absolute, 0o700); err != nil {
|
|
return nil, fmt.Errorf("protect evidence root: %w", err)
|
|
}
|
|
staging := filepath.Join(absolute, ".staging")
|
|
if err := ensureDurableDirectory(staging, 0o700, directorySync); err != nil {
|
|
return nil, fmt.Errorf("create evidence staging directory: %w", err)
|
|
}
|
|
if err := directorySync(absolute); err != nil {
|
|
return nil, fmt.Errorf("persist evidence staging directory: %w", err)
|
|
}
|
|
if err := os.Chmod(staging, 0o700); err != nil {
|
|
return nil, fmt.Errorf("protect evidence staging directory: %w", err)
|
|
}
|
|
if _, err := database.Exec("SELECT storage_key FROM evidence_assets LIMIT 1"); err != nil {
|
|
return nil, fmt.Errorf("evidence migration is not available: %w", err)
|
|
}
|
|
return &Store{
|
|
database: database,
|
|
root: absolute,
|
|
now: time.Now,
|
|
random: rand.Reader,
|
|
syncDirectory: directorySync,
|
|
syncFile: func(file *os.File) error { return file.Sync() },
|
|
renameFile: os.Rename,
|
|
commitTx: func(transaction *sql.Tx) error { return transaction.Commit() },
|
|
}, nil
|
|
}
|
|
|
|
func (store *Store) Stage(reader io.Reader, contentType string) (staged core.StagedFile, resultErr error) {
|
|
if reader == nil || contentType != core.PNGContentType {
|
|
return core.StagedFile{}, core.ErrInvalid
|
|
}
|
|
temporary, err := os.CreateTemp(filepath.Join(store.root, ".staging"), "upload-*.png")
|
|
if err != nil {
|
|
return core.StagedFile{}, err
|
|
}
|
|
staged.Path = temporary.Name()
|
|
defer func() {
|
|
if resultErr != nil {
|
|
_ = temporary.Close()
|
|
_ = os.Remove(staged.Path)
|
|
}
|
|
}()
|
|
if err := temporary.Chmod(0o600); err != nil {
|
|
return core.StagedFile{}, err
|
|
}
|
|
hasher := sha256.New()
|
|
written, err := io.Copy(io.MultiWriter(temporary, hasher), io.LimitReader(reader, core.MaxFileBytes+1))
|
|
if err != nil {
|
|
return core.StagedFile{}, err
|
|
}
|
|
if written > core.MaxFileBytes {
|
|
return core.StagedFile{}, core.ErrTooLarge
|
|
}
|
|
if written == 0 {
|
|
return core.StagedFile{}, core.ErrInvalid
|
|
}
|
|
if err := temporary.Sync(); err != nil {
|
|
return core.StagedFile{}, err
|
|
}
|
|
if err := temporary.Close(); err != nil {
|
|
return core.StagedFile{}, err
|
|
}
|
|
|
|
imageFile, err := os.Open(staged.Path)
|
|
if err != nil {
|
|
return core.StagedFile{}, err
|
|
}
|
|
defer imageFile.Close()
|
|
width, height, err := validatePNG(imageFile)
|
|
if err != nil {
|
|
return core.StagedFile{}, err
|
|
}
|
|
|
|
staged.SHA256 = hex.EncodeToString(hasher.Sum(nil))
|
|
staged.ByteSize = written
|
|
staged.ContentType = core.PNGContentType
|
|
staged.Width = width
|
|
staged.Height = height
|
|
return staged, nil
|
|
}
|
|
|
|
func (store *Store) Discard(staged core.StagedFile) {
|
|
if store.isStagedPath(staged.Path) {
|
|
_ = os.Remove(staged.Path)
|
|
}
|
|
}
|
|
|
|
func (store *Store) Commit(ctx context.Context, principal deviceauth.Principal, metadata core.UploadMetadata, staged core.StagedFile) (core.Asset, bool, error) {
|
|
if !store.isStagedPath(staged.Path) || !validPrincipal(principal) || !validMetadata(metadata) || metadata.SHA256 != staged.SHA256 || staged.ContentType != core.PNGContentType || staged.ByteSize < 1 || staged.ByteSize > core.MaxFileBytes || staged.Width < 1 || staged.Height < 1 || staged.Width > core.MaxImageSide || staged.Height > core.MaxImageSide || int64(staged.Width)*int64(staged.Height) > core.MaxImagePixels {
|
|
store.Discard(staged)
|
|
return core.Asset{}, false, core.ErrInvalid
|
|
}
|
|
defer store.Discard(staged)
|
|
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
|
|
transaction, err := store.database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return core.Asset{}, false, err
|
|
}
|
|
defer transaction.Rollback()
|
|
|
|
existing, found, err := findByUploadKey(ctx, transaction, principal.ID, metadata.UploadKey)
|
|
if err != nil {
|
|
return core.Asset{}, false, err
|
|
}
|
|
if found {
|
|
if !sameUpload(existing, principal, metadata, staged) {
|
|
return core.Asset{}, false, core.ErrConflict
|
|
}
|
|
if err := store.verifyStoredFile(existing); err != nil {
|
|
return core.Asset{}, false, err
|
|
}
|
|
if err := store.commitTx(transaction); err != nil {
|
|
return core.Asset{}, false, err
|
|
}
|
|
return existing, true, nil
|
|
}
|
|
|
|
var ownedClaimCount int
|
|
if err := transaction.QueryRowContext(ctx, `SELECT COUNT(*) FROM purchase_attempt_claims
|
|
WHERE task_id = ? AND attempt_id = ? AND claimed_by_device_id = ? AND closed_at IS NULL`,
|
|
metadata.TaskID, metadata.AttemptID, principal.ID).Scan(&ownedClaimCount); err != nil {
|
|
return core.Asset{}, false, err
|
|
}
|
|
// Evidence is auditable only when the authenticated device owns the current attempt. The
|
|
// idempotent asset lookup above deliberately remains first so closing a claim later cannot
|
|
// destroy stable replay of an already committed screenshot.
|
|
if ownedClaimCount != 1 {
|
|
return core.Asset{}, false, core.ErrInvalid
|
|
}
|
|
|
|
storageKey := storageKey(metadata.SHA256)
|
|
finalPath, err := store.pathForKey(storageKey)
|
|
if err != nil {
|
|
return core.Asset{}, false, err
|
|
}
|
|
finalDirectory := filepath.Dir(finalPath)
|
|
if err := ensureDurableDirectory(finalDirectory, 0o700, store.syncDirectory); err != nil {
|
|
return core.Asset{}, false, err
|
|
}
|
|
// Always repeat the shard-parent boundary. If an earlier attempt created this
|
|
// directory and its parent sync failed, a retry must not trust mere existence.
|
|
if err := store.syncDirectory(store.root); err != nil {
|
|
return core.Asset{}, false, fmt.Errorf("persist evidence shard directory: %w", err)
|
|
}
|
|
if err := os.Chmod(finalDirectory, 0o700); err != nil {
|
|
return core.Asset{}, false, err
|
|
}
|
|
if info, statErr := os.Stat(finalPath); statErr == nil {
|
|
if !info.Mode().IsRegular() || info.Size() != staged.ByteSize || fileSHA256(finalPath) != staged.SHA256 {
|
|
return core.Asset{}, false, errors.New("stored evidence content does not match its key")
|
|
}
|
|
} else if !errors.Is(statErr, os.ErrNotExist) {
|
|
return core.Asset{}, false, statErr
|
|
} else {
|
|
publishPath, err := store.preparePublishFile(staged, finalDirectory)
|
|
if err != nil {
|
|
return core.Asset{}, false, err
|
|
}
|
|
defer os.Remove(publishPath)
|
|
if err := store.renameFile(publishPath, finalPath); err != nil {
|
|
return core.Asset{}, false, err
|
|
}
|
|
}
|
|
// The publication file was fsynced in this shard before its same-directory rename.
|
|
// Persist the final directory entry before SQLite can expose a referencing row.
|
|
// A directory sync failure is deliberately fatal; the unreachable file may remain
|
|
// as an orphan, but no evidence_assets row may be committed for it.
|
|
if err := store.syncDirectory(finalDirectory); err != nil {
|
|
return core.Asset{}, false, fmt.Errorf("persist evidence directory entry: %w", err)
|
|
}
|
|
|
|
id, err := newUUID(store.random)
|
|
if err != nil {
|
|
return core.Asset{}, false, err
|
|
}
|
|
now := store.now().UTC()
|
|
asset := core.Asset{
|
|
ID: id, TaskID: metadata.TaskID, AttemptID: metadata.AttemptID,
|
|
Kind: metadata.Kind, PrivacyTier: metadata.PrivacyTier, SHA256: staged.SHA256,
|
|
ByteSize: staged.ByteSize, ContentType: staged.ContentType, Width: staged.Width, Height: staged.Height,
|
|
CapturedAt: metadata.CapturedAt.UTC(), UploadedByDeviceID: principal.ID,
|
|
StorageKey: storageKey, CreatedAt: now,
|
|
}
|
|
_, err = transaction.ExecContext(ctx, `INSERT INTO evidence_assets
|
|
(id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
asset.ID, metadata.UploadKey, asset.TaskID, asset.AttemptID, asset.Kind, asset.PrivacyTier,
|
|
asset.SHA256, asset.ByteSize, asset.ContentType, asset.Width, asset.Height, asset.StorageKey,
|
|
asset.UploadedByDeviceID, asset.CapturedAt.Format(time.RFC3339Nano), asset.CreatedAt.Format(time.RFC3339Nano))
|
|
if err != nil {
|
|
return core.Asset{}, false, err
|
|
}
|
|
if err := store.commitTx(transaction); err != nil {
|
|
return core.Asset{}, false, err
|
|
}
|
|
return asset, false, nil
|
|
}
|
|
|
|
func (store *Store) Open(ctx context.Context, id string) (core.Asset, io.ReadSeekCloser, error) {
|
|
if !validUUID(id) {
|
|
return core.Asset{}, nil, core.ErrNotFound
|
|
}
|
|
asset, found, err := findByID(ctx, store.database, id)
|
|
if err != nil {
|
|
return core.Asset{}, nil, err
|
|
}
|
|
if !found || asset.StorageKey != storageKey(asset.SHA256) {
|
|
return core.Asset{}, nil, core.ErrNotFound
|
|
}
|
|
path, err := store.pathForKey(asset.StorageKey)
|
|
if err != nil {
|
|
return core.Asset{}, nil, core.ErrNotFound
|
|
}
|
|
file, err := os.Open(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return core.Asset{}, nil, core.ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return core.Asset{}, nil, err
|
|
}
|
|
info, err := file.Stat()
|
|
if err != nil || !info.Mode().IsRegular() || info.Size() != asset.ByteSize {
|
|
_ = file.Close()
|
|
if err != nil {
|
|
return core.Asset{}, nil, err
|
|
}
|
|
return core.Asset{}, nil, core.ErrNotFound
|
|
}
|
|
return asset, file, nil
|
|
}
|
|
|
|
func (store *Store) verifyStoredFile(asset core.Asset) error {
|
|
path, err := store.pathForKey(asset.StorageKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil || !info.Mode().IsRegular() || info.Size() != asset.ByteSize || fileSHA256(path) != asset.SHA256 {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return errors.New("stored evidence file is invalid")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (store *Store) isStagedPath(path string) bool {
|
|
if path == "" {
|
|
return false
|
|
}
|
|
relative, err := filepath.Rel(filepath.Join(store.root, ".staging"), filepath.Clean(path))
|
|
return err == nil && relative != "." && relative != "" && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
|
|
}
|
|
|
|
func (store *Store) pathForKey(key string) (string, error) {
|
|
path := filepath.Join(store.root, filepath.FromSlash(key))
|
|
relative, err := filepath.Rel(store.root, path)
|
|
if err != nil || relative == "." || relative == "" || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
|
return "", errors.New("invalid evidence storage key")
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func (store *Store) preparePublishFile(staged core.StagedFile, directory string) (path string, resultErr error) {
|
|
source, err := os.Open(staged.Path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer source.Close()
|
|
|
|
temporary, err := os.CreateTemp(directory, ".publish-*.png")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
temporaryPath := temporary.Name()
|
|
path = temporaryPath
|
|
defer func() {
|
|
if resultErr != nil {
|
|
_ = temporary.Close()
|
|
_ = os.Remove(temporaryPath)
|
|
}
|
|
}()
|
|
if err := temporary.Chmod(0o600); err != nil {
|
|
return "", err
|
|
}
|
|
hasher := sha256.New()
|
|
written, err := io.Copy(io.MultiWriter(temporary, hasher), source)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if written != staged.ByteSize || hex.EncodeToString(hasher.Sum(nil)) != staged.SHA256 {
|
|
return "", errors.New("staged evidence changed before publication")
|
|
}
|
|
width, height, err := validatePNG(temporary)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if width != staged.Width || height != staged.Height {
|
|
return "", errors.New("staged evidence dimensions changed before publication")
|
|
}
|
|
if err := store.syncFile(temporary); err != nil {
|
|
return "", fmt.Errorf("sync evidence publication file: %w", err)
|
|
}
|
|
if err := temporary.Close(); err != nil {
|
|
return "", err
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func validatePNG(reader io.ReadSeeker) (int, int, error) {
|
|
if _, err := reader.Seek(0, io.SeekStart); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
signature := make([]byte, len(pngSignature))
|
|
if _, err := io.ReadFull(reader, signature); err != nil || string(signature) != string(pngSignature) {
|
|
return 0, 0, core.ErrInvalid
|
|
}
|
|
if _, err := reader.Seek(0, io.SeekStart); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
configuration, err := png.DecodeConfig(reader)
|
|
if err != nil || configuration.Width < 1 || configuration.Height < 1 || configuration.Width > core.MaxImageSide || configuration.Height > core.MaxImageSide || int64(configuration.Width)*int64(configuration.Height) > core.MaxImagePixels {
|
|
return 0, 0, core.ErrInvalid
|
|
}
|
|
if _, err := reader.Seek(0, io.SeekStart); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
if _, err := png.Decode(reader); err != nil {
|
|
return 0, 0, core.ErrInvalid
|
|
}
|
|
var trailing [1]byte
|
|
if count, err := reader.Read(trailing[:]); count != 0 || !errors.Is(err, io.EOF) {
|
|
return 0, 0, core.ErrInvalid
|
|
}
|
|
return configuration.Width, configuration.Height, nil
|
|
}
|
|
|
|
func ensureDurableDirectory(path string, mode os.FileMode, syncParent func(string) error) error {
|
|
info, err := os.Stat(path)
|
|
if err == nil {
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("path exists but is not a directory: %s", path)
|
|
}
|
|
return nil
|
|
}
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
|
|
parent := filepath.Dir(path)
|
|
if parent == path {
|
|
return fmt.Errorf("cannot create filesystem root as a managed directory: %s", path)
|
|
}
|
|
if err := ensureDurableDirectory(parent, mode, syncParent); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Mkdir(path, mode); err != nil && !errors.Is(err, os.ErrExist) {
|
|
return err
|
|
}
|
|
info, err = os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("path exists but is not a directory: %s", path)
|
|
}
|
|
if err := os.Chmod(path, mode); err != nil {
|
|
return err
|
|
}
|
|
// Syncing the parent makes creation of this directory durable. This also covers
|
|
// a concurrent creator: returning success without the parent sync could otherwise
|
|
// allow the following database transaction to outrun the directory entry.
|
|
if err := syncParent(parent); err != nil {
|
|
return fmt.Errorf("persist directory creation for %s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func storageKey(hash string) string { return hash[:2] + "/" + hash + ".png" }
|
|
|
|
func validMetadata(metadata core.UploadMetadata) bool {
|
|
return validUUID(metadata.UploadKey) && validUUID(metadata.TaskID) && validUUID(metadata.AttemptID) && metadata.Kind == core.KindSKUPanelGate1 && metadata.PrivacyTier == core.PrivacyInternalRaw && validSHA256(metadata.SHA256) && !metadata.CapturedAt.IsZero() && metadata.CapturedAt.Location() == time.UTC
|
|
}
|
|
|
|
func validPrincipal(principal deviceauth.Principal) bool {
|
|
return deviceauth.ValidDeviceID(principal.ID)
|
|
}
|
|
|
|
func validSHA256(value string) bool {
|
|
if len(value) != 64 {
|
|
return false
|
|
}
|
|
for _, character := range value {
|
|
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validUUID(value string) bool {
|
|
if len(value) != 36 {
|
|
return false
|
|
}
|
|
for index, character := range value {
|
|
if index == 8 || index == 13 || index == 18 || index == 23 {
|
|
if character != '-' {
|
|
return false
|
|
}
|
|
continue
|
|
}
|
|
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
|
|
return false
|
|
}
|
|
}
|
|
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
|
|
}
|
|
|
|
func newUUID(reader io.Reader) (string, error) {
|
|
bytes := make([]byte, 16)
|
|
if _, err := io.ReadFull(reader, bytes); err != nil {
|
|
return "", err
|
|
}
|
|
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
|
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
|
encoded := hex.EncodeToString(bytes)
|
|
return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:], nil
|
|
}
|
|
|
|
func fileSHA256(path string) string {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer file.Close()
|
|
hasher := sha256.New()
|
|
if _, err := io.Copy(hasher, file); err != nil {
|
|
return ""
|
|
}
|
|
return hex.EncodeToString(hasher.Sum(nil))
|
|
}
|
|
|
|
type rowScanner interface{ Scan(...any) error }
|
|
|
|
func findByUploadKey(ctx context.Context, query interface {
|
|
QueryRowContext(context.Context, string, ...any) *sql.Row
|
|
}, deviceID, uploadKey string) (core.Asset, bool, error) {
|
|
return scanAsset(query.QueryRowContext(ctx, `SELECT id, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at FROM evidence_assets WHERE uploaded_by_device_id = ? AND upload_key = ?`, deviceID, uploadKey))
|
|
}
|
|
|
|
func findByID(ctx context.Context, query interface {
|
|
QueryRowContext(context.Context, string, ...any) *sql.Row
|
|
}, id string) (core.Asset, bool, error) {
|
|
return scanAsset(query.QueryRowContext(ctx, `SELECT id, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at FROM evidence_assets WHERE id = ?`, id))
|
|
}
|
|
|
|
func scanAsset(row rowScanner) (core.Asset, bool, error) {
|
|
var asset core.Asset
|
|
var captured, created string
|
|
err := row.Scan(&asset.ID, &asset.TaskID, &asset.AttemptID, &asset.Kind, &asset.PrivacyTier, &asset.SHA256, &asset.ByteSize, &asset.ContentType, &asset.Width, &asset.Height, &asset.StorageKey, &asset.UploadedByDeviceID, &captured, &created)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return core.Asset{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return core.Asset{}, false, err
|
|
}
|
|
asset.CapturedAt, err = time.Parse(time.RFC3339Nano, captured)
|
|
if err != nil {
|
|
return core.Asset{}, false, err
|
|
}
|
|
asset.CreatedAt, err = time.Parse(time.RFC3339Nano, created)
|
|
if err != nil {
|
|
return core.Asset{}, false, err
|
|
}
|
|
return asset, true, nil
|
|
}
|
|
|
|
func sameUpload(asset core.Asset, principal deviceauth.Principal, metadata core.UploadMetadata, staged core.StagedFile) bool {
|
|
return asset.TaskID == metadata.TaskID && asset.AttemptID == metadata.AttemptID && asset.Kind == metadata.Kind && asset.PrivacyTier == metadata.PrivacyTier && asset.SHA256 == metadata.SHA256 && asset.ByteSize == staged.ByteSize && asset.ContentType == staged.ContentType && asset.Width == staged.Width && asset.Height == staged.Height && asset.UploadedByDeviceID == principal.ID && asset.CapturedAt.Equal(metadata.CapturedAt)
|
|
}
|