Initial commit

This commit is contained in:
Artem Mamonov
2025-02-06 02:36:10 +01:00
commit acf9b43671
24 changed files with 1946 additions and 0 deletions

142
internal/albums/albums.go Normal file
View File

@@ -0,0 +1,142 @@
package albums
import (
"errors"
"github.com/google/uuid"
"photodisk/internal/auth"
"photodisk/internal/config"
db "photodisk/internal/db/sqlite"
"photodisk/internal/fs"
"strings"
"time"
)
type Metrics struct {
Files int `json:"files" db:"files"`
FilesSize int `json:"files_size" db:"files_size"`
Visits int `json:"visits" db:"visits"`
Downloads int `json:"downloads" db:"downloads"`
FavLists int `json:"fav_lists" db:"fav_lists"`
}
type Comment struct {
ID string `json:"id" db:"id"`
AlbumID string `json:"album_id" db:"album_id"`
PhotoID string `json:"photo_id" db:"photo_id"`
Message string `json:"message" db:"message"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type FavouriteList struct {
ID string `json:"id" db:"id"`
AlbumID string `json:"album_id" db:"album_id"`
PhotoID string `json:"photo_id" db:"photo_id"`
}
type Album struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Password string `json:"-" db:"password"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
ExpireAt time.Time `json:"expire_at" db:"expire_at"`
Watermarked bool `json:"watermarked" db:"watermarked"`
AllowDownloads bool `json:"allow_downloads" db:"allow_downloads"`
AllowComments bool `json:"allow_comments" db:"allow_comments"`
AllowFavourite bool `json:"allow_favourite" db:"allow_favourite"`
Metrics
}
func GenerateAlbumId() string {
return uuid.New().String()
}
func CreateAlbum(album Album) error {
if strings.TrimSpace(album.Name) == "" {
return errors.New("name is empty")
}
hashedPassword := ""
if album.Password != "" {
var err error
hashedPassword, err = auth.HashPassword(album.Password)
if err != nil {
return err
}
}
album.Password = hashedPassword
album.Watermarked = false
album.CreatedAt = time.Now()
album.ExpireAt = time.Now().Add(config.Get().AlbumTtl)
q := `INSERT INTO albums (id, name, is_active, created_at, expire_at, password, watermarked)
VALUES (:id, :name, 1, :created_at, :expire_at, :password, :watermarked)`
_, err := db.GetDb().NamedExec(q, album)
if err != nil {
fs.DeleteAlbum(album.ID)
return err
}
err = fs.CreateAlbum(album.ID)
if err != nil {
return err
}
return err
}
func DeleteAlbum(id string) error {
if err := fs.DeleteAlbum(id); err != nil {
return err
}
_, err := db.GetDb().Exec("DELETE FROM albums WHERE id = ?", id)
return err
}
func ListAlbums() ([]Album, error) {
var albums []Album
err := db.GetDb().Select(&albums, "SELECT * FROM albums ORDER BY created_at DESC")
if err != nil {
return nil, err
}
return albums, nil
}
func GetAlbum(id string, password string) (Album, error) {
var album Album
err := db.GetDb().Get(&album, "SELECT * FROM albums WHERE id = ?", id)
if err != nil {
return album, err
}
if album.Password != "" {
if err := auth.CheckPasswordHash(password, album.Password); err != nil {
return album, err
}
}
return album, nil
}
func UpdateAlbum(album Album) error {
if strings.TrimSpace(album.Name) == "" {
return errors.New("name is empty")
}
_, err := db.GetDb().NamedExec(`UPDATE albums SET
name = :name,
is_active = :is_active,
expire_at = :expire_at,
password = :password,
watermarked = :watermarked,
allow_downloads = :allow_download,
allow_comments = :allow_comment,
allow_favourite = :allow_favourite
WHERE id = :id`, album)
return err
}

62
internal/api/api.go Normal file
View File

