init
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/example/sndit/backend/internal/config"
|
||||
"github.com/example/sndit/backend/internal/db"
|
||||
)
|
||||
|
||||
func main() {
|
||||
directory := flag.String("dir", "migrations", "directory containing SQL migrations")
|
||||
flag.Parse()
|
||||
|
||||
cfg := config.Load()
|
||||
ctx := context.Background()
|
||||
database, err := db.New(ctx, cfg.DBDriver, cfg.DatabaseDSN())
|
||||
if err != nil {
|
||||
log.Fatalf("database unavailable: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
if err := run(ctx, database, *directory, cfg.DBDriver); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, database *sql.DB, directory, driver string) error {
|
||||
if _, err := database.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("create migration table: %w", err)
|
||||
}
|
||||
|
||||
directory = migrationDirectory(directory, driver)
|
||||
files, err := migrationFiles(directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, path := range files {
|
||||
version := filepath.Base(path)
|
||||
var applied bool
|
||||
if err := database.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, version).Scan(&applied); err != nil {
|
||||
return fmt.Errorf("check migration %s: %w", version, err)
|
||||
}
|
||||
if applied {
|
||||
continue
|
||||
}
|
||||
|
||||
sql, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", version, err)
|
||||
}
|
||||
|
||||
tx, err := database.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %s: %w", version, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("apply migration %s: %w", version, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("record migration %s: %w", version, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", version, err)
|
||||
}
|
||||
log.Printf("applied migration %s", version)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrationDirectory(directory, driver string) string {
|
||||
if strings.EqualFold(driver, "sqlite") || strings.EqualFold(driver, "sqlite3") {
|
||||
return filepath.Join(directory, "sqlite")
|
||||
}
|
||||
return directory
|
||||
}
|
||||
|
||||
func migrationFiles(directory string) ([]string, error) {
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migration directory: %w", err)
|
||||
}
|
||||
|
||||
files := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
||||
files = append(files, filepath.Join(directory, entry.Name()))
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/example/sndit/backend/internal/config"
|
||||
"github.com/example/sndit/backend/internal/db"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
demoEmail = "demo@example.com"
|
||||
demoPassword = "DemoPassword123!"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
ctx := context.Background()
|
||||
database, err := db.New(ctx, cfg.DBDriver, cfg.DatabaseDSN())
|
||||
if err != nil {
|
||||
log.Fatalf("database unavailable: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
if err := seed(ctx, database); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("seeded %s with the Northline Studio demo gallery", demoEmail)
|
||||
}
|
||||
|
||||
func seed(ctx context.Context, database *sql.DB) error {
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(demoPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash demo password: %w", err)
|
||||
}
|
||||
if _, err := database.ExecContext(ctx, `
|
||||
INSERT INTO users (id, email, password_hash, name)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash, name = EXCLUDED.name, updated_at = CURRENT_TIMESTAMP
|
||||
`, demoUserID, demoEmail, string(passwordHash), "Northline Studio"); err != nil {
|
||||
return fmt.Errorf("seed demo user: %w", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(ctx, `
|
||||
INSERT INTO galleries (
|
||||
id, user_id, slug, title, client_name, description, status,
|
||||
downloads_enabled, favorites_enabled, download_all_enabled, watermark_enabled,
|
||||
cover_media_id, theme_config, branding_config, published_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'published', TRUE, TRUE, TRUE, FALSE, $7, $8, $9, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
user_id = EXCLUDED.user_id, title = EXCLUDED.title, client_name = EXCLUDED.client_name,
|
||||
description = EXCLUDED.description, status = EXCLUDED.status,
|
||||
downloads_enabled = EXCLUDED.downloads_enabled, favorites_enabled = EXCLUDED.favorites_enabled,
|
||||
download_all_enabled = EXCLUDED.download_all_enabled, watermark_enabled = EXCLUDED.watermark_enabled,
|
||||
cover_media_id = EXCLUDED.cover_media_id, theme_config = EXCLUDED.theme_config,
|
||||
branding_config = EXCLUDED.branding_config, published_at = EXCLUDED.published_at,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, demoGalleryID, demoUserID, "emma-james-wedding", "Emma & James", "Emma & James", "An early summer wedding, held close to the water and the people who make it home.", demoCoverID,
|
||||
`{"mode":"light","layout":"editorial","accent":"#ad695b","font":"serif"}`,
|
||||
`{"studioName":"Northline Studio","tagline":"Photographs for keeps.","websiteUrl":"https://example.com","instagramUrl":"https://instagram.com"}`); err != nil {
|
||||
return fmt.Errorf("seed demo gallery: %w", err)
|
||||
}
|
||||
|
||||
mediaRows := []struct {
|
||||
id, filename, mime, externalURL string
|
||||
sortOrder int
|
||||
duration float64
|
||||
}{
|
||||
{demoCoverID.String(), "emma-james-01.jpg", "image/jpeg", "https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=1800&q=88", 1, 0},
|
||||
{demoPhotoID.String(), "emma-james-02.jpg", "image/jpeg", "https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=1800&q=88", 2, 0},
|
||||
{demoVideoID.String(), "emma-james-film.mp4", "video/mp4", "https://storage.googleapis.com/coverr-main/mp4/Mt_Baker.mp4", 3, 31.0},
|
||||
}
|
||||
for _, item := range mediaRows {
|
||||
if _, err := database.ExecContext(ctx, `
|
||||
INSERT INTO media (id, gallery_id, original_filename, mime_type, file_size, storage_key, external_url, processing_status, sort_order, duration_seconds)
|
||||
VALUES ($1, $2, $3, $4, 0, $5, $6, 'READY', $7, $8)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
gallery_id = EXCLUDED.gallery_id, original_filename = EXCLUDED.original_filename,
|
||||
mime_type = EXCLUDED.mime_type, storage_key = EXCLUDED.storage_key,
|
||||
external_url = EXCLUDED.external_url, processing_status = EXCLUDED.processing_status,
|
||||
sort_order = EXCLUDED.sort_order, duration_seconds = EXCLUDED.duration_seconds, updated_at = CURRENT_TIMESTAMP
|
||||
`, uuid.MustParse(item.id), demoGalleryID, item.filename, item.mime, "demo/"+item.filename, item.externalURL, item.sortOrder, item.duration); err != nil {
|
||||
return fmt.Errorf("seed demo media %s: %w", item.filename, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
demoUserID = uuid.MustParse("55555555-5555-4555-8555-555555555555")
|
||||
demoGalleryID = uuid.MustParse("66666666-6666-4666-8666-666666666666")
|
||||
demoCoverID = uuid.MustParse("77777777-7777-4777-8777-777777777777")
|
||||
demoPhotoID = uuid.MustParse("88888888-8888-4888-8888-888888888888")
|
||||
demoVideoID = uuid.MustParse("99999999-9999-4999-8999-999999999999")
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/example/sndit/backend/internal/auth"
|
||||
"github.com/example/sndit/backend/internal/config"
|
||||
"github.com/example/sndit/backend/internal/db"
|
||||
devtools "github.com/example/sndit/backend/internal/dev"
|
||||
"github.com/example/sndit/backend/internal/downloads"
|
||||
"github.com/example/sndit/backend/internal/galleries"
|
||||
"github.com/example/sndit/backend/internal/gifts"
|
||||
"github.com/example/sndit/backend/internal/media"
|
||||
"github.com/example/sndit/backend/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
database, err := db.New(ctx, cfg.DBDriver, cfg.DatabaseDSN())
|
||||
if err != nil {
|
||||
log.Fatalf("database unavailable: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
objectStorage, err := storage.NewMinIO(storage.Config{
|
||||
Endpoint: cfg.StorageEndpoint,
|
||||
AccessKey: cfg.StorageAccessKey,
|
||||
SecretKey: cfg.StorageSecretKey,
|
||||
Bucket: cfg.StorageBucket,
|
||||
UseSSL: cfg.StorageUseSSL,
|
||||
CORSOrigins: cfg.CORSOrigin,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("storage unavailable: %v", err)
|
||||
}
|
||||
if err := objectStorage.EnsureBucket(ctx); err != nil {
|
||||
log.Fatalf("storage unavailable: %v", err)
|
||||
}
|
||||
|
||||
authRepository := auth.NewRepository(database)
|
||||
authService, err := auth.NewService(authRepository, cfg.SessionSecret, cfg.CookieSecure)
|
||||
if err != nil {
|
||||
log.Fatalf("authentication unavailable: %v", err)
|
||||
}
|
||||
galleryRepository := galleries.NewRepository(database)
|
||||
mediaRepository := media.NewRepository(database)
|
||||
mediaProcessor := media.NewProcessor(mediaRepository, objectStorage, 2)
|
||||
defer mediaProcessor.Close()
|
||||
downloadRepository := downloads.NewRepository(database)
|
||||
downloadService := downloads.NewService(downloadRepository, mediaRepository, objectStorage, 1)
|
||||
defer downloadService.Close()
|
||||
|
||||
authHandler := auth.NewHandler(authService)
|
||||
galleryHandler := galleries.NewHandler(galleryRepository, mediaRepository, objectStorage, authService)
|
||||
mediaHandler := media.NewHandler(mediaRepository, objectStorage, mediaProcessor, authService)
|
||||
downloadHandler := downloads.NewHandler(galleryRepository, mediaRepository, objectStorage, authService, downloadService)
|
||||
devHandler := devtools.NewHandler(database, objectStorage, authService, devtools.Config{
|
||||
DBDriver: cfg.DBDriver,
|
||||
StorageEndpoint: cfg.StorageEndpoint,
|
||||
StorageBucket: cfg.StorageBucket,
|
||||
StorageUseSSL: cfg.StorageUseSSL,
|
||||
CORSOrigins: cfg.CORSOrigin,
|
||||
CookieSecure: cfg.CookieSecure,
|
||||
})
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", health)
|
||||
authHandler.RegisterRoutes(mux)
|
||||
galleryHandler.RegisterProtectedRoutes(mux, authService.Require)
|
||||
mediaHandler.RegisterRoutes(mux, authService.Require)
|
||||
galleryHandler.RegisterPublicRoutes(mux)
|
||||
downloadHandler.RegisterRoutes(mux)
|
||||
devHandler.RegisterRoutes(mux, authService.Require)
|
||||
|
||||
// Keep the original public gift endpoint available while the gallery product
|
||||
// uses /api/public/galleries/:slug.
|
||||
legacyGifts := gifts.NewHandler(gifts.NewService(gifts.NewRepository(database)))
|
||||
mux.Handle("/api/gifts/", legacyGifts.Routes())
|
||||
|
||||
server := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: withCORS(withLogging(mux), cfg.CORSOrigin),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("API listening on http://localhost%s", server.Addr)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("server failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("server shutdown failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func health(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}` + "\n"))
|
||||
}
|
||||
|
||||
func withCORS(next http.Handler, allowedOrigin string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" && originAllowed(origin, allowedOrigin) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Vary", "Origin")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func originAllowed(origin, configured string) bool {
|
||||
for _, allowed := range strings.Split(configured, ",") {
|
||||
if strings.TrimSpace(allowed) == "*" || strings.TrimSpace(allowed) == origin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func withLogging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
started := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(started).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user