Initial commit
This commit is contained in:
142
internal/albums/albums.go
Normal file
142
internal/albums/albums.go
Normal 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
62
internal/api/api.go
Normal 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
33
internal/auth/auth.go
Normal 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
81
internal/auth/user.go
Normal 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
40
internal/config/app.go
Normal 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
|
||||
}
|
||||
353
internal/controller/handler.go
Normal file
353
internal/controller/handler.go
Normal 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"})
|
||||
}
|
||||
20
internal/controller/helper.go
Normal file
20
internal/controller/helper.go
Normal 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
32
internal/db/sqlite/db.go
Normal 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
|
||||
}
|
||||
69
internal/db/sqlite/init_tables.go
Normal file
69
internal/db/sqlite/init_tables.go
Normal 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
96
internal/fs/fs.go
Normal 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"
|
||||
}
|
||||
67
internal/watermark/watermark.go
Normal file
67
internal/watermark/watermark.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user