add option to have unencrypted direct messages

This commit is contained in:
2026-05-21 15:31:57 +02:00
parent d0a014705b
commit eb209f6d27
5 changed files with 102 additions and 44 deletions

View File

@@ -62,6 +62,7 @@
"enable_danmaku": true,
"private_messages": true,
"dm_attachments": true,
"dm_unencrypted": false,
"dm_attachment_expiry_days": 90,
"halls_enabled": true,
"userhalls_enabled": true,

View File

@@ -344,7 +344,7 @@ if (window.__dmLoaded) {
// Try to get recipient's key — but don't block if they don't have one yet
currentOtherPubKey = await getRemotePublicKey(currentOtherId);
if (!currentOtherPubKey) {
if (!window.f0ckSession?.dm_unencrypted && !currentOtherPubKey) {
// Recipient has no key — clearly block sending
const notice = document.getElementById('dm-key-notice');
if (notice) {
@@ -531,23 +531,33 @@ if (window.__dmLoaded) {
async function decryptBatch(messages) {
if (!messages.length) return [];
if (!currentOtherPubKey) return messages.map(m => ({ ...m, plaintext: null }));
// Derive shared key ONCE for the whole batch — ECDH is expensive, don't repeat per message
let sharedKey;
try {
sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey);
} catch (e) {
return messages.map(m => ({ ...m, plaintext: null }));
let sharedKey = null;
if (_privateKey && currentOtherPubKey) {
try {
sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey);
} catch (e) {
// Key derivation failed — will treat encrypted messages as null plaintext
}
}
const result = [];
for (const m of messages) {
try {
const plaintext = await decryptMessage(sharedKey, m.ciphertext, m.iv);
result.push({ ...m, plaintext });
} catch (e) {
result.push({ ...m, plaintext: null });
if (m.iv === 'unencrypted' || !m.iv) {
// Unencrypted message
result.push({ ...m, plaintext: m.ciphertext });
} else {
// Encrypted message
if (sharedKey) {
try {
const plaintext = await decryptMessage(sharedKey, m.ciphertext, m.iv);
result.push({ ...m, plaintext });
} catch (e) {
result.push({ ...m, plaintext: null });
}
} else {
result.push({ ...m, plaintext: null });
}
}
}
return result;
@@ -906,13 +916,21 @@ if (window.__dmLoaded) {
div.dataset.editing = '';
return;
}
if (!currentOtherPubKey) { showFlashMsg('Cannot encrypt: no key', 'error'); return; }
saveBtn.disabled = true;
saveBtn.textContent = '…';
try {
const sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey);
const { iv: newIv, ciphertext: newCt } = await encryptMessage(sharedKey, newText);
let newIv, newCt;
if (window.f0ckSession?.dm_unencrypted) {
newIv = 'unencrypted';
newCt = newText;
} else {
if (!currentOtherPubKey) { showFlashMsg('Cannot encrypt: no key', 'error'); saveBtn.disabled = false; saveBtn.textContent = 'Save'; return; }
const sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey);
const enc = await encryptMessage(sharedKey, newText);
newIv = enc.iv;
newCt = enc.ciphertext;
}
const res = await fetch(`/api/dm/message/${m.id}`, {
method: 'PATCH',
@@ -1107,7 +1125,12 @@ if (window.__dmLoaded) {
let iv, encryptedBuffer;
try {
const raw = await file.arrayBuffer();
({ iv, encryptedBuffer } = await encryptAttachment(sharedKey, raw));
if (window.f0ckSession?.dm_unencrypted) {
iv = 'unencrypted';
encryptedBuffer = raw;
} else {
({ iv, encryptedBuffer } = await encryptAttachment(sharedKey, raw));
}
} catch (e) {
showFlashMsg('Encryption failed: ' + e.message, 'error');
return null;
@@ -1405,12 +1428,7 @@ if (window.__dmLoaded) {
// Shared fetch+decrypt helper — returns a Blob or null
async function fetchAndDecryptAttachment(id, mime) {
if (!currentOtherPubKey) {
showFlashMsg('Cannot decrypt: no encryption key', 'error');
return null;
}
try {
const sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey);
const res = await fetch(`/api/dm/attachment/${id}`, {
headers: { 'X-CSRF-Token': csrfToken() }
});
@@ -1420,6 +1438,15 @@ if (window.__dmLoaded) {
if (!ivHeader) { showFlashMsg('Server did not return IV — please refresh', 'error'); return null; }
const encBuf = await res.arrayBuffer();
if (ivHeader === 'unencrypted') {
return new Blob([encBuf], { type: mime || 'application/octet-stream' });
}
if (!currentOtherPubKey) {
showFlashMsg('Cannot decrypt: no encryption key', 'error');
return null;
}
const sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey);
const plainBuf = await decryptAttachment(sharedKey, ivHeader, new Uint8Array(encBuf));
return new Blob([plainBuf], { type: mime || 'application/octet-stream' });
} catch (e) {
@@ -1489,7 +1516,7 @@ if (window.__dmLoaded) {
const attachBtn = document.createElement('button');
attachBtn.type = 'button';
attachBtn.title = 'Send encrypted attachment';
attachBtn.title = window.f0ckSession?.dm_unencrypted ? 'Send attachment' : 'Send encrypted attachment';
attachBtn.className = 'dm-attach-btn';
attachBtn.innerHTML = '<i class="fa-solid fa-paperclip"></i>';
actions.prepend(attachBtn);
@@ -1504,13 +1531,15 @@ if (window.__dmLoaded) {
const file = attachInput.files[0];
if (!file) return;
if (!currentOtherPubKey) {
showFlashMsg('Cannot attach: recipient has no encryption key', 'error');
return;
}
if (!hasKey()) {
showFlashMsg('Cannot attach: your encryption key is not loaded', 'error');
return;
if (!window.f0ckSession?.dm_unencrypted) {
if (!currentOtherPubKey) {
showFlashMsg('Cannot attach: recipient has no encryption key', 'error');
return;
}
if (!hasKey()) {
showFlashMsg('Cannot attach: your encryption key is not loaded', 'error');
return;
}
}
// Show progress state on button
@@ -1524,7 +1553,10 @@ if (window.__dmLoaded) {
};
try {
const sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey);
let sharedKey = null;
if (!window.f0ckSession?.dm_unencrypted) {
sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey);
}
const sentinel = await uploadDmAttachment(file, sharedKey, currentOtherId, onProgress);
if (sentinel) {
// Append sentinel to the textarea (with a newline separator)
@@ -1540,7 +1572,7 @@ if (window.__dmLoaded) {
} finally {
attachBtn.disabled = false;
attachBtn.innerHTML = origHtml;
attachBtn.title = 'Send encrypted attachment';
attachBtn.title = window.f0ckSession?.dm_unencrypted ? 'Send attachment' : 'Send encrypted attachment';
attachInput.value = '';
}
});
@@ -1830,21 +1862,31 @@ if (window.__dmLoaded) {
? `${activeReply.quoteLines.join('\n')}\n\n${rawText}`
: rawText;
// If recipient has no key, block sending — we cannot encrypt for them
if (!currentOtherPubKey) {
// Re-check in case they registered since page load
currentOtherPubKey = await getRemotePublicKey(currentOtherId);
}
if (!currentOtherPubKey) {
showFlashMsg('This user hasn\'t set up their encryption key yet. Sending is not possible until they do.', 'error');
return;
if (!window.f0ckSession?.dm_unencrypted) {
// If recipient has no key, block sending — we cannot encrypt for them
if (!currentOtherPubKey) {
// Re-check in case they registered since page load
currentOtherPubKey = await getRemotePublicKey(currentOtherId);
}
if (!currentOtherPubKey) {
showFlashMsg('This user hasn\'t set up their encryption key yet. Sending is not possible until they do.', 'error');
return;
}
}
sendInFlight = true;
btn.disabled = true;
try {
const sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey);
const { ciphertext, iv } = await encryptMessage(sharedKey, text);
let ciphertext, iv;
if (window.f0ckSession?.dm_unencrypted) {
ciphertext = text;
iv = 'unencrypted';
} else {
const sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey);
const enc = await encryptMessage(sharedKey, text);
ciphertext = enc.ciphertext;
iv = enc.iv;
}
const res = await dmFetch('POST', `/api/dm/send/${currentOtherId}`, { ciphertext, iv });
if (res.success) {
input.value = '';
@@ -1880,6 +1922,11 @@ if (window.__dmLoaded) {
// ─────────────────────────────────────────────────────────────────────────
async function ensureKeyReady() {
if (window.f0ckSession?.dm_unencrypted) {
const btns = document.querySelectorAll('.dm-manage-keys-btn');
btns.forEach(btn => btn.style.display = 'none');
return;
}
const status = await loadOrCreateKeyPair();
// Always re-upload public key silently (on every page, so recipients can find us)
uploadPublicKey().catch(e => console.warn('[DM] pubkey upload failed:', e));

View File

@@ -8,6 +8,7 @@ let bypass_duplicate_check = false;
let protect_files = false;
let private_messages = true;
let dm_attachments = true;
let dm_unencrypted = false;
let default_layout = 'modern';
let enable_pdf = false;
let enable_cleanup = false;
@@ -63,6 +64,9 @@ export const setPrivateMessages = (val) => private_messages = !!val;
export const getDmAttachments = () => dm_attachments;
export const setDmAttachments = (val) => dm_attachments = !!val;
export const getDmUnencrypted = () => dm_unencrypted;
export const setDmUnencrypted = (val) => dm_unencrypted = !!val;
export const getDmAttachmentExpiryDays = () => {
const v = parseInt(cfg.websrv.dm_attachment_expiry_days);
return (Number.isFinite(v) && v > 0) ? v : 90;

View File

@@ -18,7 +18,7 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs";
import { handleMetaStrip } from "./meta_strip_handler.mjs";
import { handleCommentUpload } from "./comment_upload_handler.mjs";
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode } from "./inc/settings.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode } from "./inc/settings.mjs";
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
import { createI18n } from "./inc/i18n.mjs";
import security from "./inc/security.mjs";
@@ -1007,6 +1007,10 @@ process.on('uncaughtException', err => {
// Default is true; requires private_messages to also be enabled
setDmAttachments(cfg.websrv.dm_attachments !== false);
console.log(`[BOOT] DM attachments: ${cfg.websrv.dm_attachments !== false ? 'ENABLED' : 'DISABLED'}`);
// Load dm_unencrypted from config.json (static — not a DB setting)
setDmUnencrypted(!!cfg.websrv.dm_unencrypted);
console.log(`[BOOT] DM unencrypted: ${cfg.websrv.dm_unencrypted ? 'ENABLED' : 'DISABLED'}`);
// Load default_layout from config.json (static)
if (cfg.websrv.default_layout) {
setDefaultLayout(cfg.websrv.default_layout);
@@ -1094,6 +1098,7 @@ process.on('uncaughtException', err => {
enable_profile_description: !!cfg.websrv.enable_profile_description,
get private_messages() { return getPrivateMessages(); },
get dm_attachments() { return getDmAttachments(); },
get dm_unencrypted() { return getDmUnencrypted(); },
get enable_pdf() { return getEnablePdf(); },
get enable_cleanup() { return getEnableCleanup(); },
get cleanup_start_date() { return getCleanupStartDate(); },

View File

@@ -412,7 +412,8 @@
fileupload_comments_size: {{ fileupload_comments_size }},
fileupload_comments_max: {{ fileupload_comments_max }},
fileupload_comments_mode: "{{ fileupload_comments_mode }}",
dm_attachments: @if(dm_attachments) true @else false @endif
dm_attachments: @if(dm_attachments) true @else false @endif,
dm_unencrypted: @if(dm_unencrypted) true @else false @endif
};
window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};
window.f0ckI18n = {