docs: document server-side rendering pipeline, response timing middleware, and Telegram pairing API

- Add comprehensive documentation for backend content rendering in AGENTS.md, detailing the new `render_content` and `render_title` Jinja globals built on mistune with media processing, emoji shortcodes, and XSS protection
- Document the `X-Response-Time` header and bottom-left render time indicator in README.md
- Update bot token pricing documentation to clarify fallback vs gateway cost headers
- Add `email_accounts` to soft-delete tables and `idx_users_role` composite index in database schema
- Implement `telegram_pairings` and `telegram_links` table creation with column migration and indexes
- Add `/profile/{username}/telegram` endpoint to docs API with request/unpair actions
- Register `TelegramService` in main.py lifespan and add `response_timing` middleware emitting `X-Response-Time` header
- Introduce `TelegramPairForm` model and `guard_public_host_sync` synchronous host validation function
This commit is contained in:
2026-06-18 22:09:34 +00:00
parent 95dca73291
commit 6ceca3d0d4
146 changed files with 6079 additions and 392 deletions
+28
View File
@@ -1225,3 +1225,31 @@ body:has(.page-messages) {
color: var(--text-muted);
text-decoration: underline;
}
.response-time-indicator {
position: fixed;
left: 0.5rem;
bottom: 0.5rem;
z-index: 50;
padding: 0.15rem 0.45rem;
font-family: var(--font-mono);
font-size: 0.6875rem;
line-height: 1;
color: var(--text-muted);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-input);
opacity: 0.55;
pointer-events: none;
user-select: none;
}
.response-time-indicator:hover {
opacity: 0.9;
}
@media (max-width: 768px) {
.response-time-indicator {
display: none;
}
}
+4
View File
@@ -133,6 +133,10 @@ dp-content {
display: block;
}
dp-title {
display: inline;
}
dp-upload {
display: block;
}
+2 -23
View File
@@ -550,29 +550,8 @@ pre.code-pre > .code-gutter {
font-variant-numeric: tabular-nums;
}
pre.code-has-copy {
position: relative;
}
pre.code-has-copy > .code-copy-btn {
position: absolute;
top: 0.4rem;
right: 0.4rem;
z-index: 1;
padding: 0.2rem 0.6rem;
font-size: 0.7rem;
color: var(--text-secondary);
background: rgba(40, 44, 52, 0.9);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: var(--radius);
cursor: pointer;
opacity: 1;
}
pre.code-has-copy > .code-copy-btn:hover {
color: var(--white);
border-color: var(--accent, #6366f1);
}
/* The generic `pre.code-has-copy` copy-button styles moved to markdown.css so they
apply to backend-rendered content everywhere, not only on docs pages. */
/* ---- Docs search ---- */
.docs-search-form {
+24
View File
@@ -150,6 +150,30 @@ dp-content:hover .content-copy-btn,
border-color: var(--accent);
}
pre.code-has-copy {
position: relative;
}
pre.code-has-copy > .code-copy-btn {
position: absolute;
top: 0.4rem;
right: 0.4rem;
z-index: 1;
padding: 0.2rem 0.6rem;
font-size: 0.7rem;
color: var(--text-secondary);
background: rgba(40, 44, 52, 0.9);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: var(--radius);
cursor: pointer;
opacity: 1;
}
pre.code-has-copy > .code-copy-btn:hover {
color: var(--white);
border-color: var(--accent, #6366f1);
}
.embed-youtube {
position: relative;
width: 100%;
+2
View File
@@ -22,6 +22,7 @@ import { CustomizationToggle } from "./CustomizationToggle.js";
import { NotificationPrefs } from "./NotificationPrefs.js";
import { AiCorrection } from "./AiCorrection.js";
import { AiModifier } from "./AiModifier.js";
import { TelegramPairing } from "./TelegramPairing.js";
import { AdminNotificationDefaults } from "./AdminNotificationDefaults.js";
import { UserAiUsage } from "./UserAiUsage.js";
import { Tabs } from "./Tabs.js";
@@ -66,6 +67,7 @@ class Application {
this.notificationPrefs = new NotificationPrefs();
this.aiCorrection = new AiCorrection();
this.aiModifier = new AiModifier();
this.telegramPairing = new TelegramPairing();
this.adminNotificationDefaults = new AdminNotificationDefaults();
this.userAiUsage = new UserAiUsage();
this.tabs = new Tabs();
+18 -4
View File
@@ -14,8 +14,9 @@ class BackupMonitor {
this.scheduleModal = document.getElementById("backup-schedule-modal");
this.scheduleKind = document.getElementById("backup-schedule-kind");
this.scheduleTitle = document.getElementById("backup-schedule-title");
this.pollMs = 8000;
this.pollMs = 30000;
this.targets = [];
this.canDownload = false;
}
start() {
@@ -23,12 +24,26 @@ class BackupMonitor {
this.bindRunForm();
this.bindScheduleForm();
this.bindActions();
this.subscribe();
this.poller = new Poller(() => this.poll(), this.pollMs);
}
subscribe() {
const pubsub = window.app && window.app.pubsub;
if (!pubsub) return;
if (this.unsubscribe) this.unsubscribe();
this.unsubscribe = pubsub.subscribe("admin.backups", (data) => this.applyPush(data));
}
applyPush(data) {
this.targets = data.targets || this.targets;
this.render(data);
}
async poll() {
try {
const data = await Http.getJson("/admin/backups/data");
this.canDownload = !!data.can_download_backups;
this.targets = data.targets || [];
this.render(data);
} catch {
@@ -257,9 +272,9 @@ class BackupMonitor {
backupActions(backup) {
const cell = this.el("td", "backups-actions");
if (backup.status === "done") {
if (this.canDownload && backup.download_url) {
if (this.canDownload) {
const link = this.el("a", "admin-btn admin-btn-sm", "Download");
link.href = backup.download_url;
link.href = `/admin/backups/${backup.uid}/download`;
cell.appendChild(link);
} else {
const blocked = this.el("button", "admin-btn admin-btn-sm", "Download");
@@ -329,7 +344,6 @@ class BackupMonitor {
}
render(data) {
this.canDownload = !!data.can_download_backups;
if (this.generated && data.generated_at) {
this.generated.textContent = `Updated ${DateFormat.format(data.generated_at, true)}`;
}
+10
View File
@@ -1,6 +1,7 @@
// retoor <retoor@molodetz.nl>
import { contentRenderer } from "./ContentRenderer.js";
import { CodeBlock } from "./CodeBlock.js";
import { MentionInput } from "./MentionInput.js";
import { EmojiPicker } from "./EmojiPicker.js";
@@ -20,6 +21,15 @@ export class ContentEnhancer {
});
});
contentRenderer.highlightAll();
this.enhanceCodeBlocks();
}
enhanceCodeBlocks(root = document) {
root.querySelectorAll(".rendered-content pre").forEach((pre) => {
if (pre.querySelector("code")) {
CodeBlock.enhance(pre, { lineNumbers: false });
}
});
}
initEmojiPickers() {
+28
View File
@@ -70,6 +70,34 @@ export class ContentRenderer {
return html;
}
escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
renderInline(text) {
if (!text) return "";
text = this.normalizeDashes(text);
text = this.replaceShortcodes(text);
let html;
if (typeof marked !== "undefined" && typeof marked.parseInline === "function") {
html = marked.parseInline(text, { gfm: true });
} else {
html = this.escapeHtml(text);
}
if (typeof DOMPurify === "undefined") {
throw new Error("DOMPurify not loaded; refusing to render untrusted HTML");
}
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["b", "strong", "i", "em", "code", "del", "s", "mark", "sub", "sup", "span", "br"],
ALLOWED_ATTR: [],
});
}
processMedia(html) {
const temp = document.createElement("div");
temp.innerHTML = html;
+13
View File
@@ -23,9 +23,22 @@ class ServiceMonitor {
if (pubsub) {
const topic = this.detail ? `admin.services.${this.detailName}` : "admin.services";
pubsub.subscribe(topic, (data) => this.applyData(data));
if (this.detail) {
pubsub.subscribe(`admin.services.${this.detailName}.logs`, (data) => this.appendLive(data));
}
}
}
appendLive(data) {
if (!data || !data.line || !this.detail) return;
const pre = this.detail.querySelector("[data-live-log]");
if (!pre) return;
const lines = pre.textContent === "No live output yet." ? [] : pre.textContent.split("\n");
lines.push(data.line);
pre.textContent = lines.slice(-300).join("\n");
pre.scrollTop = pre.scrollHeight;
}
applyData(data) {
if (!data) return;
if (this.detail) {
+73
View File
@@ -0,0 +1,73 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
class TelegramPairing {
constructor() {
this.root = document.querySelector("[data-telegram-pairing]");
if (!this.root) return;
this.username = this.root.dataset.username;
this.statusLabel = this.root.querySelector("[data-telegram-status]");
this.requestBtn = this.root.querySelector("[data-telegram-request]");
this.unpairBtn = this.root.querySelector("[data-telegram-unpair]");
this.codeBox = this.root.querySelector("[data-telegram-code]");
this.codeValue = this.root.querySelector("[data-telegram-code-value]");
this.codeHint = this.root.querySelector("[data-telegram-code-hint]");
this.message = this.root.querySelector("[data-telegram-status-msg]");
if (this.requestBtn) this.requestBtn.addEventListener("click", () => this.request());
if (this.unpairBtn) this.unpairBtn.addEventListener("click", () => this.unpair());
}
async request() {
this.requestBtn.disabled = true;
this.setMessage("Requesting...", "");
try {
const data = await Http.send(`/profile/${this.username}/telegram`, { action: "request" });
this.showCode(data.code, data.ttl_minutes);
this.setMessage("Send this code to the bot on Telegram", "success");
} catch (error) {
this.setMessage("Could not request a code", "error");
} finally {
this.requestBtn.disabled = false;
}
}
async unpair() {
this.unpairBtn.disabled = true;
this.setMessage("Disconnecting...", "");
try {
await Http.send(`/profile/${this.username}/telegram`, { action: "unpair" });
this.setPaired(false);
this.hideCode();
this.setMessage("Telegram disconnected", "success");
} catch (error) {
this.setMessage("Could not disconnect", "error");
} finally {
this.unpairBtn.disabled = false;
}
}
showCode(code, ttlMinutes) {
if (this.codeValue) this.codeValue.textContent = code;
if (this.codeHint) this.codeHint.textContent = `Valid for ${ttlMinutes} minutes. Open the Telegram bot and send it this code.`;
if (this.codeBox) this.codeBox.hidden = false;
}
hideCode() {
if (this.codeBox) this.codeBox.hidden = true;
}
setPaired(paired) {
if (this.statusLabel) this.statusLabel.textContent = paired ? "Connected" : "Not connected";
if (this.unpairBtn) this.unpairBtn.hidden = !paired;
}
setMessage(text, type) {
if (!this.message) return;
this.message.textContent = text;
this.message.dataset.state = type;
}
}
window.TelegramPairing = TelegramPairing;
export { TelegramPairing };
@@ -0,0 +1,22 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
import { contentRenderer } from "../ContentRenderer.js";
export class AppTitle extends Component {
connectedCallback() {
if (this._rendered) {
return;
}
if (typeof DOMPurify === "undefined" || typeof marked === "undefined") {
window.addEventListener("load", () => this.connectedCallback(), { once: true });
return;
}
this._rendered = true;
this.classList.add("rendered-title");
const source = (this.textContent || "").trim();
this.innerHTML = contentRenderer.renderInline(source);
}
}
customElements.define("dp-title", AppTitle);
+1
View File
@@ -3,6 +3,7 @@
import "./AppAvatar.js";
import "./AppCode.js";
import "./AppContent.js";
import "./AppTitle.js";
import "./AppUpload.js";
import "./AppToast.js";
import "./AppDialog.js";
+20 -1
View File
@@ -107,10 +107,14 @@ export default class DeviiTerminalElement extends FloatingWindow {
this._reportVisibility();
});
this.socket = new DeviiSocket({
onOpen: () => this._reportVisibility(),
onOpen: () => {
this._reportVisibility();
this._reportClientInfo();
},
onReady: () => {
this._notice("connected.");
this._reportVisibility();
this._reportClientInfo();
},
onClose: () => this._notice("disconnected, reconnecting..."),
onMessage: (message) => this._onMessage(message),
@@ -643,6 +647,21 @@ export default class DeviiTerminalElement extends FloatingWindow {
});
}
_reportClientInfo() {
if (!this.socket) return;
let timezone = "";
try {
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "";
} catch (error) {
timezone = "";
}
this.socket.send({
type: "clientinfo",
timezone,
offset: -new Date().getTimezoneOffset(),
});
}
async _clientRequest(message) {
let result;
try {