Add automatic SSL certificate generation
All checks were successful
Build and Push / build-all (push) Successful in 8m13s

This commit is contained in:
doomtube 2026-01-06 15:22:41 -05:00
parent e13fffdaac
commit 42855330c0
11 changed files with 105 additions and 38 deletions

View file

@ -598,25 +598,31 @@ void UserController::addPgpKey(const HttpRequestPtr &req,
std::string publicKey = (*json)["publicKey"].asString(); std::string publicKey = (*json)["publicKey"].asString();
std::string fingerprint = (*json)["fingerprint"].asString(); std::string fingerprint = (*json)["fingerprint"].asString();
std::string origin = (*json).get("origin", "imported").asString();
// Validate origin value
if (origin != "generated" && origin != "imported") {
origin = "imported";
}
if (publicKey.empty() || fingerprint.empty()) { if (publicKey.empty() || fingerprint.empty()) {
callback(jsonError("Missing key data")); callback(jsonError("Missing key data"));
return; return;
} }
auto dbClient = app().getDbClient(); auto dbClient = app().getDbClient();
// Check if fingerprint already exists // Check if fingerprint already exists
*dbClient << "SELECT id FROM pgp_keys WHERE fingerprint = $1" *dbClient << "SELECT id FROM pgp_keys WHERE fingerprint = $1"
<< fingerprint << fingerprint
>> [dbClient, user, publicKey, fingerprint, callback](const Result& r) { >> [dbClient, user, publicKey, fingerprint, origin, callback](const Result& r) {
if (!r.empty()) { if (!r.empty()) {
callback(jsonError("This PGP key is already registered")); callback(jsonError("This PGP key is already registered"));
return; return;
} }
*dbClient << "INSERT INTO pgp_keys (user_id, public_key, fingerprint) VALUES ($1, $2, $3)" *dbClient << "INSERT INTO pgp_keys (user_id, public_key, fingerprint, key_origin) VALUES ($1, $2, $3, $4)"
<< user.id << publicKey << fingerprint << user.id << publicKey << fingerprint << origin
>> [callback](const Result&) { >> [callback](const Result&) {
Json::Value resp; Json::Value resp;
resp["success"] = true; resp["success"] = true;
@ -641,22 +647,23 @@ void UserController::getPgpKeys(const HttpRequestPtr &req,
} }
auto dbClient = app().getDbClient(); auto dbClient = app().getDbClient();
*dbClient << "SELECT public_key, fingerprint, created_at FROM pgp_keys " *dbClient << "SELECT public_key, fingerprint, key_origin, created_at FROM pgp_keys "
"WHERE user_id = $1 ORDER BY created_at DESC" "WHERE user_id = $1 ORDER BY created_at DESC"
<< user.id << user.id
>> [callback](const Result& r) { >> [callback](const Result& r) {
Json::Value resp; Json::Value resp;
resp["success"] = true; resp["success"] = true;
Json::Value keys(Json::arrayValue); Json::Value keys(Json::arrayValue);
for (const auto& row : r) { for (const auto& row : r) {
Json::Value key; Json::Value key;
key["publicKey"] = row["public_key"].as<std::string>(); key["publicKey"] = row["public_key"].as<std::string>();
key["fingerprint"] = row["fingerprint"].as<std::string>(); key["fingerprint"] = row["fingerprint"].as<std::string>();
key["keyOrigin"] = row["key_origin"].isNull() ? "imported" : row["key_origin"].as<std::string>();
key["createdAt"] = row["created_at"].as<std::string>(); key["createdAt"] = row["created_at"].as<std::string>();
keys.append(key); keys.append(key);
} }
resp["keys"] = keys; resp["keys"] = keys;
callback(jsonResp(resp)); callback(jsonResp(resp));
} }
@ -1038,7 +1045,7 @@ void UserController::getUserPgpKeys(const HttpRequestPtr &,
try { try {
// Public endpoint - no authentication required // Public endpoint - no authentication required
auto dbClient = app().getDbClient(); auto dbClient = app().getDbClient();
*dbClient << "SELECT pk.public_key, pk.fingerprint, pk.created_at " *dbClient << "SELECT pk.public_key, pk.fingerprint, pk.key_origin, pk.created_at "
"FROM pgp_keys pk JOIN users u ON pk.user_id = u.id " "FROM pgp_keys pk JOIN users u ON pk.user_id = u.id "
"WHERE u.username = $1 ORDER BY pk.created_at DESC" "WHERE u.username = $1 ORDER BY pk.created_at DESC"
<< username << username
@ -1046,15 +1053,16 @@ void UserController::getUserPgpKeys(const HttpRequestPtr &,
Json::Value resp; Json::Value resp;
resp["success"] = true; resp["success"] = true;
Json::Value keys(Json::arrayValue); Json::Value keys(Json::arrayValue);
for (const auto& row : r) { for (const auto& row : r) {
Json::Value key; Json::Value key;
key["publicKey"] = row["public_key"].as<std::string>(); key["publicKey"] = row["public_key"].as<std::string>();
key["fingerprint"] = row["fingerprint"].as<std::string>(); key["fingerprint"] = row["fingerprint"].as<std::string>();
key["keyOrigin"] = row["key_origin"].isNull() ? "imported" : row["key_origin"].as<std::string>();
key["createdAt"] = row["created_at"].as<std::string>(); key["createdAt"] = row["created_at"].as<std::string>();
keys.append(key); keys.append(key);
} }
resp["keys"] = keys; resp["keys"] = keys;
callback(jsonResp(resp)); callback(jsonResp(resp));
} }

View file

@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS pgp_keys (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
public_key TEXT NOT NULL, public_key TEXT NOT NULL,
fingerprint VARCHAR(40) UNIQUE NOT NULL, fingerprint VARCHAR(40) UNIQUE NOT NULL,
key_origin VARCHAR(20) DEFAULT 'imported',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
); );

View file

@ -152,6 +152,8 @@ class ChatWebSocket {
...info, ...info,
username: data.newName username: data.newName
})); }));
// Persist to localStorage for reconnection
localStorage.setItem('guestName', data.newName);
console.log('Rename successful:', data.newName); console.log('Rename successful:', data.newName);
break; break;
@ -173,7 +175,10 @@ class ChatWebSocket {
case 'error': case 'error':
console.error('Chat error:', data.error); console.error('Chat error:', data.error);
// Optionally show error to user // Show error to user
if (data.error && typeof window !== 'undefined') {
alert(data.error);
}
break; break;
case 'mod_action_success': case 'mod_action_success':

View file

@ -144,7 +144,7 @@
msg = msg.replace(/^#\s+:(\w+):$/gm, (match, stickerName) => { msg = msg.replace(/^#\s+:(\w+):$/gm, (match, stickerName) => {
const stickerKey = stickerName.toLowerCase(); const stickerKey = stickerName.toLowerCase();
if (stickersMap[stickerKey]) { if (stickersMap[stickerKey]) {
return `# <img src="${stickersMap[stickerKey]}" alt="${stickerName}" title="${stickerName}" data-sticker="${stickerName}" class="sticker-img" />`; return `# <img src="${stickersMap[stickerKey]}" alt="${stickerName}" title="${stickerName}" data-sticker="${stickerName}" class="sticker-img" onerror="this.onerror=null;this.src='/dlive2.gif';" />`;
} }
return match; return match;
}); });
@ -153,7 +153,7 @@
msg = msg.replace(/^##\s+:(\w+):$/gm, (match, stickerName) => { msg = msg.replace(/^##\s+:(\w+):$/gm, (match, stickerName) => {
const stickerKey = stickerName.toLowerCase(); const stickerKey = stickerName.toLowerCase();
if (stickersMap[stickerKey]) { if (stickersMap[stickerKey]) {
return `## <img src="${stickersMap[stickerKey]}" alt="${stickerName}" title="${stickerName}" data-sticker="${stickerName}" class="sticker-img" />`; return `## <img src="${stickersMap[stickerKey]}" alt="${stickerName}" title="${stickerName}" data-sticker="${stickerName}" class="sticker-img" onerror="this.onerror=null;this.src='/dlive2.gif';" />`;
} }
return match; return match;
}); });
@ -164,7 +164,7 @@
msg = msg.replace(/:(\w+):/g, (match, stickerName) => { msg = msg.replace(/:(\w+):/g, (match, stickerName) => {
const stickerKey = stickerName.toLowerCase(); const stickerKey = stickerName.toLowerCase();
if (stickersMap[stickerKey]) { if (stickersMap[stickerKey]) {
return `<img src="${stickersMap[stickerKey]}" alt="${stickerName}" title="${stickerName}" data-sticker="${stickerName}" class="sticker-img" />`; return `<img src="${stickersMap[stickerKey]}" alt="${stickerName}" title="${stickerName}" data-sticker="${stickerName}" class="sticker-img" onerror="this.onerror=null;this.src='/dlive2.gif';" />`;
} }
return match; return match;
}); });
@ -185,7 +185,7 @@
// Step 5: Sanitize with DOMPurify - allow img tags and safe CSS // Step 5: Sanitize with DOMPurify - allow img tags and safe CSS
html = DOMPurify.sanitize(html, { html = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'img', 'br', 'strong', 'em', 'code', 'pre', 'del', 'span'], ALLOWED_TAGS: ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'img', 'br', 'strong', 'em', 'code', 'pre', 'del', 'span'],
ALLOWED_ATTR: ['src', 'alt', 'title', 'class', 'style', 'data-sticker'], ALLOWED_ATTR: ['src', 'alt', 'title', 'class', 'style', 'data-sticker', 'onerror'],
FORBID_TAGS: ['a', 'button', 'script'], FORBID_TAGS: ['a', 'button', 'script'],
ALLOW_DATA_ATTR: false ALLOW_DATA_ATTR: false
}); });

