From 5601c282b76e7716a9fecaedaf7010a9beff55b7 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Sat, 19 Sep 2026 21:35:08 +0200 Subject: [PATCH] hgfd --- README.md | 16 ++++++- config_example.json | 8 +++- package-lock.json | 23 +++++++++- public/s/js/f0ckm.js | 4 ++ public/s/js/settings.js | 43 ++++++++++------- scripts/generate-config.mjs | 92 +++++++++++++++++++++++++++++++++++++ src/inc/config.mjs | 29 ++++++++++++ src/inc/routes/admin.mjs | 10 ++++ src/inc/routes/ajax.mjs | 18 +++++++- src/inc/routes/index.mjs | 8 ++-- src/index.mjs | 23 +++++++++- views/settings.html | 12 +++-- views/snippets/footer.html | 3 +- 13 files changed, 257 insertions(+), 32 deletions(-) create mode 100755 scripts/generate-config.mjs diff --git a/README.md b/README.md index 69a6634..f1272af 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Happy to finally bring you f0ckm! The long awaited imageboard solution that you It features extensive tagging, searching, filtering and a variety of options. -The software is mostly generic, it can be modified easily via config.json to suit your communities needs. Most things can be enabled/disabled very easily and modified to needs. +The software is mostly generic, it can be modified easily via `config.yaml` (or `config.json`) to suit your communities needs. Most things can be enabled/disabled very easily and modified to needs. The software comes without any warranties or entitlements of any kind! The developer is not responsible for anything you do with the use of this software. @@ -20,7 +20,12 @@ first things fill with for example: f0ckm (prefilled) -`cp config_example.json config.json` +Copy example configuration and customize: + +`cp config_example.yaml config.yaml` +`npm run config:gen` + +(Or `cp config_example.json config.json` if configuring directly in JSON) Edit to needs, for sql you can do this: @@ -74,6 +79,13 @@ Create admin user in dev env `DB_HOST=localhost DB_PORT=5454 node scripts/create-admin.mjs admin 'YOUR_PASSWORD_HERE'` now visit http://localhost:1337 in your browser, you can develop without needing to rebuild the docker image for every change + +## Configuration Management + +You can manage configuration in YAML (`config.yaml`) or JSON (`config.json`): +- `npm run config:gen`: Compiles `config.yaml` to `config.json`. Automatically runs before `npm run dev` and `npm start`. +- `npm run config:watch`: Watches `config.yaml` for edits and recompiles `config.json` automatically on save. +- `npm run config:to-yaml`: Converts existing `config.json` into `config.yaml`. ## NGINX diff --git a/config_example.json b/config_example.json index df9b90b..c936a08 100644 --- a/config_example.json +++ b/config_example.json @@ -164,7 +164,11 @@ "fileupload_comments_size": 104857600, "fileupload_comments_max": 5, "fileupload_comments_mode": "attachment", - "fileupload_comments_mimes": ["image", "video", "audio"], + "fileupload_comments_mimes": [ + "image", + "video", + "audio" + ], "show_content_warning": true, "default_comment_display_mode": 1, "phrases": [ @@ -302,4 +306,4 @@ "site_key": "YOUR_RECAPTCHA_V2_SITE_KEY", "secret_key": "YOUR_RECAPTCHA_V2_SECRET_KEY" } -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index c25b2ab..8da0cde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "jszip": "3.10.1", "marked": "18.0.2", "matrix-js-sdk": "^40.3.0-rc.0", - "postgres": "^3.3.4" + "postgres": "^3.3.4", + "yaml": "^2.9.1" } }, "node_modules/@babel/runtime": { @@ -448,6 +449,21 @@ "bin": { "uuid": "dist-node/bin/uuid" } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } }, "dependencies": { @@ -757,6 +773,11 @@ "version": "13.0.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==" + }, + "yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==" } } } diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 515ded6..3ced171 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -1786,6 +1786,10 @@ window.cancelAnimFrame = (function () { // ── Onara Alternative Flavor: Fullscreen Modal Wrapper ───────────────────── const isOnaraActive = () => { + // Global config onara overweighs always the user pref + if (window.f0ckSession && window.f0ckSession.onara_cfg !== undefined && window.f0ckSession.onara_cfg !== null) { + return !!window.f0ckSession.onara_cfg; + } const stored = (() => { try { return localStorage.getItem('f0ck_onara_enabled'); } catch { return null; } })(); if (stored !== null) { const active = stored === 'true'; diff --git a/public/s/js/settings.js b/public/s/js/settings.js index e0eab84..f01b182 100644 --- a/public/s/js/settings.js +++ b/public/s/js/settings.js @@ -1586,29 +1586,38 @@ }; - // Onara enable/disable toggle (localStorage only — no server round-trip needed) + // Onara enable/disable toggle const onaraEnabledToggle = document.getElementById('onara_enabled_toggle'); if (onaraEnabledToggle) { const LS_KEY = 'f0ck_onara_enabled'; - // Seed checkbox from localStorage; fall back to the server-set session value - const stored = localStorage.getItem(LS_KEY); - const sessionOnara = !!(window.f0ckSession?.onara); - onaraEnabledToggle.checked = stored !== null ? stored === 'true' : sessionOnara; - // Reflect current state into window.onara so isOnaraActive() sees it - window.onara = onaraEnabledToggle.checked; - try { - document.cookie = `f0ck_onara=${onaraEnabledToggle.checked ? '1' : '0'}; path=/; max-age=31536000; SameSite=Lax`; - } catch {} + const hasGlobalConfig = window.f0ckSession && window.f0ckSession.onara_cfg !== undefined && window.f0ckSession.onara_cfg !== null; - onaraEnabledToggle.addEventListener('change', () => { - const enabled = onaraEnabledToggle.checked; - localStorage.setItem(LS_KEY, String(enabled)); + if (hasGlobalConfig) { + const globalVal = !!window.f0ckSession.onara_cfg; + onaraEnabledToggle.checked = globalVal; + onaraEnabledToggle.disabled = true; + window.onara = globalVal; + } else { + // Seed checkbox from localStorage; fall back to the server-set session value + const stored = localStorage.getItem(LS_KEY); + const sessionOnara = !!(window.f0ckSession?.onara); + onaraEnabledToggle.checked = stored !== null ? stored === 'true' : sessionOnara; + // Reflect current state into window.onara so isOnaraActive() sees it + window.onara = onaraEnabledToggle.checked; try { - document.cookie = `f0ck_onara=${enabled ? '1' : '0'}; path=/; max-age=31536000; SameSite=Lax`; + document.cookie = `f0ck_onara=${onaraEnabledToggle.checked ? '1' : '0'}; path=/; max-age=31536000; SameSite=Lax`; } catch {} - window.onara = enabled; - showStatus('Onara viewer ' + (enabled ? 'enabled' : 'disabled') + '.', 'success'); - }); + + onaraEnabledToggle.addEventListener('change', () => { + const enabled = onaraEnabledToggle.checked; + localStorage.setItem(LS_KEY, String(enabled)); + try { + document.cookie = `f0ck_onara=${enabled ? '1' : '0'}; path=/; max-age=31536000; SameSite=Lax`; + } catch {} + window.onara = enabled; + showStatus('Onara viewer ' + (enabled ? 'enabled' : 'disabled') + '.', 'success'); + }); + } } // Quote Emojis Toggle diff --git a/scripts/generate-config.mjs b/scripts/generate-config.mjs new file mode 100755 index 0000000..28d7049 --- /dev/null +++ b/scripts/generate-config.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import YAML from 'yaml'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.resolve(__dirname, '..'); + +const DEFAULT_CONFIG_YAML = path.join(rootDir, 'config.yaml'); +const DEFAULT_CONFIG_JSON = path.join(rootDir, 'config.json'); +const EXAMPLE_CONFIG_YAML = path.join(rootDir, 'config_example.yaml'); +const EXAMPLE_CONFIG_JSON = path.join(rootDir, 'config_example.json'); + +function convertYamlToJson(yamlPath, jsonPath) { + if (!fs.existsSync(yamlPath)) { + console.error(`[CONFIG-GEN] Error: Source YAML file not found at ${yamlPath}`); + return false; + } + try { + const yamlContent = fs.readFileSync(yamlPath, 'utf8'); + const parsed = YAML.parse(yamlContent); + if (parsed === null || typeof parsed !== 'object') { + throw new Error('Parsed YAML is not an object'); + } + const jsonContent = JSON.stringify(parsed, null, 2) + '\n'; + fs.writeFileSync(jsonPath, jsonContent, 'utf8'); + console.log(`[CONFIG-GEN] Generated ${path.relative(rootDir, jsonPath)} from ${path.relative(rootDir, yamlPath)}`); + return true; + } catch (err) { + console.error(`[CONFIG-GEN] Failed to generate JSON from ${path.relative(rootDir, yamlPath)}: ${err.message}`); + return false; + } +} + +function convertJsonToYaml(jsonPath, yamlPath) { + if (!fs.existsSync(jsonPath)) { + console.error(`[CONFIG-GEN] Error: Source JSON file not found at ${jsonPath}`); + return false; + } + try { + const jsonContent = fs.readFileSync(jsonPath, 'utf8'); + const parsed = JSON.parse(jsonContent); + const doc = new YAML.Document(parsed); + const yamlContent = String(doc); + fs.writeFileSync(yamlPath, yamlContent, 'utf8'); + console.log(`[CONFIG-GEN] Generated ${path.relative(rootDir, yamlPath)} from ${path.relative(rootDir, jsonPath)}`); + return true; + } catch (err) { + console.error(`[CONFIG-GEN] Failed to generate YAML from ${path.relative(rootDir, jsonPath)}: ${err.message}`); + return false; + } +} + +const args = process.argv.slice(2); +const isWatch = args.includes('--watch') || args.includes('-w'); +const isToYaml = args.includes('--to-yaml'); +const includeAll = args.includes('--all') || args.includes('--example'); + +if (isToYaml) { + convertJsonToYaml(DEFAULT_CONFIG_JSON, DEFAULT_CONFIG_YAML); + if (includeAll || fs.existsSync(EXAMPLE_CONFIG_JSON)) { + convertJsonToYaml(EXAMPLE_CONFIG_JSON, EXAMPLE_CONFIG_YAML); + } + process.exit(0); +} + +// Initial generation +let success = convertYamlToJson(DEFAULT_CONFIG_YAML, DEFAULT_CONFIG_JSON); +if (includeAll && fs.existsSync(EXAMPLE_CONFIG_YAML)) { + convertYamlToJson(EXAMPLE_CONFIG_YAML, EXAMPLE_CONFIG_JSON); +} + +if (!success && !isWatch) { + process.exit(1); +} + +if (isWatch) { + console.log(`[CONFIG-GEN] Watching ${path.relative(rootDir, DEFAULT_CONFIG_YAML)} for changes... (Press Ctrl+C to stop)`); + let debounceTimer = null; + fs.watch(DEFAULT_CONFIG_YAML, (eventType) => { + if (eventType === 'change' || eventType === 'rename') { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + console.log(`[CONFIG-GEN] File change detected, re-generating config.json...`); + convertYamlToJson(DEFAULT_CONFIG_YAML, DEFAULT_CONFIG_JSON); + }, 100); + } + }); +} diff --git a/src/inc/config.mjs b/src/inc/config.mjs index 106f085..554c573 100644 --- a/src/inc/config.mjs +++ b/src/inc/config.mjs @@ -100,4 +100,33 @@ config.paths = { import: resolveImportPath() }; +const configFilePath = path.resolve(base, "config.json"); +let watchDebounceTimer = null; +try { + if (fs.existsSync(configFilePath)) { + const watcher = fs.watch(configFilePath, (eventType) => { + if (eventType === 'change' || eventType === 'rename') { + if (watchDebounceTimer) clearTimeout(watchDebounceTimer); + watchDebounceTimer = setTimeout(() => { + try { + const raw = fs.readFileSync(configFilePath, 'utf8'); + const updated = JSON.parse(raw); + Object.assign(config, updated); + if (config.sql) { + config.sql.host = process.env.DB_HOST || process.env.POSTGRES_HOST || process.env.PGHOST || config.sql.host; + config.sql.port = parseInt(process.env.DB_PORT || process.env.POSTGRES_PORT || process.env.PGPORT || config.sql.port, 10); + config.sql.user = process.env.DB_USER || process.env.POSTGRES_USER || process.env.PGUSER || config.sql.user; + config.sql.password = process.env.DB_PASS || process.env.POSTGRES_PASSWORD || process.env.PGPASSWORD || config.sql.password; + config.sql.database = process.env.DB_NAME || process.env.POSTGRES_DB || process.env.PGDATABASE || config.sql.database; + } + console.log('[CONFIG] config.json reloaded dynamically'); + } catch (_) {} + }, 100); + if (watchDebounceTimer.unref) watchDebounceTimer.unref(); + } + }); + if (watcher.unref) watcher.unref(); + } +} catch (_) {} + export default config; diff --git a/src/inc/routes/admin.mjs b/src/inc/routes/admin.mjs index f22a3b5..5fde7b4 100644 --- a/src/inc/routes/admin.mjs +++ b/src/inc/routes/admin.mjs @@ -12,6 +12,7 @@ import cfg from "../config.mjs"; import security from "../security.mjs"; import crypto from "crypto"; import path from "path"; +import YAML from "yaml"; import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getShitpostMode, ensureAllItemsHaveSlugs, getEnableItemSlugs, getBrandImageUrl } from "../settings.mjs"; export default (router, tpl) => { @@ -939,6 +940,15 @@ export default (router, tpl) => { // Write formatted JSON to config.json on disk await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2) + "\n", "utf-8"); + // Also update config.yaml if it exists + const configYamlPath = path.resolve(process.cwd(), "config.yaml"); + try { + const yamlDoc = new YAML.Document(updatedConfig); + await fs.writeFile(configYamlPath, String(yamlDoc), "utf-8"); + } catch (yamlErr) { + console.warn('[ADMIN] Could not sync config.yaml:', yamlErr.message); + } + // Mutate in-memory cfg object so changes apply immediately Object.assign(cfg, updatedConfig); diff --git a/src/inc/routes/ajax.mjs b/src/inc/routes/ajax.mjs index 6f86f85..78ca750 100644 --- a/src/inc/routes/ajax.mjs +++ b/src/inc/routes/ajax.mjs @@ -221,7 +221,14 @@ export default (router, tpl) => { console.log(`[AJAX-RANDOM] ${itemid} total=${tAjaxRender - tAjaxStart}ms | getRandom=${tRandom - tAjaxStart}ms | getf0ck=${tAjaxFetch - tRandom}ms | aux=${tAjaxAux - tAjaxFetch}ms | render=${tAjaxRender - tAjaxAux}ms`); let itemPage = null; - if (query.onara === '1' || req.cookies?.onara === '1' || req.cookies?.f0ck_onara === '1' || cfg.onara) { + const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; + const cookieOnara = req.cookies?.f0ck_onara !== undefined + ? (req.cookies.f0ck_onara === '1' || req.cookies.f0ck_onara === 'true') + : (req.cookies?.onara !== undefined ? (req.cookies.onara === '1' || req.cookies.onara === 'true') : null); + const effectiveOnara = (cfgOnara !== undefined && cfgOnara !== null) + ? !!cfgOnara + : (query.onara === '1' || cookieOnara === true); + if (effectiveOnara) { try { itemPage = await f0cklib.getItemPage({ targetItemId: data.item?.id || itemid, @@ -487,7 +494,14 @@ export default (router, tpl) => { - Render: ${tAjaxRender - tAjaxAux}ms`); let itemPage = null; - if (query.onara === '1' || req.cookies?.onara === '1' || req.cookies?.f0ck_onara === '1' || cfg.onara || query.get_page === '1') { + const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; + const cookieOnara = req.cookies?.f0ck_onara !== undefined + ? (req.cookies.f0ck_onara === '1' || req.cookies.f0ck_onara === 'true') + : (req.cookies?.onara !== undefined ? (req.cookies.onara === '1' || req.cookies.onara === 'true') : null); + const effectiveOnara = (cfgOnara !== undefined && cfgOnara !== null) + ? !!cfgOnara + : (query.onara === '1' || cookieOnara === true); + if (effectiveOnara || query.get_page === '1') { try { itemPage = await f0cklib.getItemPage({ targetItemId: data.item?.id || itemid, diff --git a/src/inc/routes/index.mjs b/src/inc/routes/index.mjs index f706a17..8c37050 100644 --- a/src/inc/routes/index.mjs +++ b/src/inc/routes/index.mjs @@ -494,12 +494,14 @@ export default (router, tpl) => { return res.reply({ body }); } + const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; const cookieOnara = req.cookies?.f0ck_onara !== undefined ? (req.cookies.f0ck_onara === '1' || req.cookies.f0ck_onara === 'true') : (req.cookies?.onara !== undefined ? (req.cookies.onara === '1' || req.cookies.onara === 'true') : null); - const isOnara = cookieOnara !== null - ? cookieOnara - : !!(req.session?.onara || (cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara)); + // Global config onara overweighs always the user pref + const isOnara = (cfgOnara !== undefined && cfgOnara !== null) + ? !!cfgOnara + : (cookieOnara !== null ? cookieOnara : !!req.session?.onara); if (mode === 'item' && isOnara) { const itemHtml = tpl.render('ajax-item', data, req); diff --git a/src/index.mjs b/src/index.mjs index 19b28c7..91fa549 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -1947,7 +1947,14 @@ process.on('uncaughtException', err => { enable_comments: cfg.enable_comments !== false, public_nsfw: !!cfg.websrv.public_nsfw, public_untagged: !!cfg.websrv.public_untagged, - onara: !!(cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara), + get onara() { + const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; + return !!c; + }, + get onara_cfg() { + const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; + return (c !== undefined && c !== null) ? !!c : null; + }, is_onara_item: false, enable_private_uploads: cfg.enable_private_uploads !== false, get enable_expiring_uploads() { return getEnableExpiringUploads(); }, @@ -2147,12 +2154,26 @@ process.on('uncaughtException', err => { data.max_file_size = lib.formatSize(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1)); data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1)); data.web_url_upload = data.web_url_upload !== undefined ? data.web_url_upload : !!cfg.websrv.web_url_upload; + + const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; + const cookieOnara = activeReq.cookies?.f0ck_onara !== undefined + ? (activeReq.cookies.f0ck_onara === '1' || activeReq.cookies.f0ck_onara === 'true') + : (activeReq.cookies?.onara !== undefined ? (activeReq.cookies.onara === '1' || activeReq.cookies.onara === 'true') : null); + const effectiveOnara = (cfgOnara !== undefined && cfgOnara !== null) + ? !!cfgOnara + : (cookieOnara !== null ? cookieOnara : !!activeReq.session?.onara); + data.onara = effectiveOnara; + data.onara_cfg = (cfgOnara !== undefined && cfgOnara !== null) ? !!cfgOnara : null; } else { data.recaptcha_enabled = perRequestRecaptcha; data.theme = data.theme || cfg.websrv.theme || 'f0ck'; data.user_strict_bool = false; data.user_logged_in_bool = false; globals.csrf_token = data.csrf_token || ''; + + const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; + data.onara = !!cfgOnara; + data.onara_cfg = (cfgOnara !== undefined && cfgOnara !== null) ? !!cfgOnara : null; } return originalRender.call(tpl, view, data, (data.url && data.url !== req?.url) ? undefined : req); diff --git a/views/settings.html b/views/settings.html index c5d8962..c796489 100644 --- a/views/settings.html +++ b/views/settings.html @@ -253,11 +253,17 @@
-
diff --git a/views/snippets/footer.html b/views/snippets/footer.html index a8c43cb..0797de1 100644 --- a/views/snippets/footer.html +++ b/views/snippets/footer.html @@ -231,7 +231,7 @@ @endif @if(private_society && !session)