forked from retoor/devplacepy
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,10 @@ dp-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
dp-title {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
dp-upload {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user