Browse Source

Init commit

master
Przemyslaw Kadej 3 weeks ago
parent
commit
b181689c18
  1. 17
      .env.example
  2. 17
      .gitignore
  3. 20
      backend/Dockerfile
  4. 85
      backend/cmd/server/main.go
  5. 29
      backend/go.mod
  6. 34
      backend/go.sum
  7. 6
      backend/internal/admin/admin.go
  8. 60
      backend/internal/auth/auth.go
  9. 63
      backend/internal/auth/handler.go
  10. 175
      backend/internal/files/files.go
  11. 54
      backend/internal/middleware/middleware.go
  12. 69
      docker-compose.yml
  13. 12
      frontend/index.html
  14. 3260
      frontend/package-lock.json
  15. 29
      frontend/package.json
  16. 6
      frontend/postcss.config.js
  17. 17
      frontend/src/App.vue
  18. 15
      frontend/src/api/client.js
  19. 61
      frontend/src/components/Navbar.vue
  20. 7
      frontend/src/main.js
  21. 37
      frontend/src/router/index.js
  22. 32
      frontend/src/stores/auth.js
  23. 18
      frontend/src/stores/theme.js
  24. 3
      frontend/src/style.css
  25. 9
      frontend/src/views/AdminPanel.vue
  26. 82
      frontend/src/views/Login.vue
  27. 287
      frontend/src/views/PublicBrowser.vue
  28. 12
      frontend/src/views/UserDashboard.vue
  29. 9
      frontend/tailwind.config.js
  30. 14
      frontend/vite.config.js
  31. 64
      nginx/conf.d/dropzone.conf
  32. 13
      postgres/init/01_schema.sql
  33. 17
      scripts/backup_db.sh
  34. 15
      scripts/deploy.sh

17
.env.example

@ -0,0 +1,17 @@
# Copy to .env and fill in the values
# cp .env.example .env
# --- PostgreSQL (docker-compose: container initialisation) ---
DB_NAME=dropzone
DB_USER=dropzone
DB_PASSWORD=CHANGE_TO_STRONG_PASSWORD
# --- Backend (go run ./cmd/server — outside Docker) ---
DATABASE_URL=postgres://dropzone:CHANGE_TO_STRONG_PASSWORD@localhost:15432/dropzone?sslmode=disable
PORT=8080
# --- JWT ---
JWT_SECRET=CHANGE_TO_RANDOM_STRING_MIN_32_CHARS
# --- Nginx / domain (production) ---
DOMAIN=dropzone.example.com

17
.gitignore

