init
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user