From: Roberto Morado Date: Mon, 10 Nov 2025 22:48:41 +0000 (-0500) Subject: Initial commit X-Git-Url: https://git.morado.dev/url?a=commitdiff_plain;h=6ab52a9d47df2bb59ff7500d8dc1bd2e4dc924bd;p=ama.twowitnessproject.org Initial commit --- 6ab52a9d47df2bb59ff7500d8dc1bd2e4dc924bd diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1bed868 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/storage \ No newline at end of file diff --git a/main.tsx b/main.tsx new file mode 100644 index 0000000..96a559b --- /dev/null +++ b/main.tsx @@ -0,0 +1,2372 @@ +// deno-lint-ignore-file +/** @jsxImportSource https://esm.sh/hono@4.7/jsx */ +import { Hono } from "https://esm.sh/hono@4.7"; +import { + deleteCookie, + getCookie, + setCookie, +} from "https://esm.sh/hono@4.7/cookie"; +import * as bcrypt from "https://deno.land/x/bcrypt@v0.4.1/mod.ts"; + +// ============================================================================ +// PHASE 1: CORE STRUCTURE & FOUNDATION +// ============================================================================ + +// ============================================================================ +// CONFIGURATION & CONSTANTS +// ============================================================================ + +const CONFIG = { + SESSION_EXPIRY_DAYS: 7, + SESSION_COOKIE_NAME: "ama_session", + DATABASE_DIRECTORY: "./storage", + WEBHOOK_RETRY_ATTEMPTS: 3, + WEBHOOK_RETRY_DELAYS: [0, 5000, 25000], // milliseconds + CHARACTER_LIMITS: { + QUESTION: 500, + ANSWER: 5000, + WEBHOOK_URL: 500, + }, + LIKE_COOLDOWN_HOURS: 24, + ACCENT_COLOR: "#3B82F6", // Soft blue +} as const; + +const CRON_SCHEDULES = { + CLEANUP_SESSIONS: "0 3 * * *", // Daily at 3 AM + CLEANUP_FAILED_WEBHOOKS: "0 4 * * 1", // Weekly Sunday at 4 AM +} as const; + +// ============================================================================ +// TYPE DEFINITIONS +// ============================================================================ + +interface User { + username: string; + passwordHash: string; +} + +interface Session { + username: string; + createdAt: number; + expiresAt: number; +} + +interface Question { + id: string; + question: string; + questionLikes: number; + answer: string | null; + answerLikes: number; + createdAt: number; + answeredAt: number | null; + questionLiked?: boolean; + answerLiked?: boolean; +} + +interface FailedWebhook { + questionId: string; + question: string; + attempts: number; + lastError: string; + createdAt: number; +} + +// ============================================================================ +// DATABASE LAYER (Repository Pattern) +// ============================================================================ + +class KVRepository { + private kv: Deno.Kv; + + constructor(kv: Deno.Kv) { + this.kv = kv; + } + + // User operations + async getUserByUsername(username: string): Promise { + const result = await this.kv.get(["users", username]); + return result.value; + } + + // Session operations + async createSession(sessionId: string, session: Session): Promise { + await this.kv.set(["sessions", sessionId], session); + } + + async getSession(sessionId: string): Promise { + const result = await this.kv.get(["sessions", sessionId]); + return result.value; + } + + async deleteSession(sessionId: string): Promise { + await this.kv.delete(["sessions", sessionId]); + } + + async deleteExpiredSessions(): Promise { + const now = Date.now(); + let deletedCount = 0; + + const sessions = this.kv.list({ prefix: ["sessions"] }); + for await (const entry of sessions) { + if (entry.value.expiresAt < now) { + await this.kv.delete(entry.key); + deletedCount++; + } + } + + return deletedCount; + } + + // Question operations + async createQuestion(question: Question): Promise { + const engagement = this.calculateEngagement(question); + + await this.kv.atomic() + .set(["questions", question.id], question) + .set([ + "questions_by_engagement", + engagement, + question.createdAt, + question.id, + ], question.id) + .commit(); + } + + async getQuestion(questionId: string): Promise { + const result = await this.kv.get(["questions", questionId]); + return result.value; + } + + async getAllQuestionsSortedByEngagement(): Promise { + const questions: Question[] = []; + + // List in reverse order (highest engagement first, then newest) + const entries = this.kv.list({ + prefix: ["questions_by_engagement"], + }); + + for await (const entry of entries) { + const questionId = entry.value; + const question = await this.getQuestion(questionId); + if (question) { + questions.push(question); + } + } + + return questions; + } + + async updateQuestionWithAnswer( + questionId: string, + answer: string, + ): Promise { + const question = await this.getQuestion(questionId); + if (!question) return null; + + const oldEngagement = this.calculateEngagement(question); + + question.answer = answer; + question.answeredAt = Date.now(); + + const newEngagement = this.calculateEngagement(question); + + await this.kv.atomic() + .set(["questions", questionId], question) + .delete([ + "questions_by_engagement", + oldEngagement, + question.createdAt, + questionId, + ]) + .set([ + "questions_by_engagement", + newEngagement, + question.createdAt, + questionId, + ], questionId) + .commit(); + + return question; + } + + async incrementQuestionLikes(questionId: string): Promise { + const question = await this.getQuestion(questionId); + if (!question) return null; + + const oldEngagement = this.calculateEngagement(question); + + question.questionLikes++; + + const newEngagement = this.calculateEngagement(question); + + await this.kv.atomic() + .set(["questions", questionId], question) + .delete([ + "questions_by_engagement", + oldEngagement, + question.createdAt, + questionId, + ]) + .set([ + "questions_by_engagement", + newEngagement, + question.createdAt, + questionId, + ], questionId) + .commit(); + + return question; + } + + async incrementAnswerLikes(questionId: string): Promise { + const question = await this.getQuestion(questionId); + if (!question || !question.answer) return null; + + const oldEngagement = this.calculateEngagement(question); + + question.answerLikes++; + + const newEngagement = this.calculateEngagement(question); + + await this.kv.atomic() + .set(["questions", questionId], question) + .delete([ + "questions_by_engagement", + oldEngagement, + question.createdAt, + questionId, + ]) + .set([ + "questions_by_engagement", + newEngagement, + question.createdAt, + questionId, + ], questionId) + .commit(); + + return question; + } + + async decrementQuestionLikes(questionId: string): Promise { + const question = await this.getQuestion(questionId); + if (!question || question.questionLikes <= 0) return null; + + const oldEngagement = this.calculateEngagement(question); + + question.questionLikes--; + + const newEngagement = this.calculateEngagement(question); + + await this.kv.atomic() + .set(["questions", questionId], question) + .delete([ + "questions_by_engagement", + oldEngagement, + question.createdAt, + questionId, + ]) + .set([ + "questions_by_engagement", + newEngagement, + question.createdAt, + questionId, + ], questionId) + .commit(); + + return question; + } + + async decrementAnswerLikes(questionId: string): Promise { + const question = await this.getQuestion(questionId); + if (!question || !question.answer || question.answerLikes <= 0) return null; + + const oldEngagement = this.calculateEngagement(question); + + question.answerLikes--; + + const newEngagement = this.calculateEngagement(question); + + await this.kv.atomic() + .set(["questions", questionId], question) + .delete([ + "questions_by_engagement", + oldEngagement, + question.createdAt, + questionId, + ]) + .set([ + "questions_by_engagement", + newEngagement, + question.createdAt, + questionId, + ], questionId) + .commit(); + + return question; + } + + async deleteQuestionLike(questionId: string, ipHash: string): Promise { + await this.kv.delete(["question_likes", questionId, ipHash]); + } + + async deleteAnswerLike(questionId: string, ipHash: string): Promise { + await this.kv.delete(["answer_likes", questionId, ipHash]); + } + + async bulkDeleteQuestions(questionIds: string[]): Promise { + for (const questionId of questionIds) { + const question = await this.getQuestion(questionId); + if (question) { + const engagement = this.calculateEngagement(question); + + await this.kv.atomic() + .delete(["questions", questionId]) + .delete([ + "questions_by_engagement", + engagement, + question.createdAt, + questionId, + ]) + .commit(); + } + } + } + + // Like tracking operations + async hasUserLikedQuestion( + questionId: string, + ipHash: string, + ): Promise { + const result = await this.kv.get(["question_likes", questionId, ipHash]); + + if (!result.value) return false; + + const likedAt = result.value as number; + const cooldownMs = CONFIG.LIKE_COOLDOWN_HOURS * 60 * 60 * 1000; + + return Date.now() - likedAt < cooldownMs; + } + + async hasUserLikedAnswer( + questionId: string, + ipHash: string, + ): Promise { + const result = await this.kv.get(["answer_likes", questionId, ipHash]); + + if (!result.value) return false; + + const likedAt = result.value as number; + const cooldownMs = CONFIG.LIKE_COOLDOWN_HOURS * 60 * 60 * 1000; + + return Date.now() - likedAt < cooldownMs; + } + + async recordQuestionLike(questionId: string, ipHash: string): Promise { + await this.kv.set(["question_likes", questionId, ipHash], Date.now()); + } + + async recordAnswerLike(questionId: string, ipHash: string): Promise { + await this.kv.set(["answer_likes", questionId, ipHash], Date.now()); + } + + // Config operations + async getWebhookUrl(): Promise { + const result = await this.kv.get(["config", "webhook_url"]); + return result.value; + } + + async setWebhookUrl(url: string): Promise { + await this.kv.set(["config", "webhook_url"], url); + } + + // Failed webhook operations + async saveFailedWebhook(webhook: FailedWebhook): Promise { + await this.kv.set( + ["failed_webhooks", webhook.createdAt, webhook.questionId], + webhook, + ); + } + + async getAllFailedWebhooks(): Promise { + const webhooks: FailedWebhook[] = []; + + const entries = this.kv.list({ + prefix: ["failed_webhooks"], + }); + + for await (const entry of entries) { + webhooks.push(entry.value); + } + + return webhooks; + } + + async deleteFailedWebhook( + timestamp: number, + questionId: string, + ): Promise { + await this.kv.delete(["failed_webhooks", timestamp, questionId]); + } + + async deleteOldFailedWebhooks(daysOld: number): Promise { + const cutoffTime = Date.now() - (daysOld * 24 * 60 * 60 * 1000); + let deletedCount = 0; + + const webhooks = this.kv.list({ + prefix: ["failed_webhooks"], + }); + for await (const entry of webhooks) { + if (entry.value.createdAt < cutoffTime) { + await this.kv.delete(entry.key); + deletedCount++; + } + } + + return deletedCount; + } + + // Helper: Calculate engagement score for sorting + private calculateEngagement(question: Question): number { + // Weight answer likes slightly more (1.5x) to promote quality content + // Multiply by 10 to avoid floating point issues with KV keys + // Result: questionLikes * 10 + answerLikes * 15 + return (question.questionLikes * 10) + (question.answerLikes * 15); + } +} + +// ============================================================================ +// SERVICES LAYER (Business Logic) +// ============================================================================ + +class AuthenticationService { + constructor(private repository: KVRepository) {} + + async authenticateUser(username: string, password: string): Promise { + const user = await this.repository.getUserByUsername(username); + + if (!user) return false; + + return await bcrypt.compare(password, user.passwordHash); + } + + async createUserSession(username: string): Promise { + const sessionId = crypto.randomUUID(); + const now = Date.now(); + const expiryMs = CONFIG.SESSION_EXPIRY_DAYS * 24 * 60 * 60 * 1000; + + const session: Session = { + username, + createdAt: now, + expiresAt: now + expiryMs, + }; + + await this.repository.createSession(sessionId, session); + + return sessionId; + } + + async validateSession(sessionId: string): Promise { + const session = await this.repository.getSession(sessionId); + + if (!session) return null; + + if (session.expiresAt < Date.now()) { + await this.repository.deleteSession(sessionId); + return null; + } + + return session.username; + } + + async destroySession(sessionId: string): Promise { + await this.repository.deleteSession(sessionId); + } +} + +class QuestionService { + constructor(private repository: KVRepository) {} + + async createQuestion(questionText: string): Promise { + this.validateQuestionLength(questionText); + + const question: Question = { + id: crypto.randomUUID(), + question: questionText.trim(), + questionLikes: 0, + answer: null, + answerLikes: 0, + createdAt: Date.now(), + answeredAt: null, + }; + + await this.repository.createQuestion(question); + + return question; + } + + async getAllQuestions(): Promise { + return await this.repository.getAllQuestionsSortedByEngagement(); + } + + async getQuestionById(questionId: string): Promise { + return await this.repository.getQuestion(questionId); + } + + async answerQuestion( + questionId: string, + answerText: string, + ): Promise { + this.validateAnswerLength(answerText); + + return await this.repository.updateQuestionWithAnswer( + questionId, + answerText.trim(), + ); + } + + async deleteQuestions(questionIds: string[]): Promise { + await this.repository.bulkDeleteQuestions(questionIds); + } + + private validateQuestionLength(text: string): void { + if (text.length > CONFIG.CHARACTER_LIMITS.QUESTION) { + throw new Error( + `Question exceeds maximum length of ${CONFIG.CHARACTER_LIMITS.QUESTION} characters`, + ); + } + } + + private validateAnswerLength(text: string): void { + if (text.length > CONFIG.CHARACTER_LIMITS.ANSWER) { + throw new Error( + `Answer exceeds maximum length of ${CONFIG.CHARACTER_LIMITS.ANSWER} characters`, + ); + } + } +} + +class LikeService { + constructor(private repository: KVRepository) {} + + async toggleQuestionLike( + questionId: string, + ipAddress: string, + ): Promise<{ question: Question; liked: boolean } | null> { + const ipHash = await this.hashIpAddress(ipAddress); + + const hasLiked = await this.repository.hasUserLikedQuestion( + questionId, + ipHash, + ); + + if (hasLiked) { + // Unlike: remove like and decrement count + await this.repository.deleteQuestionLike(questionId, ipHash); + const question = await this.repository.decrementQuestionLikes(questionId); + return question ? { question, liked: false } : null; + } else { + // Like: add like and increment count + await this.repository.recordQuestionLike(questionId, ipHash); + const question = await this.repository.incrementQuestionLikes(questionId); + return question ? { question, liked: true } : null; + } + } + + async toggleAnswerLike( + questionId: string, + ipAddress: string, + ): Promise<{ question: Question; liked: boolean } | null> { + const question = await this.repository.getQuestion(questionId); + + if (!question?.answer) { + return null; + } + + const ipHash = await this.hashIpAddress(ipAddress); + + const hasLiked = await this.repository.hasUserLikedAnswer( + questionId, + ipHash, + ); + + if (hasLiked) { + // Unlike: remove like and decrement count + await this.repository.deleteAnswerLike(questionId, ipHash); + const updatedQuestion = await this.repository.decrementAnswerLikes( + questionId, + ); + return updatedQuestion + ? { question: updatedQuestion, liked: false } + : null; + } else { + // Like: add like and increment count + await this.repository.recordAnswerLike(questionId, ipHash); + const updatedQuestion = await this.repository.incrementAnswerLikes( + questionId, + ); + return updatedQuestion + ? { question: updatedQuestion, liked: true } + : null; + } + } + + async checkQuestionLikeStatus( + questionId: string, + ipAddress: string, + ): Promise { + const ipHash = await this.hashIpAddress(ipAddress); + return await this.repository.hasUserLikedQuestion(questionId, ipHash); + } + + async checkAnswerLikeStatus( + questionId: string, + ipAddress: string, + ): Promise { + const ipHash = await this.hashIpAddress(ipAddress); + return await this.repository.hasUserLikedAnswer(questionId, ipHash); + } + + private async hashIpAddress(ipAddress: string): Promise { + const salt = Deno.env.get("IP_HASH_SALT") || "default-salt"; + const data = new TextEncoder().encode(ipAddress + salt); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); + } +} + +class WebhookService { + constructor(private repository: KVRepository) {} + + async notifyNewQuestion(question: Question): Promise { + const webhookUrl = await this.repository.getWebhookUrl(); + + if (!webhookUrl) { + console.log("No webhook URL configured, skipping notification"); + return; + } + + const success = await this.sendWebhookWithRetry(webhookUrl, question); + + if (!success) { + await this.saveFailedWebhook(question, "All retry attempts failed"); + } + } + + async retryFailedWebhook( + timestamp: number, + questionId: string, + ): Promise { + const webhooks = await this.repository.getAllFailedWebhooks(); + const webhook = webhooks.find( + (w) => w.createdAt === timestamp && w.questionId === questionId, + ); + + if (!webhook) return false; + + const webhookUrl = await this.repository.getWebhookUrl(); + if (!webhookUrl) return false; + + const question = await this.repository.getQuestion(questionId); + if (!question) return false; + + const success = await this.sendWebhookWithRetry(webhookUrl, question); + + if (success) { + await this.repository.deleteFailedWebhook(timestamp, questionId); + } + + return success; + } + + private async sendWebhookWithRetry( + url: string, + question: Question, + ): Promise { + const payload = this.buildDiscordPayload(question); + + for (let attempt = 0; attempt < CONFIG.WEBHOOK_RETRY_ATTEMPTS; attempt++) { + if (attempt > 0) { + await this.sleep(CONFIG.WEBHOOK_RETRY_DELAYS[attempt]); + } + + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (response.ok) { + return true; + } + + console.error( + `Webhook attempt ${ + attempt + 1 + } failed with status ${response.status}`, + ); + } catch (error) { + console.error(`Webhook attempt ${attempt + 1} error:`, error); + } + } + + return false; + } + + private buildDiscordPayload(question: Question) { + const appUrl = Deno.env.get("APP_URL") || "http://localhost:8000"; + const answerUrl = `${appUrl}/question/${question.id}`; + + return { + embeds: [{ + title: "🆕 New Question", + description: `${question.question}\n\n[answer here](${answerUrl})`, + color: 3447003, // Blue + timestamp: new Date(question.createdAt).toISOString(), + }], + }; + } + + private async saveFailedWebhook( + question: Question, + error: string, + ): Promise { + const failedWebhook: FailedWebhook = { + questionId: question.id, + question: question.question, + attempts: CONFIG.WEBHOOK_RETRY_ATTEMPTS, + lastError: error, + createdAt: Date.now(), + }; + + await this.repository.saveFailedWebhook(failedWebhook); + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +// ============================================================================ +// MIDDLEWARE +// ============================================================================ + +function authMiddleware(authService: AuthenticationService) { + return async (c: any, next: () => Promise) => { + const sessionId = getCookie(c, CONFIG.SESSION_COOKIE_NAME); + + if (!sessionId) { + // Capture the current path to redirect back after login + const redirectTo = encodeURIComponent(c.req.path); + return c.redirect(`/login?redirect=${redirectTo}`); + } + + const username = await authService.validateSession(sessionId); + + if (!username) { + deleteCookie(c, CONFIG.SESSION_COOKIE_NAME); + // Capture the current path to redirect back after login + const redirectTo = encodeURIComponent(c.req.path); + return c.redirect(`/login?redirect=${redirectTo}`); + } + + await next(); + }; +} + +function extractIpAddress(c: any): string { + return c.req.header("x-forwarded-for")?.split(",")[0].trim() || + c.req.header("x-real-ip") || + "unknown"; +} + +// ============================================================================ +// UI COMPONENTS (Atomic Design) +// ============================================================================ + +// Atoms +const Button = ({ type = "button", children, ...props }: any) => ( + +); + +const Input = (props: any) => ; + +const Textarea = (props: any) => + +
+ + Cancel +
+ + + , + ); + } + + async handleSubmitAnswer(c: any) { + try { + const questionId = c.req.param("id"); + const formData = await c.req.formData(); + const answerText = formData.get("answer") as string; + + if (!answerText?.trim()) { + return c.redirect(`/question/${questionId}?error=empty`); + } + + const question = await this.questionService.answerQuestion( + questionId, + answerText, + ); + + if (!question) { + return c.redirect("/?error=notfound"); + } + + return c.redirect("/?success=answered"); + } catch (error) { + console.error("Submit answer error:", error); + + if ( + error instanceof Error && + error.message.includes("exceeds maximum length") + ) { + return c.redirect(`/question/${c.req.param("id")}?error=toolong`); + } + + return c.redirect(`/question/${c.req.param("id")}?error=failed`); + } + } + + async renderSettingsPage(c: any) { + const webhookUrl = await this.repository.getWebhookUrl() || ""; + const failedWebhooks = await this.repository.getAllFailedWebhooks(); + const success = c.req.query("success"); + const error = c.req.query("error"); + + return c.html( + +
+ + + {success === "true" && ( +
+ Settings saved successfully! +
+ )} + {success === "retry" && ( +
+ Webhook retry successful! +
+ )} + {error === "retry" && ( +
+ Webhook retry failed. Please try again. +
+ )} + {error === "failed" && ( +
+ Failed to save settings. Please try again. +
+ )} + +
+

Webhook Configuration

+

+ Enter your Discord webhook URL to receive notifications when new + questions are submitted. +

+
+ + + +
+
+ +
+

Failed Webhooks ({failedWebhooks.length})

+ + {failedWebhooks.length === 0 + ?

No failed webhooks

+ : ( + failedWebhooks.map((webhook) => { + const date = new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(webhook.createdAt)); + + return ( +
+
{webhook.question}
+
+ Failed after {webhook.attempts} attempts | {date} +
+
+ Error: {webhook.lastError} +
+
+ +
+
+ ); + }) + )} +
+
+
, + ); + } + + async handleSaveWebhookUrl(c: any) { + try { + const formData = await c.req.formData(); + const webhookUrl = formData.get("webhookUrl") as string; + + if (webhookUrl && webhookUrl.trim()) { + await this.repository.setWebhookUrl(webhookUrl.trim()); + } + + return c.redirect("/settings?success=true"); + } catch (error) { + console.error("Save webhook error:", error); + return c.redirect("/settings?error=failed"); + } + } + + async handleRetryWebhook(c: any) { + try { + const timestamp = parseInt(c.req.param("timestamp")); + const questionId = c.req.param("questionId"); + + const success = await this.webhookService.retryFailedWebhook( + timestamp, + questionId, + ); + + if (success) { + return c.redirect("/settings?success=retry"); + } else { + return c.redirect("/settings?error=retry"); + } + } catch (error) { + console.error("Retry webhook error:", error); + return c.redirect("/settings?error=failed"); + } + } + + // ============================================================================ +} + +// ============================================================================ +// APPLICATION INITIALIZATION +// ============================================================================ + +async function initializeApp() { + // Initialize Deno KV + await Deno.mkdir(CONFIG.DATABASE_DIRECTORY, { recursive: true }); + const kv = await Deno.openKv(`${CONFIG.DATABASE_DIRECTORY}/db`); + + // Initialize repositories and services + const repository = new KVRepository(kv); + const authService = new AuthenticationService(repository); + const questionService = new QuestionService(repository); + const likeService = new LikeService(repository); + const webhookService = new WebhookService(repository); + + const routeHandlers = new RouteHandlers( + questionService, + likeService, + authService, + webhookService, + repository, + ); + + // Create Hono app + const app = new Hono(); + + const requireAuth = authMiddleware(authService); + + // ============================================================================ + // PHASE 2: PUBLIC ROUTES + // ============================================================================ + app.get("/", (c) => routeHandlers.renderHomepage(c)); + app.post("/", (c) => routeHandlers.submitQuestion(c)); + app.post("/api/like/question/:id", (c) => routeHandlers.likeQuestion(c)); + app.post("/api/like/answer/:id", (c) => routeHandlers.likeAnswer(c)); + app.get("/about", (c) => routeHandlers.renderAboutPage(c)); + + // ============================================================================ + // PHASE 3: ADMIN ROUTES + // ============================================================================ + app.get("/login", (c) => routeHandlers.renderLoginPage(c)); + app.post("/login", (c) => routeHandlers.handleLogin(c)); + app.get("/logout", (c) => routeHandlers.handleLogout(c)); + app.get( + "/", + requireAuth, + (c) => routeHandlers.renderAdminDashboard(c), + ); + app.post( + "/bulk-delete", + requireAuth, + (c) => routeHandlers.handleBulkDelete(c), + ); + app.get( + "/question/:id", + requireAuth, + (c) => routeHandlers.renderAnswerForm(c), + ); + app.post( + "/question/:id", + requireAuth, + (c) => routeHandlers.handleSubmitAnswer(c), + ); + app.get( + "/settings", + requireAuth, + (c) => routeHandlers.renderSettingsPage(c), + ); + app.post( + "/settings/webhook", + requireAuth, + (c) => routeHandlers.handleSaveWebhookUrl(c), + ); + app.post( + "/settings/retry/:timestamp/:questionId", + requireAuth, + (c) => routeHandlers.handleRetryWebhook(c), + ); + // ============================================================================ + + // ============================================================================ + // PHASE 4: CRON JOBS GO HERE + // ============================================================================ + Deno.cron("cleanup_sessions", CRON_SCHEDULES.CLEANUP_SESSIONS, async () => { + console.log("🧹 Running session cleanup..."); + try { + const deleted = await repository.deleteExpiredSessions(); + console.log(`✅ Cleaned up ${deleted} expired sessions`); + } catch (error) { + console.error("❌ Session cleanup failed:", error); + } + }); + + Deno.cron( + "cleanup_failed_webhooks", + CRON_SCHEDULES.CLEANUP_FAILED_WEBHOOKS, + async () => { + console.log("🧹 Running failed webhook cleanup..."); + try { + const deleted = await repository.deleteOldFailedWebhooks(30); + console.log(`✅ Cleaned up ${deleted} old failed webhooks (>30 days)`); + } catch (error) { + console.error("❌ Failed webhook cleanup failed:", error); + } + }, + ); + // ============================================================================ + + return app; +} + +// ============================================================================ +// SERVER START +// ============================================================================ + +if (import.meta.main) { + const app = await initializeApp(); + const port = parseInt(Deno.env.get("PORT") || "5959"); + + console.log(`🚀 AMA server starting on http://localhost:${port}`); + console.log(`✅ Phase 1 complete: Core structure initialized`); + console.log(`✅ Phase 2 complete: Public features active`); + console.log(`✅ Phase 3 complete: Admin features active`); + console.log(`✅ Phase 4 complete: Background tasks scheduled`); + console.log(`✅ Phase 5 complete: Enhancements applied`); + console.log(`📋 Main route:`); + console.log( + ` GET / - Homepage (public) or Dashboard (admin)`, + ); + console.log(` Public routes:`); + console.log(` POST / - Submit question`); + console.log(` POST /api/like/... - Toggle like on questions/answers`); + console.log(` Admin routes (require auth):`); + console.log(` GET /login - Admin login`); + console.log(` GET /question/:id - Answer/edit question`); + console.log(` GET /settings - Configure webhook`); + console.log(` GET /logout - Logout`); + console.log(`⏰ Cron jobs:`); + console.log(` Daily at 3 AM - Clean expired sessions`); + console.log(` Weekly Sunday at 4 AM - Clean old failed webhooks`); + console.log(`🎉 Application ready with unified routing!`); + + Deno.serve({ port }, app.fetch); +} diff --git a/users.ts b/users.ts new file mode 100644 index 0000000..1fc3f10 --- /dev/null +++ b/users.ts @@ -0,0 +1,197 @@ +// users.ts - Manage blog users from the terminal +// Examples: +// deno run --allow-read --allow-write --unstable-kv users.ts create +// deno run --allow-read --allow-write --unstable-kv users.ts list +// deno run --allow-read --allow-write --unstable-kv users.ts delete alice + +import * as bcrypt from "https://deno.land/x/bcrypt@v0.4.1/mod.ts"; + +interface User { + username: string; + passwordHash: string; + createdAt: number; +} + +const KV_PATH = "./storage/db"; + +async function prompt(message: string): Promise { + console.log(message); + const buf = new Uint8Array(1024); + const n = await Deno.stdin.read(buf); + return new TextDecoder().decode(buf.subarray(0, n || 0)).trim(); +} + +async function promptPassword(message: string): Promise { + // Note: This is a simple implementation. In production, you'd want to hide input + console.log(message); + const buf = new Uint8Array(1024); + const n = await Deno.stdin.read(buf); + return new TextDecoder().decode(buf.subarray(0, n || 0)).trim(); +} + +async function promptYesNo(message: string): Promise { + const answer = (await prompt(`${message} (y/N)`)).toLowerCase(); + return answer === "y" || answer === "yes"; +} + +function printHelp() { + console.log("Manage blog users stored in Deno KV.\n"); + console.log("Usage:"); + console.log( + " deno run --allow-read --allow-write --unstable-kv users.ts \n", + ); + console.log("Commands:"); + console.log(" create Create a new user (default)"); + console.log(" list List existing users with creation dates"); + console.log(" delete Delete a user after confirmation"); + console.log(" help Show this message"); +} + +async function createUser() { + console.log("=".repeat(50)); + console.log("Create New User for Minimalist Blog"); + console.log("=".repeat(50)); + console.log(); + + const username = await prompt("Enter username:"); + + if (!username || username.length < 3) { + console.error("❌ Username must be at least 3 characters long"); + Deno.exit(1); + } + + const kv = await Deno.openKv(KV_PATH); + + // Check if user already exists + const existingUser = await kv.get(["users", username]); + if (existingUser.value) { + console.error(`❌ User "${username}" already exists`); + kv.close(); + Deno.exit(1); + } + + const password = await promptPassword("Enter password:"); + + if (!password || password.length < 6) { + console.error("❌ Password must be at least 6 characters long"); + kv.close(); + Deno.exit(1); + } + + const confirmPassword = await promptPassword("Confirm password:"); + + if (password !== confirmPassword) { + console.error("❌ Passwords do not match"); + kv.close(); + Deno.exit(1); + } + + console.log(); + console.log("🔐 Hashing password..."); + + const passwordHash = await bcrypt.hash(password); + + const user: User = { + username, + passwordHash, + createdAt: Date.now(), + }; + + await kv.set(["users", username], user); + + console.log(); + console.log("✅ User created successfully!"); + console.log(` Username: ${username}`); + console.log(` Created: ${new Date(user.createdAt).toLocaleString()}`); + console.log(); + console.log("You can now login at http://localhost:8000/login"); + + kv.close(); +} + +async function listUsers() { + const kv = await Deno.openKv(KV_PATH); + const users: User[] = []; + + const entries = kv.list({ prefix: ["users"] }); + for await (const entry of entries) { + users.push(entry.value); + } + + kv.close(); + + if (users.length === 0) { + console.log("No users found in the database."); + return; + } + + users.sort((a, b) => a.username.localeCompare(b.username)); + console.log(`Found ${users.length} user(s):\n`); + console.log(`${"Username".padEnd(20)}Created`); + console.log("-".repeat(38)); + for (const user of users) { + const created = new Date(user.createdAt).toLocaleString(); + console.log(`${user.username.padEnd(20)}${created}`); + } +} + +async function deleteUser(username: string | undefined) { + if (!username) { + console.error("❌ Please provide a username to delete."); + Deno.exit(1); + } + + const kv = await Deno.openKv(KV_PATH); + const key = ["users", username]; + const result = await kv.get(key); + + if (!result.value) { + console.error(`❌ User \"${username}\" does not exist.`); + kv.close(); + Deno.exit(1); + } + + const confirm = await promptYesNo( + `Are you sure you want to delete user \"${username}\"?`, + ); + if (!confirm) { + console.log("Aborted."); + kv.close(); + return; + } + + await kv.delete(key); + kv.close(); + console.log(`🗑️ Deleted user \"${username}\".`); +} + +async function main() { + const [command, maybeArg] = Deno.args; + + switch (command) { + case "help": + case "--help": + case "-h": + printHelp(); + return; + case "list": + case "--list": + case "-l": + await listUsers(); + return; + case "delete": + case "--delete": + await deleteUser(maybeArg); + return; + case "create": + case undefined: + await createUser(); + return; + default: + console.error(`Unknown command: ${command}`); + printHelp(); + Deno.exit(1); + } +} + +await main();