This commit is contained in:
2026-08-22 02:59:16 +02:00
commit 6a5bb1d699
100 changed files with 17409 additions and 0 deletions
+145
View File
@@ -0,0 +1,145 @@
package downloads
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/example/sndit/backend/internal/auth"
"github.com/example/sndit/backend/internal/galleries"
"github.com/example/sndit/backend/internal/media"
"github.com/example/sndit/backend/internal/storage"
"github.com/google/uuid"
)
type Handler struct {
galleries *galleries.Repository
media *media.Repository
storage storage.Storage
auth *auth.Service
service *Service
}
func NewHandler(galleryRepository *galleries.Repository, mediaRepository *media.Repository, objectStorage storage.Storage, authService *auth.Service, service *Service) *Handler {
return &Handler{galleries: galleryRepository, media: mediaRepository, storage: objectStorage, auth: authService, service: service}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/public/galleries/{slug}/media/{mediaId}/download", h.Download)
mux.HandleFunc("POST /api/public/galleries/{slug}/download-all", h.DownloadAll)
mux.HandleFunc("GET /api/public/galleries/{slug}/download-all/{jobId}", h.DownloadAllStatus)
}
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
record, err := h.publicRecord(r)
if err != nil {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
if !record.DownloadsEnabled {
writeError(w, http.StatusForbidden, "downloads are disabled")
return
}
mediaID, err := uuid.Parse(r.PathValue("mediaId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id")
return
}
item, err := h.media.GetByID(r.Context(), mediaID)
if err != nil || item.GalleryID != record.ID || item.ProcessingStatus != media.StatusReady {
writeError(w, http.StatusNotFound, "media not found")
return
}
url, err := h.downloadURL(r, item)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not create download")
return
}
visitorID := h.auth.EnsureVisitor(w, r)
_ = h.media.RecordDownload(r.Context(), record.ID, &mediaID, visitorID)
writeJSON(w, http.StatusOK, map[string]string{"url": url})
}
func (h *Handler) DownloadAll(w http.ResponseWriter, r *http.Request) {
record, err := h.publicRecord(r)
if err != nil {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
if !record.DownloadAllEnabled || !record.DownloadsEnabled {
writeError(w, http.StatusForbidden, "gallery downloads are disabled")
return
}
visitorID := h.auth.EnsureVisitor(w, r)
job, err := h.service.Create(r.Context(), record.ID, visitorID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not start gallery download")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"jobId": job.ID.String(), "status": job.Status})
}
func (h *Handler) DownloadAllStatus(w http.ResponseWriter, r *http.Request) {
record, err := h.publicRecord(r)
if err != nil {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
if !record.DownloadAllEnabled || !record.DownloadsEnabled {
writeError(w, http.StatusForbidden, "gallery downloads are disabled")
return
}
jobID, err := uuid.Parse(r.PathValue("jobId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid download job id")
return
}
visitorID := h.auth.EnsureVisitor(w, r)
job, err := h.service.Get(r.Context(), jobID, record.ID, visitorID)
if err != nil {
writeError(w, http.StatusNotFound, "download job not found")
return
}
response := map[string]any{"jobId": job.ID.String(), "status": job.Status}
if job.Error != "" {
response["error"] = job.Error
}
if job.Status == StatusReady {
url, err := h.storage.CreateDownloadURL(r.Context(), job.StorageKey, time.Hour)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not create download URL")
return
}
response["url"] = url
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) publicRecord(r *http.Request) (galleries.GalleryRecord, error) {
record, err := h.galleries.GetPublicBySlug(r.Context(), strings.TrimSpace(r.PathValue("slug")))
if err != nil || record.IsExpired() {
return galleries.GalleryRecord{}, galleries.ErrNotFound
}
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) {
return galleries.GalleryRecord{}, galleries.ErrNotFound
}
return record, nil
}
func (h *Handler) downloadURL(r *http.Request, item media.Record) (string, error) {
if item.ExternalURL != "" {
return item.ExternalURL, nil
}
return h.storage.CreateDownloadURL(r.Context(), item.StorageKey, time.Hour)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+22
View File
@@ -0,0 +1,22 @@
package downloads
import "github.com/google/uuid"
const (
StatusQueued = "QUEUED"
StatusProcessing = "PROCESSING"
StatusReady = "READY"
StatusFailed = "FAILED"
)
type Job struct {
ID uuid.UUID
GalleryID uuid.UUID
VisitorID string
Status string
StorageKey string
Error string
CreatedAt string
UpdatedAt string
CompletedAt string
}
+94
View File
@@ -0,0 +1,94 @@
package downloads
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
var ErrNotFound = errors.New("download job not found")
func (r *Repository) Create(ctx context.Context, galleryID uuid.UUID, visitorID string) (Job, error) {
job := Job{ID: uuid.New(), GalleryID: galleryID, VisitorID: visitorID, Status: StatusQueued}
_, err := r.db.ExecContext(ctx, `
INSERT INTO download_jobs (id, gallery_id, visitor_id, status)
VALUES ($1, $2, $3, $4)
`, job.ID, job.GalleryID, job.VisitorID, job.Status)
if err != nil {
return Job{}, fmt.Errorf("create download job: %w", err)
}
return r.GetForVisitor(ctx, job.ID, galleryID, visitorID)
}
func (r *Repository) GetForVisitor(ctx context.Context, jobID, galleryID uuid.UUID, visitorID string) (Job, error) {
return r.get(ctx, `
WHERE id = $1 AND gallery_id = $2 AND visitor_id = $3
`, jobID, galleryID, visitorID)
}
func (r *Repository) Get(ctx context.Context, jobID uuid.UUID) (Job, error) {
return r.get(ctx, `WHERE id = $1`, jobID)
}
func (r *Repository) get(ctx context.Context, predicate string, args ...any) (Job, error) {
var (
job Job
storageKey, jobError, createdAt, updatedAt sql.NullString
completedAt sql.NullString
)
err := r.db.QueryRowContext(ctx, `
SELECT id, gallery_id, visitor_id, status, storage_key, error, created_at, updated_at, completed_at
FROM download_jobs
`+predicate+`
`, args...).Scan(
&job.ID, &job.GalleryID, &job.VisitorID, &job.Status, &storageKey, &jobError,
&createdAt, &updatedAt, &completedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return Job{}, ErrNotFound
}
if err != nil {
return Job{}, fmt.Errorf("find download job: %w", err)
}
job.StorageKey = storageKey.String
job.Error = jobError.String
job.CreatedAt = createdAt.String
job.UpdatedAt = updatedAt.String
job.CompletedAt = completedAt.String
return job, nil
}
func (r *Repository) MarkProcessing(ctx context.Context, jobID uuid.UUID) error {
_, err := r.db.ExecContext(ctx, `
UPDATE download_jobs SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2
`, StatusProcessing, jobID)
return err
}
func (r *Repository) MarkReady(ctx context.Context, jobID uuid.UUID, storageKey string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE download_jobs SET status = $1, storage_key = $2, error = NULL,
updated_at = CURRENT_TIMESTAMP, completed_at = CURRENT_TIMESTAMP
WHERE id = $3
`, StatusReady, storageKey, jobID)
return err
}
func (r *Repository) MarkFailed(ctx context.Context, jobID uuid.UUID, message string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE download_jobs SET status = $1, error = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $3
`, StatusFailed, message, jobID)
return err
}
+185
View File
@@ -0,0 +1,185 @@
package downloads
import (
"archive/zip"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/example/sndit/backend/internal/media"
"github.com/example/sndit/backend/internal/storage"
"github.com/google/uuid"
)
type Service struct {
repository *Repository
media *media.Repository
storage storage.Storage
jobs chan uuid.UUID
stop chan struct{}
waitGroup sync.WaitGroup
}
var placeholderClient = &http.Client{Timeout: time.Minute}
func NewService(repository *Repository, mediaRepository *media.Repository, objectStorage storage.Storage, workers int) *Service {
if workers < 1 {
workers = 1
}
service := &Service{
repository: repository,
media: mediaRepository,
storage: objectStorage,
jobs: make(chan uuid.UUID, 32),
stop: make(chan struct{}),
}
for index := 0; index < workers; index++ {
service.waitGroup.Add(1)
go service.worker()
}
return service
}
func (s *Service) Create(ctx context.Context, galleryID uuid.UUID, visitorID string) (Job, error) {
job, err := s.repository.Create(ctx, galleryID, visitorID)
if err != nil {
return Job{}, err
}
select {
case s.jobs <- job.ID:
case <-s.stop:
return Job{}, fmt.Errorf("download service is stopping")
}
return job, nil
}
func (s *Service) Get(ctx context.Context, jobID, galleryID uuid.UUID, visitorID string) (Job, error) {
return s.repository.GetForVisitor(ctx, jobID, galleryID, visitorID)
}
func (s *Service) Close() {
close(s.stop)
s.waitGroup.Wait()
}
func (s *Service) worker() {
defer s.waitGroup.Done()
for {
select {
case jobID := <-s.jobs:
s.process(jobID)
case <-s.stop:
return
}
}
}
func (s *Service) process(jobID uuid.UUID) {
ctx := context.Background()
if err := s.repository.MarkProcessing(ctx, jobID); err != nil {
return
}
var job Job
// The worker needs the gallery ID and visitor only for status storage. The
// job lookup below is intentionally not visitor-scoped because the ID is
// generated internally and never exposed before creation succeeds.
job, err := s.repository.Get(ctx, jobID)
if err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
items, err := s.media.ListByGallery(ctx, job.GalleryID)
if err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
temporary, err := os.CreateTemp("", "gallery-download-*.zip")
if err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
archive := zip.NewWriter(temporary)
for _, item := range items {
if item.ProcessingStatus != media.StatusReady {
continue
}
object, err := s.openItem(ctx, item)
if err != nil {
_ = archive.Close()
_ = temporary.Close()
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
entry, err := archive.Create(filepath.Base(item.OriginalFilename))
if err == nil {
_, err = io.Copy(entry, object)
}
_ = object.Close()
if err != nil {
_ = archive.Close()
_ = temporary.Close()
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
}
if err := archive.Close(); err != nil {
_ = temporary.Close()
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
if err := temporary.Close(); err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
fileInfo, err := os.Stat(temporaryPath)
if err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
key := fmt.Sprintf("galleries/%s/downloads/%s.zip", job.GalleryID, job.ID)
file, err := os.Open(temporaryPath)
if err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
err = s.storage.Put(ctx, key, file, fileInfo.Size(), "application/zip")
_ = file.Close()
if err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
_ = s.repository.MarkReady(ctx, jobID, key)
}
func (s *Service) openItem(ctx context.Context, item media.Record) (io.ReadCloser, error) {
if item.ExternalURL != "" {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, item.ExternalURL, nil)
if err != nil {
return nil, err
}
response, err := placeholderClient.Do(request)
if err != nil {
return nil, err
}
if response.StatusCode >= http.StatusBadRequest {
_ = response.Body.Close()
return nil, fmt.Errorf("download placeholder returned %s", response.Status)
}
return response.Body, nil
}
if strings.TrimSpace(item.StorageKey) == "" {
return nil, fmt.Errorf("media has no storage object")
}
return s.storage.Get(ctx, item.StorageKey)
}