@@ -0,0 +1,62 @@
package api
import (
"github.com/gin-gonic/gin"
"net/http"
"path/filepath"
"photodisk/internal/auth"
"photodisk/internal/config"
"photodisk/internal/controller"
)
func AuthMiddleware(c *gin.Context) {
sessionId, err := c.Cookie("session_id")
if sessionId == "" || err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
c.Abort()
return
}
user, err := auth.CheckSession(sessionId)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
c.Abort()
return
}
controller.SetUser(c, user)
c.Next()
}
func Start(addr string) error {
// Start the server
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "pong",
})
})
r.POST("/login", controller.Login)
r.POST("/albums/new", AuthMiddleware, controller.CreateAlbum)
r.PUT("/albums/:id", AuthMiddleware, controller.UpdateAlbum)
r.GET("/albums/:id", AuthMiddleware, controller.GetAlbum)
r.DELETE("/albums/:id", AuthMiddleware, controller.DeleteAlbum)
r.GET("/albums", AuthMiddleware, controller.ListAlbums)
r.GET("/albums/:id/list", controller.ListImages)
r.GET("/albums/:id/:image", controller.ServeImage)
r.POST("/album/:id/upload", AuthMiddleware, controller.UploadImage)
// Serve static files
r.Static("/static", config.Get().Static)
r.GET("/", func(c *gin.Context) {
c.File(filepath.Join(config.Get().Static, "index.html"))
})
return r.Run(addr) // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}

33
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,33 @@
package auth
import "golang.org/x/crypto/bcrypt"
var (
ErrEmptyPassword = Error{"password is empty"}
ErrPasswordIncorrect = Error{"password is incorrect"}
)
type Error struct {
Err string
}
func (e Error) Error() string {
return e.Err
}
func HashPassword(password string) (string, error) {
if password == "" {
return "", ErrEmptyPassword
}
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
return string(bytes), err
}
func CheckPasswordHash(password, hash string) error {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
if err != nil {
return ErrPasswordIncorrect
}
return nil
}

81
internal/auth/user.go Normal file
View File

@@ -0,0 +1,81 @@
package auth
import (
"database/sql"
"github.com/google/uuid"
"log"
db "photodisk/internal/db/sqlite"
"time"
)
var (
ErrUserNotFound = Error{"user not found"}
ErrSessionNotFound = Error{"session not found"}
)
type User struct {
Id int `db:"id"`
Username string `db:"username"`
Password string `db:"password"`
}
type Session struct {
Id string `db:"id"`
UserId int `db:"user_id"`
ExpiresAt string `db:"expires_at"`
}
func GenerateToken() string {
// generate token using uuid
return uuid.New().String()
}
func CreateSession(userId int, expireAt time.Time) (string, error) {
token := GenerateToken()
_, err := db.GetDb().Exec("INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)", token, userId, expireAt)
if err != nil {
log.Println(err)
return "", err
}
return token, nil
}
func CheckSession(sessionId string) (User, error) {
user := User{}
q := `SELECT users.id, users.username
FROM users
JOIN sessions ON users.id = sessions.user_id
WHERE sessions.id = ? AND sessions.expires_at > datetime('now')`
err := db.GetDb().Get(&user, q, sessionId)
if err != nil {
if err != sql.ErrNoRows {
log.Println(err)
}
return User{}, ErrSessionNotFound
}
return user, nil
}
func Login(username, password string) (User, error) {
user := User{}
err := db.GetDb().Get(&user, "SELECT * FROM users WHERE username = ?", username)
if err != nil {
log.Println(err)
if err != sql.ErrNoRows {
}
return User{}, ErrUserNotFound
}
err = CheckPasswordHash(password, user.Password)
if err != nil {
log.Println(err)
return User{}, err
}
return user, nil
}

40
internal/config/app.go Normal file
View File

@@ -0,0 +1,40 @@
package config
import (
"gopkg.in/yaml.v3"
"os"
"time"
)
var app App
type App struct {
Data string `yaml:"data"`
Db string `yaml:"db"`
Static string `yaml:"static"`
Watermark string `yaml:"watermark"`
SessionTtl time.Duration `yaml:"session_ttl"`
AlbumTtl time.Duration `yaml:"album_ttl"`
}
func ReadConfig(filename string) error {
// read config from yaml file
file, err := os.Open(filename)
if err != nil {
return err
}
defer file.Close()
// decode yaml
decoder := yaml.NewDecoder(file)
err = decoder.Decode(&app)
if err != nil {
return err
}
return nil
}
func Get() App {
return app
}

