From b07704b34da7896d36581eb6b6b0a27cc17cdff3 Mon Sep 17 00:00:00 2001 From: user <> Date: Sat, 6 Jun 2026 16:37:20 +0200 Subject: [PATCH] captcha support --- backend/cmd/server/main.go | 2 + backend/internal/files/captcha.go | 152 ++++++++++++++++++++++ backend/internal/files/files.go | 78 ++++++++++++ frontend/src/views/PublicBrowser.vue | 180 ++++++++++++++++++++++++++- 4 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 backend/internal/files/captcha.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 2a90841..21f0548 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -59,6 +59,8 @@ func main() { 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)) + r.Get("/api/captcha", files.CaptchaHandler(jwtSecret)) + r.Post("/api/files/zip/public", files.PublicZipHandler(diskRoot, jwtSecret)) // Protected endpoints (example) r.Group(func(r chi.Router) { diff --git a/backend/internal/files/captcha.go b/backend/internal/files/captcha.go new file mode 100644 index 0000000..e56e446 --- /dev/null +++ b/backend/internal/files/captcha.go @@ -0,0 +1,152 @@ +package files + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + "net/http" + "strconv" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const captchaTTL = 10 * time.Minute + +type captchaClaims struct { + Expected int `json:"ans"` + jwt.RegisteredClaims +} + +// jtiStore tracks used token IDs to make each captcha token single-use. +type jtiStore struct { + mu sync.Mutex + used map[string]time.Time // jti → token expiry +} + +var usedJTIs = &jtiStore{used: make(map[string]time.Time)} + +func init() { + // Periodically remove expired JTIs so the map doesn't grow forever. + go func() { + ticker := time.NewTicker(captchaTTL) + defer ticker.Stop() + for range ticker.C { + usedJTIs.mu.Lock() + now := time.Now() + for jti, exp := range usedJTIs.used { + if now.After(exp) { + delete(usedJTIs.used, jti) + } + } + usedJTIs.mu.Unlock() + } + }() +} + +// claim marks a JTI as used. Returns false if it was already used. +func (s *jtiStore) claim(jti string, exp time.Time) bool { + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.used[jti]; exists { + return false + } + s.used[jti] = exp + return true +} + +func randInt(min, max int) int { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(max-min+1))) + return int(n.Int64()) + min +} + +func randJTI() string { + b := make([]byte, 16) + rand.Read(b) //nolint:errcheck + return hex.EncodeToString(b) +} + +// CaptchaHandler generates a math question and returns it with a signed token +// that embeds the expected answer. The token is single-use and expires in 10 minutes. +func CaptchaHandler(jwtSecret string) http.HandlerFunc { + type response struct { + Question string `json:"question"` + Token string `json:"token"` + } + + return func(w http.ResponseWriter, r *http.Request) { + ops := []string{"+", "-", "×"} + op := ops[randInt(0, 2)] + + var a, b, expected int + switch op { + case "+": + a, b = randInt(2, 20), randInt(1, 15) + expected = a + b + case "-": + a, b = randInt(5, 20), randInt(1, 10) + expected = a - b + case "×": + a, b = randInt(1, 10), randInt(1, 10) + expected = a * b + } + + exp := time.Now().Add(captchaTTL) + token := jwt.NewWithClaims(jwt.SigningMethodHS256, captchaClaims{ + Expected: expected, + RegisteredClaims: jwt.RegisteredClaims{ + ID: randJTI(), + ExpiresAt: jwt.NewNumericDate(exp), + }, + }) + signed, err := token.SignedString([]byte(jwtSecret)) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response{ //nolint:errcheck + Question: fmt.Sprintf("%d %s %d", a, op, b), + Token: signed, + }) + } +} + +// verifyCaptcha validates the signed token and checks that answerStr matches +// the embedded expected answer. Each token can only be used once. +func verifyCaptcha(tokenStr, answerStr, jwtSecret string) error { + answer, err := strconv.Atoi(answerStr) + if err != nil { + return errors.New("invalid answer") + } + + parsed, err := jwt.ParseWithClaims(tokenStr, &captchaClaims{}, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return []byte(jwtSecret), nil + }) + if err != nil || !parsed.Valid { + return errors.New("invalid or expired token") + } + + claims, ok := parsed.Claims.(*captchaClaims) + if !ok || claims.ID == "" { + return errors.New("malformed token") + } + + if claims.Expected != answer { + return errors.New("wrong answer") + } + + if !usedJTIs.claim(claims.ID, claims.ExpiresAt.Time) { + return errors.New("token already used") + } + + return nil +} diff --git a/backend/internal/files/files.go b/backend/internal/files/files.go index 42b3426..7527aab 100644 --- a/backend/internal/files/files.go +++ b/backend/internal/files/files.go @@ -1,6 +1,7 @@ package files import ( + "archive/zip" "encoding/csv" "encoding/json" "io" @@ -238,6 +239,83 @@ func PublicRawHandler(diskRoot string) http.HandlerFunc { } } +// PublicZipHandler streams a ZIP archive of selected files from /public/. +// Expects JSON body: {"dir": "...", "files": [...], "captcha_token": "...", "captcha_answer": "7"} +func PublicZipHandler(diskRoot, jwtSecret string) http.HandlerFunc { + publicRoot, _ := filepath.Abs(filepath.Join(diskRoot, "public")) + + return func(w http.ResponseWriter, r *http.Request) { + var body struct { + Dir string `json:"dir"` + Files []string `json:"files"` + CaptchaToken string `json:"captcha_token"` + CaptchaAnswer string `json:"captcha_answer"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + if err := verifyCaptcha(body.CaptchaToken, body.CaptchaAnswer, jwtSecret); err != nil { + http.Error(w, "captcha failed: "+err.Error(), http.StatusForbidden) + return + } + + if len(body.Files) == 0 { + http.Error(w, "no files specified", http.StatusBadRequest) + return + } + + // Resolve and validate directory + dirPath, _ := filepath.Abs(filepath.Join(publicRoot, filepath.Clean("/"+body.Dir))) + if !strings.HasPrefix(dirPath+string(filepath.Separator), publicRoot+string(filepath.Separator)) && + dirPath != publicRoot { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + // Resolve and validate each file before we start writing the response + type resolvedFile struct { + abs string + name string + } + resolved := make([]resolvedFile, 0, len(body.Files)) + for _, name := range body.Files { + // Reject any path separators in the filename — files must be in dirPath directly + if strings.ContainsAny(name, "/\\") { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + abs := filepath.Join(dirPath, name) + info, err := os.Stat(abs) + if err != nil || info.IsDir() { + http.Error(w, "not found: "+name, http.StatusNotFound) + return + } + resolved = append(resolved, resolvedFile{abs: abs, name: info.Name()}) + } + + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="download.zip"`) + + zw := zip.NewWriter(w) + defer zw.Close() + + for _, rf := range resolved { + fw, err := zw.Create(rf.name) + if err != nil { + return + } + f, err := os.Open(rf.abs) + if err != nil { + return + } + io.Copy(fw, f) //nolint:errcheck + f.Close() + } + } +} + // PublicListHandler returns a handler that lists /public/ or a subdirectory. func PublicListHandler(diskRoot string) http.HandlerFunc { publicRoot, _ := filepath.Abs(filepath.Join(diskRoot, "public")) diff --git a/frontend/src/views/PublicBrowser.vue b/frontend/src/views/PublicBrowser.vue index 8c734d7..cd58fb3 100644 --- a/frontend/src/views/PublicBrowser.vue +++ b/frontend/src/views/PublicBrowser.vue @@ -1,6 +1,21 @@ @@ -115,6 +208,89 @@ const currentPath = ref(route.query.path ?? '') const entries = ref([]) const loading = ref(false) const readmeHtml = ref('') +const selected = ref(new Set()) +const showConfirm = ref(false) +const downloading = ref(false) + +const captcha = ref({ question: '', token: '', answer: '' }) +const captchaLoading = ref(false) +const captchaError = ref('') + +const captchaCorrect = computed(() => String(captcha.value.answer).trim() !== '' && captcha.value.token !== '') + +async function generateCaptcha() { + captchaLoading.value = true + captcha.value = { question: '', token: '', answer: '' } + try { + const res = await fetch('/api/captcha') + const data = await res.json() + captcha.value.question = data.question + captcha.value.token = data.token + } catch { + captcha.value.question = 'Failed to load captcha' + } finally { + captchaLoading.value = false + } +} + +function closeConfirm() { + showConfirm.value = false + captcha.value = { question: '', token: '', answer: '' } + captchaError.value = '' +} + +const fileEntries = computed(() => entries.value.filter(e => !e.is_dir)) +const allFilesSelected = computed(() => fileEntries.value.length > 0 && fileEntries.value.every(e => selected.value.has(e.name))) +const someFilesSelected = computed(() => !allFilesSelected.value && fileEntries.value.some(e => selected.value.has(e.name))) + +function toggleFile(name) { + const s = new Set(selected.value) + s.has(name) ? s.delete(name) : s.add(name) + selected.value = s +} + +function toggleAll() { + if (allFilesSelected.value) { + selected.value = new Set() + } else { + selected.value = new Set(fileEntries.value.map(e => e.name)) + } +} + +async function confirmDownload() { + if (!captchaCorrect.value || downloading.value) return + downloading.value = true + try { + const res = await fetch('/api/files/zip/public', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + dir: currentPath.value, + files: [...selected.value], + captcha_token: captcha.value.token, + captcha_answer: String(captcha.value.answer), + }), + }) + if (!res.ok) { + captchaError.value = (await res.text()).trim() || 'Download failed — please try again.' + return + } + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + const dt = new Date().toISOString().replace(/[-:T]/g, '_').slice(0, 19) + a.download = `q2dropzone_${dt}.zip` + a.click() + URL.revokeObjectURL(url) + selected.value = new Set() + closeConfirm() + } catch { + captchaError.value = 'Network error — please try again.' + } finally { + downloading.value = false + } +} const pathSegments = computed(() => currentPath.value ? currentPath.value.split('/') : [] @@ -131,6 +307,7 @@ function encodedFullPath(name) { async function navigate(path) { currentPath.value = path + selected.value = new Set() router.push({ query: path ? { path } : {} }) await fetchEntries() } @@ -140,6 +317,7 @@ watch(() => route.query.path, (p) => { const next = p ?? '' if (next !== currentPath.value) { currentPath.value = next + selected.value = new Set() fetchEntries() } })