Almaatla commited on
Commit
e3318a4
·
verified ·
1 Parent(s): 5f5e5d5

Upload 4 files

Browse files

add upload of png, md and txt files. with new display handling.

Files changed (4) hide show
  1. index.html +711 -0
  2. main.py +362 -0
  3. models.py +46 -0
  4. requirements.txt +6 -4
index.html ADDED
@@ -0,0 +1,711 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>Real-Time Display Board</title>
7
+ <style>
8
+ :root {
9
+ --bg: #111;
10
+ --panel: #181818;
11
+ --header: #222;
12
+ --border: #333;
13
+ --muted: #aaa;
14
+ --text: #eee;
15
+ --ok-bg: #154;
16
+ --ok-fg: #8f8;
17
+ --warn-bg: #444;
18
+ --warn-fg: #ccc;
19
+ --bad-bg: #511;
20
+ --bad-fg: #f88;
21
+ --accent: #8cf;
22
+ }
23
+
24
+ * { box-sizing: border-box; }
25
+ body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); }
26
+
27
+ /* Keep top visible */
28
+ .topbar {
29
+ position: sticky; /* stays visible while page scrolls */
30
+ top: 0;
31
+ z-index: 10;
32
+ background: var(--header);
33
+ border-bottom: 1px solid var(--border);
34
+ }
35
+
36
+ header {
37
+ padding: 10px 16px;
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: space-between;
41
+ gap: 12px;
42
+ }
43
+
44
+ #status { font-size: 0.9em; }
45
+ #status span { padding: 4px 8px; border-radius: 4px; display: inline-block; }
46
+ .status-connected { background: var(--ok-bg); color: var(--ok-fg); }
47
+ .status-connecting { background: var(--warn-bg); color: var(--warn-fg); }
48
+ .status-disconnected { background: var(--bad-bg); color: var(--bad-fg); }
49
+
50
+ #controls {
51
+ padding: 8px 16px;
52
+ background: var(--panel);
53
+ display: flex;
54
+ gap: 10px;
55
+ align-items: center;
56
+ flex-wrap: wrap;
57
+ border-top: 1px solid var(--border);
58
+ }
59
+
60
+ label { font-size: 0.9em; }
61
+ input, select, button {
62
+ padding: 6px 8px;
63
+ border-radius: 6px;
64
+ border: 1px solid #444;
65
+ background: #000;
66
+ color: var(--text);
67
+ }
68
+ button { cursor: pointer; background: #111; }
69
+ button:hover { background: #151515; }
70
+
71
+ #apiKeyInput { width: 280px; }
72
+
73
+ /* Messages area */
74
+ #messages {
75
+ padding: 8px 16px 16px;
76
+ height: calc(100vh - 114px); /* approximate height of sticky topbar */
77
+ overflow-y: auto;
78
+ overscroll-behavior: contain;
79
+ }
80
+
81
+ .message { border-bottom: 1px solid var(--border); padding: 8px 0; }
82
+ .meta { font-size: 0.82em; color: var(--muted); margin-bottom: 3px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
83
+ .poster { font-weight: 700; color: var(--accent); }
84
+ .timestamp { color: #ccc; }
85
+ .category { font-size: 0.78em; padding: 2px 7px; border-radius: 999px; background: #333; color: var(--text); }
86
+ .content { white-space: pre-wrap; }
87
+
88
+ .file-link {
89
+ color: var(--accent);
90
+ text-decoration: underline;
91
+ cursor: pointer;
92
+ }
93
+ .file-link:hover {
94
+ color: #adf;
95
+ }
96
+
97
+ .image-content {
98
+ max-width: 100%;
99
+ border-radius: 8px;
100
+ margin-top: 8px;
101
+ border: 1px solid var(--border);
102
+ }
103
+
104
+ /* Modal styles */
105
+ .modal-overlay {
106
+ position: fixed;
107
+ top: 0;
108
+ left: 0;
109
+ right: 0;
110
+ bottom: 0;
111
+ background: rgba(0, 0, 0, 0.85);
112
+ display: flex;
113
+ align-items: center;
114
+ justify-content: center;
115
+ z-index: 1000;
116
+ padding: 20px;
117
+ }
118
+
119
+ .modal-content {
120
+ background: var(--panel);
121
+ border: 1px solid var(--border);
122
+ border-radius: 8px;
123
+ max-width: 800px;
124
+ width: 100%;
125
+ max-height: 80vh;
126
+ display: flex;
127
+ flex-direction: column;
128
+ }
129
+
130
+ .modal-header {
131
+ padding: 12px 16px;
132
+ border-bottom: 1px solid var(--border);
133
+ display: flex;
134
+ justify-content: space-between;
135
+ align-items: center;
136
+ background: var(--header);
137
+ border-radius: 8px 8px 0 0;
138
+ }
139
+
140
+ .modal-title {
141
+ font-weight: 600;
142
+ color: var(--text);
143
+ }
144
+
145
+ .modal-close {
146
+ background: transparent;
147
+ border: none;
148
+ color: var(--muted);
149
+ font-size: 1.5em;
150
+ cursor: pointer;
151
+ padding: 0 4px;
152
+ line-height: 1;
153
+ }
154
+ .modal-close:hover {
155
+ color: var(--text);
156
+ }
157
+
158
+ .modal-body {
159
+ padding: 16px;
160
+ overflow-y: auto;
161
+ flex: 1;
162
+ }
163
+
164
+ .modal-body pre {
165
+ margin: 0;
166
+ padding: 12px;
167
+ background: var(--bg);
168
+ border: 1px solid var(--border);
169
+ border-radius: 6px;
170
+ white-space: pre-wrap;
171
+ word-wrap: break-word;
172
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
173
+ font-size: 0.9em;
174
+ line-height: 1.6;
175
+ }
176
+
177
+ /* Markdown rendered content */
178
+ .markdown-content h1, .markdown-content h2, .markdown-content h3,
179
+ .markdown-content h4, .markdown-content h5, .markdown-content h6 {
180
+ margin-top: 16px;
181
+ margin-bottom: 8px;
182
+ color: var(--accent);
183
+ }
184
+ .markdown-content p {
185
+ margin: 8px 0;
186
+ line-height: 1.6;
187
+ }
188
+ .markdown-content code {
189
+ background: var(--bg);
190
+ padding: 2px 6px;
191
+ border-radius: 4px;
192
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
193
+ font-size: 0.9em;
194
+ }
195
+ .markdown-content pre {
196
+ background: var(--bg);
197
+ padding: 12px;
198
+ border-radius: 6px;
199
+ border: 1px solid var(--border);
200
+ overflow-x: auto;
201
+ }
202
+ .markdown-content pre code {
203
+ background: transparent;
204
+ padding: 0;
205
+ }
206
+ .markdown-content ul, .markdown-content ol {
207
+ margin: 8px 0;
208
+ padding-left: 24px;
209
+ }
210
+ .markdown-content li {
211
+ margin: 4px 0;
212
+ }
213
+ .markdown-content strong {
214
+ color: var(--text);
215
+ }
216
+ .markdown-content em {
217
+ font-style: italic;
218
+ }
219
+ .markdown-content blockquote {
220
+ border-left: 4px solid var(--accent);
221
+ margin: 8px 0;
222
+ padding-left: 16px;
223
+ color: var(--muted);
224
+ }
225
+ </style>
226
+ </head>
227
+ <body>
228
+ <div class="topbar">
229
+ <header>
230
+ <div>Real-Time Display Board</div>
231
+ <div id="status"><span class="status-disconnected">Disconnected</span></div>
232
+ </header>
233
+
234
+ <div id="controls">
235
+ <label>Reader API Key:
236
+ <input id="apiKeyInput" type="password" placeholder="Enter READER_KEY" autocomplete="off" />
237
+ </label>
238
+ <button id="connectBtn">Connect</button>
239
+
240
+ <label>Poster filter:
241
+ <select id="posterFilter">
242
+ <option value="">All posters</option>
243
+ </select>
244
+ </label>
245
+
246
+ <label>Keyword filter:
247
+ <input id="keywordFilter" type="text" placeholder="Search poster, content, metadata" />
248
+ </label>
249
+
250
+ <label>
251
+ <input type="checkbox" id="autoScrollToggle" checked />
252
+ Auto-scroll
253
+ </label>
254
+ </div>
255
+ </div>
256
+
257
+ <div id="messages"></div>
258
+
259
+ <div id="fileModal" class="modal-overlay" style="display: none;">
260
+ <div class="modal-content">
261
+ <div class="modal-header">
262
+ <span class="modal-title" id="modalTitle">File Content</span>
263
+ <button class="modal-close" onclick="closeModal()">&times;</button>
264
+ </div>
265
+ <div class="modal-body" id="modalBody"></div>
266
+ </div>
267
+ </div>
268
+
269
+ <script>
270
+ const statusEl = document.getElementById("status");
271
+ const apiKeyInput = document.getElementById("apiKeyInput");
272
+ const connectBtn = document.getElementById("connectBtn");
273
+ const messagesEl = document.getElementById("messages");
274
+ const posterFilterEl = document.getElementById("posterFilter");
275
+ const keywordFilterEl = document.getElementById("keywordFilter");
276
+ const autoScrollToggle = document.getElementById("autoScrollToggle");
277
+
278
+ let socket = null;
279
+ let manualDisconnect = false;
280
+
281
+ let reconnectDelay = 1000;
282
+ const maxReconnectDelay = 30000;
283
+
284
+ let allMessages = [];
285
+ let autoScrollEnabled = true;
286
+
287
+ // Track whether user is at bottom without doing heavy work on every scroll tick
288
+ let userAtBottom = true;
289
+ let scrollTicking = false;
290
+
291
+ // Modal elements
292
+ const fileModal = document.getElementById("fileModal");
293
+ const modalTitle = document.getElementById("modalTitle");
294
+ const modalBody = document.getElementById("modalBody");
295
+
296
+ // Simple markdown to HTML converter
297
+ function parseMarkdown(text) {
298
+ if (!text) return "";
299
+
300
+ // Escape HTML first
301
+ let html = text
302
+ .replace(/&/g, "&amp;")
303
+ .replace(/</g, "&lt;")
304
+ .replace(/>/g, "&gt;");
305
+
306
+ // Headers (# ## ###)
307
+ html = html.replace(/^#{6}\s+(.+)$/gm, "<h6>$1</h6>");
308
+ html = html.replace(/^#{5}\s+(.+)$/gm, "<h5>$1</h5>");
309
+ html = html.replace(/^#{4}\s+(.+)$/gm, "<h4>$1</h4>");
310
+ html = html.replace(/^#{3}\s+(.+)$/gm, "<h3>$1</h3>");
311
+ html = html.replace(/^#{2}\s+(.+)$/gm, "<h2>$1</h2>");
312
+ html = html.replace(/^#{1}\s+(.+)$/gm, "<h1>$1</h1>");
313
+
314
+ // Code blocks (```code```)
315
+ html = html.replace(/```[\s\S]*?```/g, (match) => {
316
+ const code = match.slice(3, -3).trim();
317
+ return `<pre><code>${code}</code></pre>`;
318
+ });
319
+
320
+ // Inline code (`code`)
321
+ html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
322
+
323
+ // Bold (**text** or __text__)
324
+ html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
325
+ html = html.replace(/__([^_]+)__/g, "<strong>$1</strong>");
326
+
327
+ // Italic (*text* or _text_)
328
+ html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
329
+ html = html.replace(/_([^_]+)_/g, "<em>$1</em>");
330
+
331
+ // Blockquote (> text)
332
+ html = html.replace(/^>\s+(.+)$/gm, "<blockquote>$1</blockquote>");
333
+
334
+ // Lists
335
+ // Unordered lists (- item or * item)
336
+ html = html.replace(/^(\s*)[-*]\s+(.+)$/gm, (match, indent, item) => {
337
+ const level = Math.floor(indent.length / 2);
338
+ return `<li class="list-level-${level}">${item}</li>`;
339
+ });
340
+ // Ordered lists (1. item)
341
+ html = html.replace(/^(\s*)\d+\.\s+(.+)$/gm, (match, indent, item) => {
342
+ const level = Math.floor(indent.length / 2);
343
+ return `<li class="list-level-${level}">${item}</li>`;
344
+ });
345
+
346
+ // Wrap consecutive li elements in ul
347
+ html = html.replace(/(<li[^>]*>.*?<\/li>)(\n<li[^>]*>.*?<\/li>)*/g, (match) => {
348
+ return `<ul>${match}</ul>`;
349
+ });
350
+
351
+ // Line breaks - convert remaining newlines to paragraphs
352
+ const paragraphs = html.split(/\n+/).filter(p => p.trim());
353
+ html = paragraphs.map(p => {
354
+ if (p.match(/^<(h\d|ul|li|blockquote|pre)/)) return p;
355
+ return `<p>${p}</p>`;
356
+ }).join("");
357
+
358
+ return html;
359
+ }
360
+
361
+ function requestFileContent(messageId) {
362
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
363
+ alert("WebSocket is not connected.");
364
+ return;
365
+ }
366
+ socket.send(JSON.stringify({ type: "get_file_content", message_id: messageId }));
367
+ }
368
+
369
+ function displayFileContent(content, title, type) {
370
+ modalTitle.textContent = title || "File Content";
371
+ modalBody.innerHTML = "";
372
+
373
+ if (type === "md") {
374
+ const div = document.createElement("div");
375
+ div.className = "markdown-content";
376
+ div.innerHTML = parseMarkdown(content);
377
+ modalBody.appendChild(div);
378
+ } else {
379
+ const pre = document.createElement("pre");
380
+ pre.textContent = content;
381
+ modalBody.appendChild(pre);
382
+ }
383
+
384
+ fileModal.style.display = "flex";
385
+ }
386
+
387
+ function closeModal() {
388
+ fileModal.style.display = "none";
389
+ }
390
+
391
+ // Close modal on overlay click
392
+ fileModal.addEventListener("click", (e) => {
393
+ if (e.target === fileModal) {
394
+ closeModal();
395
+ }
396
+ });
397
+
398
+ // Close modal on Escape key
399
+ document.addEventListener("keydown", (e) => {
400
+ if (e.key === "Escape" && fileModal.style.display === "flex") {
401
+ closeModal();
402
+ }
403
+ });
404
+
405
+ function setStatus(state, text) {
406
+ const span = document.createElement("span");
407
+ span.textContent = text;
408
+ span.className =
409
+ state === "connected" ? "status-connected" :
410
+ state === "connecting" ? "status-connecting" :
411
+ "status-disconnected";
412
+ statusEl.innerHTML = "";
413
+ statusEl.appendChild(span);
414
+ }
415
+
416
+ function humanTime(ts) {
417
+ const d = new Date(ts);
418
+ // If ts is missing/invalid, show placeholder rather than crashing
419
+ if (Number.isNaN(d.getTime())) return "--:--:--";
420
+ return d.toTimeString().split(" ")[0];
421
+ }
422
+
423
+ function computeAtBottom() {
424
+ // Only read layout values when scheduled (rAF)
425
+ return Math.abs(messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight) < 5;
426
+ }
427
+
428
+ messagesEl.addEventListener("scroll", () => {
429
+ if (scrollTicking) return;
430
+ scrollTicking = true;
431
+
432
+ requestAnimationFrame(() => {
433
+ const atBottom = computeAtBottom();
434
+ userAtBottom = atBottom;
435
+
436
+ if (!atBottom) {
437
+ autoScrollEnabled = false;
438
+ autoScrollToggle.checked = false;
439
+ } else {
440
+ if (autoScrollToggle.checked) autoScrollEnabled = true;
441
+ }
442
+
443
+ scrollTicking = false;
444
+ });
445
+ }, { passive: true }); // helps scroll performance by letting browser scroll immediately [web:48]
446
+
447
+ autoScrollToggle.addEventListener("change", () => {
448
+ autoScrollEnabled = autoScrollToggle.checked;
449
+ if (autoScrollEnabled) {
450
+ messagesEl.scrollTop = messagesEl.scrollHeight;
451
+ userAtBottom = true;
452
+ }
453
+ });
454
+
455
+ posterFilterEl.addEventListener("change", () => renderMessages());
456
+ keywordFilterEl.addEventListener("input", () => renderMessages());
457
+
458
+ function updatePosterFilterOptions() {
459
+ const posters = new Set(allMessages.map(m => m.poster_id).filter(Boolean));
460
+ const current = posterFilterEl.value;
461
+
462
+ // Build options efficiently
463
+ const frag = document.createDocumentFragment();
464
+ const optAll = document.createElement("option");
465
+ optAll.value = "";
466
+ optAll.textContent = "All posters";
467
+ frag.appendChild(optAll);
468
+
469
+ posters.forEach(p => {
470
+ const opt = document.createElement("option");
471
+ opt.value = p;
472
+ opt.textContent = p;
473
+ frag.appendChild(opt);
474
+ });
475
+
476
+ posterFilterEl.innerHTML = "";
477
+ posterFilterEl.appendChild(frag);
478
+
479
+ if ([...posterFilterEl.options].some(o => o.value === current)) {
480
+ posterFilterEl.value = current;
481
+ }
482
+ }
483
+
484
+ function messageMatchesFilters(m) {
485
+ const posterFilter = posterFilterEl.value.trim().toLowerCase();
486
+ const keyword = keywordFilterEl.value.trim().toLowerCase();
487
+
488
+ if (posterFilter && (m.poster_id || "").toLowerCase() !== posterFilter) return false;
489
+ if (!keyword) return true;
490
+
491
+ const tokens = [];
492
+ tokens.push(m.poster_id || "");
493
+ tokens.push(m.content || "");
494
+ if (m.category) tokens.push(m.category);
495
+ if (m.metadata && typeof m.metadata === "object") {
496
+ for (const v of Object.values(m.metadata)) {
497
+ try { tokens.push(String(v)); } catch {}
498
+ }
499
+ }
500
+ return tokens.join(" ").toLowerCase().includes(keyword);
501
+ }
502
+
503
+ function buildMessageNode(msg) {
504
+ const div = document.createElement("div");
505
+ div.className = "message";
506
+
507
+ const meta = document.createElement("div");
508
+ meta.className = "meta";
509
+
510
+ const poster = document.createElement("span");
511
+ poster.className = "poster";
512
+ poster.textContent = msg.poster_id || "(unknown)";
513
+
514
+ const ts = document.createElement("span");
515
+ ts.className = "timestamp";
516
+ ts.textContent = humanTime(msg.timestamp);
517
+
518
+ meta.appendChild(poster);
519
+ meta.appendChild(ts);
520
+
521
+ // Add message type indicator for file types
522
+ if (msg.message_type && msg.message_type !== "text") {
523
+ const typeLabel = document.createElement("span");
524
+ typeLabel.className = "category";
525
+ typeLabel.textContent = msg.message_type.toUpperCase();
526
+ meta.appendChild(typeLabel);
527
+ }
528
+
529
+ if (msg.category) {
530
+ const cat = document.createElement("span");
531
+ cat.className = "category";
532
+ cat.textContent = msg.category;
533
+ meta.appendChild(cat);
534
+ }
535
+
536
+ const content = document.createElement("div");
537
+ content.className = "content";
538
+
539
+ // Handle different message types
540
+ const msgType = msg.message_type || "text";
541
+
542
+ if (msgType === "png") {
543
+ // PNG: show title in meta, display image
544
+ if (msg.title) {
545
+ const titleDiv = document.createElement("div");
546
+ titleDiv.textContent = msg.title;
547
+ titleDiv.style.marginBottom = "8px";
548
+ titleDiv.style.color = "var(--accent)";
549
+ content.appendChild(titleDiv);
550
+ }
551
+ if (msg.file_url) {
552
+ const img = document.createElement("img");
553
+ img.src = msg.file_url;
554
+ img.className = "image-content";
555
+ img.alt = msg.title || "Image";
556
+ content.appendChild(img);
557
+ }
558
+ } else if (msgType === "md" || msgType === "txt") {
559
+ // Markdown or Text: show clickable title
560
+ if (msg.title) {
561
+ const link = document.createElement("span");
562
+ link.className = "file-link";
563
+ link.textContent = msg.title;
564
+ link.onclick = () => requestFileContent(msg.id);
565
+ content.appendChild(link);
566
+ } else {
567
+ content.textContent = msg.content || "";
568
+ }
569
+ } else {
570
+ // Default text type
571
+ content.textContent = msg.content || "";
572
+ }
573
+
574
+ div.appendChild(meta);
575
+ div.appendChild(content);
576
+ return div;
577
+ }
578
+
579
+ function renderMessages() {
580
+ // Keep the current scroll position; only autoscroll if user is already at bottom + enabled
581
+ const shouldStickToBottom = autoScrollEnabled && userAtBottom;
582
+
583
+ const frag = document.createDocumentFragment();
584
+ for (const m of allMessages) {
585
+ if (!messageMatchesFilters(m)) continue;
586
+ frag.appendChild(buildMessageNode(m));
587
+ }
588
+
589
+ messagesEl.innerHTML = "";
590
+ messagesEl.appendChild(frag);
591
+
592
+ if (shouldStickToBottom) {
593
+ messagesEl.scrollTop = messagesEl.scrollHeight;
594
+ userAtBottom = true;
595
+ }
596
+ }
597
+
598
+ function appendMessageIfVisible(msg) {
599
+ // Avoid full rerender for every new message: append if it matches current filters
600
+ const shouldStickToBottom = autoScrollEnabled && userAtBottom;
601
+
602
+ if (messageMatchesFilters(msg)) {
603
+ messagesEl.appendChild(buildMessageNode(msg));
604
+ }
605
+
606
+ // Keep dropdown up to date (cheap, small list)
607
+ updatePosterFilterOptions();
608
+
609
+ if (shouldStickToBottom) {
610
+ messagesEl.scrollTop = messagesEl.scrollHeight;
611
+ userAtBottom = true;
612
+ }
613
+ }
614
+
615
+ function connectSocket() {
616
+ const key = apiKeyInput.value.trim();
617
+ if (!key) {
618
+ alert("Please enter READER_KEY.");
619
+ return;
620
+ }
621
+
622
+ const loc = window.location;
623
+ const protocol = loc.protocol === "https:" ? "wss:" : "ws:";
624
+ const wsUrl = `${protocol}//${loc.host}/ws?api_key=${encodeURIComponent(key)}`;
625
+
626
+ setStatus("connecting", "Connecting...");
627
+ socket = new WebSocket(wsUrl);
628
+
629
+ socket.onopen = () => {
630
+ setStatus("connected", "Connected");
631
+ reconnectDelay = 1000;
632
+ connectBtn.textContent = "Disconnect";
633
+ };
634
+
635
+ socket.onmessage = (event) => {
636
+ let data;
637
+ try { data = JSON.parse(event.data); } catch { return; }
638
+
639
+ if (data.type === "history") {
640
+ allMessages = Array.isArray(data.messages) ? data.messages : [];
641
+ allMessages.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
642
+ updatePosterFilterOptions();
643
+ // On fresh history load, always go to bottom if auto-scroll is enabled
644
+ if (autoScrollToggle.checked) {
645
+ autoScrollEnabled = true;
646
+ userAtBottom = true;
647
+ }
648
+ renderMessages();
649
+ return;
650
+ }
651
+
652
+ if (data.type === "new_post" && data.message) {
653
+ allMessages.push(data.message);
654
+ // Keep only last 50 on client too (server already does, but this keeps UI stable)
655
+ if (allMessages.length > 50) allMessages = allMessages.slice(allMessages.length - 50);
656
+ appendMessageIfVisible(data.message);
657
+ return;
658
+ }
659
+
660
+ if (data.type === "file_content") {
661
+ if (data.error) {
662
+ alert("Error loading file: " + data.error);
663
+ } else if (data.content !== undefined) {
664
+ displayFileContent(data.content, data.title || "File", data.file_type || "txt");
665
+ }
666
+ return;
667
+ }
668
+
669
+ if (data.type === "error") {
670
+ alert(data.error || "Error from server");
671
+ return;
672
+ }
673
+
674
+ // Ignore ping
675
+ };
676
+
677
+ socket.onclose = () => {
678
+ socket = null;
679
+ connectBtn.textContent = "Connect";
680
+ setStatus("disconnected", "Disconnected");
681
+
682
+ if (!manualDisconnect) {
683
+ setStatus("connecting", "Reconnecting...");
684
+ setTimeout(() => connectSocket(), reconnectDelay);
685
+ reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay);
686
+ }
687
+ };
688
+
689
+ socket.onerror = () => {
690
+ // onclose will handle UI
691
+ };
692
+ }
693
+
694
+ connectBtn.addEventListener("click", () => {
695
+ if (socket) {
696
+ manualDisconnect = true;
697
+ socket.close();
698
+ socket = null;
699
+ setStatus("disconnected", "Disconnected");
700
+ connectBtn.textContent = "Connect";
701
+ return;
702
+ }
703
+ manualDisconnect = false;
704
+ connectSocket();
705
+ });
706
+
707
+ // Initial UI state
708
+ setStatus("disconnected", "Disconnected");
709
+ </script>
710
+ </body>
711
+ </html>
main.py ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, Header, HTTPException, File, UploadFile, Form
2
+ from fastapi.responses import HTMLResponse, JSONResponse
3
+ from fastapi.staticfiles import StaticFiles
4
+ from fastapi.encoders import jsonable_encoder
5
+
6
+ from uuid import uuid4
7
+ from datetime import datetime, timezone, timedelta
8
+ from typing import Optional
9
+ import os
10
+ import asyncio
11
+ import base64
12
+ import json
13
+
14
+ from .models import Message, PostRequest, HistoryPayload, NewPostPayload, ErrorPayload, FileContentPayload
15
+ from .storage import messages, connected_readers, storage_lock
16
+ from .rate_limit import (
17
+ is_ip_blocked,
18
+ record_auth_failure,
19
+ record_auth_success,
20
+ get_client_ip,
21
+ )
22
+ from .logger_config import logger
23
+
24
+ POSTER_KEY = os.getenv("POSTER_KEY", "")
25
+ READER_KEY = os.getenv("READER_KEY", "")
26
+
27
+ app = FastAPI()
28
+
29
+ # Static HTML (root)
30
+ app.mount("/static", StaticFiles(directory="app/static"), name="static")
31
+
32
+
33
+ @app.get("/", response_class=HTMLResponse)
34
+ async def root():
35
+ with open("app/static/index.html", "r", encoding="utf-8") as f:
36
+ return f.read()
37
+
38
+
39
+ @app.get("/health")
40
+ async def health():
41
+ async with storage_lock:
42
+ message_count = len(messages)
43
+ connected = len(connected_readers)
44
+ return {
45
+ "status": "healthy",
46
+ "message_count": message_count,
47
+ "connected_readers": connected,
48
+ }
49
+
50
+
51
+ @app.post("/api/v1/posts")
52
+ async def create_post(
53
+ request: Request,
54
+ payload: PostRequest,
55
+ x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
56
+ ):
57
+ client_ip = get_client_ip(request)
58
+
59
+ # 1) IP block check
60
+ if is_ip_blocked(client_ip):
61
+ logger.warning(
62
+ f"{datetime.utcnow().isoformat()} - AUTH_FAILURE - IP: {client_ip} - Reason: blocked_ip - Endpoint: /api/v1/posts"
63
+ )
64
+ return JSONResponse(
65
+ status_code=429,
66
+ content={"error": "Too many failed authentication attempts"},
67
+ )
68
+
69
+ # 2) API key validation
70
+ if not x_api_key or x_api_key != POSTER_KEY:
71
+ record_auth_failure(client_ip)
72
+ logger.warning(
73
+ f"{datetime.utcnow().isoformat()} - AUTH_FAILURE - IP: {client_ip} - Reason: invalid_poster_key - Endpoint: /api/v1/posts"
74
+ )
75
+ raise HTTPException(status_code=401, detail="Invalid API key")
76
+
77
+ # 3) Defensive length check (pydantic also enforces this)
78
+ if len(payload.content) > 1000:
79
+ raise HTTPException(
80
+ status_code=413, detail="Content exceeds 1000 character limit"
81
+ )
82
+
83
+ now = datetime.now(timezone.utc)
84
+ msg = Message(
85
+ id=str(uuid4()),
86
+ poster_id=payload.poster_id,
87
+ content=payload.content,
88
+ timestamp=now,
89
+ category=payload.category,
90
+ metadata=payload.metadata or {},
91
+ )
92
+
93
+ async with storage_lock:
94
+ messages.append(msg)
95
+
96
+ # Retention: expire >48h (50-message maxlen still takes precedence)
97
+ cutoff = now - timedelta(hours=48)
98
+ tmp = [m for m in messages if m.timestamp >= cutoff]
99
+ messages.clear()
100
+ for m in tmp:
101
+ messages.append(m)
102
+
103
+ # Broadcast to all connected readers (JSON-safe)
104
+ payload_out = jsonable_encoder(NewPostPayload(type="new_post", message=msg))
105
+ stale = []
106
+ for ws in connected_readers:
107
+ try:
108
+ await ws.send_json(payload_out)
109
+ except Exception:
110
+ stale.append(ws)
111
+ for ws in stale:
112
+ connected_readers.discard(ws)
113
+
114
+ logger.info(
115
+ f"{datetime.utcnow().isoformat()} - AUTH_SUCCESS - IP: {client_ip} - UserType: poster - Action: post - PosterID: {payload.poster_id}"
116
+ )
117
+ record_auth_success(client_ip)
118
+
119
+ return JSONResponse(
120
+ status_code=201,
121
+ content={
122
+ "message_id": msg.id,
123
+ "status": "accepted",
124
+ "timestamp": msg.timestamp.isoformat(),
125
+ },
126
+ )
127
+
128
+
129
+ @app.post("/api/v1/upload")
130
+ async def upload_file(
131
+ request: Request,
132
+ poster_id: str = Form(...),
133
+ category: Optional[str] = Form(default=None),
134
+ file: UploadFile = File(...),
135
+ x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
136
+ ):
137
+ client_ip = get_client_ip(request)
138
+
139
+ # 1) IP block check
140
+ if is_ip_blocked(client_ip):
141
+ logger.warning(
142
+ f"{datetime.utcnow().isoformat()} - AUTH_FAILURE - IP: {client_ip} - Reason: blocked_ip - Endpoint: /api/v1/upload"
143
+ )
144
+ return JSONResponse(
145
+ status_code=429,
146
+ content={"error": "Too many failed authentication attempts"},
147
+ )
148
+
149
+ # 2) API key validation
150
+ if not x_api_key or x_api_key != POSTER_KEY:
151
+ record_auth_failure(client_ip)
152
+ logger.warning(
153
+ f"{datetime.utcnow().isoformat()} - AUTH_FAILURE - IP: {client_ip} - Reason: invalid_poster_key - Endpoint: /api/v1/upload"
154
+ )
155
+ raise HTTPException(status_code=401, detail="Invalid API key")
156
+
157
+ # 3) Validate file extension and MIME type
158
+ filename = file.filename or "unknown"
159
+ file_ext = os.path.splitext(filename)[1].lower()
160
+
161
+ allowed_extensions = {".png", ".md", ".txt"}
162
+ if file_ext not in allowed_extensions:
163
+ raise HTTPException(status_code=400, detail="Only PNG, MD, and TXT files are allowed")
164
+
165
+ # MIME type validation
166
+ mime_type = file.content_type or ""
167
+ valid_mime_types = {
168
+ ".png": ["image/png"],
169
+ ".md": ["text/markdown", "text/plain", "application/octet-stream"],
170
+ ".txt": ["text/plain", "application/octet-stream"],
171
+ }
172
+
173
+ if mime_type and mime_type not in valid_mime_types.get(file_ext, []):
174
+ # Allow if no content-type provided, otherwise validate
175
+ pass # Be lenient with MIME types as they can vary
176
+
177
+ # 4) Read file content and validate size
178
+ content = await file.read()
179
+ file_size = len(content)
180
+
181
+ if file_ext == ".png":
182
+ if file_size > 2 * 1024 * 1024: # 2MB
183
+ raise HTTPException(status_code=413, detail="PNG file exceeds 2MB limit")
184
+ else: # .md or .txt
185
+ if file_size > 100 * 1024: # 100KB
186
+ raise HTTPException(status_code=413, detail="Text file exceeds 100KB limit")
187
+
188
+ # 5) Process file based on type
189
+ now = datetime.now(timezone.utc)
190
+ msg_id = str(uuid4())
191
+
192
+ if file_ext == ".png":
193
+ # Store PNG as base64 data URL
194
+ base64_data = base64.b64encode(content).decode("utf-8")
195
+ file_url = f"data:image/png;base64,{base64_data}"
196
+ msg = Message(
197
+ id=msg_id,
198
+ poster_id=poster_id,
199
+ content=filename,
200
+ timestamp=now,
201
+ category=category,
202
+ metadata={},
203
+ message_type="png",
204
+ file_url=file_url,
205
+ title=filename,
206
+ )
207
+ elif file_ext == ".md":
208
+ # Extract first line as title, store full content
209
+ text_content = content.decode("utf-8", errors="replace")
210
+ first_line = text_content.split("\n")[0].strip() or filename
211
+ msg = Message(
212
+ id=msg_id,
213
+ poster_id=poster_id,
214
+ content=filename,
215
+ timestamp=now,
216
+ category=category,
217
+ metadata={},
218
+ message_type="md",
219
+ title=first_line,
220
+ file_content=text_content,
221
+ )
222
+ else: # .txt
223
+ # Extract first line as title, store full content
224
+ text_content = content.decode("utf-8", errors="replace")
225
+ first_line = text_content.split("\n")[0].strip() or filename
226
+ msg = Message(
227
+ id=msg_id,
228
+ poster_id=poster_id,
229
+ content=filename,
230
+ timestamp=now,
231
+ category=category,
232
+ metadata={},
233
+ message_type="txt",
234
+ title=first_line,
235
+ file_content=text_content,
236
+ )
237
+
238
+ # 6) Store message and broadcast (same as create_post)
239
+ async with storage_lock:
240
+ messages.append(msg)
241
+
242
+ # Retention: expire >48h (50-message maxlen still takes precedence)
243
+ cutoff = now - timedelta(hours=48)
244
+ tmp = [m for m in messages if m.timestamp >= cutoff]
245
+ messages.clear()
246
+ for m in tmp:
247
+ messages.append(m)
248
+
249
+ # Broadcast to all connected readers (JSON-safe)
250
+ payload_out = jsonable_encoder(NewPostPayload(type="new_post", message=msg))
251
+ stale = []
252
+ for ws in connected_readers:
253
+ try:
254
+ await ws.send_json(payload_out)
255
+ except Exception:
256
+ stale.append(ws)
257
+ for ws in stale:
258
+ connected_readers.discard(ws)
259
+
260
+ logger.info(
261
+ f"{datetime.utcnow().isoformat()} - AUTH_SUCCESS - IP: {client_ip} - UserType: poster - Action: upload - PosterID: {poster_id}"
262
+ )
263
+ record_auth_success(client_ip)
264
+
265
+ return JSONResponse(
266
+ status_code=201,
267
+ content={
268
+ "message_id": msg.id,
269
+ "status": "accepted",
270
+ "timestamp": msg.timestamp.isoformat(),
271
+ },
272
+ )
273
+
274
+
275
+ @app.websocket("/ws")
276
+ async def websocket_endpoint(websocket: WebSocket):
277
+ client_ip = websocket.client.host if websocket.client else "unknown"
278
+
279
+ # 1) IP block check
280
+ if is_ip_blocked(client_ip):
281
+ logger.warning(
282
+ f"{datetime.utcnow().isoformat()} - AUTH_FAILURE - IP: {client_ip} - Reason: blocked_ip - Endpoint: /ws"
283
+ )
284
+ await websocket.close(code=1008)
285
+ return
286
+
287
+ # 2) API key validation (query param)
288
+ api_key = websocket.query_params.get("api_key")
289
+ if not api_key or api_key != READER_KEY:
290
+ record_auth_failure(client_ip)
291
+ logger.warning(
292
+ f"{datetime.utcnow().isoformat()} - AUTH_FAILURE - IP: {client_ip} - Reason: invalid_reader_key - Endpoint: /ws"
293
+ )
294
+ await websocket.accept()
295
+ await websocket.send_json(
296
+ jsonable_encoder(ErrorPayload(type="error", error="Invalid API key"))
297
+ )
298
+ await websocket.close(code=1008)
299
+ return
300
+
301
+ await websocket.accept()
302
+ logger.info(
303
+ f"{datetime.utcnow().isoformat()} - AUTH_SUCCESS - IP: {client_ip} - UserType: reader - Action: connect"
304
+ )
305
+ record_auth_success(client_ip)
306
+
307
+ # Register + send initial history (JSON-safe)
308
+ async with storage_lock:
309
+ connected_readers.add(websocket)
310
+ history_payload = HistoryPayload(type="history", messages=list(messages))
311
+ await websocket.send_json(jsonable_encoder(history_payload))
312
+
313
+ try:
314
+ while True:
315
+ # Keepalive: wait for client message; if none, send ping
316
+ try:
317
+ data = await asyncio.wait_for(websocket.receive_text(), timeout=30.0)
318
+ # Handle file content request
319
+ try:
320
+ msg = json.loads(data)
321
+ if msg.get("type") == "get_file_content":
322
+ message_id = msg.get("message_id")
323
+ async with storage_lock:
324
+ target_msg = None
325
+ for m in messages:
326
+ if m.id == message_id:
327
+ target_msg = m
328
+ break
329
+
330
+ if target_msg is None:
331
+ await websocket.send_json(
332
+ jsonable_encoder(ErrorPayload(
333
+ type="error",
334
+ error="Message not found"
335
+ ))
336
+ )
337
+ elif target_msg.message_type not in ("md", "txt"):
338
+ await websocket.send_json(
339
+ jsonable_encoder(ErrorPayload(
340
+ type="error",
341
+ error="Invalid message type"
342
+ ))
343
+ )
344
+ else:
345
+ await websocket.send_json(
346
+ jsonable_encoder(FileContentPayload(
347
+ type="file_content",
348
+ message_id=message_id,
349
+ content=target_msg.file_content or "",
350
+ content_type=target_msg.message_type
351
+ ))
352
+ )
353
+ except json.JSONDecodeError:
354
+ # Ignore non-JSON messages (keepalive behavior)
355
+ pass
356
+ except asyncio.TimeoutError:
357
+ await websocket.send_json({"type": "ping"})
358
+ except WebSocketDisconnect:
359
+ pass
360
+ finally:
361
+ async with storage_lock:
362
+ connected_readers.discard(websocket)
models.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from datetime import datetime
3
+ from typing import Optional, Dict, Any, List, Literal
4
+
5
+
6
+ class PostRequest(BaseModel):
7
+ poster_id: str = Field(..., min_length=1)
8
+ content: str = Field(..., min_length=1, max_length=1000)
9
+ category: Optional[str] = None
10
+ metadata: Optional[Dict[str, Any]] = None
11
+
12
+
13
+ class Message(BaseModel):
14
+ id: str
15
+ poster_id: str
16
+ content: str
17
+ timestamp: datetime
18
+ category: Optional[str] = None
19
+ metadata: Dict[str, Any] = {}
20
+ # File-related fields
21
+ message_type: Literal["text", "png", "md", "txt"] = "text"
22
+ file_url: Optional[str] = None
23
+ title: Optional[str] = None
24
+ file_content: Optional[str] = None # For MD/TXT - stored but not shown in list
25
+
26
+
27
+ class HistoryPayload(BaseModel):
28
+ type: str = "history"
29
+ messages: List[Message]
30
+
31
+
32
+ class NewPostPayload(BaseModel):
33
+ type: str = "new_post"
34
+ message: Message
35
+
36
+
37
+ class ErrorPayload(BaseModel):
38
+ type: str = "error"
39
+ error: str
40
+
41
+
42
+ class FileContentPayload(BaseModel):
43
+ type: str = "file_content"
44
+ message_id: str
45
+ content: str
46
+ content_type: Literal["md", "txt"]
requirements.txt CHANGED
@@ -1,4 +1,6 @@
1
- fastapi==0.104.1
2
- uvicorn[standard]==0.24.0
3
- websockets==12.0
4
- python-dotenv==1.0.0
 
 
 
1
+ fastapi==0.104.1
2
+ uvicorn[standard]==0.24.0
3
+ websockets==12.0
4
+ python-dotenv==1.0.0
5
+ python-multipart==0.0.19
6
+ markdown==3.7