View File

@@ -0,0 +1,353 @@
package controller
import (
"database/sql"
"fmt"
"github.com/gin-gonic/gin"
"image/jpeg"
"net/http"
"path/filepath"
"photodisk/internal/albums"
"photodisk/internal/auth"
"photodisk/internal/config"
"photodisk/internal/fs"
"photodisk/internal/watermark"
"time"
)
// Login
type LoginRequest struct {
Username string `form:"username" json:"username" binding:"required"`
Password string `form:"password" json:"password" binding:"required"`
}
func Login(c *gin.Context) {
var req LoginRequest
if err := c.Bind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := auth.Login(req.Username, req.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
expiresAt := time.Now().Add(config.Get().SessionTtl)
sessionId, err := auth.CreateSession(user.Id, expiresAt)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
cookie := &http.Cookie{
Name: "session_id",
Value: sessionId,
Expires: expiresAt,
Path: "/",
}
http.SetCookie(c.Writer, cookie)
c.JSON(http.StatusOK, gin.H{"session": sessionId})
}
// Create
type CreateAlbumRequest struct {
Name string `form:"name" binding:"required"`
Password string `form:"password"`
}
type CreateAlbumResponse struct {
Id string `json:"id"`
}
func CreateAlbum(c *gin.Context) {
var req CreateAlbumRequest
if err := c.Bind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
id := albums.GenerateAlbumId()
if err := albums.CreateAlbum(albums.Album{
ID: id,
Name: req.Name,
Password: req.Password,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, CreateAlbumResponse{Id: id})
}
// Delete
type DeleteAlbumRequest struct {
Id string `form:"id" binding:"required"`
}
type DeleteAlbumResponse struct {
}
func DeleteAlbum(c *gin.Context) {
var req DeleteAlbumRequest
if err := c.Bind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := albums.DeleteAlbum(req.Id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, DeleteAlbumResponse{})
}
// List
type ListAlbumsRequest struct {
}
type ListAlbumsResponse struct {
Albums []albums.Album `json:"albums"`
}
func ListAlbums(c *gin.Context) {
var req ListAlbumsRequest
if err := c.Bind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
list, err := albums.ListAlbums()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, ListAlbumsResponse{Albums: list})
}
// List Images
type ListImagesRequest struct {
Password string `form:"password"`
}
type ListImagesResponse struct {
Images []string `json:"images"`
}
func ListImages(c *gin.Context) {
id := c.Param("id")
var req ListImagesRequest
if err := c.Bind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
album, err := albums.GetAlbum(id, req.Password)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
files, err := fs.ListFiles(album.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var images []string
for _, file := range files {
suffix := ""
if filepath.Ext(file) != ".mp4" {
suffix = "?width=200"
}
images = append(images, fmt.Sprintf("/albums/%s/%s%s", album.ID, file, suffix))
}
c.JSON(http.StatusOK, ListImagesResponse{Images: images})
}
// Get Album
type GetAlbumRequest struct {
Id string `form:"id" binding:"required"`
Password string `form:"password"`
}
type GetAlbumResponse struct {
Album albums.Album `json:"album"`
}
func GetAlbum(c *gin.Context) {
var req GetAlbumRequest
if err := c.Bind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
album, err := albums.GetAlbum(req.Id, req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, GetAlbumResponse{Album: album})
}
// Update Album
type UpdateAlbumRequest struct {
Id string `form:"id" binding:"required"`
Name string `form:"name" binding:"required"`
Password string `form:"password"`
IsActive bool `form:"is_active"`
}
type UpdateAlbumResponse struct {
Album albums.Album `json:"album"`
}
func UpdateAlbum(c *gin.Context) {
var req UpdateAlbumRequest
if err := c.Bind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
album, err := albums.GetAlbum(req.Id, req.Password)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
album.Name = req.Name
album.IsActive = req.IsActive
if err := albums.UpdateAlbum(album); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, UpdateAlbumResponse{Album: album})
}
// Serve Image
type ServeImageRequest struct {
Width int `form:"width"`
}
func ServeImage(c *gin.Context) {
albumID := c.Param("id")
fileName := c.Param("image")
if albumID == "" || fileName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "album and file query parameters are required"})
return
}
var req ServeImageRequest
if err := c.Bind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// just to check access
album, err := albums.GetAlbum(albumID, "")
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err})
return
}
path, exists := fs.FileExists(album.ID, fileName)
if exists {
/*
// Cache control
data := []byte(time.Now().String())
etag := fmt.Sprintf("%x", md5.Sum(data))
c.Header("Cache-Control", "public, max-age=3600")
c.Header("ETag", etag)
if match := c.GetHeader("If-None-Match"); match != "" {
if strings.Contains(match, etag) {
c.Status(http.StatusNotModified)
return
}
}
*/
if album.Watermarked && fs.IsImageFile(fileName) {
watermarkedImg, err := watermark.AddWatermark(path, config.Get().Watermark)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Header("Content-Type", "image/jpeg")
// Resize image
if req.Width > 0 {
watermarkedImg = watermark.Resize(watermarkedImg, req.Width)
}
jpeg.Encode(c.Writer, watermarkedImg, nil)
return
}
c.File(path)
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
}
// Upload Image
func UploadImage(c *gin.Context) {
albumID := c.Param("id")
// Parse the multipart form
err := c.Request.ParseMultipartForm(10 << 20) // Max size: 10MB
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check if the album exists
_, err = albums.GetAlbum(albumID, "") // FIXME: add password support
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Album not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
files := c.Request.MultipartForm.File["files[]"]
for _, fileHeader := range files {
file, err := fileHeader.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
file.Close()
return
}
if err := fs.CreateFile(albumID, fileHeader.Filename, file); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
file.Close()
return
}
file.Close()
}
c.JSON(http.StatusOK, gin.H{"message": "Images uploaded successfully"})
}

