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