Browse Source

Updates

master
user 3 weeks ago
parent
commit
f49ae74348
  1. 10
      .env.example
  2. 136
      backend/internal/files/files.go
  3. 21
      docker-compose.yml
  4. 37
      frontend/src/views/PublicBrowser.vue
  5. 1582
      frontend/yarn.lock
  6. 11
      nginx/Dockerfile
  7. 64
      nginx/conf.d/dropzone.conf
  8. 50
      nginx/templates/dropzone.conf.template

10
.env.example

@ -1,17 +1,13 @@
# Copy to .env and fill in the values
# cp .env.example .env
# --- PostgreSQL (docker-compose: container initialisation) ---
# --- PostgreSQL ---
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
# --- Storage: path to disk with user files ---
DISK_ROOT=/mnt/dysk

136
backend/internal/files/files.go

@ -1,23 +1,34 @@
package files
import (
"encoding/csv"
"encoding/json"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
)
type MvdInfo struct {
PlayersA string `json:"players_a"`
PlayersB string `json:"players_b"`
Score string `json:"score"`
Map string `json:"map"`
}
type Entry struct {
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
MvdInfo *MvdInfo `json:"mvd_info,omitempty"`
}
type listResponse struct {
@ -25,6 +36,108 @@ type listResponse struct {
Entries []Entry `json:"entries"`
}
// mvd report cache — keyed by absolute directory path
type mvdCacheEntry struct {
modTime time.Time
records map[string]MvdInfo // filename → info
}
var (
mvdCacheMu sync.RWMutex
mvdCache = make(map[string]*mvdCacheEntry)
)
const mvdReportName = "mvd_report.csv"
// getMvdReport returns parsed mvd_report.csv for dirPath, using a mod-time-aware cache.
// Returns nil if the file doesn't exist. Safe for concurrent use.
func getMvdReport(dirPath string) map[string]MvdInfo {
csvPath := filepath.Join(dirPath, mvdReportName)
info, err := os.Stat(csvPath)
if err != nil {
return nil
}
modTime := info.ModTime()
mvdCacheMu.RLock()
entry, ok := mvdCache[dirPath]
mvdCacheMu.RUnlock()
if ok && entry.modTime.Equal(modTime) {
return entry.records
}
// Cache miss or stale — parse under write lock
mvdCacheMu.Lock()
defer mvdCacheMu.Unlock()
// Double-check after acquiring write lock
entry, ok = mvdCache[dirPath]
if ok && entry.modTime.Equal(modTime) {
return entry.records
}
records := parseMvdCSV(csvPath)
mvdCache[dirPath] = &mvdCacheEntry{modTime: modTime, records: records}
return records
}
func parseMvdCSV(csvPath string) map[string]MvdInfo {
f, err := os.Open(csvPath)
if err != nil {
return nil
}
defer f.Close()
r := csv.NewReader(f)
r.Comma = ';'
r.TrimLeadingSpace = true
header, err := r.Read()
if err != nil {
return nil
}
// Map header names (lowercase) to column indices
colIdx := make(map[string]int, len(header))
for i, h := range header {
colIdx[strings.ToLower(strings.TrimSpace(h))] = i
}
col := func(row []string, name string) string {
i, ok := colIdx[name]
if !ok || i >= len(row) {
return ""
}
return strings.TrimSpace(row[i])
}
result := make(map[string]MvdInfo)
for {
row, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
continue
}
filename := col(row, "file")
if filename == "" {
filename = col(row, "filename")
}
if filename == "" {
continue
}
result[filename] = MvdInfo{
PlayersA: col(row, "players_a"),
PlayersB: col(row, "players_b"),
Score: col(row, "score"),
Map: col(row, "map"),
}
}
return result
}
// PublicDownloadHandler returns a handler for downloading files from <diskRoot>/public/.
//
// useXAccel=true (production behind nginx): the backend only validates the path and
@ -144,10 +257,17 @@ func PublicListHandler(diskRoot string) http.HandlerFunc {
return
}
mvd := getMvdReport(targetDir)
entries, err := os.ReadDir(targetDir)
result := make([]Entry, 0)
if err == nil {
for _, e := range entries {
// Hide mvd_report.csv from the listing
if !e.IsDir() && e.Name() == mvdReportName {
continue
}
info, err := e.Info()
if err != nil {
continue
@ -156,12 +276,24 @@ func PublicListHandler(diskRoot string) http.HandlerFunc {
if !e.IsDir() {
size = info.Size()
}
result = append(result, Entry{
entry := Entry{
Name: e.Name(),
IsDir: e.IsDir(),
Size: size,
Modified: info.ModTime(),
})
}
if !e.IsDir() && mvd != nil {
mi, ok := mvd[e.Name()]
if !ok {
// CSV exists but this file is not listed in it — hide it
continue
}
entry.MvdInfo = &mi
}
result = append(result, entry)
}
}
// If the directory doesn't exist — return an empty list without an error

21
docker-compose.yml

@ -13,7 +13,7 @@ services:
- "15432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./postgres/init:/docker-entrypoint-initdb.d:ro # run once on init
- ./postgres/init:/docker-entrypoint-initdb.d:ro
networks:
- internal
@ -31,28 +31,29 @@ services:
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
DISK_ROOT: /mnt/dysk
DISK_ROOT: ${DISK_ROOT}
NGINX_ACCEL: "true"
volumes:
- /mnt/dysk:/mnt/dysk # physical server disk
- ${DISK_ROOT}:${DISK_ROOT}
depends_on:
- postgres
networks:
- internal
# --- Nginx (reverse proxy + frontend) ---
# --- Nginx: frontend + reverse proxy (SSL terminates na .66) ---
nginx:
image: nginx:1.27-alpine
build:
context: .
dockerfile: nginx/Dockerfile
container_name: dropzone_nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
DISK_ROOT: ${DISK_ROOT}
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
- ./nginx/templates:/etc/nginx/templates:ro
- ${DISK_ROOT}:${DISK_ROOT}:ro
depends_on:
- backend
networks:

37
frontend/src/views/PublicBrowser.vue

@ -59,15 +59,23 @@
>
<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>
<template v-else>
<a
:href="`/api/files/download/public/${encodedFullPath(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>{{ fileIcon(entry.name) }}</span>{{ entry.name }}
</a>
<div v-if="entry.mvd_info" class="mt-0.5 ml-6 text-xs text-gray-400 dark:text-gray-500 flex flex-wrap gap-x-3">
<span v-if="entry.mvd_info.players_a || entry.mvd_info.players_b">
👥 {{ entry.mvd_info.players_a }} vs {{ entry.mvd_info.players_b }}
</span>
<span v-if="entry.mvd_info.score">🏆 {{ entry.mvd_info.score }}</span>
<span v-if="entry.mvd_info.map">🗺 {{ entry.mvd_info.map }}</span>
</div>
</template>
</td>
<td class="py-2 pr-8 text-gray-500 dark:text-gray-400 whitespace-nowrap">
{{ entry.is_dir ? '—' : formatSize(entry.size) }}
@ -114,6 +122,11 @@ function fullPath(name) {
return currentPath.value ? `${currentPath.value}/${name}` : name
}
function encodedFullPath(name) {
const segments = currentPath.value ? [...currentPath.value.split('/'), name] : [name]
return segments.map(encodeURIComponent).join('/')
}
async function navigate(path) {
currentPath.value = path
router.replace({ query: path ? { path } : {} })
@ -167,6 +180,12 @@ async function fetchReadme() {
}
}
function fileIcon(name) {
const ext = name.slice(name.lastIndexOf('.')).toLowerCase()
if (ext === '.mvd2') return '🎮'
return '📄'
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'

1582
frontend/yarn.lock

File diff suppressed because it is too large

11
nginx/Dockerfile

@ -0,0 +1,11 @@
# Stage 1: build Vue frontend
FROM node:20-alpine AS frontend-builder
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
# Stage 2: nginx with built frontend baked in
FROM nginx:1.27-alpine
COPY --from=frontend-builder /app/dist /app/frontend/dist

64
nginx/conf.d/dropzone.conf

@ -1,64 +0,0 @@
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/;
}
}

50
nginx/templates/dropzone.conf.template

@ -0,0 +1,50 @@
server {
listen 80;
server_name q2dropzone.xyz;
client_max_body_size 6g;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 60s;
# --- Frontend ---
location / {
root /app/frontend/dist;
try_files $uri $uri/ /index.html;
}
# --- Backend API ---
location /api/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
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 https;
proxy_buffering off;
proxy_request_buffering off;
}
# --- tus: resumable large file uploads ---
location /files/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
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 https;
proxy_buffering off;
proxy_request_buffering off;
}
# --- X-Accel-Redirect ---
location /internal/public/ {
internal;
alias ${DISK_ROOT}/public/;
}
location /internal/userfiles/ {
internal;
alias ${DISK_ROOT}/;
}
}
Loading…
Cancel
Save