Browse Source

captcha support

master
user 3 weeks ago
parent
commit
b07704b34d
  1. 2
      backend/cmd/server/main.go
  2. 152
      backend/internal/files/captcha.go
  3. 78
      backend/internal/files/files.go
  4. 180
      frontend/src/views/PublicBrowser.vue

2
backend/cmd/server/main.go

@ -59,6 +59,8 @@ func main() {
useXAccel := os.Getenv("NGINX_ACCEL") == "true" useXAccel := os.Getenv("NGINX_ACCEL") == "true"
r.Get("/api/files/download/public/*", files.PublicDownloadHandler(diskRoot, useXAccel)) r.Get("/api/files/download/public/*", files.PublicDownloadHandler(diskRoot, useXAccel))
r.Get("/api/files/raw/public/*", files.PublicRawHandler(diskRoot)) 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) // Protected endpoints (example)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {

152
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
}

78
backend/internal/files/files.go

@ -1,6 +1,7 @@
package files package files
import ( import (
"archive/zip"
"encoding/csv" "encoding/csv"
"encoding/json" "encoding/json"
"io" "io"
@ -238,6 +239,83 @@ func PublicRawHandler(diskRoot string) http.HandlerFunc {
} }
} }
// PublicZipHandler streams a ZIP archive of selected files from <diskRoot>/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 <diskRoot>/public/ or a subdirectory. // PublicListHandler returns a handler that lists <diskRoot>/public/ or a subdirectory.
func PublicListHandler(diskRoot string) http.HandlerFunc { func PublicListHandler(diskRoot string) http.HandlerFunc {
publicRoot, _ := filepath.Abs(filepath.Join(diskRoot, "public")) publicRoot, _ := filepath.Abs(filepath.Join(diskRoot, "public"))

180
frontend/src/views/PublicBrowser.vue

@ -1,6 +1,21 @@
<template> <template>
<div class="max-w-7xl mx-auto px-4 py-10"> <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> <div class="flex items-center justify-between mb-4 gap-4 flex-wrap">
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Public files</h1>
<button
:disabled="selected.size === 0"
@click="showConfirm = true; generateCaptcha()"
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
:class="selected.size === 0
? 'bg-gray-200 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700 text-white cursor-pointer'"
>
Download selected
<span v-if="selected.size > 0" class="bg-blue-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{{ selected.size }}
</span>
</button>
</div>
<!-- Breadcrumb --> <!-- Breadcrumb -->
<nav class="text-sm text-gray-500 dark:text-gray-400 mb-6 flex items-center gap-1 flex-wrap"> <nav class="text-sm text-gray-500 dark:text-gray-400 mb-6 flex items-center gap-1 flex-wrap">
@ -41,6 +56,15 @@
<table class="w-full text-sm border-collapse min-w-[600px]"> <table class="w-full text-sm border-collapse min-w-[600px]">
<thead> <thead>
<tr class="border-b border-gray-200 dark:border-gray-700 text-left text-gray-500 dark:text-gray-400"> <tr class="border-b border-gray-200 dark:border-gray-700 text-left text-gray-500 dark:text-gray-400">
<th class="pb-2 pr-3 w-8">
<input
type="checkbox"
:checked="allFilesSelected"
:indeterminate="someFilesSelected"
@change="toggleAll"
class="rounded border-gray-300 dark:border-gray-600 cursor-pointer"
/>
</th>
<th class="pb-2 font-medium w-full">Name</th> <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 pr-8">Size</th>
<th class="pb-2 font-medium whitespace-nowrap">Modified</th> <th class="pb-2 font-medium whitespace-nowrap">Modified</th>
@ -51,7 +75,17 @@
v-for="entry in entries" v-for="entry in entries"
:key="entry.name" :key="entry.name"
class="border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800" class="border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
:class="{ 'bg-blue-50 dark:bg-blue-900/20': selected.has(entry.name) }"
> >
<td class="py-2 pr-3">
<input
v-if="!entry.is_dir"
type="checkbox"
:checked="selected.has(entry.name)"
@change="toggleFile(entry.name)"
class="rounded border-gray-300 dark:border-gray-600 cursor-pointer"
/>
</td>
<td class="py-2 pr-4"> <td class="py-2 pr-4">
<button <button
v-if="entry.is_dir" v-if="entry.is_dir"
@ -88,6 +122,65 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Confirm modal -->
<Teleport to="body">
<div
v-if="showConfirm"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@click.self="closeConfirm"
>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6 w-full max-w-sm mx-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Download files</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
Download <strong>{{ selected.size }}</strong> selected file{{ selected.size > 1 ? 's' : '' }} as ZIP?
</p>
<ul class="text-xs text-gray-500 dark:text-gray-400 mb-5 max-h-32 overflow-y-auto space-y-1 bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<li v-for="name in selected" :key="name" class="truncate"> {{ name }}</li>
</ul>
<!-- Math captcha -->
<div class="mb-5">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<span v-if="captchaLoading" class="text-gray-400">Loading captcha</span>
<span v-else>Verify: {{ captcha.question }} = ?</span>
</label>
<input
v-model="captcha.answer"
type="number"
placeholder="Answer"
:disabled="captchaLoading || !captcha.token"
class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100
focus:outline-none focus:ring-2 focus:ring-blue-500
disabled:opacity-50"
@keyup.enter="captchaCorrect && confirmDownload()"
@input="captchaError = ''"
/>
<p v-if="captchaError" class="mt-1 text-xs text-red-500">{{ captchaError }}</p>
</div>
<div class="flex gap-3 justify-end">
<button
@click="closeConfirm"
class="px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
:disabled="!captchaCorrect || downloading"
@click="confirmDownload"
class="px-4 py-2 text-sm rounded-lg font-medium transition-colors"
:class="captchaCorrect && !downloading
? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-400 cursor-not-allowed'"
>
{{ downloading ? 'Packaging…' : 'Download ZIP' }}
</button>
</div>
</div>
</div>
</Teleport>
</template> </template>
</div> </div>
</template> </template>
@ -115,6 +208,89 @@ const currentPath = ref(route.query.path ?? '')
const entries = ref([]) const entries = ref([])
const loading = ref(false) const loading = ref(false)
const readmeHtml = ref('') 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(() => const pathSegments = computed(() =>
currentPath.value ? currentPath.value.split('/') : [] currentPath.value ? currentPath.value.split('/') : []
@ -131,6 +307,7 @@ function encodedFullPath(name) {
async function navigate(path) { async function navigate(path) {
currentPath.value = path currentPath.value = path
selected.value = new Set()
router.push({ query: path ? { path } : {} }) router.push({ query: path ? { path } : {} })
await fetchEntries() await fetchEntries()
} }
@ -140,6 +317,7 @@ watch(() => route.query.path, (p) => {
const next = p ?? '' const next = p ?? ''
if (next !== currentPath.value) { if (next !== currentPath.value) {
currentPath.value = next currentPath.value = next
selected.value = new Set()
fetchEntries() fetchEntries()
} }
}) })

Loading…
Cancel
Save