package services import ( "archive/zip" "bytes" "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "io" "log/slog" "mime/multipart" "net/http" "os" "path/filepath" "sort" "strings" "time" "go.etcd.io/bbolt" "warpbox.dev/backend/libs/helpers" ) var boxesBucket = []byte("boxes") type UploadService struct { maxUploadSize int64 baseURL string dataDir string filesDir string db *bbolt.DB logger *slog.Logger storage *StorageService } type UploadOptions struct { MaxDays int ExpiresInMinutes int MaxDownloads int Password string PasswordSalt string PasswordHash string ObfuscateMetadata bool OwnerID string CollectionID string SkipSizeLimit bool CreatorIP string StorageBackendID string } type IncomingFile interface { Name() string Size() int64 ContentType() string Open() (io.ReadCloser, error) } type multipartIncomingFile struct { header *multipart.FileHeader } func (f multipartIncomingFile) Name() string { return f.header.Filename } func (f multipartIncomingFile) Size() int64 { return f.header.Size } func (f multipartIncomingFile) ContentType() string { return f.header.Header.Get("Content-Type") } func (f multipartIncomingFile) Open() (io.ReadCloser, error) { return f.header.Open() } type StagedUploadFile struct { Filename string FileSize int64 MIMEType string Path string } func (f StagedUploadFile) Name() string { return f.Filename } func (f StagedUploadFile) Size() int64 { return f.FileSize } func (f StagedUploadFile) ContentType() string { return f.MIMEType } func (f StagedUploadFile) Open() (io.ReadCloser, error) { return os.Open(f.Path) } type Box struct { ID string `json:"id"` OwnerID string `json:"ownerId,omitempty"` CollectionID string `json:"collectionId,omitempty"` Title string `json:"title,omitempty"` CreatedAt time.Time `json:"createdAt"` ExpiresAt time.Time `json:"expiresAt"` MaxDownloads int `json:"maxDownloads"` DownloadCount int `json:"downloadCount"` PasswordSalt string `json:"passwordSalt,omitempty"` PasswordHash string `json:"passwordHash,omitempty"` DeleteTokenHash string `json:"deleteTokenHash,omitempty"` Obfuscate bool `json:"obfuscate"` CreatorIP string `json:"creatorIp,omitempty"` StorageBackendID string `json:"storageBackendId,omitempty"` Files []File `json:"files"` } type File struct { ID string `json:"id"` Name string `json:"name"` StoredName string `json:"storedName"` Size int64 `json:"size"` ContentType string `json:"contentType"` PreviewKind string `json:"previewKind"` Thumbnail string `json:"thumbnail,omitempty"` ObjectKey string `json:"objectKey,omitempty"` ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"` Processing bool `json:"processing,omitempty"` ProcessingError string `json:"processingError,omitempty"` UploadedAt time.Time `json:"uploadedAt"` } type UploadResult struct { BoxID string `json:"boxId"` BoxURL string `json:"boxUrl"` ZipURL string `json:"zipUrl"` ThumbnailURL string `json:"thumbnailUrl"` ManageURL string `json:"manageUrl"` DeleteURL string `json:"deleteUrl"` ExpiresAt string `json:"expiresAt"` Files []ResultFile `json:"files"` } type ResultFile struct { ID string `json:"id"` Name string `json:"name"` Size string `json:"size"` URL string `json:"url"` ThumbnailURL string `json:"thumbnailUrl"` Processing bool `json:"processing,omitempty"` } type AdminStats struct { TotalBoxes int TotalFiles int TotalSize int64 UploadsLast24H int ExpiredBoxes int ProtectedBoxes int TotalDownloads int TotalSizeLabel string } type AdminBox struct { ID string OwnerID string CreatedAt time.Time ExpiresAt time.Time FileCount int TotalSize int64 TotalSizeLabel string DownloadCount int MaxDownloads int Protected bool Expired bool } type UserBox struct { Box Box CollectionName string TotalSizeLabel string } func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) { filesDir := filepath.Join(dataDir, "files") dbDir := filepath.Join(dataDir, "db") if err := os.MkdirAll(dbDir, 0o755); err != nil { return nil, err } if err := os.MkdirAll(filepath.Join(dataDir, "emoji"), 0o755); err != nil { return nil, err } db, err := bbolt.Open(filepath.Join(dbDir, "warpbox.bbolt"), 0o600, &bbolt.Options{Timeout: time.Second}) if err != nil { return nil, err } if err := db.Update(func(tx *bbolt.Tx) error { _, err := tx.CreateBucketIfNotExists(boxesBucket) return err }); err != nil { db.Close() return nil, err } storage, err := NewStorageService(db, dataDir) if err != nil { db.Close() return nil, err } return &UploadService{ maxUploadSize: maxUploadSize, baseURL: strings.TrimRight(baseURL, "/"), dataDir: dataDir, filesDir: filesDir, db: db, logger: logger, storage: storage, }, nil } func (s *UploadService) Close() error { return s.db.Close() } func (s *UploadService) DB() *bbolt.DB { return s.db } func (s *UploadService) MaxUploadSize() int64 { return s.maxUploadSize } func (s *UploadService) MaxUploadSizeLabel() string { return helpers.FormatBytes(s.maxUploadSize) } func (s *UploadService) Storage() *StorageService { return s.storage } func (s *UploadService) ValidateSize(size int64) error { if size > s.maxUploadSize { return fmt.Errorf("file exceeds max upload size of %s", s.MaxUploadSizeLabel()) } return nil } func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOptions) (UploadResult, error) { return s.CreateBoxFromIncoming(multipartIncomingFiles(files), opts) } func (s *UploadService) CreateBoxFromIncoming(files []IncomingFile, opts UploadOptions) (UploadResult, error) { return s.CreateBoxFromIncomingContext(context.Background(), files, opts) } func (s *UploadService) CreateBoxFromIncomingContext(ctx context.Context, files []IncomingFile, opts UploadOptions) (UploadResult, error) { if len(files) == 0 { return UploadResult{}, fmt.Errorf("no files were uploaded") } now := time.Now().UTC() var expiresAt time.Time switch { case opts.ExpiresInMinutes < 0 || opts.MaxDays < 0: // "Forever" — a date far enough out that the box effectively never // expires. No schema change; CanDownload/cleanup keep working as-is. expiresAt = now.AddDate(100, 0, 0) case opts.ExpiresInMinutes > 0: expiresAt = now.Add(time.Duration(opts.ExpiresInMinutes) * time.Minute) default: days := opts.MaxDays if days <= 0 { days = 7 } expiresAt = now.Add(time.Duration(days) * 24 * time.Hour) } box := Box{ ID: randomID(10), OwnerID: strings.TrimSpace(opts.OwnerID), CollectionID: strings.TrimSpace(opts.CollectionID), CreatorIP: strings.TrimSpace(opts.CreatorIP), StorageBackendID: normalizeBackendID(opts.StorageBackendID), CreatedAt: now, ExpiresAt: expiresAt, MaxDownloads: opts.MaxDownloads, Obfuscate: opts.ObfuscateMetadata && strings.TrimSpace(opts.Password) != "", Files: make([]File, 0, len(files)), } deleteToken := randomID(32) box.DeleteTokenHash = deleteTokenHash(box.ID, deleteToken) if strings.TrimSpace(opts.PasswordHash) != "" { box.PasswordSalt = opts.PasswordSalt box.PasswordHash = opts.PasswordHash } else if strings.TrimSpace(opts.Password) != "" { salt, hash := hashPassword(opts.Password) box.PasswordSalt = salt box.PasswordHash = hash } if err := s.writeIncomingFilesToBox(ctx, &box, files, opts); err != nil { return UploadResult{}, err } if err := s.SaveBox(box); err != nil { return UploadResult{}, err } s.logger.Info("upload complete", "source", "user-upload", "severity", "user_activity", "code", 2001, "box_id", box.ID, "file_count", len(box.Files), ) return s.resultForBox(box, deleteToken), nil } // AppendFiles adds files to an existing box (used to group a ShareX multi-file // selection into a single box). The box keeps its original expiry, password and // other settings; only the new files are written. func (s *UploadService) AppendFiles(boxID string, files []*multipart.FileHeader, opts UploadOptions) (UploadResult, error) { return s.AppendIncomingFiles(boxID, multipartIncomingFiles(files), opts) } func (s *UploadService) AppendIncomingFiles(boxID string, files []IncomingFile, opts UploadOptions) (UploadResult, error) { if len(files) == 0 { return UploadResult{}, fmt.Errorf("no files were uploaded") } box, err := s.GetBox(boxID) if err != nil { return UploadResult{}, err } if err := s.writeIncomingFilesToBox(context.Background(), &box, files, opts); err != nil { return UploadResult{}, err } if err := s.SaveBox(box); err != nil { return UploadResult{}, err } s.logger.Info("upload appended", "source", "user-upload", "severity", "user_activity", "code", 2001, "box_id", box.ID, "added", len(files), "file_count", len(box.Files), ) return s.resultForBox(box, ""), nil } // writeFilesToBox streams each uploaded file into the box's storage backend and // appends the file metadata to box.Files. The box's StorageBackendID determines // where files land, so it works for both new and existing boxes. func (s *UploadService) writeFilesToBox(box *Box, files []*multipart.FileHeader, opts UploadOptions) error { return s.writeIncomingFilesToBox(context.Background(), box, multipartIncomingFiles(files), opts) } func multipartIncomingFiles(files []*multipart.FileHeader) []IncomingFile { incoming := make([]IncomingFile, 0, len(files)) for _, file := range files { incoming = append(incoming, multipartIncomingFile{header: file}) } return incoming } func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, files []IncomingFile, opts UploadOptions) error { backend, err := s.storage.Backend(box.StorageBackendID) if err != nil { return err } for _, incoming := range files { if !opts.SkipSizeLimit { if err := s.ValidateSize(incoming.Size()); err != nil { return err } } maxSize := s.maxUploadSize if opts.SkipSizeLimit { maxSize = 0 } file, err := incoming.Open() if err != nil { return err } fileID := randomID(8) storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(incoming.Name())) objectKey := boxObjectKey(box.ID, storedName) contentType := incoming.ContentType() if contentType == "" { buffer := make([]byte, 512) n, _ := file.Read(buffer) contentType = http.DetectContentType(buffer[:n]) if seeker, ok := file.(io.Seeker); ok { _, _ = seeker.Seek(0, io.SeekStart) } } if err := s.writeUploadedObject(ctx, backend, objectKey, file, incoming.Size(), maxSize, contentType); err != nil { file.Close() _ = backend.Delete(context.Background(), objectKey) return err } file.Close() box.Files = append(box.Files, File{ ID: fileID, Name: filepath.Base(incoming.Name()), StoredName: storedName, Size: incoming.Size(), ContentType: contentType, PreviewKind: previewKind(contentType), ObjectKey: objectKey, UploadedAt: time.Now().UTC(), }) } return nil } func (s *UploadService) GetBox(id string) (Box, error) { var box Box err := s.db.View(func(tx *bbolt.Tx) error { data := tx.Bucket(boxesBucket).Get([]byte(id)) if data == nil { return os.ErrNotExist } return json.Unmarshal(data, &box) }) if err != nil { return Box{}, err } return box, nil } func (s *UploadService) ListBoxes(limit int) ([]Box, error) { boxes := make([]Box, 0) err := s.db.View(func(tx *bbolt.Tx) error { cursor := tx.Bucket(boxesBucket).Cursor() for key, value := cursor.Last(); key != nil; key, value = cursor.Prev() { var box Box if err := json.Unmarshal(value, &box); err != nil { return err } boxes = append(boxes, box) if limit > 0 && len(boxes) >= limit { break } } return nil }) return boxes, err } func (s *UploadService) ActiveBoxCountForUser(userID string) (int, error) { return s.activeBoxCount(func(box Box) bool { return box.OwnerID == userID }) } func (s *UploadService) ActiveBoxCountForIP(ip string) (int, error) { return s.activeBoxCount(func(box Box) bool { return box.OwnerID == "" && box.CreatorIP == ip }) } func (s *UploadService) activeBoxCount(match func(Box) bool) (int, error) { boxes, err := s.ListBoxes(0) if err != nil { return 0, err } now := time.Now().UTC() count := 0 for _, box := range boxes { if match(box) && box.ExpiresAt.After(now) { count++ } } return count, nil } func (s *UploadService) AdminStats() (AdminStats, error) { boxes, err := s.ListBoxes(0) if err != nil { return AdminStats{}, err } var stats AdminStats cutoff := time.Now().UTC().Add(-24 * time.Hour) now := time.Now().UTC() for _, box := range boxes { stats.TotalBoxes++ stats.TotalDownloads += box.DownloadCount if box.CreatedAt.After(cutoff) { stats.UploadsLast24H++ } if box.ExpiresAt.Before(now) { stats.ExpiredBoxes++ } if s.IsProtected(box) { stats.ProtectedBoxes++ } for _, file := range box.Files { stats.TotalFiles++ stats.TotalSize += file.Size } } stats.TotalSizeLabel = helpers.FormatBytes(stats.TotalSize) return stats, nil } func (s *UploadService) AdminBoxes(limit int) ([]AdminBox, error) { boxes, err := s.ListBoxes(limit) if err != nil { return nil, err } now := time.Now().UTC() rows := make([]AdminBox, 0, len(boxes)) for _, box := range boxes { var size int64 for _, file := range box.Files { size += file.Size } rows = append(rows, AdminBox{ ID: box.ID, OwnerID: box.OwnerID, CreatedAt: box.CreatedAt, ExpiresAt: box.ExpiresAt, FileCount: len(box.Files), TotalSize: size, TotalSizeLabel: helpers.FormatBytes(size), DownloadCount: box.DownloadCount, MaxDownloads: box.MaxDownloads, Protected: s.IsProtected(box), Expired: box.ExpiresAt.Before(now), }) } return rows, nil } func (s *UploadService) UserBoxes(userID string, collectionNames map[string]string) ([]UserBox, error) { boxes, err := s.ListBoxes(0) if err != nil { return nil, err } rows := make([]UserBox, 0) for _, box := range boxes { if box.OwnerID != userID { continue } var size int64 for _, file := range box.Files { size += file.Size } rows = append(rows, UserBox{ Box: box, CollectionName: collectionNames[box.CollectionID], TotalSizeLabel: helpers.FormatBytes(size), }) } sort.Slice(rows, func(i, j int) bool { return rows[i].Box.CreatedAt.After(rows[j].Box.CreatedAt) }) return rows, nil } func (s *UploadService) UserStorageUsed(userID string) (int64, error) { return s.userStorageUsed(userID, false) } func (s *UploadService) UserActiveStorageUsed(userID string) (int64, error) { return s.userStorageUsed(userID, true) } func (s *UploadService) userStorageUsed(userID string, activeOnly bool) (int64, error) { boxes, err := s.ListBoxes(0) if err != nil { return 0, err } var total int64 now := time.Now().UTC() for _, box := range boxes { if box.OwnerID != userID { continue } if activeOnly && !box.ExpiresAt.After(now) { continue } for _, file := range box.Files { total += file.Size } } return total, nil } func (s *UploadService) RenameOwnedBox(boxID, userID, title string) error { box, err := s.GetBox(boxID) if err != nil { return err } if box.OwnerID != userID { return os.ErrPermission } box.Title = strings.TrimSpace(title) return s.SaveBox(box) } func (s *UploadService) MoveOwnedBox(boxID, userID, collectionID string) error { box, err := s.GetBox(boxID) if err != nil { return err } if box.OwnerID != userID { return os.ErrPermission } box.CollectionID = strings.TrimSpace(collectionID) return s.SaveBox(box) } func (s *UploadService) DeleteOwnedBox(boxID, userID string) error { box, err := s.GetBox(boxID) if err != nil { return err } if box.OwnerID != userID { return os.ErrPermission } return s.DeleteBoxWithSource(boxID, "user-delete") } func (s *UploadService) DeleteBox(boxID string) error { return s.DeleteBoxWithSource(boxID, "admin") } func (s *UploadService) DeleteBoxesForStorageBackend(backendID, source string) (int, error) { backendID = normalizeBackendID(backendID) if backendID == StorageBackendLocal { return 0, fmt.Errorf("local storage cannot be deleted") } boxes, err := s.ListBoxes(0) if err != nil { return 0, err } deleted := 0 for _, box := range boxes { if s.BoxStorageBackendID(box) != backendID { continue } if err := s.DeleteBoxWithSource(box.ID, source); err != nil { return deleted, err } deleted++ } return deleted, nil } func (s *UploadService) DeleteBoxWithToken(boxID, token string) error { box, err := s.GetBox(boxID) if err != nil { return err } if !s.VerifyDeleteToken(box, token) { return os.ErrPermission } return s.DeleteBoxWithSource(boxID, "anonymous-delete") } func (s *UploadService) DeleteBoxWithSource(boxID, source string) error { box, _ := s.GetBox(boxID) if err := s.db.Update(func(tx *bbolt.Tx) error { return tx.Bucket(boxesBucket).Delete([]byte(boxID)) }); err != nil { return err } if box.ID != "" { backendID := s.BoxStorageBackendID(box) backend, err := s.storage.Backend(backendID) if err != nil { backend, err = s.storage.BackendForMaintenance(backendID) } if err == nil { if err := backend.DeletePrefix(context.Background(), box.ID); err != nil { return err } } } else { if err := os.RemoveAll(filepath.Join(s.filesDir, boxID)); err != nil { return err } } s.logger.Info("box deleted", "source", source, "severity", "user_activity", "code", 2101, "box_id", boxID) return nil } // RemoveFileFromBox deletes a single file's stored objects (and thumbnail) and // removes it from the box. If it was the box's last file, the whole box is // deleted. Returns whether the box itself was removed. func (s *UploadService) RemoveFileFromBox(boxID, fileID string) (bool, error) { box, err := s.GetBox(boxID) if err != nil { return false, err } index := -1 for i, file := range box.Files { if file.ID == fileID { index = i break } } if index < 0 { return false, os.ErrNotExist } file := box.Files[index] backendID := s.BoxStorageBackendID(box) backend, err := s.storage.Backend(backendID) if err != nil { backend, err = s.storage.BackendForMaintenance(backendID) } if err == nil { if key := s.FileObjectKey(box, file); key != "" { _ = backend.Delete(context.Background(), key) } if key := s.ThumbnailObjectKey(box, file); key != "" { _ = backend.Delete(context.Background(), key) } } box.Files = append(box.Files[:index], box.Files[index+1:]...) if len(box.Files) == 0 { if err := s.DeleteBoxWithSource(box.ID, "admin"); err != nil { return false, err } return true, nil } if err := s.SaveBox(box); err != nil { return false, err } s.logger.Info("admin removed file", "source", "admin", "severity", "user_activity", "code", 2305, "box_id", box.ID, "file_id", fileID) return false, nil } // AdminUpdateBox lets an admin change a box's expiry, download limit, and // optionally clear password protection. func (s *UploadService) AdminUpdateBox(boxID string, expiresAt time.Time, maxDownloads int, removePassword bool) error { box, err := s.GetBox(boxID) if err != nil { return err } if !expiresAt.IsZero() { box.ExpiresAt = expiresAt.UTC() } if maxDownloads < 0 { maxDownloads = 0 } box.MaxDownloads = maxDownloads if removePassword { box.PasswordHash = "" box.PasswordSalt = "" box.Obfuscate = false } if err := s.SaveBox(box); err != nil { return err } s.logger.Info("admin updated box", "source", "admin", "severity", "user_activity", "code", 2306, "box_id", box.ID) return nil } func (s *UploadService) FindFile(box Box, fileID string) (File, error) { for _, file := range box.Files { if file.ID == fileID { return file, nil } } return File{}, os.ErrNotExist } func (s *UploadService) FilePath(box Box, file File) string { return filepath.Join(s.filesDir, box.ID, file.StoredName) } func (s *UploadService) ThumbnailPath(box Box, file File) string { if file.Thumbnail == "" { return "" } return filepath.Join(s.filesDir, box.ID, file.Thumbnail) } func (s *UploadService) BoxMetadataPath(box Box) string { return filepath.Join(s.filesDir, box.ID, ".warpbox.box.json") } func (s *UploadService) BoxStorageBackendID(box Box) string { return normalizeBackendID(box.StorageBackendID) } func (s *UploadService) FileObjectKey(box Box, file File) string { if file.ObjectKey != "" { return file.ObjectKey } return boxObjectKey(box.ID, file.StoredName) } func (s *UploadService) ThumbnailObjectKey(box Box, file File) string { if file.ThumbnailObjectKey != "" { return file.ThumbnailObjectKey } if file.Thumbnail == "" { return "" } return boxObjectKey(box.ID, file.Thumbnail) } func (s *UploadService) OpenFileObject(ctx context.Context, box Box, file File) (StorageObject, error) { if file.Processing { return StorageObject{}, fmt.Errorf("file is still processing") } backend, err := s.storage.Backend(s.BoxStorageBackendID(box)) if err != nil { return StorageObject{}, err } return backend.Get(ctx, s.FileObjectKey(box, file)) } func (s *UploadService) OpenThumbnailObject(ctx context.Context, box Box, file File) (StorageObject, error) { key := s.ThumbnailObjectKey(box, file) if key == "" { return StorageObject{}, os.ErrNotExist } backend, err := s.storage.Backend(s.BoxStorageBackendID(box)) if err != nil { return StorageObject{}, err } return backend.Get(ctx, key) } func (s *UploadService) PutThumbnailObject(ctx context.Context, box Box, name string, body io.Reader, size int64, contentType string) (string, error) { backend, err := s.storage.Backend(s.BoxStorageBackendID(box)) if err != nil { return "", err } key := boxObjectKey(box.ID, name) return key, backend.Put(ctx, key, body, size, contentType) } func (s *UploadService) IsProtected(box Box) bool { return box.PasswordHash != "" && box.PasswordSalt != "" } func (s *UploadService) VerifyPassword(box Box, password string) bool { if !s.IsProtected(box) { return true } hash := passwordHash(box.PasswordSalt, password) return subtle.ConstantTimeCompare([]byte(hash), []byte(box.PasswordHash)) == 1 } func (s *UploadService) UnlockToken(box Box) string { sum := sha256.Sum256([]byte(box.ID + ":" + box.PasswordHash)) return hex.EncodeToString(sum[:]) } func (s *UploadService) VerifyDeleteToken(box Box, token string) bool { if box.DeleteTokenHash == "" || strings.TrimSpace(token) == "" { return false } hash := deleteTokenHash(box.ID, token) return subtle.ConstantTimeCompare([]byte(hash), []byte(box.DeleteTokenHash)) == 1 } func (s *UploadService) CanDownload(box Box) error { if time.Now().UTC().After(box.ExpiresAt) { return fmt.Errorf("box has expired") } if box.MaxDownloads > 0 && box.DownloadCount >= box.MaxDownloads { return fmt.Errorf("download limit reached") } return nil } func (s *UploadService) RecordDownload(boxID string) error { return s.db.Update(func(tx *bbolt.Tx) error { bucket := tx.Bucket(boxesBucket) data := bucket.Get([]byte(boxID)) if data == nil { return os.ErrNotExist } var box Box if err := json.Unmarshal(data, &box); err != nil { return err } box.DownloadCount++ next, err := json.Marshal(box) if err != nil { return err } if err := bucket.Put([]byte(boxID), next); err != nil { return err } return s.writeBoxMetadata(box) }) } func (s *UploadService) WriteZip(w io.Writer, box Box) error { archive := zip.NewWriter(w) defer archive.Close() for _, file := range box.Files { object, err := s.OpenFileObject(context.Background(), box, file) if err != nil { return err } source := object.Body header := &zip.FileHeader{ Name: file.Name, Method: zip.Deflate, Modified: file.UploadedAt, } target, err := archive.CreateHeader(header) if err != nil { source.Close() return err } if _, err := io.Copy(target, source); err != nil { source.Close() return err } source.Close() } return nil } func (s *UploadService) SaveBox(box Box) error { if err := s.saveBoxRecord(box); err != nil { return err } return s.writeBoxMetadata(box) } func (s *UploadService) saveBoxRecord(box Box) error { if box.StorageBackendID == "" { box.StorageBackendID = StorageBackendLocal } data, err := json.Marshal(box) if err != nil { return err } return s.db.Update(func(tx *bbolt.Tx) error { return tx.Bucket(boxesBucket).Put([]byte(box.ID), data) }) } func (s *UploadService) resultForBox(box Box, deleteToken string) UploadResult { files := make([]ResultFile, 0, len(box.Files)) for _, file := range box.Files { files = append(files, ResultFile{ ID: file.ID, Name: file.Name, Size: helpers.FormatBytes(file.Size), URL: fmt.Sprintf("%s/d/%s/f/%s", s.baseURL, box.ID, file.ID), ThumbnailURL: fmt.Sprintf("%s/d/%s/thumb/%s", s.baseURL, box.ID, file.ID), Processing: file.Processing, }) } // The box-level thumbnail points at the most recently added file, so a // per-file ShareX upload previews the file it just sent. thumbnailURL := fmt.Sprintf("%s/d/%s/og-image.jpg", s.baseURL, box.ID) if len(files) > 0 { thumbnailURL = files[len(files)-1].ThumbnailURL } result := UploadResult{ BoxID: box.ID, BoxURL: fmt.Sprintf("%s/d/%s", s.baseURL, box.ID), ZipURL: fmt.Sprintf("%s/d/%s/zip", s.baseURL, box.ID), ThumbnailURL: thumbnailURL, ExpiresAt: box.ExpiresAt.Format(time.RFC3339), Files: files, } if deleteToken != "" { result.ManageURL = fmt.Sprintf("%s/d/%s/manage/%s", s.baseURL, box.ID, deleteToken) result.DeleteURL = fmt.Sprintf("%s/d/%s/manage/%s/delete", s.baseURL, box.ID, deleteToken) } return result } func writeUploadedFile(path string, source multipart.File, maxSize int64) error { target, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) if err != nil { return err } defer target.Close() var written int64 if maxSize <= 0 { written, err = io.Copy(target, source) } else { written, err = io.Copy(target, io.LimitReader(source, maxSize+1)) } if err != nil { os.Remove(path) return err } if maxSize > 0 && written > maxSize { os.Remove(path) return fmt.Errorf("file exceeds max upload size") } return nil } func (s *UploadService) writeUploadedObject(ctx context.Context, backend StorageBackend, key string, source io.Reader, size, maxSize int64, contentType string) error { var reader io.Reader = source putSize := size if maxSize > 0 { if size > maxSize { return fmt.Errorf("file exceeds max upload size") } reader = io.LimitReader(source, maxSize) putSize = size } if ctx != nil { reader = contextReader{ctx: ctx, reader: reader} } return backend.Put(ctx, key, reader, putSize, contentType) } type contextReader struct { ctx context.Context reader io.Reader } func (r contextReader) Read(p []byte) (int, error) { select { case <-r.ctx.Done(): return 0, r.ctx.Err() default: return r.reader.Read(p) } } func boxObjectKey(boxID, name string) string { return filepath.ToSlash(filepath.Join(boxID, name)) } func randomID(byteCount int) string { data := make([]byte, byteCount) if _, err := rand.Read(data); err != nil { return fmt.Sprintf("%d", time.Now().UnixNano()) } return base64.RawURLEncoding.EncodeToString(data) } func RandomPublicToken(byteCount int) string { return randomID(byteCount) } func hashPassword(password string) (string, string) { salt := randomID(18) return salt, passwordHash(salt, password) } func passwordHash(salt, password string) string { sum := sha256.Sum256([]byte(salt + ":" + password)) return hex.EncodeToString(sum[:]) } func deleteTokenHash(boxID, token string) string { sum := sha256.Sum256([]byte("warpbox-delete:" + boxID + ":" + token)) return hex.EncodeToString(sum[:]) } func previewKind(contentType string) string { switch { case strings.HasPrefix(contentType, "image/"): return "image" case strings.HasPrefix(contentType, "video/"): return "video" case strings.HasPrefix(contentType, "audio/"): return "audio" default: return "file" } } func (s *UploadService) writeBoxMetadata(box Box) error { data, err := json.MarshalIndent(box, "", " ") if err != nil { return err } backend, err := s.storage.Backend(s.BoxStorageBackendID(box)) if err != nil { return err } return backend.Put(context.Background(), boxObjectKey(box.ID, ".warpbox.box.json"), bytes.NewReader(data), int64(len(data)), "application/json") }