View file

@ -271,16 +271,9 @@
} }
// Send rename message to server // Send rename message to server
// Note: State updates (localStorage + store) happen in rename_success handler
// to avoid updating UI before server confirms the rename
if (chatWebSocket.sendRename(newGuestName.trim())) { if (chatWebSocket.sendRename(newGuestName.trim())) {
// Store in localStorage for persistence
localStorage.setItem('guestName', newGuestName.trim());
// Update local store
chatUserInfo.update(info => ({
...info,
username: newGuestName.trim()
}));
showRenameModal = false; showRenameModal = false;
newGuestName = ''; newGuestName = '';
} else { } else {

View file

@ -138,7 +138,7 @@
preview = preview.replace(/:(\w+):/g, (match, name) => { preview = preview.replace(/:(\w+):/g, (match, name) => {
const key = name.toLowerCase(); const key = name.toLowerCase();
if (stickerMap && stickerMap[key]) { if (stickerMap && stickerMap[key]) {
return `<img src="${stickerMap[key]}" alt="${name}" class="sticker-img-small" />`; return `<img src="${stickerMap[key]}" alt="${name}" class="sticker-img-small" onerror="this.onerror=null;this.src='/dlive2.gif';" />`;
} }
return match; return match;
}); });
@ -147,7 +147,7 @@
return DOMPurify.sanitize(html, { return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'img', 'br', 'strong', 'em', 'code', 'del', 'span'], ALLOWED_TAGS: ['p', 'img', 'br', 'strong', 'em', 'code', 'del', 'span'],
ALLOWED_ATTR: ['src', 'alt', 'class'], ALLOWED_ATTR: ['src', 'alt', 'class', 'onerror'],
FORBID_TAGS: ['a', 'button', 'script'] FORBID_TAGS: ['a', 'button', 'script']
}); });
} }

