Initial commit
This commit is contained in:
30
Dockerfile
Normal file
30
Dockerfile
Normal 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
36
Makefile
Normal 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
45
cmd/backend/main.go
Normal 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
6
config.yaml
Normal 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
BIN
data/photodisk.db
Normal file
Binary file not shown.
BIN
data/photodisk.db-shm
Normal file
BIN
data/photodisk.db-shm
Normal file
Binary file not shown.
BIN
data/photodisk.db-wal
Normal file
BIN
data/photodisk.db-wal
Normal file
Binary file not shown.
BIN
data/watermark.png
Normal file
BIN
data/watermark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
37
go.mod
Normal file
37
go.mod
Normal 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
90
go.sum
Normal 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
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)
|
||||
}
|
||||
116
static/index.html
Normal file
116
static/index.html
Normal 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">×</span>
|
||||
<div id="modal-content"></div>
|
||||
<div id="modal-prev" class="modal-nav">❮</div>
|
||||
<div id="modal-next" class="modal-nav">❯</div>
|
||||
</div>
|
||||
<!-- Add Project Modal -->
|
||||
<div id="add-project-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close">×</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
238
static/script.js
Normal 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
353
static/style.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user