View File

@@ -0,0 +1,20 @@
package controller
import (
"github.com/gin-gonic/gin"
"log"
"photodisk/internal/auth"
)
func SetUser(c *gin.Context, user auth.User) {
c.Set("user", user)
}
func GetUser(c *gin.Context) auth.User {
user, ok := c.Get("user")
if !ok {
log.Fatal("user not found in context")
}
return user.(auth.User)
}

32
internal/db/sqlite/db.go Normal file
View File

@@ -0,0 +1,32 @@
package sqlite
import (
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
)
var db *sqlx.DB
func OpenDb(fname string) (*sqlx.DB, error) {
conn, err := sqlx.Open("sqlite3", fname+"?cache=shared&mode=rwc&_journal_mode=WAL")
if err != nil {
return nil, err
}
conn.SetMaxOpenConns(1) // sqlite3 does not support multiple connections
db = conn
return conn, nil
}
func CloseDb() error {
if db != nil {
return db.Close()
}
return nil
}
func GetDb() *sqlx.DB {
return db
}

View File

@@ -0,0 +1,69 @@
package sqlite
import (
"database/sql"
"log"
)
func InitTables() {
// Create tables
q := `
CREATE TABLE IF NOT EXISTS albums (
id TEXT NOT NULL PRIMARY KEY,
name TEXT,
is_active INTEGER DEFAULT 1,
watermarked INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expire_at DATETIME,
password TEXT,
allow_comments INTEGER DEFAULT 0,
allow_downloads INTEGER DEFAULT 0,
allow_favourite INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS metrics (
album_id TEXT NOT NULL PRIMARY KEY,
views INTEGER DEFAULT 0,
downloads INTEGER DEFAULT 0,
files INTEGER DEFAULT 0,
files_size INTEGER DEFAULT 0,
fav_lists INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
album_id TEXT,
photo_id TEXT,
message TEXT,
created_at DATETIME
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username TEXT,
password TEXT
);
CREATE TABLE IF NOT EXISTS sessions (
id VARCHAR(36) PRIMARY KEY,
user_id INTEGER,
token TEXT,
expires_at DATETIME
);
`
_, err := db.Exec(q)
if err != nil {
log.Fatalf("%q: %s\n", err, q)
}
// Insert default user if not exists
q = `SELECT * FROM users WHERE username = 'admin'`
row := db.QueryRow(q)
var id int
err = row.Scan(&id)
if err != sql.ErrNoRows {
return
}
q = `INSERT INTO users (username, password) VALUES ('admin', '$2a$14$09LaSuG93OEdVXZMBP.8Ruy4rvP54OeEGBoNP/6DAHMR/K0ITNBYq')`
_, err = db.Exec(q)
if err != nil {
log.Fatalf("%q: %s\n", err, q)
}
}

