feat: implement shared-album multi-user photo album
Backend (Go/Gin/pgx): env config, pgx pool with retries, Garage S3 client (path-style, region garage), embedded golang-migrate migrations, JWT auth with refresh rotation, albums CRUD, members/roles/invites with no-escalation matrix, photo upload + async imaging/goexif WebP variants, cursor pagination, presigned URLs, likes/comments, SSE hub (album + personal), notifications, share links. Frontend (SvelteKit 2 + Svelte 5 + Tailwind v4): API wrapper with 401 refresh, auth store, all screens, Uploader/PhotoGrid/Lightbox/Comments, SSE client with reconnect + REST catch-up, three themes + system via data-theme. Deploy without Docker: Makefile builds the frontend into backend/static and embeds it (go:embed) into a single statically linked binary; systemd unit and nginx SSE example included. Use gen2brain/webp (-tags nodynamic) instead of cgo kolesa-team/go-webp to keep CGO_ENABLED=0 static builds; design.md/proposal.md updated accordingly.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/httpx"
|
||||
)
|
||||
|
||||
const maxUploadSize = 50 << 20
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
var since *time.Time
|
||||
if raw := c.Query("since"); raw != "" {
|
||||
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
since = &t
|
||||
} else if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
t := time.UnixMilli(ms)
|
||||
since = &t
|
||||
}
|
||||
}
|
||||
list, next, err := h.svc.List(c.Request.Context(), c.Param("id"), auth.UserID(c), c.Query("cursor"), since, limit)
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"photos": list, "next_cursor": next})
|
||||
}
|
||||
|
||||
func (h *Handler) Upload(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize)
|
||||
form, err := c.MultipartForm()
|
||||
if err != nil {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "expected multipart form"))
|
||||
return
|
||||
}
|
||||
files := form.File["files"]
|
||||
if len(files) == 0 {
|
||||
files = form.File["file"]
|
||||
}
|
||||
if len(files) == 0 {
|
||||
httpx.RespondError(c, httpx.BadRequest("no_files", "no files provided"))
|
||||
return
|
||||
}
|
||||
|
||||
created := make([]any, 0, len(files))
|
||||
for _, fh := range files {
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(f, maxUploadSize))
|
||||
f.Close()
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
p, err := h.svc.Upload(c.Request.Context(), UploadInput{
|
||||
AlbumID: c.Param("id"),
|
||||
UserID: auth.UserID(c),
|
||||
Filename: fh.Filename,
|
||||
MimeType: fh.Header.Get("Content-Type"),
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
created = append(created, p)
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"photos": created})
|
||||
}
|
||||
|
||||
func (h *Handler) Get(c *gin.Context) {
|
||||
p, err := h.svc.Get(c.Request.Context(), c.Param("id"), auth.UserID(c))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"photo": p})
|
||||
}
|
||||
|
||||
func (h *Handler) Download(c *gin.Context) {
|
||||
url, err := h.svc.Download(c.Request.Context(), c.Param("id"), auth.UserID(c))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"url": url})
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
if err := h.svc.Delete(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/gen2brain/webp"
|
||||
"github.com/rwcarlsen/goexif/exif"
|
||||
)
|
||||
|
||||
type ImageMeta struct {
|
||||
Width int
|
||||
Height int
|
||||
MimeType string
|
||||
TakenAt *time.Time
|
||||
Latitude *float64
|
||||
Longitude *float64
|
||||
Thumb []byte
|
||||
Preview []byte
|
||||
}
|
||||
|
||||
func inspect(data []byte) (width, height int, ok bool) {
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return cfg.Width, cfg.Height, true
|
||||
}
|
||||
|
||||
func extractEXIF(data []byte) (taken *time.Time, lat, lon *float64) {
|
||||
x, err := exif.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
if dt, err := x.Get(exif.DateTimeOriginal); err == nil {
|
||||
if s, err := dt.StringVal(); err == nil {
|
||||
if t, err := time.Parse("2006:01:02 15:04:05", s); err == nil {
|
||||
utc := t.UTC()
|
||||
taken = &utc
|
||||
}
|
||||
}
|
||||
}
|
||||
if la, lo, err := x.LatLong(); err == nil {
|
||||
lat, lon = &la, &lo
|
||||
}
|
||||
return taken, lat, lon
|
||||
}
|
||||
|
||||
func buildVariants(data []byte) (thumb, preview []byte, err error) {
|
||||
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
thumbImg := imaging.Fit(img, 200, 200, imaging.Lanczos)
|
||||
previewImg := imaging.Fit(img, 1200, 1200, imaging.Lanczos)
|
||||
|
||||
opts := webp.Options{Quality: 82}
|
||||
var thumbBuf bytes.Buffer
|
||||
if err := webp.Encode(&thumbBuf, thumbImg, opts); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var previewBuf bytes.Buffer
|
||||
if err := webp.Encode(&previewBuf, previewImg, opts); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return thumbBuf.Bytes(), previewBuf.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/httpx"
|
||||
"shared-album/internal/models"
|
||||
"shared-album/internal/roles"
|
||||
"shared-album/internal/sse"
|
||||
"shared-album/internal/storage"
|
||||
)
|
||||
|
||||
type AlbumAccess interface {
|
||||
Access(ctx context.Context, albumID, userID string) (*models.Album, string, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store *Store
|
||||
albums AlbumAccess
|
||||
storage *storage.Client
|
||||
hub *sse.Hub
|
||||
}
|
||||
|
||||
func NewService(store *Store, albums AlbumAccess, storage *storage.Client, hub *sse.Hub) *Service {
|
||||
return &Service{store: store, albums: albums, storage: storage, hub: hub}
|
||||
}
|
||||
|
||||
type UploadInput struct {
|
||||
AlbumID string
|
||||
UserID string
|
||||
Filename string
|
||||
MimeType string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, error) {
|
||||
_, role, err := s.albums.Access(ctx, in.AlbumID, in.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !roles.CanUpload(role) {
|
||||
return nil, httpx.Forbidden("forbidden", "you do not have permission to upload photos")
|
||||
}
|
||||
|
||||
mime := in.MimeType
|
||||
if mime == "" {
|
||||
mime = http.DetectContentType(in.Data)
|
||||
}
|
||||
if !strings.HasPrefix(mime, "image/") {
|
||||
return nil, httpx.BadRequest("invalid_file", "file is not an image")
|
||||
}
|
||||
|
||||
width, height, ok := inspect(in.Data)
|
||||
if !ok {
|
||||
return nil, httpx.BadRequest("invalid_image", "could not decode image")
|
||||
}
|
||||
taken, lat, lon := extractEXIF(in.Data)
|
||||
|
||||
id := auth.NewID()
|
||||
ext := strings.ToLower(filepath.Ext(in.Filename))
|
||||
if ext == "" {
|
||||
ext = ".bin"
|
||||
}
|
||||
storageKey := fmt.Sprintf("albums/%s/original/%s%s", in.AlbumID, id, ext)
|
||||
|
||||
if err := s.storage.Put(ctx, storageKey, bytes.NewReader(in.Data), int64(len(in.Data)), mime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p := &models.Photo{
|
||||
ID: id,
|
||||
AlbumID: in.AlbumID,
|
||||
UploaderID: in.UserID,
|
||||
StorageKey: storageKey,
|
||||
OriginalFilename: in.Filename,
|
||||
Size: int64(len(in.Data)),
|
||||
Width: width,
|
||||
Height: height,
|
||||
MimeType: mime,
|
||||
TakenAt: taken,
|
||||
Latitude: lat,
|
||||
Longitude: lon,
|
||||
}
|
||||
if err := s.store.Create(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := in.Data
|
||||
go s.processVariants(p.ID, in.AlbumID, data)
|
||||
|
||||
if err := s.attachURLs(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Service) processVariants(photoID, albumID string, data []byte) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
thumb, preview, err := buildVariants(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
thumbKey := fmt.Sprintf("albums/%s/thumb/%s.webp", albumID, photoID)
|
||||
previewKey := fmt.Sprintf("albums/%s/preview/%s.webp", albumID, photoID)
|
||||
if err := s.storage.Put(ctx, thumbKey, bytes.NewReader(thumb), int64(len(thumb)), "image/webp"); err != nil {
|
||||
return
|
||||
}
|
||||
if err := s.storage.Put(ctx, previewKey, bytes.NewReader(preview), int64(len(preview)), "image/webp"); err != nil {
|
||||
return
|
||||
}
|
||||
if err := s.store.SetVariants(ctx, photoID, thumbKey, previewKey); err != nil {
|
||||
return
|
||||
}
|
||||
s.store.SetCover(ctx, albumID, photoID)
|
||||
if p, err := s.store.GetByID(ctx, photoID, ""); err == nil {
|
||||
if err := s.attachURLs(ctx, p); err == nil {
|
||||
s.hub.Publish(albumID, "photo.created", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error {
|
||||
if p.StorageKey != "" {
|
||||
url, err := s.storage.PresignGet(ctx, p.StorageKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.OriginalURL = url
|
||||
p.DownloadURL = url
|
||||
}
|
||||
if p.ThumbKey != "" {
|
||||
if url, err := s.storage.PresignGet(ctx, p.ThumbKey); err == nil {
|
||||
p.ThumbURL = url
|
||||
}
|
||||
}
|
||||
if p.PreviewKey != "" {
|
||||
if url, err := s.storage.PresignGet(ctx, p.PreviewKey); err == nil {
|
||||
p.PreviewURL = url
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, albumID, userID, cursor string, since *time.Time, limit int) ([]models.Photo, string, error) {
|
||||
if _, _, err := s.albums.Access(ctx, albumID, userID); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
out, err := s.store.List(ctx, ListParams{
|
||||
AlbumID: albumID,
|
||||
Viewer: userID,
|
||||
Cursor: cursor,
|
||||
Since: since,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
for i := range out {
|
||||
_ = s.attachURLs(ctx, &out[i])
|
||||
}
|
||||
next := ""
|
||||
if since == nil && len(out) == limit && limit > 0 {
|
||||
next = out[len(out)-1].ID
|
||||
}
|
||||
return out, next, nil
|
||||
}
|
||||
|
||||
// ListPublic lists photos for a share link (access already validated by token).
|
||||
func (s *Service) ListPublic(ctx context.Context, albumID, cursor string, since *time.Time, limit int) ([]models.Photo, string, error) {
|
||||
out, err := s.store.List(ctx, ListParams{
|
||||
AlbumID: albumID,
|
||||
Viewer: "",
|
||||
Cursor: cursor,
|
||||
Since: since,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
for i := range out {
|
||||
_ = s.attachURLs(ctx, &out[i])
|
||||
}
|
||||
next := ""
|
||||
if since == nil && len(out) == limit && limit > 0 {
|
||||
next = out[len(out)-1].ID
|
||||
}
|
||||
return out, next, nil
|
||||
}
|
||||
|
||||
// GetPublic returns a single photo for a share link.
|
||||
func (s *Service) GetPublic(ctx context.Context, photoID string) (*models.Photo, error) {
|
||||
p, err := s.store.GetByID(ctx, photoID, "")
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := s.attachURLs(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// DownloadPublic returns a presigned original URL for a share link.
|
||||
func (s *Service) DownloadPublic(ctx context.Context, photoID string) (string, error) {
|
||||
p, err := s.GetPublic(ctx, photoID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.storage.PresignGet(ctx, p.StorageKey)
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, photoID, userID string) (*models.Photo, error) {
|
||||
p, err := s.store.GetByID(ctx, photoID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.attachURLs(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Service) Download(ctx context.Context, photoID, userID string) (string, error) {
|
||||
p, err := s.Get(ctx, photoID, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
url, err := s.storage.PresignGet(ctx, p.StorageKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
|
||||
p, err := s.store.GetByID(ctx, photoID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return httpx.NotFound("photo_not_found", "photo not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
own := p.UploaderID == userID
|
||||
if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) {
|
||||
return httpx.Forbidden("forbidden", "you cannot delete this photo")
|
||||
}
|
||||
if err := s.store.Delete(ctx, photoID); err != nil {
|
||||
return err
|
||||
}
|
||||
keys := []string{p.StorageKey}
|
||||
if p.ThumbKey != "" {
|
||||
keys = append(keys, p.ThumbKey)
|
||||
}
|
||||
if p.PreviewKey != "" {
|
||||
keys = append(keys, p.PreviewKey)
|
||||
}
|
||||
_ = s.storage.Delete(ctx, keys...)
|
||||
s.hub.Publish(p.AlbumID, "photo.deleted", map[string]string{
|
||||
"id": p.ID, "album_id": p.AlbumID, "uploader_id": p.UploaderID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"shared-album/internal/models"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("photo not found")
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
||||
|
||||
const photoColumns = `p.id, p.album_id, p.uploader_id, p.storage_key,
|
||||
COALESCE(p.thumb_key, ''), COALESCE(p.preview_key, ''),
|
||||
p.original_filename, p.size, p.width, p.height, p.mime_type,
|
||||
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
|
||||
u.id, u.name, COALESCE(u.avatar_url, ''),
|
||||
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
|
||||
(SELECT count(*) FROM comments c WHERE c.photo_id = p.id),
|
||||
EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2)`
|
||||
|
||||
func scanPhoto(row pgx.Row) (*models.Photo, error) {
|
||||
var p models.Photo
|
||||
err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||
&p.ThumbKey, &p.PreviewKey,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (s *Store) Create(ctx context.Context, p *models.Photo) error {
|
||||
return s.pool.QueryRow(ctx,
|
||||
`INSERT INTO photos (id, album_id, uploader_id, storage_key, original_filename,
|
||||
size, width, height, mime_type, taken_at, latitude, longitude, caption)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
RETURNING created_at`,
|
||||
p.ID, p.AlbumID, p.UploaderID, p.StorageKey, p.OriginalFilename,
|
||||
p.Size, p.Width, p.Height, p.MimeType, p.TakenAt, p.Latitude, p.Longitude, p.Caption,
|
||||
).Scan(&p.CreatedAt)
|
||||
}
|
||||
|
||||
func (s *Store) SetVariants(ctx context.Context, id, thumbKey, previewKey string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE photos SET thumb_key = $2, preview_key = $3 WHERE id = $1`, id, thumbKey, previewKey)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error) {
|
||||
return scanPhoto(s.pool.QueryRow(ctx,
|
||||
`SELECT `+photoColumns+`
|
||||
FROM photos p JOIN users u ON u.id = p.uploader_id
|
||||
WHERE p.id = $1`, id, viewerID))
|
||||
}
|
||||
|
||||
type ListParams struct {
|
||||
AlbumID string
|
||||
Viewer string
|
||||
Cursor string
|
||||
Since *time.Time
|
||||
Limit int
|
||||
}
|
||||
|
||||
func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error) {
|
||||
if in.Limit <= 0 || in.Limit > 100 {
|
||||
in.Limit = 50
|
||||
}
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT `+photoColumns+`
|
||||
FROM photos p JOIN users u ON u.id = p.uploader_id
|
||||
WHERE p.album_id = $1
|
||||
AND ($3 = '' OR (p.created_at, p.id) < (SELECT created_at, id FROM photos WHERE id = $3))
|
||||
AND ($4::timestamptz IS NULL OR p.created_at > $4)
|
||||
ORDER BY p.created_at DESC, p.id DESC
|
||||
LIMIT $5`,
|
||||
in.AlbumID, in.Viewer, in.Cursor, in.Since, in.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []models.Photo{}
|
||||
for rows.Next() {
|
||||
var p models.Photo
|
||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||
&p.ThumbKey, &p.PreviewKey,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, id string) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetCover(ctx context.Context, albumID, photoID string) {
|
||||
_, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = $2 WHERE id = $1`, albumID, photoID)
|
||||
}
|
||||
Reference in New Issue
Block a user