@ -0,0 +1,17 @@
# Environment
.env
# Go
backend/server
backend/*.exe
# Node / Vue
frontend/node_modules/
frontend/dist/
# Nginx certificates (uploaded manually)
nginx/certs/*.pem
nginx/certs/*.key
# Docker
*.log

20
backend/Dockerfile

@ -0,0 +1,20 @@
# Etap 1: budowanie binarki Go
FROM golang:1.23-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server
# Etap 2: minimalny obraz produkcyjny
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /build/server .
EXPOSE 8080
CMD ["./server"]

85
backend/cmd/server/main.go

@ -0,0 +1,85 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
"quake2dropzone/internal/auth"
"quake2dropzone/internal/files"
"quake2dropzone/internal/middleware"
)
func main() {
_ = godotenv.Load()
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
dsn = "postgres://dropzone:dropzone@localhost:15432/dropzone?sslmode=disable"
}
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
jwtSecret = "change-me-in-production"
}
diskRoot := os.Getenv("DISK_ROOT")
if diskRoot == "" {
diskRoot = "./diskroot"
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
db, err := pgxpool.New(context.Background(), dsn)
if err != nil {
log.Fatalf("failed to connect to database: %v", err)
}
defer db.Close()
if err := db.Ping(context.Background()); err != nil {
log.Fatalf("database ping failed: %v", err)
}
log.Println("connected to database")
r := chi.NewRouter()
r.Use(chimiddleware.Logger)
r.Use(chimiddleware.Recoverer)
// Public endpoints
r.Post("/api/auth/login", auth.NewLoginHandler(db, jwtSecret))
r.Get("/api/files/public", files.PublicListHandler(diskRoot))
r.Get("/api/files/public/*", files.PublicListHandler(diskRoot))
useXAccel := os.Getenv("NGINX_ACCEL") == "true"
r.Get("/api/files/download/public/*", files.PublicDownloadHandler(diskRoot, useXAccel))
r.Get("/api/files/raw/public/*", files.PublicRawHandler(diskRoot))
// Protected endpoints (example)
r.Group(func(r chi.Router) {
r.Use(middleware.AuthRequired(jwtSecret))
r.Get("/api/me", func(w http.ResponseWriter, r *http.Request) {
claims := middleware.GetClaims(r)
fmt.Fprintf(w, `{"username":%q,"role":%q}`, claims.Username, claims.Role)
})
})
// Admin-only endpoints (example)
r.Group(func(r chi.Router) {
r.Use(middleware.AuthRequired(jwtSecret))
r.Use(middleware.AdminRequired)
r.Get("/api/admin/users", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"users":[]}`))
})
})
log.Printf("server listening on :%s", port)
if err := http.ListenAndServe(":"+port, r); err != nil {
log.Fatalf("server error: %v", err)
}
}

29
backend/go.mod

@ -0,0 +1,29 @@
module quake2dropzone
go 1.23
require (
// Router HTTP
github.com/go-chi/chi/v5 v5.0.12
// JWT
github.com/golang-jwt/jwt/v5 v5.2.1
// PostgreSQL driver
github.com/jackc/pgx/v5 v5.6.0
// zmienne środowiskowe z pliku .env
github.com/joho/godotenv v1.5.1
// bcrypt
golang.org/x/crypto v0.24.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/stretchr/testify v1.8.4 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/text v0.16.0 // indirect
)

34
backend/go.sum

@ -0,0 +1,34 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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/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.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
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=

6
backend/internal/admin/admin.go

@ -0,0 +1,6 @@
package admin
// Admin panel.
// - move files from /mnt/dysk/<user>/public → /mnt/dysk/public
// - list all user files
// - manage user accounts

60
backend/internal/auth/auth.go

@ -0,0 +1,60 @@
package auth
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
// Claims holds the JWT payload.
type Claims struct {
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// GenerateJWT creates a JWT token valid for 24h.
func GenerateJWT(secret string, userID int64, username, role string) (string, error) {
claims := Claims{
Username: username,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
Subject: fmt.Sprintf("%d", userID),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
// ParseJWT verifies the token and returns claims.
func ParseJWT(secret, tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}
// VerifyPassword compares a plain-text password against a bcrypt hash.
func VerifyPassword(hash, password string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
// HashPassword generates a bcrypt hash from the given password.
func HashPassword(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(b), err
}

63
backend/internal/auth/handler.go

@ -0,0 +1,63 @@
package auth
import (
"encoding/json"
"net/http"
"github.com/jackc/pgx/v5/pgxpool"
)
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type userResponse struct {
ID int64 `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
}
type loginResponse struct {
Token string `json:"token"`
User userResponse `json:"user"`
}
// NewLoginHandler returns the handler for POST /api/auth/login.
func NewLoginHandler(db *pgxpool.Pool, jwtSecret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Username == "" || req.Password == "" {
http.Error(w, "invalid input", http.StatusBadRequest)
return
}
var id int64
var username, role, passwordHash string
err := db.QueryRow(r.Context(),
`SELECT id, username, role, password FROM users WHERE username = $1`,
req.Username,
).Scan(&id, &username, &role, &passwordHash)
if err != nil {
http.Error(w, "invalid username or password", http.StatusUnauthorized)
return
}
if err := VerifyPassword(passwordHash, req.Password); err != nil {
http.Error(w, "invalid username or password", http.StatusUnauthorized)
return
}
token, err := GenerateJWT(jwtSecret, id, username, role)
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(loginResponse{
Token: token,
User: userResponse{ID: id, Username: username, Role: role},
})
}
}

175
backend/internal/files/files.go

@ -0,0 +1,175 @@
package files
import (
"encoding/json"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
type Entry struct {
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
type listResponse struct {
Path string `json:"path"`
Entries []Entry `json:"entries"`
}
// PublicDownloadHandler returns a handler for downloading files from <diskRoot>/public/.
//
// useXAccel=true (production behind nginx): the backend only validates the path and
// returns the X-Accel-Redirect header — nginx serves the file directly from disk
// (sendfile), without streaming data through Go.
//
// useXAccel=false (dev with Vite proxy): data is streamed via http.ServeContent
// with Range (HTTP 206), ETag and Last-Modified support.
func PublicDownloadHandler(diskRoot string, useXAccel bool) http.HandlerFunc {
publicRoot, _ := filepath.Abs(filepath.Join(diskRoot, "public"))
return func(w http.ResponseWriter, r *http.Request) {
subPath := strings.TrimPrefix(filepath.Clean("/"+chi.URLParam(r, "*")), "/")
if subPath == "" || subPath == "." {
http.NotFound(w, r)
return
}
targetPath, _ := filepath.Abs(filepath.Join(publicRoot, subPath))
// Path traversal protection
if !strings.HasPrefix(targetPath+string(filepath.Separator),
publicRoot+string(filepath.Separator)) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
info, err := os.Stat(targetPath)
if err != nil || info.IsDir() {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Disposition",
"attachment; filename="+strconv.Quote(info.Name()))
if useXAccel {
// Nginx handles the file transfer — disk → nginx → client (sendfile)
accelURI := (&url.URL{Path: "/internal/public/" + filepath.ToSlash(subPath)}).EscapedPath()
w.Header().Set("X-Accel-Redirect", accelURI)
w.WriteHeader(http.StatusOK)
return
}
// Fallback: stream through Go (dev)
f, err := os.Open(targetPath)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
http.ServeContent(w, r, info.Name(), info.ModTime(), f)
}
}
// PublicRawHandler serves raw file content for in-browser rendering (e.g. README.md).
// Limited to 1 MB. No Content-Disposition header — intended for programmatic fetch.
func PublicRawHandler(diskRoot string) http.HandlerFunc {
publicRoot, _ := filepath.Abs(filepath.Join(diskRoot, "public"))
const maxBytes = 1 << 20 // 1 MB
return func(w http.ResponseWriter, r *http.Request) {
subPath := strings.TrimPrefix(filepath.Clean("/"+chi.URLParam(r, "*")), "/")
if subPath == "" || subPath == "." {
http.NotFound(w, r)
return
}
targetPath, _ := filepath.Abs(filepath.Join(publicRoot, subPath))
// Path traversal protection
if !strings.HasPrefix(targetPath+string(filepath.Separator),
publicRoot+string(filepath.Separator)) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
info, err := os.Stat(targetPath)
if err != nil || info.IsDir() {
http.NotFound(w, r)
return
}
if info.Size() > maxBytes {
http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
return
}
data, err := os.ReadFile(targetPath)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write(data)
}
}
// PublicListHandler returns a handler that lists <diskRoot>/public/ or a subdirectory.
func PublicListHandler(diskRoot string) http.HandlerFunc {
publicRoot, _ := filepath.Abs(filepath.Join(diskRoot, "public"))
return func(w http.ResponseWriter, r *http.Request) {
subPath := chi.URLParam(r, "*")
subPath = filepath.Clean("/" + subPath)
subPath = strings.TrimPrefix(subPath, "/")
targetDir := filepath.Join(publicRoot, subPath)
targetDir, _ = filepath.Abs(targetDir)
// Path traversal protection
if !strings.HasPrefix(targetDir+string(filepath.Separator), publicRoot+string(filepath.Separator)) &&
targetDir != publicRoot {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
entries, err := os.ReadDir(targetDir)
result := make([]Entry, 0)
if err == nil {
for _, e := range entries {
info, err := e.Info()
if err != nil {
continue
}
size := int64(0)
if !e.IsDir() {
size = info.Size()
}
result = append(result, Entry{
Name: e.Name(),
IsDir: e.IsDir(),
Size: size,
Modified: info.ModTime(),
})
}
}
// If the directory doesn't exist — return an empty list without an error
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(listResponse{
Path: subPath,
Entries: result,
})
}
}

54
backend/internal/middleware/middleware.go

@ -0,0 +1,54 @@
package middleware
import (
"context"
"net/http"
"strings"
"quake2dropzone/internal/auth"
)
type contextKey string
const ClaimsKey contextKey = "claims"
// AuthRequired validates the JWT from the Authorization: Bearer <token> header.
// On success it injects the claims into the request context.
func AuthRequired(jwtSecret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tokenStr := strings.TrimPrefix(header, "Bearer ")
claims, err := auth.ParseJWT(jwtSecret, tokenStr)
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), ClaimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// AdminRequired enforces the "admin" role in the JWT claims.
// Must be used after AuthRequired.
func AdminRequired(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(ClaimsKey).(*auth.Claims)
if !ok || claims.Role != "admin" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// GetClaims retrieves the claims from the request context.
func GetClaims(r *http.Request) *auth.Claims {
claims, _ := r.Context().Value(ClaimsKey).(*auth.Claims)
return claims
}

69
docker-compose.yml

@ -0,0 +1,69 @@
services:
# --- Database ---
postgres:
image: postgres:16-alpine
container_name: dropzone_postgres
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
ports:
- "15432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./postgres/init:/docker-entrypoint-initdb.d:ro # run once on init
networks:
- internal
# --- Go backend ---
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: dropzone_backend
restart: unless-stopped
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
DISK_ROOT: /mnt/dysk
NGINX_ACCEL: "true"
volumes:
- /mnt/dysk:/mnt/dysk # physical server disk
depends_on:
- postgres
networks:
- internal
# --- Nginx (reverse proxy + frontend) ---
nginx:
image: nginx:1.27-alpine
container_name: dropzone_nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/certs:/etc/nginx/certs:ro
- ./frontend/dist:/app/frontend/dist:ro # built frontend
- /mnt/dysk:/mnt/dysk:ro # for X-Accel-Redirect
depends_on:
- backend
networks:
- internal
- external
volumes:
postgres_data:
networks:
internal:
driver: bridge
external:
driver: bridge

12
frontend/index.html

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dropzone</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

3260
frontend/package-lock.json

File diff suppressed because it is too large

29
frontend/package.json

@ -0,0 +1,29 @@
{
"name": "dropzone-frontend",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@headlessui/vue": "^1.7.0",
"@uppy/core": "^5.2.0",
"@uppy/dashboard": "^5.1.1",
"@uppy/tus": "^5.1.1",
"axios": "^1.7.0",
"dompurify": "^3.3.1",
"marked": "^17.0.3",
"pinia": "^2.1.0",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"vite": "^7.3.1"
}
}

6
frontend/postcss.config.js

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

17
frontend/src/App.vue

@ -0,0 +1,17 @@
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors duration-200">
<Navbar />
<main>
<router-view />
</main>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import Navbar from './components/Navbar.vue'
import { useThemeStore } from './stores/theme'
const theme = useThemeStore()
onMounted(() => theme.init())
</script>

15
frontend/src/api/client.js

@ -0,0 +1,15 @@
import axios from 'axios'
const client = axios.create({
baseURL: '/api',
})
client.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export default client

61
frontend/src/components/Navbar.vue

@ -0,0 +1,61 @@
<template>
<nav class="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700 transition-colors duration-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<router-link to="/" class="text-xl font-bold text-indigo-600 dark:text-indigo-400">
Dropzone
</router-link>
<div class="flex items-center gap-4">
<template v-if="auth.isAuthenticated">
<router-link to="/dashboard" class="text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
Panel
</router-link>
<router-link v-if="auth.isAdmin" to="/admin" class="text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
Admin
</router-link>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ auth.user?.username }}</span>
<button
@click="handleLogout"
class="text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 px-3 py-1.5 rounded-md transition-colors"
>
Sign out
</button>
</template>
<template v-else>
<router-link
to="/login"
class="text-sm bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-1.5 rounded-md transition-colors"
>
Sign in
</router-link>
</template>
<button
@click="theme.toggle()"
class="w-8 h-8 flex items-center justify-center rounded-md text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
:title="theme.dark ? 'Switch to light theme' : 'Switch to dark theme'"
>
<span v-if="theme.dark"></span>
<span v-else>🌙</span>
</button>
</div>
</div>
</div>
</nav>
</template>
<script setup>
import { useAuthStore } from '../stores/auth'
import { useThemeStore } from '../stores/theme'
import { useRouter } from 'vue-router'
const auth = useAuthStore()
const theme = useThemeStore()
const router = useRouter()
function handleLogout() {
auth.logout()
router.push('/')
}
</script>

7
frontend/src/main.js

@ -0,0 +1,7 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './style.css'
createApp(App).use(createPinia()).use(router).mount('#app')

37
frontend/src/router/index.js

@ -0,0 +1,37 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import PublicBrowser from '../views/PublicBrowser.vue'
import Login from '../views/Login.vue'
import UserDashboard from '../views/UserDashboard.vue'
import AdminPanel from '../views/AdminPanel.vue'
const routes = [
{ path: '/', component: PublicBrowser },
{ path: '/login', component: Login, meta: { guestOnly: true } },
{ path: '/dashboard', component: UserDashboard, meta: { requiresAuth: true } },
{ path: '/admin', component: AdminPanel, meta: { requiresAuth: true, requiresAdmin: true } },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.meta.guestOnly && auth.isAuthenticated) {
return '/dashboard'
}
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return '/login'
}
if (to.meta.requiresAdmin && !auth.isAdmin) {
return '/dashboard'
}
})
export default router

32
frontend/src/stores/auth.js

@ -0,0 +1,32 @@
import { defineStore } from 'pinia'
import { useRouter } from 'vue-router'
import client from '../api/client'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: localStorage.getItem('token') || null,
user: JSON.parse(localStorage.getItem('user')) || null,
}),
getters: {
isAuthenticated: (state) => state.token !== null,
isAdmin: (state) => state.user?.role === 'admin',
},
actions: {
async login(username, password) {
const { data } = await client.post('/auth/login', { username, password })
this.token = data.token
this.user = data.user
localStorage.setItem('token', data.token)
localStorage.setItem('user', JSON.stringify(data.user))
},
logout() {
this.token = null
this.user = null
localStorage.removeItem('token')
localStorage.removeItem('user')
},
},
})

18
frontend/src/stores/theme.js

@ -0,0 +1,18 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useThemeStore = defineStore('theme', () => {
const dark = ref(localStorage.getItem('theme') === 'dark')
function init() {
document.documentElement.classList.toggle('dark', dark.value)
}
function toggle() {
dark.value = !dark.value
localStorage.setItem('theme', dark.value ? 'dark' : 'light')
document.documentElement.classList.toggle('dark', dark.value)
}
return { dark, init, toggle }
})

3
frontend/src/style.css

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

9
frontend/src/views/AdminPanel.vue

@ -0,0 +1,9 @@
<template>
<div class="max-w-7xl mx-auto px-4 py-10">
<h1 class="text-2xl font-bold text-gray-900 mb-6">Admin panel</h1>
<p class="text-gray-500 text-sm">
User management, directory browsing, moving files to /mnt/dysk/public.
</p>
<!-- TODO: user table, file manager -->
</div>
</template>

82
frontend/src/views/Login.vue

@ -0,0 +1,82 @@
<template>
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center bg-gray-50 py-12 px-4">
<div class="w-full max-w-md space-y-8">
<div class="text-center">
<h2 class="text-3xl font-bold text-gray-900">Sign in</h2>
</div>
<form @submit.prevent="handleLogin" class="bg-white shadow rounded-lg p-8 space-y-6">
<div v-if="error" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md text-sm">
{{ error }}
</div>
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<input
id="username"
v-model="username"
type="text"
autocomplete="username"
required
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
required
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<button
type="submit"
:disabled="loading"
class="w-full bg-indigo-600 hover:bg-indigo-700 disabled:opacity-60 text-white font-medium py-2 px-4 rounded-md transition-colors text-sm"
>
{{ loading ? 'Signing in…' : 'Sign in' }}
</button>
</form>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const auth = useAuthStore()
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function handleLogin() {
error.value = ''
loading.value = true
try {
await auth.login(username.value, password.value)
router.push('/dashboard')
} catch (err) {
if (err.response?.status === 401) {
error.value = 'Invalid username or password.'
} else {
error.value = 'Server error. Please try again.'
}
} finally {
loading.value = false
}
}
</script>

287
frontend/src/views/PublicBrowser.vue

@ -0,0 +1,287 @@
<template>
<div class="max-w-7xl mx-auto px-4 py-10">
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-4">Public files</h1>
<!-- Breadcrumb -->
<nav class="text-sm text-gray-500 dark:text-gray-400 mb-6 flex items-center gap-1 flex-wrap">
<button class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline" @click="navigate('')">public</button>
<template v-for="(segment, i) in pathSegments" :key="i">
<span>/</span>
<button class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline" @click="navigate(pathSegments.slice(0, i + 1).join('/'))">
{{ segment }}
</button>
</template>
</nav>
<!-- Loading -->
<div v-if="loading" class="text-gray-400 dark:text-gray-500 text-sm">Loading</div>
<template v-else>
<!-- README panel -->
<div v-if="readmeHtml" class="mb-8 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div class="flex items-center gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<span class="text-gray-400 dark:text-gray-500 text-xs">📄</span>
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">README.md</span>
</div>
<div
class="readme-content px-8 py-6 overflow-y-auto max-h-[50vh]
text-gray-800 dark:text-gray-200 text-sm leading-relaxed
bg-white dark:bg-gray-900"
v-html="readmeHtml"
/>
</div>
<!-- Empty -->
<div v-if="entries.length === 0" class="text-gray-400 dark:text-gray-500 text-sm">
Directory is empty.
</div>
<!-- File table -->
<table v-else class="w-full text-sm border-collapse">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700 text-left text-gray-500 dark:text-gray-400">
<th class="pb-2 font-medium w-full">Name</th>
<th class="pb-2 font-medium whitespace-nowrap pr-8">Size</th>
<th class="pb-2 font-medium whitespace-nowrap">Modified</th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in entries"
:key="entry.name"
class="border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
>
<td class="py-2 pr-4">
<button
v-if="entry.is_dir"
class="flex items-center gap-2 hover:text-blue-600 dark:hover:text-blue-400 hover:underline text-gray-800 dark:text-gray-200 text-left"
@click="navigate(currentPath ? currentPath + '/' + entry.name : entry.name)"
>
<span>📁</span>{{ entry.name }}
</button>
<a
v-else
:href="`/api/files/download/public/${fullPath(entry.name)}`"
class="flex items-center gap-2 text-gray-800 dark:text-gray-200
hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
download
>
<span>📄</span>{{ entry.name }}
</a>
</td>
<td class="py-2 pr-8 text-gray-500 dark:text-gray-400 whitespace-nowrap">
{{ entry.is_dir ? '—' : formatSize(entry.size) }}
</td>
<td class="py-2 text-gray-500 dark:text-gray-400 whitespace-nowrap">
{{ formatDate(entry.modified) }}
</td>
</tr>
</tbody>
</table>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import apiClient from '../api/client'
import { useThemeStore } from '../stores/theme'
const route = useRoute()
const router = useRouter()
const theme = useThemeStore()
const rmBorder = computed(() => theme.dark ? '#374151' : '#e5e7eb')
const rmCodeBg = computed(() => theme.dark ? '#1f2937' : '#f3f4f6')
const rmCodeClr = computed(() => theme.dark ? '#e2e8f0' : '#1f2937')
const rmThBg = computed(() => theme.dark ? '#111827' : '#f9fafb')
const rmMuted = computed(() => theme.dark ? '#9ca3af' : '#6b7280')
const rmLink = computed(() => theme.dark ? '#60a5fa' : '#3b82f6')
const currentPath = ref(route.query.path ?? '')
const entries = ref([])
const loading = ref(false)
const readmeHtml = ref('')
const pathSegments = computed(() =>
currentPath.value ? currentPath.value.split('/') : []
)
function fullPath(name) {
return currentPath.value ? `${currentPath.value}/${name}` : name
}
async function navigate(path) {
currentPath.value = path
router.replace({ query: path ? { path } : {} })
await fetchEntries()
}
// Handle browser back / forward
watch(() => route.query.path, (p) => {
const next = p ?? ''
if (next !== currentPath.value) {
currentPath.value = next
fetchEntries()
}
})
async function fetchEntries() {
loading.value = true
readmeHtml.value = ''
try {
const url = currentPath.value
? `/files/public/${currentPath.value}`
: '/files/public'
const res = await apiClient.get(url)
const all = res.data.entries ?? []
const byName = (a, b) => a.name.localeCompare(b.name)
entries.value = [
...all.filter(e => e.is_dir).sort(byName),
...all.filter(e => !e.is_dir).sort(byName),
]
if (entries.value.some(e => !e.is_dir && e.name === 'README.md')) {
await fetchReadme()
}
} catch {
entries.value = []
} finally {
loading.value = false
}
}
async function fetchReadme() {
try {
const res = await apiClient.get(
`/files/raw/public/${fullPath('README.md')}`,
{ responseType: 'text' },
)
const raw = await Promise.resolve(marked.parse(res.data))
readmeHtml.value = DOMPurify.sanitize(raw)
} catch {
readmeHtml.value = ''
}
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'
}
function formatDate(iso) {
return new Date(iso).toLocaleString('en-US', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
})
}
onMounted(fetchEntries)
</script>
<style scoped>
.readme-content :deep(h1) {
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 1rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid v-bind(rmBorder);
}
.readme-content :deep(h2) {
font-size: 1.25rem;
font-weight: 600;
margin: 1.5rem 0 0.75rem;
padding-bottom: 0.3rem;
border-bottom: 1px solid v-bind(rmBorder);
}
.readme-content :deep(h3) {
font-size: 1.1rem;
font-weight: 600;
margin: 1.25rem 0 0.5rem;
}
.readme-content :deep(h4),
.readme-content :deep(h5),
.readme-content :deep(h6) {
font-weight: 600;
margin: 1rem 0 0.4rem;
}
.readme-content :deep(p) {
margin: 0 0 0.875rem;
}
.readme-content :deep(a) {
color: v-bind(rmLink);
text-decoration: underline;
}
.readme-content :deep(code) {
font-family: ui-monospace, monospace;
font-size: 0.85em;
background: v-bind(rmCodeBg);
color: v-bind(rmCodeClr);
border-radius: 3px;
padding: 0.15em 0.4em;
}
.readme-content :deep(pre) {
background: v-bind(rmCodeBg);
border-radius: 6px;
padding: 1rem;
overflow-x: auto;
margin: 0 0 1rem;
}
.readme-content :deep(pre code) {
background: transparent;
color: v-bind(rmCodeClr);
padding: 0;
font-size: 0.85rem;
}
.readme-content :deep(ul) {
list-style: disc;
padding-left: 1.5rem;
margin: 0 0 0.875rem;
}
.readme-content :deep(ol) {
list-style: decimal;
padding-left: 1.5rem;
margin: 0 0 0.875rem;
}
.readme-content :deep(li) {
margin-bottom: 0.25rem;
}
.readme-content :deep(blockquote) {
border-left: 3px solid v-bind(rmBorder);
padding-left: 1rem;
color: v-bind(rmMuted);
margin: 0 0 0.875rem;
font-style: italic;
}
.readme-content :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 0 0 1rem;
font-size: 0.875rem;
}
.readme-content :deep(th) {
border: 1px solid v-bind(rmBorder);
padding: 0.4rem 0.75rem;
background: v-bind(rmThBg);
font-weight: 600;
text-align: left;
}
.readme-content :deep(td) {
border: 1px solid v-bind(rmBorder);
padding: 0.4rem 0.75rem;
}
.readme-content :deep(hr) {
border: none;
border-top: 1px solid v-bind(rmBorder);
margin: 1rem 0;
}
.readme-content :deep(img) {
max-width: 100%;
border-radius: 4px;
}
</style>

12
frontend/src/views/UserDashboard.vue

@ -0,0 +1,12 @@
<template>
<div class="max-w-7xl mx-auto px-4 py-10">
<h1 class="text-2xl font-bold text-gray-900 mb-2">User dashboard</h1>
<p class="text-gray-500 text-sm mb-6">Welcome, {{ auth.user?.username }}!</p>
<!-- TODO: Public / Private tabs, Uppy upload, download, delete -->
</div>
</template>
<script setup>
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
</script>

9
frontend/tailwind.config.js

@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{vue,js,ts}'],
theme: {
extend: {},
},
plugins: [],
}

14
frontend/vite.config.js

@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})

64
nginx/conf.d/dropzone.conf

@ -0,0 +1,64 @@
server {
listen 80;
server_name dropzone.example.com;
# HTTP → HTTPS redirect
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name dropzone.example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
# --- Large files: upload up to 6GB ---
client_max_body_size 6g;
# Long timeouts for large uploads (5GB on slow connections)
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 60s;
# --- Frontend (Vue 3 built to /app/frontend/dist) ---
location / {
root /app/frontend/dist;
try_files $uri $uri/ /index.html;
}
# --- Backend API ---
location /api/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off; # important for large files
proxy_request_buffering off; # do not buffer uploads in Nginx
}
# --- tus: resumable upload of large files ---
location /files/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_request_buffering off;
# Required by the tus protocol
proxy_http_version 1.1;
}
# --- Direct file serving via Nginx (X-Accel-Redirect) ---
# Backend returns X-Accel-Redirect header instead of streaming the file
location /internal/public/ {
internal;
alias /mnt/dysk/public/;
}
location /internal/userfiles/ {
internal;
alias /mnt/dysk/;
}
}

13
postgres/init/01_schema.sql

@ -0,0 +1,13 @@
-- Database schema initialisation
-- Executed once on the first PostgreSQL startup in Docker
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(64) UNIQUE NOT NULL,
password TEXT NOT NULL, -- bcrypt hash
role VARCHAR(16) NOT NULL DEFAULT 'user', -- 'user' | 'admin'
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index on username (frequently used during login)
CREATE INDEX idx_users_username ON users(username);

17
scripts/backup_db.sh

@ -0,0 +1,17 @@
#!/usr/bin/env bash
# PostgreSQL database backup
set -euo pipefail
BACKUP_DIR="/mnt/dysk/backups/db"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
FILE="$BACKUP_DIR/dropzone_$TIMESTAMP.sql.gz"
mkdir -p "$BACKUP_DIR"
docker exec dropzone_postgres pg_dump -U "$DB_USER" "$DB_NAME" \
| gzip > "$FILE"
echo "Backup saved: $FILE"
# Delete backups older than 30 days
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +30 -delete

15
scripts/deploy.sh

@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Deployment script for the production server
set -euo pipefail
echo "==> Building frontend..."
cd frontend
npm ci
npm run build
cd ..
echo "==> Restarting Docker services..."
docker compose pull
docker compose up -d --build
echo "==> Done."
Loading…
Cancel
Save