feat: initialize warpbox.dev project structure and backend
Initialize the repository with the core Go backend architecture and a frontend mockup for warpbox.dev, a self-hosted file-sharing application. - Set up Go backend modules for configuration, HTTP server, middleware, handlers, and templates. - Add local development scripts, environment templates, and basic project configuration. - Include a React-based frontend mockup under the docs directory.
This commit is contained in:
60
backend/cmd/warpbox/main.go
Normal file
60
backend/cmd/warpbox/main.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/config"
|
||||
"warpbox.dev/backend/libs/httpserver"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
logger.Error("failed to load config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
server, err := httpserver.New(cfg, logger)
|
||||
if err != nil {
|
||||
logger.Error("failed to create server", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("warpbox server starting", "addr", cfg.Addr, "env", cfg.Environment)
|
||||
errs <- server.ListenAndServe()
|
||||
}()
|
||||
|
||||
shutdown := make(chan os.Signal, 1)
|
||||
signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case err := <-errs:
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error("server stopped unexpectedly", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case sig := <-shutdown:
|
||||
logger.Info("shutdown signal received", "signal", sig.String())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
logger.Error("graceful shutdown failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("server stopped")
|
||||
}
|
||||
}
|
||||
3
backend/go.mod
Normal file
3
backend/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module warpbox.dev/backend
|
||||
|
||||
go 1.26
|
||||
118
backend/libs/config/config.go
Normal file
118
backend/libs/config/config.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AppName string
|
||||
Environment string
|
||||
Addr string
|
||||
BaseURL string
|
||||
StaticDir string
|
||||
TemplateDir string
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
MaxUploadSize int64
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
|
||||
Environment: envString("WARPBOX_ENV", "development"),
|
||||
Addr: envString("WARPBOX_ADDR", ":8080"),
|
||||
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
|
||||
StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")),
|
||||
TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")),
|
||||
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 15*time.Second),
|
||||
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 60*time.Second),
|
||||
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
|
||||
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
|
||||
}
|
||||
|
||||
if cfg.BaseURL == "" {
|
||||
return Config{}, fmt.Errorf("WARPBOX_BASE_URL cannot be empty")
|
||||
}
|
||||
if cfg.MaxUploadSize <= 0 {
|
||||
return Config{}, fmt.Errorf("WARPBOX_MAX_UPLOAD_SIZE_MB must be positive")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func defaultPath(name string) string {
|
||||
candidates := []string{
|
||||
filepath.Join("backend", name),
|
||||
name,
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return filepath.Join("backend", name)
|
||||
}
|
||||
|
||||
func envString(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envDuration(key string, fallback time.Duration) time.Duration {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
parsed, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envMegabytes(key string, fallback float64) int64 {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return megabytesToBytes(fallback)
|
||||
}
|
||||
|
||||
parsed, err := parseMegabytes(value)
|
||||
if err != nil {
|
||||
return megabytesToBytes(fallback)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func parseMegabytes(value string) (int64, error) {
|
||||
normalized := strings.TrimSpace(value)
|
||||
normalized = strings.TrimSuffix(normalized, "MB")
|
||||
normalized = strings.TrimSuffix(normalized, "Mb")
|
||||
normalized = strings.TrimSuffix(normalized, "mb")
|
||||
normalized = strings.TrimSpace(normalized)
|
||||
|
||||
sizeMB, err := strconv.ParseFloat(normalized, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid megabyte value %q: %w", value, err)
|
||||
}
|
||||
if sizeMB <= 0 {
|
||||
return 0, fmt.Errorf("megabyte value must be positive")
|
||||
}
|
||||
|
||||
return megabytesToBytes(sizeMB), nil
|
||||
}
|
||||
|
||||
func megabytesToBytes(sizeMB float64) int64 {
|
||||
return int64(math.Round(sizeMB * 1024 * 1024))
|
||||
}
|
||||
34
backend/libs/config/config_test.go
Normal file
34
backend/libs/config/config_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseMegabytes(t *testing.T) {
|
||||
tests := map[string]int64{
|
||||
"0.5": 512 * 1024,
|
||||
"0.5Mb": 512 * 1024,
|
||||
"1mb": 1024 * 1024,
|
||||
"1MB": 1024 * 1024,
|
||||
"1.5Mb": 1536 * 1024,
|
||||
" 2 ": 2 * 1024 * 1024,
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
got, err := parseMegabytes(input)
|
||||
if err != nil {
|
||||
t.Fatalf("parseMegabytes(%q) returned error: %v", input, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("parseMegabytes(%q) = %d, want %d", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMegabytesRejectsInvalidValues(t *testing.T) {
|
||||
tests := []string{"", "0", "-1", "abc"}
|
||||
|
||||
for _, input := range tests {
|
||||
if _, err := parseMegabytes(input); err == nil {
|
||||
t.Fatalf("parseMegabytes(%q) returned nil error", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
34
backend/libs/handlers/app.go
Normal file
34
backend/libs/handlers/app.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"warpbox.dev/backend/libs/config"
|
||||
"warpbox.dev/backend/libs/services"
|
||||
"warpbox.dev/backend/libs/web"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
cfg config.Config
|
||||
logger *slog.Logger
|
||||
renderer *web.Renderer
|
||||
uploadService *services.UploadService
|
||||
}
|
||||
|
||||
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService) *App {
|
||||
return &App{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
renderer: renderer,
|
||||
uploadService: uploadService,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /", a.Home)
|
||||
mux.HandleFunc("GET /healthz", a.Health)
|
||||
mux.HandleFunc("GET /api/v1/health", a.Health)
|
||||
mux.HandleFunc("POST /api/v1/upload", a.UploadPlaceholder)
|
||||
mux.Handle("GET /static/", a.Static())
|
||||
}
|
||||
20
backend/libs/handlers/health.go
Normal file
20
backend/libs/handlers/health.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/helpers"
|
||||
)
|
||||
|
||||
type healthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
func (a *App) Health(w http.ResponseWriter, r *http.Request) {
|
||||
helpers.WriteJSON(w, http.StatusOK, healthResponse{
|
||||
Status: "ok",
|
||||
Time: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
21
backend/libs/handlers/pages.go
Normal file
21
backend/libs/handlers/pages.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"warpbox.dev/backend/libs/web"
|
||||
)
|
||||
|
||||
type homeData struct {
|
||||
MaxUploadSize string
|
||||
}
|
||||
|
||||
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
||||
a.renderer.Render(w, http.StatusOK, "home.gohtml", web.PageData{
|
||||
Title: "Upload your files",
|
||||
Description: "Upload and share files through a self-hosted Warpbox instance.",
|
||||
Data: homeData{
|
||||
MaxUploadSize: a.uploadService.MaxUploadSizeLabel(),
|
||||
},
|
||||
})
|
||||
}
|
||||
31
backend/libs/handlers/static.go
Normal file
31
backend/libs/handlers/static.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (a *App) Static() http.Handler {
|
||||
fileServer := http.StripPrefix("/static/", http.FileServer(http.Dir(a.cfg.StaticDir)))
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
setStaticCacheHeaders(w, r.URL.Path)
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func setStaticCacheHeaders(w http.ResponseWriter, path string) {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
|
||||
switch ext {
|
||||
case ".avif", ".gif", ".ico", ".jpg", ".jpeg", ".png", ".svg", ".webp",
|
||||
".mp4", ".m4v", ".mov", ".webm", ".mp3", ".ogg",
|
||||
".eot", ".otf", ".ttf", ".woff", ".woff2":
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
case ".css", ".js":
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
default:
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
}
|
||||
}
|
||||
26
backend/libs/handlers/static_test.go
Normal file
26
backend/libs/handlers/static_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetStaticCacheHeaders(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"/static/css/app.css": "public, max-age=86400",
|
||||
"/static/js/app.js": "public, max-age=86400",
|
||||
"/static/img/preview.webp": "public, max-age=31536000, immutable",
|
||||
"/static/fonts/ui.woff2": "public, max-age=31536000, immutable",
|
||||
"/static/videos/intro.mp4": "public, max-age=31536000, immutable",
|
||||
"/static/uploads/file.data": "public, max-age=3600",
|
||||
}
|
||||
|
||||
for path, want := range tests {
|
||||
response := httptest.NewRecorder()
|
||||
setStaticCacheHeaders(response, path)
|
||||
|
||||
if got := response.Header().Get("Cache-Control"); got != want {
|
||||
t.Fatalf("Cache-Control for %s = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
19
backend/libs/handlers/upload.go
Normal file
19
backend/libs/handlers/upload.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"warpbox.dev/backend/libs/helpers"
|
||||
)
|
||||
|
||||
type uploadPlaceholderResponse struct {
|
||||
Message string `json:"message"`
|
||||
MaxUploadSize string `json:"maxUploadSize"`
|
||||
}
|
||||
|
||||
func (a *App) UploadPlaceholder(w http.ResponseWriter, r *http.Request) {
|
||||
helpers.WriteJSON(w, http.StatusNotImplemented, uploadPlaceholderResponse{
|
||||
Message: "upload storage is not implemented yet; backend base is ready for the upload pipeline",
|
||||
MaxUploadSize: a.uploadService.MaxUploadSizeLabel(),
|
||||
})
|
||||
}
|
||||
18
backend/libs/helpers/human.go
Normal file
18
backend/libs/helpers/human.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package helpers
|
||||
|
||||
import "fmt"
|
||||
|
||||
func FormatBytes(size int64) string {
|
||||
const unit = 1024
|
||||
if size < unit {
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
|
||||
div, exp := int64(unit), 0
|
||||
for n := size / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
19
backend/libs/helpers/human_test.go
Normal file
19
backend/libs/helpers/human_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package helpers
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFormatBytes(t *testing.T) {
|
||||
tests := map[int64]string{
|
||||
0: "0 B",
|
||||
512: "512 B",
|
||||
1024: "1.0 KiB",
|
||||
1536: "1.5 KiB",
|
||||
1073741824: "1.0 GiB",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
if got := FormatBytes(input); got != want {
|
||||
t.Fatalf("FormatBytes(%d) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
20
backend/libs/helpers/json.go
Normal file
20
backend/libs/helpers/json.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func WriteJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func WriteJSONError(w http.ResponseWriter, status int, message string) {
|
||||
WriteJSON(w, status, ErrorResponse{Error: message})
|
||||
}
|
||||
42
backend/libs/httpserver/server.go
Normal file
42
backend/libs/httpserver/server.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"warpbox.dev/backend/libs/config"
|
||||
"warpbox.dev/backend/libs/handlers"
|
||||
"warpbox.dev/backend/libs/middleware"
|
||||
"warpbox.dev/backend/libs/services"
|
||||
"warpbox.dev/backend/libs/web"
|
||||
)
|
||||
|
||||
func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
|
||||
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uploadService := services.NewUploadService(cfg.MaxUploadSize)
|
||||
app := handlers.NewApp(cfg, logger, renderer, uploadService)
|
||||
|
||||
router := http.NewServeMux()
|
||||
app.RegisterRoutes(router)
|
||||
|
||||
handler := middleware.Chain(
|
||||
router,
|
||||
middleware.Recoverer(logger),
|
||||
middleware.RequestID,
|
||||
middleware.SecurityHeaders,
|
||||
middleware.Gzip,
|
||||
middleware.Logger(logger),
|
||||
)
|
||||
|
||||
return &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: handler,
|
||||
ReadTimeout: cfg.ReadTimeout,
|
||||
WriteTimeout: cfg.WriteTimeout,
|
||||
IdleTimeout: cfg.IdleTimeout,
|
||||
}, nil
|
||||
}
|
||||
12
backend/libs/middleware/chain.go
Normal file
12
backend/libs/middleware/chain.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
type Middleware func(http.Handler) http.Handler
|
||||
|
||||
func Chain(handler http.Handler, middleware ...Middleware) http.Handler {
|
||||
for i := len(middleware) - 1; i >= 0; i-- {
|
||||
handler = middleware[i](handler)
|
||||
}
|
||||
return handler
|
||||
}
|
||||
69
backend/libs/middleware/gzip.go
Normal file
69
backend/libs/middleware/gzip.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type gzipResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
writer io.Writer
|
||||
wrote bool
|
||||
}
|
||||
|
||||
func (w *gzipResponseWriter) WriteHeader(status int) {
|
||||
if w.wrote {
|
||||
return
|
||||
}
|
||||
w.wrote = true
|
||||
header := w.Header()
|
||||
header.Del("Content-Length")
|
||||
header.Del("Accept-Ranges")
|
||||
header.Set("Content-Encoding", "gzip")
|
||||
header.Add("Vary", "Accept-Encoding")
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (w *gzipResponseWriter) Write(data []byte) (int, error) {
|
||||
if !w.wrote {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return w.writer.Write(data)
|
||||
}
|
||||
|
||||
func Gzip(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !acceptsGzip(r) || shouldSkipGzip(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
gz := gzip.NewWriter(w)
|
||||
defer gz.Close()
|
||||
|
||||
next.ServeHTTP(&gzipResponseWriter{
|
||||
ResponseWriter: w,
|
||||
writer: gz,
|
||||
}, r)
|
||||
})
|
||||
}
|
||||
|
||||
func acceptsGzip(r *http.Request) bool {
|
||||
return strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
|
||||
}
|
||||
|
||||
func shouldSkipGzip(r *http.Request) bool {
|
||||
if r.Method == http.MethodHead || r.Header.Get("Range") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
path := r.URL.Path
|
||||
switch ext := strings.ToLower(path[strings.LastIndex(path, ".")+1:]); ext {
|
||||
case "br", "gz", "zip", "7z", "rar", "jpg", "jpeg", "png", "gif", "webp", "avif", "mp4", "webm", "mov", "m4v", "mp3", "ogg", "woff", "woff2", "ttf", "otf":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
63
backend/libs/middleware/gzip_test.go
Normal file
63
backend/libs/middleware/gzip_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGzipCompressesEligibleResponses(t *testing.T) {
|
||||
handler := Gzip(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", "11")
|
||||
_, _ = io.WriteString(w, "hello world")
|
||||
}))
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/page", nil)
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
if got := response.Header().Get("Content-Encoding"); got != "gzip" {
|
||||
t.Fatalf("Content-Encoding = %q, want gzip", got)
|
||||
}
|
||||
if got := response.Header().Get("Content-Length"); got != "" {
|
||||
t.Fatalf("Content-Length = %q, want empty for gzipped response", got)
|
||||
}
|
||||
if got := response.Header().Get("Vary"); got != "Accept-Encoding" {
|
||||
t.Fatalf("Vary = %q, want Accept-Encoding", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGzipSkipsRangeAndHeadRequests(t *testing.T) {
|
||||
handler := Gzip(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, "hello world")
|
||||
}))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
rangeHeader string
|
||||
}{
|
||||
{name: "range", method: http.MethodGet, rangeHeader: "bytes=0-4"},
|
||||
{name: "head", method: http.MethodHead},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
request := httptest.NewRequest(tt.method, "/asset.css", nil)
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
if tt.rangeHeader != "" {
|
||||
request.Header.Set("Range", tt.rangeHeader)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
if got := response.Header().Get("Content-Encoding"); got != "" {
|
||||
t.Fatalf("Content-Encoding = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
54
backend/libs/middleware/logger.go
Normal file
54
backend/libs/middleware/logger.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(status int) {
|
||||
r.status = status
|
||||
r.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Write(data []byte) (int, error) {
|
||||
if r.status == 0 {
|
||||
r.status = http.StatusOK
|
||||
}
|
||||
n, err := r.ResponseWriter.Write(data)
|
||||
r.bytes += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
func Logger(logger *slog.Logger) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
recorder := &statusRecorder{ResponseWriter: w}
|
||||
|
||||
next.ServeHTTP(recorder, r)
|
||||
|
||||
status := recorder.status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
|
||||
logger.Info("http request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", status,
|
||||
"bytes", recorder.bytes,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"request_id", RequestIDFromContext(r.Context()),
|
||||
"remote_addr", r.RemoteAddr,
|
||||
"user_agent", r.UserAgent(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
26
backend/libs/middleware/recoverer.go
Normal file
26
backend/libs/middleware/recoverer.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
func Recoverer(logger *slog.Logger) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
logger.Error("panic recovered",
|
||||
"error", recovered,
|
||||
"stack", string(debug.Stack()),
|
||||
"request_id", RequestIDFromContext(r.Context()),
|
||||
)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
38
backend/libs/middleware/request_id.go
Normal file
38
backend/libs/middleware/request_id.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type requestIDKey struct{}
|
||||
|
||||
func RequestID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := r.Header.Get("X-Request-ID")
|
||||
if requestID == "" {
|
||||
requestID = newRequestID()
|
||||
}
|
||||
|
||||
w.Header().Set("X-Request-ID", requestID)
|
||||
ctx := context.WithValue(r.Context(), requestIDKey{}, requestID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func RequestIDFromContext(ctx context.Context) string {
|
||||
if value, ok := ctx.Value(requestIDKey{}).(string); ok {
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func newRequestID() string {
|
||||
var data [16]byte
|
||||
if _, err := rand.Read(data[:]); err != nil {
|
||||
return "request-id-unavailable"
|
||||
}
|
||||
return hex.EncodeToString(data[:])
|
||||
}
|
||||
16
backend/libs/middleware/security.go
Normal file
16
backend/libs/middleware/security.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
func SecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
header := w.Header()
|
||||
header.Set("X-Content-Type-Options", "nosniff")
|
||||
header.Set("X-Frame-Options", "DENY")
|
||||
header.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
header.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
header.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; style-src 'self'; script-src 'self'; base-uri 'self'; frame-ancestors 'none'")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
30
backend/libs/services/upload.go
Normal file
30
backend/libs/services/upload.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"warpbox.dev/backend/libs/helpers"
|
||||
)
|
||||
|
||||
type UploadService struct {
|
||||
maxUploadSize int64
|
||||
}
|
||||
|
||||
func NewUploadService(maxUploadSize int64) *UploadService {
|
||||
return &UploadService{maxUploadSize: maxUploadSize}
|
||||
}
|
||||
|
||||
func (s *UploadService) MaxUploadSize() int64 {
|
||||
return s.maxUploadSize
|
||||
}
|
||||
|
||||
func (s *UploadService) MaxUploadSizeLabel() string {
|
||||
return helpers.FormatBytes(s.maxUploadSize)
|
||||
}
|
||||
|
||||
func (s *UploadService) ValidateSize(size int64) error {
|
||||
if size > s.maxUploadSize {
|
||||
return fmt.Errorf("file exceeds max upload size of %s", s.MaxUploadSizeLabel())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
79
backend/libs/web/renderer.go
Normal file
79
backend/libs/web/renderer.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Renderer struct {
|
||||
templates map[string]*template.Template
|
||||
appName string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
type PageData struct {
|
||||
AppName string
|
||||
BaseURL string
|
||||
Title string
|
||||
Description string
|
||||
CurrentYear int
|
||||
Data any
|
||||
}
|
||||
|
||||
func NewRenderer(templateDir, appName, baseURL string) (*Renderer, error) {
|
||||
layouts, err := filepath.Glob(filepath.Join(templateDir, "layouts", "*.gohtml"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
partials, err := filepath.Glob(filepath.Join(templateDir, "partials", "*.gohtml"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pages, err := filepath.Glob(filepath.Join(templateDir, "pages", "*.gohtml"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
templates := make(map[string]*template.Template, len(pages))
|
||||
for _, page := range pages {
|
||||
files := append([]string{}, layouts...)
|
||||
files = append(files, partials...)
|
||||
files = append(files, page)
|
||||
|
||||
parsed, err := template.ParseFiles(files...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
templates[filepath.Base(page)] = parsed
|
||||
}
|
||||
|
||||
return &Renderer{
|
||||
templates: templates,
|
||||
appName: appName,
|
||||
baseURL: baseURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) Render(w http.ResponseWriter, status int, page string, data PageData) {
|
||||
data.AppName = r.appName
|
||||
data.BaseURL = r.baseURL
|
||||
data.CurrentYear = time.Now().Year()
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
tmpl, ok := r.templates[page]
|
||||
if !ok {
|
||||
http.Error(w, "template not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tmpl.ExecuteTemplate(w, page, data); err != nil {
|
||||
http.Error(w, "template render error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
329
backend/static/css/app.css
Normal file
329
backend/static/css/app.css
Normal file
@@ -0,0 +1,329 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--background: #ffffff;
|
||||
--foreground: #09090b;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #09090b;
|
||||
--muted: #f4f4f5;
|
||||
--muted-foreground: #71717a;
|
||||
--border: #e4e4e7;
|
||||
--input: #e4e4e7;
|
||||
--primary: #18181b;
|
||||
--primary-foreground: #fafafa;
|
||||
--ring: #a1a1aa;
|
||||
--radius: 0.5rem;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: radial-gradient(circle at top, #fafafa 0, var(--background) 34rem);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: -4rem;
|
||||
z-index: 10;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.nav {
|
||||
width: min(1180px, calc(100% - 2rem));
|
||||
min-height: 4.5rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.brand,
|
||||
.nav-links {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.upload-view {
|
||||
width: min(620px, calc(100% - 2rem));
|
||||
min-height: calc(100vh - 9rem);
|
||||
margin: 0 auto;
|
||||
padding: 3rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: var(--card-foreground);
|
||||
font-size: 1.8rem;
|
||||
line-height: 1.15;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.panel-header p {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.upload-panel {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
min-height: 17rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.65rem;
|
||||
padding: 2rem;
|
||||
border: 1px dashed var(--input);
|
||||
border-radius: var(--radius);
|
||||
background: var(--muted);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.drop-zone:hover,
|
||||
.drop-zone.is-dragging {
|
||||
border-color: var(--primary);
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.drop-zone input {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drop-icon {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-size: 2rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.drop-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.drop-copy {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.advanced-options {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.8rem 0.9rem;
|
||||
}
|
||||
|
||||
.advanced-options summary {
|
||||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.option-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
label span {
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--foreground);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
min-height: 2.25rem;
|
||||
border: 1px solid var(--input);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-footer p {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.button,
|
||||
button {
|
||||
min-height: 2.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
padding: 0.45rem 0.85rem;
|
||||
color: var(--foreground);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.button-primary:hover {
|
||||
background: #27272a;
|
||||
}
|
||||
|
||||
.button-outline {
|
||||
border-color: var(--border);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.button-outline:hover,
|
||||
.button-ghost:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
width: min(1180px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.upload-view {
|
||||
min-height: calc(100vh - 9rem);
|
||||
padding: 2.5rem 0;
|
||||
}
|
||||
|
||||
.option-grid,
|
||||
.form-footer,
|
||||
.site-footer {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
min-height: 14rem;
|
||||
}
|
||||
}
|
||||
1
backend/static/fonts/.gitkeep
Normal file
1
backend/static/fonts/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/static/img/.gitkeep
Normal file
1
backend/static/img/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
42
backend/static/js/app.js
Normal file
42
backend/static/js/app.js
Normal file
@@ -0,0 +1,42 @@
|
||||
(function () {
|
||||
const dropZone = document.querySelector(".drop-zone");
|
||||
const fileInput = document.querySelector("#file-input");
|
||||
|
||||
if (!dropZone || !fileInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
["dragenter", "dragover"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.add("is-dragging");
|
||||
});
|
||||
});
|
||||
|
||||
["dragleave", "drop"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.remove("is-dragging");
|
||||
});
|
||||
});
|
||||
|
||||
dropZone.addEventListener("drop", (event) => {
|
||||
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
||||
fileInput.files = event.dataTransfer.files;
|
||||
updateDropLabel(event.dataTransfer.files.length);
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
updateDropLabel(fileInput.files.length);
|
||||
});
|
||||
|
||||
function updateDropLabel(count) {
|
||||
const title = dropZone.querySelector(".drop-title");
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
title.textContent = count === 1 ? "1 file selected" : `${count} files selected`;
|
||||
}
|
||||
})();
|
||||
1
backend/static/uploads/.gitkeep
Normal file
1
backend/static/uploads/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
43
backend/templates/layouts/base.gohtml
Normal file
43
backend/templates/layouts/base.gohtml
Normal file
@@ -0,0 +1,43 @@
|
||||
{{define "base"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{if .Title}}{{.Title}} - {{end}}{{.AppName}}</title>
|
||||
<meta name="description" content="{{.Description}}">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<meta property="og:site_name" content="{{.AppName}}">
|
||||
<meta property="og:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}">
|
||||
<meta property="og:description" content="{{.Description}}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{.BaseURL}}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
<script defer src="/static/js/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<header class="site-header">
|
||||
<nav class="nav" aria-label="Main navigation">
|
||||
<a class="brand" href="/" aria-label="{{.AppName}} home">
|
||||
<span class="brand-mark" aria-hidden="true">W</span>
|
||||
<span>{{.AppName}}</span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a class="button button-ghost" href="/api/v1/health">API</a>
|
||||
<a class="button button-outline" href="/healthz">Health</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<span>© {{.CurrentYear}} {{.AppName}}</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
46
backend/templates/pages/home.gohtml
Normal file
46
backend/templates/pages/home.gohtml
Normal file
@@ -0,0 +1,46 @@
|
||||
{{define "home.gohtml"}}{{template "base" .}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="upload-view" aria-labelledby="upload-title">
|
||||
<form class="upload-panel" action="/api/v1/upload" method="post" enctype="multipart/form-data">
|
||||
<div class="panel-header">
|
||||
<h1 id="upload-title">Upload your files</h1>
|
||||
<p>Drag files here or choose them from your device.</p>
|
||||
</div>
|
||||
|
||||
<label class="drop-zone" for="file-input">
|
||||
<span class="drop-icon" aria-hidden="true">+</span>
|
||||
<span class="drop-title">Drop files to upload</span>
|
||||
<span class="drop-copy">Click to browse</span>
|
||||
<input id="file-input" name="file" type="file" multiple>
|
||||
</label>
|
||||
|
||||
<details class="advanced-options">
|
||||
<summary>Advanced options</summary>
|
||||
<div class="option-grid">
|
||||
<label>
|
||||
<span>Expires after</span>
|
||||
<select name="max_days">
|
||||
<option value="7">7 days</option>
|
||||
<option value="1">1 day</option>
|
||||
<option value="30">30 days</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Max downloads</span>
|
||||
<input type="number" name="max_downloads" min="1" placeholder="Unlimited">
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input type="password" name="password" autocomplete="new-password" placeholder="Optional">
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="form-footer">
|
||||
<p>Current max file size: {{.Data.MaxUploadSize}}</p>
|
||||
<button class="button button-primary" type="submit">Upload files</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user