View file

@ -113,7 +113,7 @@
msg = msg.replace(/:(\w+):/g, (match, name) => { msg = msg.replace(/:(\w+):/g, (match, name) => {
const key = name.toLowerCase(); const key = name.toLowerCase();
if (stickerMap && stickerMap[key]) { if (stickerMap && stickerMap[key]) {
return `<img src="${stickerMap[key]}" alt="${name}" title="${name}" class="sticker-img" />`; return `<img src="${stickerMap[key]}" alt="${name}" title="${name}" class="sticker-img" onerror="this.onerror=null;this.src='/dlive2.gif';" />`;
} }
return match; return match;
}); });
@ -125,7 +125,7 @@
return DOMPurify.sanitize(html, { return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'img', 'br', 'strong', 'em', 'code', 'pre', 'del', 'span'], ALLOWED_TAGS: ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'img', 'br', 'strong', 'em', 'code', 'pre', 'del', 'span'],
ALLOWED_ATTR: ['src', 'alt', 'title', 'class', 'style'], ALLOWED_ATTR: ['src', 'alt', 'title', 'class', 'style', 'onerror'],
FORBID_TAGS: ['a', 'button', 'script'] FORBID_TAGS: ['a', 'button', 'script']
}); });
} }

View file

@ -394,12 +394,40 @@
flex: 1; flex: 1;
} }
.fingerprint-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
flex-wrap: wrap;
}
.fingerprint-display { .fingerprint-display {
font-family: monospace; font-family: monospace;
font-size: 0.9rem; font-size: 0.9rem;
margin-bottom: 0.25rem;
} }
.key-origin-tag {
display: inline-block;
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
}
.key-origin-tag.generated {
background: rgba(40, 167, 69, 0.15);
color: #28a745;
border: 1px solid rgba(40, 167, 69, 0.3);
}
.key-origin-tag.imported {
background: rgba(0, 123, 255, 0.15);
color: #007bff;
border: 1px solid rgba(0, 123, 255, 0.3);
}
.key-date { .key-date {
color: var(--gray); color: var(--gray);
font-size: 0.85rem; font-size: 0.85rem;
@ -796,15 +824,20 @@
{#if pgpKeys.length > 0} {#if pgpKeys.length > 0}
{#each pgpKeys as key} {#each pgpKeys as key}
<div class="pgp-key-item"> <div class="pgp-key-item">
<div <div
class="pgp-key-header" class="pgp-key-header"
on:click={() => toggleKey(key.fingerprint)} on:click={() => toggleKey(key.fingerprint)}
on:keypress={(e) => e.key === 'Enter' && toggleKey(key.fingerprint)} on:keypress={(e) => e.key === 'Enter' && toggleKey(key.fingerprint)}
role="button" role="button"
tabindex="0" tabindex="0"
> >
<div class="pgp-key-info"> <div class="pgp-key-info">
<div class="fingerprint-display">{key.fingerprint}</div> <div class="fingerprint-row">
<div class="fingerprint-display">{key.fingerprint}</div>
<span class="key-origin-tag {key.keyOrigin || 'imported'}">
{key.keyOrigin === 'generated' ? 'Generated' : 'Imported'}
</span>
</div>
<div class="key-date">Added {new Date(key.createdAt).toLocaleDateString()}</div> <div class="key-date">Added {new Date(key.createdAt).toLocaleDateString()}</div>
</div> </div>
<span class="expand-icon" class:expanded={expandedKeys[key.fingerprint]}> <span class="expand-icon" class:expanded={expandedKeys[key.fingerprint]}>

View file

@ -932,7 +932,8 @@
credentials: 'include', credentials: 'include',
body: JSON.stringify({ body: JSON.stringify({
publicKey: generatedPublicKey, publicKey: generatedPublicKey,
fingerprint: fingerprint fingerprint: fingerprint,
origin: 'generated'
}) })
}); });
@ -987,7 +988,8 @@
credentials: 'include', credentials: 'include',
body: JSON.stringify({ body: JSON.stringify({
publicKey: newPublicKey, publicKey: newPublicKey,
fingerprint fingerprint,
origin: 'imported'
}) })
}); });

BIN
frontend/static/dlive2.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -675,6 +675,31 @@ http {
return 403; return 403;
} }
# Public stickers endpoint - no authentication required (guests need stickers for chat)
location = /api/admin/stickers {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cache stickers list for 5 minutes
expires 5m;
add_header Cache-Control "public, max-age=300" always;
}
# Other API endpoints (authenticated) # Other API endpoints (authenticated)
location /api/ { location /api/ {
limit_req zone=api_limit burst=20 nodelay; limit_req zone=api_limit burst=20 nodelay;