feat: self-host TweetNaCl, add server-side PoW captcha (PHP), activate categoriesService

This commit is contained in:
2026-02-06 14:04:24 +01:00
parent 1aa723728e
commit ebb5b2f86d
13 changed files with 210 additions and 20 deletions

View File

@@ -2,6 +2,7 @@ import { t, i18n } from '../../i18n.js'
import { router } from '../../router.js'
import { auth } from '../../services/auth.js'
import { directus } from '../../services/directus.js'
import { categoriesService } from '../../services/categories.js'
import { SUPPORTED_CURRENCIES, getDisplayCurrency } from '../../services/currency.js'
import { escapeHTML } from '../../utils/helpers.js'
import '../location-picker.js'
@@ -181,7 +182,7 @@ class PageCreate extends HTMLElement {
async loadCategories() {
try {
this.categories = await directus.getCategories()
this.categories = await categoriesService.getAll()
} catch (e) {
console.error('Failed to load categories:', e)
this.categories = []

View File

@@ -1,5 +1,5 @@
// PoW Captcha Web Component
import { generateChallenge, solveChallenge } from '../services/pow-captcha.js'
import { generateChallenge, solveChallenge, verifySolution } from '../services/pow-captcha.js'
import { t, i18n } from '../i18n.js'
export class PowCaptcha extends HTMLElement {
@@ -53,20 +53,22 @@ export class PowCaptcha extends HTMLElement {
this.render()
try {
// Generate challenge (in production, fetch from server)
const { challenge, difficulty, timestamp, signature } = generateChallenge()
const { challenge, difficulty, timestamp, signature } = await generateChallenge()
// Solve challenge
const result = await solveChallenge(challenge, difficulty)
// Store solution for form submission
this.solution = {
const solution = {
challenge,
difficulty,
nonce: result.nonce,
signature,
timestamp
}
const verification = await verifySolution(solution)
if (!verification.ok) throw new Error('Verification failed')
this.solution = { ...solution, token: verification.token }
this.solving = false
this.solved = true
this.dispatchEvent(new CustomEvent('solved', { detail: this.solution }))

View File

@@ -14,10 +14,9 @@ class CryptoService {
}
async init() {
// Dynamically import TweetNaCl from CDN
if (!window.nacl) {
await this.loadScript('https://cdn.jsdelivr.net/npm/tweetnacl@1.0.3/nacl-fast.min.js')
await this.loadScript('https://cdn.jsdelivr.net/npm/tweetnacl-util@0.15.1/nacl-util.min.js')
await this.loadScript('/js/vendor/nacl-fast.min.js')
await this.loadScript('/js/vendor/nacl-util.min.js')
}
this.nacl = window.nacl

View File

@@ -1,22 +1,67 @@
// Proof-of-Work Captcha Service
// Client must find nonce where SHA256(challenge + nonce) has N leading zeros
// Server-first: tries /pow/challenge endpoint, falls back to local generation
const DIFFICULTY = 4 // Number of leading zeros required (4 = ~65k attempts avg)
const POW_SERVER = 'https://pow.dgray.io'
const DIFFICULTY = 4
const SERVER_TIMEOUT_MS = 1500
// TODO: Replace with a server-side endpoint. Currently generates challenge
// client-side with a btoa() "signature" that provides no real security.
export function generateChallenge() {
function localGenerateChallenge() {
const challenge = crypto.randomUUID()
const timestamp = Date.now()
return {
challenge,
difficulty: DIFFICULTY,
timestamp,
signature: btoa(`${challenge}:${timestamp}:${DIFFICULTY}`)
signature: btoa(`${challenge}:${timestamp}:${DIFFICULTY}`),
source: 'local'
}
}
export async function generateChallenge() {
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), SERVER_TIMEOUT_MS)
const response = await fetch(`${POW_SERVER}/challenge`, {
method: 'GET',
signal: controller.signal
})
clearTimeout(timeout)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
return { ...data, source: 'server' }
} catch {
return localGenerateChallenge()
}
}
export async function verifySolution(solution) {
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), SERVER_TIMEOUT_MS)
const response = await fetch(`${POW_SERVER}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(solution),
signal: controller.signal
})
clearTimeout(timeout)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
return { ok: data.ok === true, token: data.captcha_token || null }
} catch {
const hash = await sha256(`${solution.challenge}${solution.nonce}`)
const prefix = '0'.repeat(solution.difficulty || DIFFICULTY)
return { ok: hash.startsWith(prefix), token: null }
}
}
// Solve challenge (runs in browser)
export async function solveChallenge(challenge, difficulty, onProgress) {
let nonce = 0
const prefix = '0'.repeat(difficulty)
@@ -35,19 +80,16 @@ export async function solveChallenge(challenge, difficulty, onProgress) {
nonce++
// Report progress every 1000 attempts
if (onProgress && nonce % 1000 === 0) {
onProgress({ attempts: nonce, elapsed: Date.now() - startTime })
}
// Yield to main thread every 100 attempts
if (nonce % 100 === 0) {
await new Promise(r => setTimeout(r, 0))
}
}
}
// SHA256 helper
async function sha256(message) {
const msgBuffer = new TextEncoder().encode(message)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)

1
js/vendor/nacl-fast.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
js/vendor/nacl-util.min.js vendored Normal file
View File

@@ -0,0 +1 @@
!function(e,n){"use strict";"undefined"!=typeof module&&module.exports?module.exports=n():(e.nacl||(e.nacl={}),e.nacl.util=n())}(this,function(){"use strict";var e={};function o(e){if(!/^(?:[A-Za-z0-9+\/]{2}[A-Za-z0-9+\/]{2})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/.test(e))throw new TypeError("invalid encoding")}return e.decodeUTF8=function(e){if("string"!=typeof e)throw new TypeError("expected string");var n,r=unescape(encodeURIComponent(e)),t=new Uint8Array(r.length);for(n=0;n<r.length;n++)t[n]=r.charCodeAt(n);return t},e.encodeUTF8=function(e){var n,r=[];for(n=0;n<e.length;n++)r.push(String.fromCharCode(e[n]));return decodeURIComponent(escape(r.join("")))},"undefined"==typeof atob?void 0!==Buffer.from?(e.encodeBase64=function(e){return Buffer.from(e).toString("base64")},e.decodeBase64=function(e){return o(e),new Uint8Array(Array.prototype.slice.call(Buffer.from(e,"base64"),0))}):(e.encodeBase64=function(e){return new Buffer(e).toString("base64")},e.decodeBase64=function(e){return o(e),new Uint8Array(Array.prototype.slice.call(new Buffer(e,"base64"),0))}):(e.encodeBase64=function(e){var n,r=[],t=e.length;for(n=0;n<t;n++)r.push(String.fromCharCode(e[n]));return btoa(r.join(""))},e.decodeBase64=function(e){o(e);var n,r=atob(e),t=new Uint8Array(r.length);for(n=0;n<r.length;n++)t[n]=r.charCodeAt(n);return t}),e});