96
internal/fs/fs.go Normal file
View File

@@ -0,0 +1,96 @@
package fs
import (
"errors"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"photodisk/internal/config"
)
const AlbumDir = "albums"
func AlbumsPath() string {
return filepath.Join(config.Get().Data, AlbumDir)
}
func AlbumPath(id string) string {
return filepath.Join(AlbumsPath(), id)
}
func AlbumFile(id string, fileName string) string {
return filepath.Join(AlbumPath(id), fileName)
}
func CreateAlbum(id string) error {
return os.MkdirAll(AlbumPath(id), os.ModePerm)
}
func DeleteAlbum(id string) error {
return os.RemoveAll(AlbumPath(id))
}
func FileExists(albumId string, fileName string) (string, bool) {
path := AlbumFile(albumId, fileName)
_, err := os.Stat(path)
return path, err == nil
}
func ListFiles(albumId string) ([]string, error) {
files, err := os.ReadDir(AlbumPath(albumId))
if err != nil {
return nil, err
}
var fileNames []string
for _, file := range files {
fileNames = append(fileNames, file.Name())
}
return fileNames, nil
}
func CreateFile(albumId string, fileName string, file multipart.File) error {
filePath := filepath.Join(AlbumPath(albumId), fileName)
// Check if file already exists
if _, err := os.Stat(filePath); !os.IsNotExist(err) {
return errors.New("file already exists")
}
// Validate the file is a real JPEG image
buffer := make([]byte, 512)
_, err := file.Read(buffer)
if err != nil {
return err
}
contentType := http.DetectContentType(buffer)
if contentType != "image/jpeg" && contentType != "video/mp4" {
return errors.New("file is not a JPEG image or MP4 video")
}
// Reset file pointer to the beginning
file.Seek(0, 0)
// Create destination file
out, err := os.Create(filePath)
if err != nil {
return err
}
defer out.Close()
// Copy the uploaded file to the destination file
_, err = io.Copy(out, file)
if err != nil {
return err
}
return nil
}
func IsImageFile(fileName string) bool {
ext := filepath.Ext(fileName)
return ext == ".jpg" || ext == ".jpeg" || ext == ".png"
}

View File

@@ -0,0 +1,67 @@
package watermark
import (
"errors"
"github.com/nfnt/resize"
"image"
"image/draw"
"image/jpeg"
"image/png"
"os"
"sync"
)
func AddWatermark(origImage string, watermarkImage string) (image.Image, error) {
wg := sync.WaitGroup{}
var img image.Image
var watermark image.Image
wg.Add(1)
go func() {
defer wg.Done()
var err error
file, err := os.Open(origImage)
if err != nil {
return
}
defer file.Close()
img, err = jpeg.Decode(file)
if err != nil {
return
}
}()
wg.Add(1)
go func() {
defer wg.Done()
var err error
watermarkFile, err := os.Open(watermarkImage)
if err != nil {
return
}
defer watermarkFile.Close()
watermark, err = png.Decode(watermarkFile)
if err != nil {
return
}
}()
wg.Wait()
if img == nil || watermark == nil {
return nil, errors.New("failed to decode image")
}
offset := image.Pt(10, 10)
b := img.Bounds()
m := image.NewRGBA(b)
draw.Draw(m, b, img, image.Point{}, draw.Src)
draw.Draw(m, watermark.Bounds().Add(offset), watermark, image.Point{}, draw.Over)
return m, nil
}
func Resize(img image.Image, width int) image.Image {
return resize.Resize(uint(width), 0, img, resize.Lanczos3)
}