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

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM golang:1.20 AS builder
# Set the working directory
WORKDIR /app
# Copy go.mod and go.sum files
COPY . .
# Download all dependencies
RUN go mod download
# Build the Go application
RUN make build-linux
# Use a minimal base image
FROM scratch
# Set the working directory
WORKDIR /app
# Copy the binary from the builder stage
COPY --from=builder /app/cmd/backend .
COPY --from=builder /app/static ./static
COPY --from=builder /app/config.yaml .
# Expose the application port
EXPOSE 8080
# Command to run the binary
CMD ["./backend", "-config", "config.yaml"]

36
Makefile Normal file
View File

@@ -0,0 +1,36 @@
APP_NAME = backend
CMD_DIR = cmd/$(APP_NAME)
DOCKER_IMAGE_NAME = photodisk:latest
DOCKER_REGISTRY = registry.rgkn.dev
# Default target: builds the application for the host OS
build:
@echo "Building the application for the host OS..."
go build -o $(CMD_DIR)/$(APP_NAME) $(CMD_DIR)/main.go
# Build the application for Linux
build-linux:
@echo "Building the application for Linux..."
CGO_ENABLED=1 go build -tags musl --ldflags "-extldflags -static" -o $(CMD_DIR)/$(APP_NAME) $(CMD_DIR)/main.go
# Run the application
run:
@echo "Running the application..."
go run $(CMD_DIR)/main.go $(ARGS)
# Build Docker image
docker:
@echo "Building Docker image..."
docker buildx build -t $(DOCKER_REGISTRY)/$(DOCKER_IMAGE_NAME) --platform linux/amd64,linux/arm64 -f Dockerfile --push .
deploy: docker
@echo "Deploying Docker image..."
docker tag $(DOCKER_IMAGE_NAME) $(DOCKER_REGISTRY)/$(DOCKER_IMAGE_NAME)
docker push $(DOCKER_REGISTRY)/$(DOCKER_IMAGE_NAME)
# Clean up
clean:
@echo "Cleaning up..."
rm -f $(CMD_DIR)/$(APP_NAME)
.PHONY: build build-linux run docker clean

45
cmd/backend/main.go Normal file
View File

@@ -0,0 +1,45 @@
package main
import (
"flag"
"log"
"os"
"photodisk/internal/api"
"photodisk/internal/config"
db "photodisk/internal/db/sqlite"
)
var Flags struct {
Help bool
ConfigPath string
}
func flags(path string) {
fs := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
fs.BoolVar(&Flags.Help, "h", false, "Help message")
fs.StringVar(&Flags.ConfigPath, "path", path, "Path to config file")
fs.Parse(os.Args[1:])
if Flags.Help {
fs.PrintDefaults()
os.Exit(0)
}
}
func main() {
// read config filename from flags
flags("config.yaml")
if err := config.ReadConfig(Flags.ConfigPath); err != nil {
log.Fatal(err)
}
if _, err := db.OpenDb(config.Get().Db); err != nil {
log.Fatal(err)
}
defer db.CloseDb()
db.InitTables()
if err := api.Start(":8080"); err != nil {
log.Fatal(err)
}
}

6
config.yaml Normal file
View File

@@ -0,0 +1,6 @@
data: data
db: data/photodisk.db
static: static
watermark: data/watermark.png
session_ttl: 24h
album_ttl: 2160h # 90 days

BIN
data/photodisk.db Normal file

Binary file not shown.

BIN
data/photodisk.db-shm Normal file

Binary file not shown.

BIN
data/photodisk.db-wal Normal file

Binary file not shown.

BIN
data/watermark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

37
go.mod Normal file
View File

@@ -0,0 +1,37 @@
module photodisk
go 1.20
require (
github.com/bytedance/sonic v1.11.7 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gin-gonic/gin v1.10.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

90
go.sum Normal file
View File

@@ -0,0 +1,90 @@
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bytedance/sonic v1.11.7 h1:k/l9p1hZpNIMJSk37wL9ltkcpqLfIho1vYthi4xT2t4=
github.com/bytedance/sonic v1.11.7/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

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)
}

116
static/index.html Normal file
View File

