34 changed files with 4648 additions and 0 deletions
@ -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 |
||||
@ -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 |
||||
@ -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"] |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
) |
||||
@ -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= |
||||
@ -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
|
||||
@ -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 |
||||
|
} |
||||
@ -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}, |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
@ -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, |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
@ -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 |
||||
@ -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> |
||||
File diff suppressed because it is too large
@ -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" |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,6 @@ |
|||||
|
export default { |
||||
|
plugins: { |
||||
|
tailwindcss: {}, |
||||
|
autoprefixer: {}, |
||||
|
}, |
||||
|
} |
||||
@ -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> |
||||
@ -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 |
||||
@ -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> |
||||
@ -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') |
||||
@ -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 |
||||
@ -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') |
||||
|
}, |
||||
|
}, |
||||
|
}) |
||||
@ -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 } |
||||
|
}) |
||||
@ -0,0 +1,3 @@ |
|||||
|
@tailwind base; |
||||
|
@tailwind components; |
||||
|
@tailwind utilities; |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -0,0 +1,9 @@ |
|||||
|
/** @type {import('tailwindcss').Config} */ |
||||
|
export default { |
||||
|
darkMode: 'class', |
||||
|
content: ['./index.html', './src/**/*.{vue,js,ts}'], |
||||
|
theme: { |
||||
|
extend: {}, |
||||
|
}, |
||||
|
plugins: [], |
||||
|
} |
||||
@ -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, |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
}) |
||||
@ -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/; |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
@ -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 |
||||
@ -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…
Reference in new issue