@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Album Manager</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" integrity="sha512-HfPclDlQUkcjYlZPzfdEftS5HkVU/LN8pPwqcSeCjJ83JGnVcqt7FYVgVkxGWMPN9IasvZp+hqQjW1FB2FYlnQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>
<body>
<div class="container">
<header>
<h1>Cloud Drive</h1>
<div class="progress-bar">
<div id="progress" style="width: 70%;">70.2 GB of 100 GB</div>
</div>
</header>
<div class="main-content">
<aside>
<button id="add-project" class="btn">+ Add Project</button>
<nav>
<ul>
<li id="projects-tab">Projects</li>
<li>Expiring</li>
<li>Archived Projects</li>
</ul>
</nav>
</aside>
<!-- Update the login form to only show if the user is not logged in -->
<section class="content">
<form id="login-form" style="display: none;">
<h2>Login</h2>
<div>
<label for="username">Username:</label>
<input type="text" id="username" name="username" required>
</div>
<div>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit">Login</button>
</form>
<table id="album-table" style="display:none;">
<thead>
<tr>
<th>Project</th>
<th>Shoot Date</th>
<th>Expires</th>
<th>Likes</th>
<th>Link to Project</th>
</tr>
</thead>
<tbody id="project-list">
<!-- Project items will be dynamically populated here -->
</tbody>
</table>
<div id="album-view" style="display:none;">
<div id="album-header">
<button id="back-to-projects" class="btn">Back to Projects</button>
<h2 id="album-name"></h2>
<button id="upload-button" class="btn">Upload</button>
</div>
<input type="file" id="file-input" multiple style="display:none;">
<div id="image-gallery"></div>
<div id="progress-bar" class="progress-bar">
<div id="upload-progress"></div>
</div>
</div>
</section>
</div>
</div>
<div id="modal" class="modal">
<span id="close-modal">&times;</span>
<div id="modal-content"></div>
<div id="modal-prev" class="modal-nav">&#10094;</div>
<div id="modal-next" class="modal-nav">&#10095;</div>
</div>
<!-- Add Project Modal -->
<div id="add-project-modal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>Add New Project</h2>
<form id="add-project-form">
<label for="name">Project Name</label>
<input type="text" id="name" name="name" required>
<label for="password">Password (optional)</label>
<input type="password" id="password" name="password">
<label>
<input type="checkbox" id="watermarked" name="watermarked">
Watermarked
</label>
<label>
<input type="checkbox" id="allow_comments" name="allow_comments">
Allow Comments
</label>
<label>
<input type="checkbox" id="allow_favourite" name="allow_favourite">
Allow Favourite
</label>
<label>
<input type="checkbox" id="allow_downloads" name="allow_downloads">
Allow Downloads
</label>
<button type="submit">Create Project</button>
</form>
</div>
</div>
<script src="/static/script.js"></script>
</body>
</html>

238
static/script.js Normal file
View File

@@ -0,0 +1,238 @@
document.addEventListener('DOMContentLoaded', () => {
const projectList = document.getElementById('project-list');
const loginForm = document.getElementById('login-form');
const albumTable = document.getElementById('album-table');
const albumView = document.getElementById('album-view');
const albumName = document.getElementById('album-name');
const imageGallery = document.getElementById('image-gallery');
const uploadButton = document.getElementById('upload-button');
const fileInput = document.getElementById('file-input');
const progressBar = document.getElementById('progress-bar');
const uploadProgress = document.getElementById('upload-progress');
let currentAlbumId = null;
let currentIndex = 0;
const checkLoginStatus = async () => {
try {
const response = await fetch('/albums', { credentials: 'include' });
if (response.ok) {
loginForm.style.display = 'none';
loadProjects();
} else {
loginForm.style.display = 'block';
}
} catch (error) {
console.error('Error checking login status:', error);
loginForm.style.display = 'block';
}
};
const loadProjects = async () => {
try {
const response = await fetch('/albums', { credentials: 'include' });
const data = await response.json();
projectList.innerHTML = '';
if (data !== undefined && data.albums !== null) {
data.albums.forEach(album => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${album.name}</td>
<td>${album.created_at}</td>
<td>${album.expire_at}</td>
<td>${album.likes}</td>
<td><a href="#" data-id="${album.id}" class="view-album">View Album</a></td>
`;
projectList.appendChild(row);
});
}
albumTable.style.display = 'block';
} catch (error) {
console.error('Error loading projects:', error);
}
};
const handleLogin = async (event) => {
event.preventDefault();
const formData = new FormData(loginForm);
try {
const response = await fetch('/login', {
method: 'POST',
body: formData
});
if (response.ok) {
loginForm.style.display = 'none';
loadProjects();
} else {
alert('Login failed');
}
} catch (error) {
console.error('Error logging in:', error);
}
};
const loadAlbum = async (albumId) => {
currentAlbumId = albumId;
albumTable.style.display = 'none'; // Hide the album table when viewing an album
try {
const response = await fetch(`/albums/${albumId}/list`);
const data = await response.json();
albumView.style.display = 'block';
albumName.textContent = data.name;
imageGallery.innerHTML = '';
data.images.forEach((image, index) => {
const fileType = image.split('.').pop();
const item = document.createElement('div');
item.className = 'gallery-item';
item.dataset.index = index;
if (fileType === 'mp4') {
item.innerHTML = `
<video src="${image}" class="gallery-thumb" data-id="${image}"></video>
`;
} else {
item.innerHTML = `
<img src="${image}" class="gallery-thumb" data-id="${image}">
`;
}
imageGallery.appendChild(item);
});
} catch (error) {
console.error('Error loading album:', error);
}
};
const handleUpload = async (formData, albumId) => {
progressBar.style.display = 'block';
try {
await fetch(`/album/${albumId}/upload`, {
method: 'POST',
body: formData,
credentials: 'include',
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
uploadProgress.style.width = `${percentCompleted}%`;
uploadProgress.textContent = `${percentCompleted}%`;
}
});
progressBar.style.display = 'none';
loadAlbum(albumId);
} catch (error) {
console.error('Error uploading images:', error);
progressBar.style.display = 'none';
}
};
const handleFiles = (files) => {
const formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append('files[]', files[i]);
}
handleUpload(formData, currentAlbumId);
};
const openModal = (index) => {
const items = document.querySelectorAll('.gallery-thumb');
currentIndex = index;
const item = items[currentIndex];
if (item.tagName === 'VIDEO') {
document.getElementById('modal-content').innerHTML = `
<video controls autoplay>
<source src="${item.dataset.id}" type="video/mp4">
</video>
`;
} else {
document.getElementById('modal-content').innerHTML = `
<img src="${item.dataset.id}">
`;
}
document.getElementById('modal').style.display = 'flex';
};
const closeModal = () => {
document.getElementById('modal').style.display = 'none';
const video = document.querySelector('#modal-content video');
if (video) {
video.pause();
}
};
const nextModal = () => {
const items = document.querySelectorAll('.gallery-thumb');
currentIndex = (currentIndex + 1) % items.length;
openModal(currentIndex);
};
const prevModal = () => {
const items = document.querySelectorAll('.gallery-thumb');
currentIndex = (currentIndex - 1 + items.length) % items.length;
openModal(currentIndex);
};
const openAddProjectModal = () => {
document.getElementById('add-project-modal').style.display = 'flex';
};
const closeAddProjectModal = () => {
document.getElementById('add-project-modal').style.display = 'none';
};
const handleAddProject = async (event) => {
event.preventDefault();
const formData = new FormData(event.target);
try {
const response = await fetch('/albums/new', {
method: 'POST',
body: formData,
credentials: 'include'
});
if (response.ok) {
closeAddProjectModal();
loadProjects();
} else {
alert('Failed to create project');
}
} catch (error) {
console.error('Error creating project:', error);
}
};
document.addEventListener('click', (event) => {
if (event.target.classList.contains('view-album')) {
const albumId = event.target.dataset.id;
loadAlbum(albumId);
}
if (event.target.classList.contains('gallery-thumb')) {
openModal(parseInt(event.target.parentElement.dataset.index));
}
if (event.target.id === 'close-modal') {
closeModal();
}
if (event.target.id === 'modal-next') {
nextModal();
}
if (event.target.id === 'modal-prev') {
prevModal();
}
if (event.target.id === 'add-project') {
openAddProjectModal();
}
if (event.target.id === 'close-add-project-modal') {
closeAddProjectModal();
}
});
loginForm.addEventListener('submit', handleLogin);
document.getElementById('add-project-form').addEventListener('submit', handleAddProject);
uploadButton.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', () => {
handleFiles(fileInput.files);
});
checkLoginStatus(); // Check if the user is logged in on page load
});

353
static/style.css Normal file
View File

@@ -0,0 +1,353 @@
/* Existing CSS */
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
display: flex;
flex-direction: column;
height: 100vh;
}
header {
background-color: #2c3e50;
color: white;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-bar {
background-color: #bdc3c7;
border-radius: 10px;
overflow: hidden;
width: 300px;
}
#progress {
background-color: #27ae60;
color: white;
padding: 5px;
text-align: center;
border-radius: 10px 0 0 10px;
}
.main-content {
display: flex;
flex: 1;
overflow: hidden;
}
aside {
background-color: #34495e;
color: white;
padding: 1rem;
width: 200px;
}
aside .btn {
background-color: #27ae60;
color: white;
border: none;
padding: 10px 20px;
cursor: pointer;
margin-bottom: 1rem;
}
aside nav ul {
list-style: none;
padding: 0;
}
aside nav ul li {
padding: 10px 0;
cursor: pointer;
}
aside nav ul li:hover {
background-color: #2c3e50;
}
.content {
flex: 1;
overflow: auto;
padding: 1rem;
background-color: white;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #ecf0f1;
}
tr:hover {
background-color: #f1f1f1;
}
/* New CSS for Login Form */
#login-form {
display: flex;
flex-direction: column;
width: 300px;
margin: 50px auto;
padding: 20px;
border: 1px solid #ccc;
background-color: #fff;
}
#login-form h2 {
margin-bottom: 20px;
}
#login-form div {
margin-bottom: 10px;
}
#login-form label {
display: block;
margin-bottom: 5px;
}
#login-form input {
width: 100%;
padding: 8px;
box-sizing: border-box;
}
#login-form button {
padding: 10px;
background-color: #27ae60;
color: white;
border: none;
cursor: pointer;
}
#login-form button:hover {
background-color: #219150;
}
/* New CSS for Album View */
#album-view {
display: flex;
flex-direction: column;
}
#album-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
#image-gallery {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
#image-gallery img, #image-gallery video {
width: 150px;
height: 150px;
object-fit: cover;
cursor: pointer;
}
#progress-bar {
display: none;
background-color: #bdc3c7;
border-radius: 10px;
overflow: hidden;
margin-top: 20px;
}
#upload-progress {
background-color: #27ae60;
color: white;
padding: 5px;
text-align: center;
border-radius: 10px 0 0 10px;
width: 0;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.9);
}
.modal-content {
margin: auto;
display: block;
width: 80%;
max-width: 700px;
}
.modal-nav {
cursor: pointer;
position: absolute;
top: 50%;
width: auto;
padding: 16px;
margin-top: -22px;
color: white;
font-weight: bold;
font-size: 20px;
transition: 0.6s ease;
user-select: none;
}
#modal-prev {
left: 0;
}
#modal-next {
right: 0;
}
#close-modal {
position: absolute;
top: 15px;
right: 35px;
color: white;
font-size: 40px;
font-weight: bold;
transition: 0.3s;
cursor: pointer;
}
#close-modal:hover,
#close-modal:focus {
color: #bbb;
text-decoration: none;
cursor: pointer;
}
/* Modal content scaled to viewport */
.modal-content {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.modal-content img, .modal-content video {
max-width: 100%;
max-height: 90vh;
}
/* Add Project Modal */
#add-project-modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.8);
}
#add-project-modal .modal-content {
background-color: #fefefe;
margin: 5% auto;
padding: 20px;
border: 1px solid #888;
border-radius: 10px;
width: 40%;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
animation: modalopen 0.5s;
}
@keyframes modalopen {
from {
opacity: 0;
transform: translateY(-50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
#add-project-modal .modal-content h2 {
margin-top: 0;
color: #333;
}
#add-project-modal .modal-content form {
display: flex;
flex-direction: column;
}
#add-project-modal .modal-content label {
margin: 10px 0 5px;
}
#add-project-modal .modal-content input[type="text"],
#add-project-modal .modal-content input[type="password"] {
padding: 10px;
margin: 5px 0 15px;
border: 1px solid #ddd;
border-radius: 4px;
width: 100%;
box-sizing: border-box;
}
#add-project-modal .modal-content input[type="checkbox"] {
width: auto;
margin-right: 10px;
}
#add-project-modal .modal-content button[type="submit"] {
background-color: #27ae60;
color: white;
padding: 10px 20px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
margin: 4px 2px;
cursor: pointer;
border: none;
border-radius: 4px;
}
#add-project-modal .modal-content button[type="submit"]:hover {
background-color: #219150;
}
#add-project-modal .close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
}
#add-project-modal .close:hover,
#add-project-modal .close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}