diff options
| author | Bobby <[email protected]> | 2026-03-10 14:00:28 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-03-10 14:00:28 +0530 |
| commit | 03a9b16a0fd7cc5a293a1289646817e21663f4bd (patch) | |
| tree | 3d11cd3cd5c61c32e4db1e9a6d1df527353789bc | |
| parent | ed6c3bc61c02a5ca6998b39781dc60877f1cfe82 (diff) | |
| download | pagoda-03a9b16a0fd7cc5a293a1289646817e21663f4bd.tar.xz pagoda-03a9b16a0fd7cc5a293a1289646817e21663f4bd.zip | |
feat: implement date and month-day pickers, enhance user warning logging, and add IP ban management
- Added DatePicker and MonthDayPicker components for improved date selection in the UI.
- Introduced MiniEditor component for rich text editing capabilities.
- Enhanced warning logging in the warning service with detailed messages.
- Removed unused RegistrationIP field from AdminUserResponse.
- Improved error handling in various utility functions with descriptive messages.
- Implemented IP ban management with models, repositories, and a new council page for viewing and lifting bans.
- Added validation for jade amounts with a dedicated validator.
- Created CSS styles for date pickers to ensure consistent UI presentation.
41 files changed, 1728 insertions, 102 deletions
diff --git a/garden/src/api.ts b/garden/src/api.ts index 4fee22e..53766a9 100644 --- a/garden/src/api.ts +++ b/garden/src/api.ts @@ -29,7 +29,8 @@ export async function api<T>(path: string, options: APIOptions = {}): Promise<AP body: options.body ? JSON.stringify(options.body) : undefined, }); - const data = await response.json(); + const text = await response.text(); + const data = text ? JSON.parse(text) : null; return { ok: response.ok, status: response.status, data }; } @@ -44,7 +45,8 @@ export async function uploadFile<T>(path: string, file: File, token: string): Pr body: form, }); - const data = await response.json(); + const text = await response.text(); + const data = text ? JSON.parse(text) : null; return { ok: response.ok, status: response.status, data }; }
\ No newline at end of file diff --git a/garden/src/components/DatePicker.tsx b/garden/src/components/DatePicker.tsx new file mode 100644 index 0000000..8e6410d --- /dev/null +++ b/garden/src/components/DatePicker.tsx @@ -0,0 +1,150 @@ +import { createSignal, onMount, onCleanup, Show, For } from "solid-js"; + +interface DatePickerProps { + value: string; + onChange: (value: string) => void; +} + +const MONTHS = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +]; + +const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; + +function daysInMonth(year: number, month: number) { + return new Date(year, month + 1, 0).getDate(); +} + +function firstDayOfMonth(year: number, month: number) { + return new Date(year, month, 1).getDay(); +} + +function parseDate(value: string) { + if (!value) return null; + const parts = value.split("-"); + return { year: parseInt(parts[0]), month: parseInt(parts[1]) - 1, day: parseInt(parts[2]) }; +} + +function formatDate(year: number, month: number, day: number) { + return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + +export default function DatePicker(props: DatePickerProps) { + const parsed = () => parseDate(props.value); + const today = new Date(); + let triggerRef: HTMLButtonElement | undefined; + let containerRef: HTMLDivElement | undefined; + + const [open, setOpen] = createSignal(false); + const [dropdownStyle, setDropdownStyle] = createSignal<Record<string, string>>({}); + const [viewYear, setViewYear] = createSignal(parsed()?.year ?? today.getFullYear()); + const [viewMonth, setViewMonth] = createSignal(parsed()?.month ?? today.getMonth()); + + function toggleOpen() { + if (!open() && triggerRef) { + const rect = triggerRef.getBoundingClientRect(); + setDropdownStyle({ top: `${rect.bottom + 2}px`, left: `${rect.left}px` }); + } + setOpen(!open()); + } + + onMount(() => { + function handleClickOutside(e: MouseEvent) { + if (open() && containerRef && !containerRef.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + onCleanup(() => document.removeEventListener("mousedown", handleClickOutside)); + }); + + function prevMonth() { + if (viewMonth() === 0) { + setViewMonth(11); + setViewYear(viewYear() - 1); + } else { + setViewMonth(viewMonth() - 1); + } + } + + function nextMonth() { + if (viewMonth() === 11) { + setViewMonth(0); + setViewYear(viewYear() + 1); + } else { + setViewMonth(viewMonth() + 1); + } + } + + function selectDay(day: number) { + props.onChange(formatDate(viewYear(), viewMonth(), day)); + setOpen(false); + } + + function clear() { + props.onChange(""); + setOpen(false); + } + + function calendarDays() { + const total = daysInMonth(viewYear(), viewMonth()); + const offset = firstDayOfMonth(viewYear(), viewMonth()); + const days: (number | null)[] = []; + for (let blank = 0; blank < offset; blank++) days.push(null); + for (let day = 1; day <= total; day++) days.push(day); + return days; + } + + function isSelected(day: number) { + const selected = parsed(); + return selected && selected.year === viewYear() && selected.month === viewMonth() && selected.day === day; + } + + function displayValue() { + const selected = parsed(); + if (!selected) return "—"; + return `${MONTHS[selected.month]} ${selected.day}, ${selected.year}`; + } + + return ( + <div class="datepicker" ref={containerRef}> + <button type="button" class="datepicker-trigger" ref={triggerRef} onClick={toggleOpen}> + {displayValue()} + </button> + <Show when={open()}> + <div class="datepicker-dropdown datepicker-dropdown-fixed" style={dropdownStyle()}> + <div class="datepicker-nav"> + <button type="button" class="datepicker-nav-btn" onClick={prevMonth}>‹</button> + <span class="datepicker-nav-title">{MONTHS[viewMonth()]} {viewYear()}</span> + <button type="button" class="datepicker-nav-btn" onClick={nextMonth}>›</button> + </div> + <div class="datepicker-weekdays"> + <For each={WEEKDAYS}> + {(day) => <span class="datepicker-weekday">{day}</span>} + </For> + </div> + <div class="datepicker-grid"> + <For each={calendarDays()}> + {(day) => ( + <Show when={day !== null} fallback={<span class="datepicker-blank" />}> + <button + type="button" + class="datepicker-day" + classList={{ "datepicker-day-selected": isSelected(day!) }} + onClick={() => selectDay(day!)} + > + {day} + </button> + </Show> + )} + </For> + </div> + <div class="datepicker-footer"> + <button type="button" class="datepicker-clear" onClick={clear}>Clear</button> + </div> + </div> + </Show> + </div> + ); +}
\ No newline at end of file diff --git a/garden/src/components/Editor.tsx b/garden/src/components/Editor.tsx index ea69eb1..5085032 100644 --- a/garden/src/components/Editor.tsx +++ b/garden/src/components/Editor.tsx @@ -491,7 +491,7 @@ export default function Editor(props: EditorProps) { if (showHeadings() && !target.closest(".editor-toolbar-dropdown-wrap:has(.editor-dropdown)")) { setShowHeadings(false); } - if (showEmoji() && !target.closest(".editor-emoji-popup")) { + if (showEmoji() && !target.closest(".editor-emoji-popup") && target !== emojiBtnRef && !emojiBtnRef.contains(target)) { setShowEmoji(false); } } diff --git a/garden/src/components/Layout.tsx b/garden/src/components/Layout.tsx index 4a643de..1580660 100644 --- a/garden/src/components/Layout.tsx +++ b/garden/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { type JSX, Show, onMount, onCleanup, createEffect } from "solid-js"; import { For } from "solid-js/web"; -import { A } from "@solidjs/router"; +import { A, useLocation } from "@solidjs/router"; import Sidebar from "./Sidebar"; import NavSection from "./NavSection"; import { auth } from "../store/auth"; @@ -13,10 +13,30 @@ interface LayoutProps { export default function Layout(props: LayoutProps) { let heartbeatInterval: ReturnType<typeof setInterval> | undefined; + const location = useLocation(); onMount(() => { auth.initialize(); stats.load(); + + function handleExternalLinks(event: MouseEvent) { + const anchor = (event.target as HTMLElement).closest("a"); + if (!anchor || !anchor.href) return; + try { + const url = new URL(anchor.href); + if (url.hostname !== window.location.hostname) { + anchor.setAttribute("target", "_blank"); + anchor.setAttribute("rel", "noopener noreferrer"); + } + } catch {} + } + document.addEventListener("click", handleExternalLinks); + onCleanup(() => document.removeEventListener("click", handleExternalLinks)); + }); + + createEffect(() => { + location.pathname; + stats.load(); }); createEffect(() => { @@ -60,7 +80,7 @@ export default function Layout(props: LayoutProps) { }> {((user) => ( <> - <li><A href={`/u/${user.username}`}>My Domain</A></li> + <li><A href={`/p/${user.username}`}>My Domain</A></li> <li><A href="/letters">Letters</A></li> <li><A href="/account">Account</A></li> <li><A href="/account/settings">Settings</A></li> @@ -94,15 +114,16 @@ export default function Layout(props: LayoutProps) { <Show when={auth.user()?.role === UserRole.Owner || auth.user()?.role === UserRole.Admin || auth.user()?.role === UserRole.Moderator}> <NavSection title="Council" accent="red"> <ul> - <li><A href="/council/users">Users</A></li> - <li><A href="/council/reports">Reports</A></li> - <li><A href="/council/forums">Forums</A></li> - <li><A href="/council/districts">Districts</A></li> - <li><A href="/council/bazaar">Bazaar</A></li> <Show when={auth.user()?.role === UserRole.Owner || auth.user()?.role === UserRole.Admin}> - <li><A href="/council/audit-log">Audit Log</A></li> <li><A href="/council/announcements">Announcements</A></li> + <li><A href="/council/audit-log">Audit Log</A></li> + <li><A href="/council/bannedips">Banned IPs</A></li> </Show> + <li><A href="/council/bazaar">Bazaar</A></li> + <li><A href="/council/districts">Districts</A></li> + <li><A href="/council/forums">Forums</A></li> + <li><A href="/council/reports">Reports</A></li> + <li><A href="/council/users">Users</A></li> </ul> </NavSection> </Show> @@ -128,7 +149,7 @@ export default function Layout(props: LayoutProps) { <For each={stats.data()?.newest_citizens}> {(citizen) => ( <li class="citizen-item"> - <A href={`/u/${citizen.username}`}> + <A href={`/p/${citizen.username}`}> <img src={citizen.avatar_url} alt="" class="citizen-avatar" /> {citizen.display_name} </A> @@ -146,7 +167,7 @@ export default function Layout(props: LayoutProps) { <For each={stats.data()?.online_citizens}> {(citizen) => ( <li class="citizen-item"> - <A href={`/u/${citizen.username}`}> + <A href={`/p/${citizen.username}`}> <img src={citizen.avatar_url} alt="" class="citizen-avatar" /> {citizen.display_name} </A> diff --git a/garden/src/components/MiniEditor.tsx b/garden/src/components/MiniEditor.tsx new file mode 100644 index 0000000..a56b9fb --- /dev/null +++ b/garden/src/components/MiniEditor.tsx @@ -0,0 +1,288 @@ +import { createSignal, onMount, onCleanup, Show } from "solid-js"; +import { createEditor, $getSelection, $isRangeSelection, $isElementNode, $createTextNode, $createParagraphNode, $insertNodes, $getRoot, COMMAND_PRIORITY_LOW } from "lexical"; +import type { LexicalEditor, TextFormatType } from "lexical"; +import { registerRichText } from "@lexical/rich-text"; +import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html"; +import { LinkNode, $createLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND, $toggleLink } from "@lexical/link"; +import { registerHistory, createEmptyHistoryState } from "@lexical/history"; +import { + IconBold, IconBoldOff, + IconItalic, + IconUnderline, + IconEyeOff, + IconLink, IconLinkOff, + IconMoodSmile, +} from "@tabler/icons-solidjs"; +import "emoji-picker-element"; + +interface MiniEditorProps { + onHtml: (html: string) => void; + initialHtml?: string; +} + +export default function MiniEditor(props: MiniEditorProps) { + let editorRef!: HTMLDivElement; + let containerRef!: HTMLDivElement; + let linkUrlRef!: HTMLInputElement; + let editorInstance: LexicalEditor; + + const [bold, setBold] = createSignal(false); + const [italic, setItalic] = createSignal(false); + const [underline, setUnderline] = createSignal(false); + const [spoiler, setSpoiler] = createSignal(false); + const [link, setLink] = createSignal(false); + const [showLinkInput, setShowLinkInput] = createSignal(false); + const [linkUrl, setLinkUrl] = createSignal(""); + const [linkText, setLinkText] = createSignal(""); + const [hasSelection, setHasSelection] = createSignal(false); + const [showEmoji, setShowEmoji] = createSignal(false); + const [emojiPos, setEmojiPos] = createSignal({ top: 0, right: 0 }); + + function ensureSelection() { + let selection = $getSelection(); + if (!$isRangeSelection(selection)) { + const root = $getRoot(); + const firstChild = root.getFirstChild(); + if ($isElementNode(firstChild)) { + selection = firstChild.select(0, 0); + } else { + const paragraph = $createParagraphNode(); + root.append(paragraph); + selection = paragraph.select(0, 0); + } + } + return selection; + } + + function ensureFocus() { + editorInstance.update(() => { ensureSelection(); }); + editorInstance.focus(); + } + + function formatText(format: TextFormatType) { + editorInstance.update(() => { + const selection = ensureSelection(); + if ($isRangeSelection(selection)) { + selection.formatText(format); + } + }); + editorInstance.focus(); + } + + function openLinkInput() { + setShowEmoji(false); + if (link()) { + ensureFocus(); + editorInstance.dispatchCommand(TOGGLE_LINK_COMMAND, null); + return; + } + + editorInstance.getEditorState().read(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const text = selection.getTextContent(); + setLinkText(text); + setHasSelection(text.length > 0); + } else { + setLinkText(""); + setHasSelection(false); + } + }); + + setLinkUrl(""); + setShowLinkInput(true); + requestAnimationFrame(() => linkUrlRef?.focus()); + } + + function submitLink() { + const url = linkUrl().trim(); + if (!url) return; + + const text = linkText().trim(); + + ensureFocus(); + editorInstance.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection) && hasSelection()) { + $toggleLink(url); + } else { + const linkNode = $createLinkNode(url); + linkNode.append($createTextNode(text || url)); + $insertNodes([linkNode]); + } + }); + + setShowLinkInput(false); + setLinkUrl(""); + setLinkText(""); + } + + function cancelLink() { + setShowLinkInput(false); + setLinkUrl(""); + setLinkText(""); + ensureFocus(); + } + + let emojiBtnRef!: HTMLButtonElement; + + function toggleEmojiPicker() { + if (!showEmoji()) { + const rect = emojiBtnRef.getBoundingClientRect(); + setEmojiPos({ top: rect.bottom + 2, right: window.innerWidth - rect.right }); + } + setShowEmoji(!showEmoji()); + } + + function insertEmoji(unicode: string) { + editorInstance.update(() => { + const selection = ensureSelection(); + if ($isRangeSelection(selection)) { + selection.insertText(unicode); + } else { + $insertNodes([$createTextNode(unicode)]); + } + }); + editorInstance.focus(); + setShowEmoji(false); + } + + onMount(() => { + editorInstance = createEditor({ + namespace: "pagoda-mini", + nodes: [LinkNode], + theme: { + paragraph: "editor-paragraph", + text: { + bold: "editor-bold", + italic: "editor-italic", + underline: "editor-underline", + highlight: "editor-spoiler", + }, + link: "editor-link", + }, + onError: (error: Error) => console.error(error), + }); + + editorInstance.setRootElement(editorRef); + const cleanupRichText = registerRichText(editorInstance); + const cleanupLink = editorInstance.registerCommand(TOGGLE_LINK_COMMAND, (payload) => { + $toggleLink(typeof payload === "string" ? payload : null); + return true; + }, COMMAND_PRIORITY_LOW); + const cleanupHistory = registerHistory(editorInstance, createEmptyHistoryState(), 300); + + if (props.initialHtml) { + editorInstance.update(() => { + const parser = new DOMParser(); + const dom = parser.parseFromString(props.initialHtml!, "text/html"); + const nodes = $generateNodesFromDOM(editorInstance, dom); + const root = $getRoot(); + root.clear(); + root.append(...nodes); + }); + } + + editorInstance.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + props.onHtml($generateHtmlFromNodes(editorInstance)); + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + setBold(selection.hasFormat("bold")); + setItalic(selection.hasFormat("italic")); + setUnderline(selection.hasFormat("underline")); + setSpoiler(selection.hasFormat("highlight")); + + const node = selection.anchor.getNode(); + const parent = node.getParent(); + setLink(parent !== null && $isLinkNode(parent)); + } + }); + }); + + function handleClickOutside(event: MouseEvent) { + const target = event.target as HTMLElement; + if (showEmoji() && !containerRef.contains(target) && !target.closest(".editor-emoji-popup")) { + setShowEmoji(false); + } + } + + document.addEventListener("mousedown", handleClickOutside); + + onCleanup(() => { + cleanupRichText(); + cleanupLink(); + cleanupHistory(); + document.removeEventListener("mousedown", handleClickOutside); + editorInstance.setRootElement(null); + }); + }); + + const s = "18"; + const w = "2"; + const wActive = "3"; + + return ( + <div class="editor-container editor-mini" ref={containerRef}> + <div class="editor-toolbar"> + <button type="button" class="editor-toolbar-btn" classList={{ "editor-toolbar-active": bold() }} onMouseDown={(e) => e.preventDefault()} onClick={() => formatText("bold")} title="Bold"> + <Show when={bold()} fallback={<IconBoldOff size={s} stroke={w} />}><IconBold size={s} stroke={w} /></Show> + </button> + <button type="button" class="editor-toolbar-btn" classList={{ "editor-toolbar-active": italic() }} onMouseDown={(e) => e.preventDefault()} onClick={() => formatText("italic")} title="Italic"> + <IconItalic size={s} stroke={italic() ? wActive : w} /> + </button> + <button type="button" class="editor-toolbar-btn" classList={{ "editor-toolbar-active": underline() }} onMouseDown={(e) => e.preventDefault()} onClick={() => formatText("underline")} title="Underline"> + <IconUnderline size={s} stroke={underline() ? wActive : w} /> + </button> + <button type="button" class="editor-toolbar-btn" classList={{ "editor-toolbar-active": spoiler() }} onMouseDown={(e) => e.preventDefault()} onClick={() => formatText("highlight")} title="Spoiler"> + <IconEyeOff size={s} stroke={spoiler() ? wActive : w} /> + </button> + <span class="editor-toolbar-divider" /> + <button type="button" class="editor-toolbar-btn" classList={{ "editor-toolbar-active": link() }} onMouseDown={(e) => e.preventDefault()} onClick={openLinkInput} title="Link"> + <Show when={link()} fallback={<IconLinkOff size={s} stroke={w} />}><IconLink size={s} stroke={w} /></Show> + </button> + <span class="editor-toolbar-divider" /> + <button type="button" ref={emojiBtnRef} class="editor-toolbar-btn" onClick={toggleEmojiPicker} title="Emoji"> + <IconMoodSmile size={s} stroke={w} /> + </button> + </div> + <Show when={showLinkInput()}> + <div class="editor-link-bar"> + <Show when={!hasSelection()}> + <input + type="text" + class="editor-link-input" + placeholder="Link text" + value={linkText()} + onInput={(e) => setLinkText(e.currentTarget.value)} + onKeyDown={(e) => { if (e.key === "Escape") cancelLink(); }} + /> + </Show> + <input + ref={linkUrlRef} + type="text" + class="editor-link-input" + placeholder="https://" + value={linkUrl()} + onInput={(e) => setLinkUrl(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") submitLink(); + if (e.key === "Escape") cancelLink(); + }} + /> + <button type="button" class="editor-link-btn editor-link-apply" onClick={submitLink}>Apply</button> + <button type="button" class="editor-link-btn" onClick={cancelLink}>Cancel</button> + </div> + </Show> + <div ref={editorRef} class="editor-root" contentEditable /> + <Show when={showEmoji()}> + <div class="editor-emoji-popup" style={{ top: `${emojiPos().top}px`, right: `${emojiPos().right}px` }}> + <emoji-picker + class="editor-emoji-picker" + on:emoji-click={(e: CustomEvent) => insertEmoji(e.detail.unicode)} + /> + </div> + </Show> + </div> + ); +}
\ No newline at end of file diff --git a/garden/src/components/MonthDayPicker.tsx b/garden/src/components/MonthDayPicker.tsx new file mode 100644 index 0000000..efdd9a5 --- /dev/null +++ b/garden/src/components/MonthDayPicker.tsx @@ -0,0 +1,147 @@ +import { createSignal, onMount, onCleanup, Show, For } from "solid-js"; + +interface MonthDayPickerProps { + value: string; + onChange: (value: string) => void; +} + +const MONTHS = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +function daysInMonth(month: number) { + return new Date(2024, month + 1, 0).getDate(); +} + +function parseMonthDay(value: string) { + if (!value) return null; + const parts = value.split("-"); + return { month: parseInt(parts[0]) - 1, day: parseInt(parts[1]) }; +} + +function formatMonthDay(month: number, day: number) { + return `${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + +export default function MonthDayPicker(props: MonthDayPickerProps) { + const initial = parseMonthDay(props.value); + const [open, setOpen] = createSignal(false); + const [selectedMonth, setSelectedMonth] = createSignal(initial?.month ?? -1); + const [selectedDay, setSelectedDay] = createSignal(initial?.day ?? -1); + const [viewMonth, setViewMonth] = createSignal(initial?.month ?? 0); + let containerRef: HTMLDivElement | undefined; + let monthColRef: HTMLDivElement | undefined; + + function dayCount() { + return daysInMonth(viewMonth()); + } + + function scrollToSelectedMonth() { + requestAnimationFrame(() => { + const el = monthColRef?.querySelector(".mdp-wheel-item-selected") as HTMLElement | null; + if (el && monthColRef) { + monthColRef.scrollTop = el.offsetTop - monthColRef.offsetHeight / 2 + el.offsetHeight / 2; + } + }); + } + + function toggleOpen() { + const next = !open(); + setOpen(next); + if (next) scrollToSelectedMonth(); + } + + function selectMonth(value: number) { + setViewMonth(value); + setSelectedMonth(value); + const d = selectedDay(); + if (d > 0) { + const max = daysInMonth(value); + const clamped = d > max ? max : d; + setSelectedDay(clamped); + props.onChange(formatMonthDay(value, clamped)); + } + } + + function selectDay(day: number) { + setSelectedDay(day); + setSelectedMonth(viewMonth()); + props.onChange(formatMonthDay(viewMonth(), day)); + } + + function isSelectedDay(day: number) { + return selectedMonth() === viewMonth() && selectedDay() === day; + } + + function clear() { + setSelectedMonth(-1); + setSelectedDay(-1); + props.onChange(""); + setOpen(false); + } + + function displayValue() { + const m = selectedMonth(); + const d = selectedDay(); + if (m < 0 || d < 0) return "—"; + return `${MONTHS[m]} ${d}`; + } + + onMount(() => { + function handleClickOutside(e: MouseEvent) { + if (open() && containerRef && !containerRef.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + onCleanup(() => document.removeEventListener("mousedown", handleClickOutside)); + }); + + return ( + <div class="mdp" ref={containerRef}> + <button type="button" class="mdp-trigger" onClick={toggleOpen}> + {displayValue()} + </button> + <Show when={open()}> + <div class="mdp-popup"> + <div class="mdp-body"> + <div class="mdp-month-col" ref={monthColRef}> + <For each={MONTHS}> + {(name: string, i: () => number) => ( + <button + type="button" + class="mdp-wheel-item" + classList={{ "mdp-wheel-item-selected": viewMonth() === i() }} + onClick={() => selectMonth(i())} + > + {name} + </button> + )} + </For> + </div> + <div class="mdp-day-col"> + <div class="mdp-grid"> + <For each={Array.from({ length: dayCount() }, (_, i) => i + 1)}> + {(d: number) => ( + <button + type="button" + class="mdp-day" + classList={{ "mdp-day-selected": isSelectedDay(d) }} + onClick={() => selectDay(d)} + > + {d} + </button> + )} + </For> + </div> + </div> + </div> + <div class="mdp-footer"> + <button type="button" class="mdp-clear" onClick={clear}>Clear</button> + </div> + </div> + </Show> + </div> + ); +}
\ No newline at end of file diff --git a/garden/src/components/StaffGuard.tsx b/garden/src/components/StaffGuard.tsx new file mode 100644 index 0000000..331b69c --- /dev/null +++ b/garden/src/components/StaffGuard.tsx @@ -0,0 +1,28 @@ +import { type JSX, Show, createEffect } from "solid-js"; +import { useNavigate } from "@solidjs/router"; +import { auth } from "../store/auth"; +import { UserRole } from "../types/roles"; + +interface StaffGuardProps { + children: JSX.Element; +} + +function isStaff(role?: string) { + return role === UserRole.Owner || role === UserRole.Admin || role === UserRole.Moderator; +} + +export default function StaffGuard(props: StaffGuardProps) { + const navigate = useNavigate(); + + createEffect(() => { + if (!auth.loading() && (!auth.user() || !isStaff(auth.user()?.role))) { + navigate("/", { replace: true }); + } + }); + + return ( + <Show when={!auth.loading() && auth.user() && isStaff(auth.user()?.role)}> + {props.children} + </Show> + ); +}
\ No newline at end of file diff --git a/garden/src/index.css b/garden/src/index.css index 07c9a3b..fe53bac 100644 --- a/garden/src/index.css +++ b/garden/src/index.css @@ -2,4 +2,5 @@ @import "./styles/layout.css"; @import "./styles/council.css"; @import "./styles/modal.css"; -@import "./styles/editor.css";
\ No newline at end of file +@import "./styles/editor.css"; +@import "./styles/datepicker.css";
\ No newline at end of file diff --git a/garden/src/pages/council/bannedips.tsx b/garden/src/pages/council/bannedips.tsx new file mode 100644 index 0000000..3a94c91 --- /dev/null +++ b/garden/src/pages/council/bannedips.tsx @@ -0,0 +1,116 @@ +import { createSignal, onMount, Show, For } from "solid-js"; +import { api } from "../../api"; +import { auth } from "../../store/auth"; +import StaffGuard from "../../components/StaffGuard"; +import Modal from "../../components/Modal"; + +interface IPBan { + ID: number; + IP: string; + Reason: string; + CreatedAt: string; +} + +interface PaginatedResponse { + items: IPBan[]; + total: number; + page: number; + per_page: number; + total_pages: number; +} + +export default function BannedIPs() { + const [bans, setBans] = createSignal<IPBan[]>([]); + const [page, setPage] = createSignal(1); + const [totalPages, setTotalPages] = createSignal(1); + const [total, setTotal] = createSignal(0); + const [loading, setLoading] = createSignal(true); + const [confirmBan, setConfirmBan] = createSignal<IPBan | null>(null); + + async function loadBans(p = 1) { + setLoading(true); + const response = await api<PaginatedResponse>(`/council/bannedips?page=${p}`, { + token: auth.token(), + }); + if (response.ok) { + setBans(response.data.items ?? []); + setPage(response.data.page); + setTotalPages(response.data.total_pages); + setTotal(response.data.total); + } + setLoading(false); + } + + async function liftBan(ban: IPBan) { + const response = await api(`/council/bannedips/${ban.ID}`, { + method: "DELETE", + token: auth.token(), + }); + if (response.ok) { + setConfirmBan(null); + loadBans(page()); + } + } + + onMount(() => loadBans()); + + function formatDate(date: string) { + return new Date(date).toLocaleDateString(); + } + + return ( + <StaffGuard> + <section> + <h2 class="page-title">Banned IPs</h2> + + <div class="council-grid council-grid-bannedips"> + <div class="council-grid-header"> + <span>IP Address</span> + <span>Reason</span> + <span>Banned At</span> + <span></span> + </div> + <Show when={!loading()} fallback={ + <div class="council-grid-empty">Loading...</div> + }> + <Show when={bans().length} fallback={ + <div class="council-grid-empty">No banned IPs.</div> + }> + <For each={bans()}> + {(ban) => ( + <div class="council-grid-row"> + <span class="council-ip">{ban.IP}</span> + <span>{ban.Reason}</span> + <span>{formatDate(ban.CreatedAt)}</span> + <span> + <button type="button" class="council-detail-action-btn council-action-unban" onClick={() => setConfirmBan(ban)}>Lift</button> + </span> + </div> + )} + </For> + </Show> + </Show> + </div> + + <Show when={totalPages() > 1}> + <div class="council-pagination"> + <button class="council-page-btn" disabled={page() <= 1} onClick={() => loadBans(page() - 1)}>Prev</button> + <span class="council-page-info">Page {page()} of {totalPages()} ({total()} bans)</span> + <button class="council-page-btn" disabled={page() >= totalPages()} onClick={() => loadBans(page() + 1)}>Next</button> + </div> + </Show> + <Show when={confirmBan()}> + {(ban) => ( + <Modal title="Lift IP Ban" onClose={() => setConfirmBan(null)}> + <p style={{ "font-size": "12px", margin: "0 0 12px" }}>Lift ban on <strong>{ban().IP}</strong>?</p> + <div class="modal-actions"> + <button type="button" class="council-detail-action-btn council-action-unban" onClick={() => liftBan(ban())}>Lift Ban</button> + <button type="button" class="council-detail-action-btn" onClick={() => setConfirmBan(null)}>Cancel</button> + </div> + </Modal> + )} + </Show> + </section> + </StaffGuard> + ); +}
\ No newline at end of file diff --git a/garden/src/pages/council/user.tsx b/garden/src/pages/council/user.tsx index ae52fae..dc445b7 100644 --- a/garden/src/pages/council/user.tsx +++ b/garden/src/pages/council/user.tsx @@ -1,11 +1,15 @@ import { createSignal, onMount, Show, For } from "solid-js"; import { useParams, A } from "@solidjs/router"; -import { api } from "../../api"; +import { api, uploadFile } from "../../api"; import { auth } from "../../store/auth"; import { UserRole } from "../../types/roles"; import type { AdminUser } from "../../types/admin"; import Modal from "../../components/Modal"; import Editor from "../../components/Editor"; +import StaffGuard from "../../components/StaffGuard"; +import DatePicker from "../../components/DatePicker"; +import MonthDayPicker from "../../components/MonthDayPicker"; +import MiniEditor from "../../components/MiniEditor"; export default function CouncilUser() { const params = useParams(); @@ -13,12 +17,24 @@ export default function CouncilUser() { const [error, setError] = createSignal(""); const [actionError, setActionError] = createSignal(""); const [editing, setEditing] = createSignal<Record<string, string>>({}); - const [modal, setModal] = createSignal<"warn" | "disable" | "ban" | null>(null); + const [modal, setModal] = createSignal<"warn" | "disable" | "ban" | "jade" | null>(null); const [warnTitle, setWarnTitle] = createSignal(""); const [warnBody, setWarnBody] = createSignal(""); const [disableReason, setDisableReason] = createSignal(""); const [disableUntil, setDisableUntil] = createSignal(""); const [banReason, setBanReason] = createSignal(""); + const [jadeAmount, setJadeAmount] = createSignal(""); + const [editingBio, setEditingBio] = createSignal(false); + const [bioHtml, setBioHtml] = createSignal(""); + const [editingSignature, setEditingSignature] = createSignal(false); + const [signatureHtml, setSignatureHtml] = createSignal(""); + const [editingBirthday, setEditingBirthday] = createSignal(false); + const [birthdayValue, setBirthdayValue] = createSignal(""); + const [signatureImage, setSignatureImage] = createSignal<File | null>(null); + const [uploadingImage, setUploadingImage] = createSignal(false); + const [modalError, setModalError] = createSignal(""); + const [submitting, setSubmitting] = createSignal(false); + const [existingImageUrl, setExistingImageUrl] = createSignal(""); onMount(async () => { const response = await api<AdminUser>(`/council/users/${params.username}`, { @@ -101,6 +117,24 @@ export default function CouncilUser() { } } + async function saveField(field: string, value: string) { + const target = user(); + if (!target) return; + setActionError(""); + + const response = await api<AdminUser>(`/council/users/${target.username}`, { + method: "PATCH", + token: auth.token(), + body: { [field]: value }, + }); + + if (response.ok) { + setUser(response.data); + } else { + setActionError((response.data as unknown as { error: string }).error); + } + } + async function saveRole(role: string) { const target = user(); if (!target) return; @@ -120,10 +154,16 @@ export default function CouncilUser() { } } + function extractImageUrl(html: string): string { + const match = html?.match(/<img\s+[^>]*src="([^"]+)"/); + return match ? match[1] : ""; + } + async function submitWarn() { const target = user(); if (!target) return; - setActionError(""); + setModalError(""); + setSubmitting(true); const response = await api<AdminUser>(`/council/users/${target.username}/warn`, { method: "POST", @@ -131,20 +171,22 @@ export default function CouncilUser() { body: { title: warnTitle(), message: warnBody() }, }); + setSubmitting(false); if (response.ok) { setUser(response.data); setModal(null); setWarnTitle(""); setWarnBody(""); } else { - setActionError((response.data as unknown as { error: string }).error); + setModalError((response.data as unknown as { error: string }).error); } } async function submitDisable() { const target = user(); if (!target) return; - setActionError(""); + setModalError(""); + setSubmitting(true); const body: Record<string, string> = { reason: disableReason() }; if (disableUntil()) body.disabled_until = new Date(disableUntil()).toISOString(); @@ -155,20 +197,22 @@ export default function CouncilUser() { body, }); + setSubmitting(false); if (response.ok) { setUser(response.data); setModal(null); setDisableReason(""); setDisableUntil(""); } else { - setActionError((response.data as unknown as { error: string }).error); + setModalError((response.data as unknown as { error: string }).error); } } async function submitBan() { const target = user(); if (!target) return; - setActionError(""); + setModalError(""); + setSubmitting(true); const response = await api<AdminUser>(`/council/users/${target.username}/ban`, { method: "POST", @@ -176,12 +220,13 @@ export default function CouncilUser() { body: { reason: banReason() }, }); + setSubmitting(false); if (response.ok) { setUser(response.data); setModal(null); setBanReason(""); } else { - setActionError((response.data as unknown as { error: string }).error); + setModalError((response.data as unknown as { error: string }).error); } } @@ -219,6 +264,34 @@ export default function CouncilUser() { } } + async function submitGiftJade() { + const target = user(); + if (!target) return; + setModalError(""); + + const amount = parseInt(jadeAmount()); + if (isNaN(amount) || amount < 1) { + setModalError("Enter a valid jade amount (minimum 1)."); + return; + } + + setSubmitting(true); + const response = await api<AdminUser>(`/council/users/${target.username}`, { + method: "PATCH", + token: auth.token(), + body: { jade: target.jade + amount }, + }); + + setSubmitting(false); + if (response.ok) { + setUser(response.data); + setModal(null); + setJadeAmount(""); + } else { + setModalError((response.data as unknown as { error: string }).error); + } + } + function EditableField(props: { label: string; field: string; value: string }) { return ( <Show when={editing()[props.field] !== undefined} fallback={ @@ -240,6 +313,7 @@ export default function CouncilUser() { class="council-detail-edit-input" value={editing()[props.field]} onInput={(e) => setEditing((prev) => ({ ...prev, [props.field]: e.currentTarget.value }))} + onKeyDown={(e) => { if (e.key === "Enter") saveEdit(props.field); if (e.key === "Escape") cancelEdit(props.field); }} /> <button type="button" class="council-detail-edit-btn council-action-save" onClick={() => saveEdit(props.field)}>Save</button> <button type="button" class="council-detail-edit-btn" onClick={() => cancelEdit(props.field)}>Cancel</button> @@ -291,6 +365,7 @@ export default function CouncilUser() { } return ( + <StaffGuard> <section> <A href="/council/users" class="council-detail-back">← Back to Users</A> @@ -313,12 +388,146 @@ export default function CouncilUser() { <EditableField label="Username" field="username" value={target().username} /> <EditableField label="Display Name" field="display_name" value={target().display_name} /> <EditableField label="Email" field="email" value={target().email} /> - <EditableField label="Bio" field="bio" value={target().bio} /> - <EditableField label="Website" field="website" value={target().website} /> + <Show when={editingBio()} fallback={ + <div class="council-detail-row"> + <span class="council-detail-label">Bio</span> + <span class="council-detail-value"> + <Show when={target().bio} fallback={<span>—</span>}> + <span class="council-detail-html" innerHTML={target().bio} /> + </Show> + <Show when={isAdmin()}> + <button type="button" class="council-detail-edit-trigger" onClick={() => setEditingBio(true)}>Edit</button> + </Show> + </span> + </div> + }> + <div class="council-detail-row council-detail-row-editor"> + <span class="council-detail-label">Bio</span> + <div class="council-detail-editor-wrap"> + <MiniEditor onHtml={setBioHtml} initialHtml={target().bio} /> + <div class="council-detail-editor-actions"> + <button type="button" class="council-detail-edit-btn council-action-save" onClick={() => { saveField("bio", bioHtml()); setEditingBio(false); }}>Save</button> + <button type="button" class="council-detail-edit-btn" onClick={() => setEditingBio(false)}>Cancel</button> + </div> + </div> + </div> + </Show> + <Show when={editing()["website"] !== undefined} fallback={ + <div class="council-detail-row"> + <span class="council-detail-label">Website</span> + <span class="council-detail-value"> + <Show when={target().website} fallback={<span>—</span>}> + <a href={target().website} target="_blank" rel="noopener noreferrer">{target().website}</a> + </Show> + <Show when={isAdmin()}> + <button type="button" class="council-detail-edit-trigger" onClick={() => startEdit("website", target().website)}>Edit</button> + </Show> + </span> + </div> + }> + <div class="council-detail-row"> + <span class="council-detail-label">Website</span> + <span class="council-detail-editable"> + <input + type="text" + class="council-detail-edit-input" + value={editing()["website"]} + onInput={(event) => setEditing((prev) => ({ ...prev, website: event.currentTarget.value }))} + onKeyDown={(e) => { if (e.key === "Enter") saveEdit("website"); if (e.key === "Escape") cancelEdit("website"); }} + /> + <button type="button" class="council-detail-edit-btn council-action-save" onClick={() => saveEdit("website")}>Save</button> + <button type="button" class="council-detail-edit-btn" onClick={() => cancelEdit("website")}>Cancel</button> + </span> + </div> + </Show> <EditableField label="Location" field="location" value={target().location} /> <EditableField label="Pronouns" field="pronouns" value={target().pronouns} /> - <EditableField label="Birthday" field="birthday" value={target().birthday?.split("T")[0] ?? ""} /> - <EditableField label="Signature" field="signature" value={target().signature} /> + <Show when={editingBirthday()} fallback={ + <div class="council-detail-row"> + <span class="council-detail-label">Birthday</span> + <span class="council-detail-value"> + <span>{target().birthday ? (() => { const raw = target().birthday!; const parts = raw.includes("T") ? raw.split("T")[0].split("-") : raw.split("-"); const month = parseInt(parts.length === 3 ? parts[1] : parts[0]) - 1; const day = parseInt(parts.length === 3 ? parts[2] : parts[1]); const months = ["January","February","March","April","May","June","July","August","September","October","November","December"]; return `${months[month]} ${day}`; })() : "—"}</span> + <Show when={isAdmin()}> + <button type="button" class="council-detail-edit-trigger" onClick={() => setEditingBirthday(true)}>Edit</button> + </Show> + </span> + </div> + }> + <div class="council-detail-row"> + <span class="council-detail-label">Birthday</span> + <span class="council-detail-editable"> + <MonthDayPicker + value={(() => { const raw = target().birthday; if (!raw) return ""; const parts = raw.includes("T") ? raw.split("T")[0].split("-") : raw.split("-"); return parts.length === 3 ? `${parts[1]}-${parts[2]}` : raw; })()} + onChange={setBirthdayValue} + /> + <button type="button" class="council-detail-edit-btn council-action-save" onClick={() => { saveField("birthday", birthdayValue()); setEditingBirthday(false); }}>Save</button> + <button type="button" class="council-detail-edit-btn" onClick={() => setEditingBirthday(false)}>Cancel</button> + </span> + </div> + </Show> + <Show when={editingSignature()} fallback={ + <div class="council-detail-row"> + <span class="council-detail-label">Signature</span> + <span class="council-detail-value"> + <Show when={target().signature} fallback={<span>—</span>}> + <span class="council-detail-html" innerHTML={target().signature} /> + </Show> + <Show when={isAdmin()}> + <button type="button" class="council-detail-edit-trigger" onClick={() => { setExistingImageUrl(extractImageUrl(target().signature)); setEditingSignature(true); }}>Edit</button> + </Show> + </span> + </div> + }> + <div class="council-detail-row council-detail-row-editor"> + <span class="council-detail-label">Signature</span> + <div class="council-detail-editor-wrap"> + <MiniEditor onHtml={setSignatureHtml} initialHtml={target().signature} /> + <div class="council-detail-signature-image"> + <Show when={signatureImage()}> + <img src={URL.createObjectURL(signatureImage()!)} alt="" class="council-detail-image-preview" /> + </Show> + <Show when={!signatureImage() && existingImageUrl()}> + <img src={existingImageUrl()} alt="" class="council-detail-image-preview" /> + </Show> + <label class="council-detail-file-label"> + <span>{signatureImage() ? signatureImage()!.name : existingImageUrl() ? "Replace image" : "Attach image (optional)"}</span> + <input + type="file" + accept="image/*" + class="council-detail-file-input" + onChange={(e) => setSignatureImage(e.currentTarget.files?.[0] ?? null)} + /> + </label> + <Show when={signatureImage() || existingImageUrl()}> + <button type="button" class="council-detail-edit-btn" onClick={() => { setSignatureImage(null); setExistingImageUrl(""); }}>Remove</button> + </Show> + </div> + <div class="council-detail-editor-actions"> + <button type="button" class="council-detail-edit-btn council-action-save" disabled={uploadingImage()} onClick={async () => { + let html = signatureHtml(); + const file = signatureImage(); + if (file) { + setUploadingImage(true); + const result = await uploadFile<{ url: string }>("/council/upload", file, auth.token()!); + setUploadingImage(false); + if (!result.ok) { + setActionError((result.data as unknown as { error: string }).error); + return; + } + html += `<img src="${result.data.url}" alt="" class="signature-image" />`; + } else if (existingImageUrl()) { + html += `<img src="${existingImageUrl()}" alt="" class="signature-image" />`; + } + saveField("signature", html); + setEditingSignature(false); + setSignatureImage(null); + setExistingImageUrl(""); + }}>{uploadingImage() ? "Uploading..." : "Save"}</button> + <button type="button" class="council-detail-edit-btn" onClick={() => { setEditingSignature(false); setSignatureImage(null); setExistingImageUrl(""); }}>Cancel</button> + </div> + </div> + </div> + </Show> </div> </div> @@ -350,10 +559,6 @@ export default function CouncilUser() { <span class="council-detail-label">Last Seen</span> <span>{formatDate(target().last_seen_at)}</span> </div> - <div class="council-detail-row"> - <span class="council-detail-label">IP</span> - <span>{target().registration_ip || "\u2014"}</span> - </div> <Show when={target().account_banned}> <div class="council-detail-row"> <span class="council-detail-label">Banned At</span> @@ -362,7 +567,7 @@ export default function CouncilUser() { <Show when={target().banned_reason}> <div class="council-detail-row"> <span class="council-detail-label">Ban Reason</span> - <span>{target().banned_reason}</span> + <span class="council-detail-html" innerHTML={target().banned_reason} /> </div> </Show> </Show> @@ -380,7 +585,7 @@ export default function CouncilUser() { <Show when={target().disabled_reason}> <div class="council-detail-row"> <span class="council-detail-label">Reason</span> - <span>{target().disabled_reason}</span> + <span class="council-detail-html" innerHTML={target().disabled_reason} /> </div> </Show> </Show> @@ -403,7 +608,7 @@ export default function CouncilUser() { <div class="council-detail-section"> <div class="council-detail-section-header">Actions</div> <div class="council-detail-actions"> - <button type="button" class="council-detail-action-btn council-action-warn" onClick={() => setModal("warn")}> + <button type="button" class="council-detail-action-btn council-action-warn" onClick={() => { setModalError(""); setModal("warn"); }}> Warn </button> <Show when={!target().account_disabled} fallback={ @@ -411,7 +616,7 @@ export default function CouncilUser() { Enable </button> }> - <button type="button" class="council-detail-action-btn council-action-disable" onClick={() => setModal("disable")}> + <button type="button" class="council-detail-action-btn council-action-disable" onClick={() => { setModalError(""); setModal("disable"); }}> Disable </button> </Show> @@ -420,13 +625,27 @@ export default function CouncilUser() { Unban </button> }> - <button type="button" class="council-detail-action-btn council-action-ban" onClick={() => setModal("ban")}> + <button type="button" class="council-detail-action-btn council-action-ban" onClick={() => { setModalError(""); setModal("ban"); }}> Ban </button> </Show> </div> </div> </Show> + + <Show when={isAdmin()}> + <div class="council-detail-section"> + <div class="council-detail-section-header">Gifts</div> + <div class="council-detail-actions"> + <button type="button" class="council-detail-action-btn council-action-jade" onClick={() => { setModalError(""); setModal("jade"); }}> + Gift Jade + </button> + <button type="button" class="council-detail-action-btn council-action-items" disabled> + Gift Items + </button> + </div> + </div> + </Show> </div> </div> @@ -446,9 +665,12 @@ export default function CouncilUser() { <label class="modal-label">Message</label> <Editor onHtml={setWarnBody} /> </div> + <Show when={modalError()}> + <div class="form-error">{modalError()}</div> + </Show> <div class="modal-actions"> - <button type="button" class="council-detail-action-btn council-action-warn" onClick={submitWarn}>Send Warning</button> - <button type="button" class="council-detail-action-btn" onClick={() => setModal(null)}>Cancel</button> + <button type="button" class="council-detail-action-btn council-action-warn" disabled={submitting()} onClick={submitWarn}>{submitting() ? "Sending..." : "Send Warning"}</button> + <button type="button" class="council-detail-action-btn" disabled={submitting()} onClick={() => setModal(null)}>Cancel</button> </div> </Modal> </Show> @@ -461,16 +683,14 @@ export default function CouncilUser() { </div> <div class="modal-field"> <label class="modal-label">Disabled Until (optional)</label> - <input - type="date" - class="modal-input" - value={disableUntil()} - onInput={(e) => setDisableUntil(e.currentTarget.value)} - /> + <DatePicker value={disableUntil()} onChange={setDisableUntil} /> </div> + <Show when={modalError()}> + <div class="form-error">{modalError()}</div> + </Show> <div class="modal-actions"> - <button type="button" class="council-detail-action-btn council-action-disable" onClick={submitDisable}>Disable</button> - <button type="button" class="council-detail-action-btn" onClick={() => setModal(null)}>Cancel</button> + <button type="button" class="council-detail-action-btn council-action-disable" disabled={submitting()} onClick={submitDisable}>{submitting() ? "Disabling..." : "Disable"}</button> + <button type="button" class="council-detail-action-btn" disabled={submitting()} onClick={() => setModal(null)}>Cancel</button> </div> </Modal> </Show> @@ -481,9 +701,35 @@ export default function CouncilUser() { <label class="modal-label">Reason</label> <Editor onHtml={setBanReason} /> </div> + <Show when={modalError()}> + <div class="form-error">{modalError()}</div> + </Show> + <div class="modal-actions"> + <button type="button" class="council-detail-action-btn council-action-ban" disabled={submitting()} onClick={submitBan}>{submitting() ? "Banning..." : "Ban"}</button> + <button type="button" class="council-detail-action-btn" disabled={submitting()} onClick={() => setModal(null)}>Cancel</button> + </div> + </Modal> + </Show> + + <Show when={modal() === "jade"}> + <Modal title="Gift Jade" onClose={() => setModal(null)}> + <div class="modal-field"> + <label class="modal-label">Amount</label> + <input + type="number" + class="modal-input" + placeholder="Enter jade amount..." + min="1" + value={jadeAmount()} + onInput={(e) => setJadeAmount(e.currentTarget.value)} + /> + </div> + <Show when={modalError()}> + <div class="form-error">{modalError()}</div> + </Show> <div class="modal-actions"> - <button type="button" class="council-detail-action-btn council-action-ban" onClick={submitBan}>Ban</button> - <button type="button" class="council-detail-action-btn" onClick={() => setModal(null)}>Cancel</button> + <button type="button" class="council-detail-action-btn council-action-jade" disabled={submitting()} onClick={submitGiftJade}>{submitting() ? "Gifting..." : "Gift Jade"}</button> + <button type="button" class="council-detail-action-btn" disabled={submitting()} onClick={() => setModal(null)}>Cancel</button> </div> </Modal> </Show> @@ -491,5 +737,6 @@ export default function CouncilUser() { )} </Show> </section> + </StaffGuard> ); }
\ No newline at end of file diff --git a/garden/src/pages/council/users.tsx b/garden/src/pages/council/users.tsx index 285c7b3..317639e 100644 --- a/garden/src/pages/council/users.tsx +++ b/garden/src/pages/council/users.tsx @@ -2,6 +2,7 @@ import { createSignal, onMount, Show, For } from "solid-js"; import { useNavigate } from "@solidjs/router"; import { council } from "../../store/council"; import type { AdminUser } from "../../types/admin"; +import StaffGuard from "../../components/StaffGuard"; export default function CouncilUsers() { const navigate = useNavigate(); @@ -36,6 +37,7 @@ export default function CouncilUsers() { } return ( + <StaffGuard> <section> <h2 class="page-title">Users</h2> @@ -112,5 +114,6 @@ export default function CouncilUsers() { </div> </Show> </section> + </StaffGuard> ); }
\ No newline at end of file diff --git a/garden/src/routes.ts b/garden/src/routes.ts index 938f0d5..6dc2c96 100644 --- a/garden/src/routes.ts +++ b/garden/src/routes.ts @@ -11,5 +11,6 @@ export const routes: RouteDefinition[] = [ { path: "/account/reactivate", component: lazy(() => import("./pages/account/reactivate")) }, { path: "/council/users", component: lazy(() => import("./pages/council/users")) }, { path: "/council/users/:username", component: lazy(() => import("./pages/council/user")) }, + { path: "/council/bannedips", component: lazy(() => import("./pages/council/bannedips")) }, { path: "**", component: lazy(() => import("./errors/404")) }, ]; diff --git a/garden/src/styles/council.css b/garden/src/styles/council.css index fc747f2..64220b0 100644 --- a/garden/src/styles/council.css +++ b/garden/src/styles/council.css @@ -385,3 +385,109 @@ color: var(--color-green); border-color: var(--color-green); } + +.council-action-jade:hover { + color: var(--color-cyan); + border-color: var(--color-cyan); +} + +.council-action-items { + opacity: 0.4; + cursor: not-allowed; +} + +.council-detail-html { + font-size: 12px; + color: var(--color-text); + line-height: 1.5; + word-break: break-word; +} + +.council-detail-html a { + color: var(--color-link); +} + +.council-detail-html a:hover { + color: var(--color-link-hover); +} + +.council-detail-row-editor { + flex-direction: column; + align-items: stretch; +} + +.council-detail-row-editor .council-detail-label { + margin-bottom: 4px; +} + +.council-detail-editor-wrap { + flex: 1; +} + +.council-detail-editor-actions { + display: flex; + gap: 6px; + justify-content: flex-end; + margin-top: 6px; +} + +.council-detail-signature-image { + display: flex; + align-items: center; + gap: 6px; + margin-top: 6px; +} + +.council-detail-file-label { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: none; + border: 1px dashed var(--color-text-muted); + font-size: 11px; + color: var(--color-text-muted); + cursor: pointer; +} + +.council-detail-file-label:hover { + border-color: var(--color-red); + color: var(--color-text); +} + +.council-detail-file-input { + display: none; +} + +.editor-mini .editor-root { + min-height: 60px; + max-height: 120px; +} + +.council-grid-bannedips .council-grid-header, +.council-grid-bannedips .council-grid-row { + grid-template-columns: 2fr 4fr 2fr 1fr; +} + +.council-ip { + font-family: var(--font-mono, monospace); +} + +.council-detail-html img { + max-width: 200px; + max-height: 100px; + object-fit: contain; +} + +.council-detail-image-preview { + max-width: 200px; + max-height: 100px; + object-fit: contain; + border: 1px solid var(--color-border); + border-radius: 2px; +} + +.council-detail-action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/garden/src/styles/datepicker.css b/garden/src/styles/datepicker.css new file mode 100644 index 0000000..657591a --- /dev/null +++ b/garden/src/styles/datepicker.css @@ -0,0 +1,271 @@ +.datepicker { + position: relative; + display: inline-block; +} + +.datepicker-trigger { + background: var(--color-bg); + border: 1px solid var(--color-border); + padding: 4px 8px; + font-family: var(--font-body); + font-size: 12px; + color: var(--color-text); + cursor: pointer; + min-width: 140px; + text-align: left; +} + +.datepicker-trigger:hover { + border-color: var(--color-purple); +} + +.datepicker-dropdown { + position: absolute; + top: 100%; + left: 0; + z-index: 50; + background: var(--color-panel); + border: 1px solid var(--color-border); + padding: 8px; + margin-top: 2px; + width: 224px; +} + +.datepicker-dropdown-fixed { + position: fixed; + top: auto; + left: auto; + z-index: 200; + margin-top: 0; +} + +.datepicker-nav { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.datepicker-nav-btn { + background: none; + border: none; + color: var(--color-text-muted); + cursor: pointer; + font-size: 16px; + padding: 2px 6px; + line-height: 1; +} + +.datepicker-nav-btn:hover { + color: var(--color-text-bright); +} + +.datepicker-nav-title { + font-family: var(--font-display); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--color-text-bright); +} + +.datepicker-weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 1px; + margin-bottom: 2px; +} + +.datepicker-weekday { + font-size: 10px; + font-weight: 600; + color: var(--color-text-muted); + text-align: center; + padding: 2px 0; +} + +.datepicker-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 1px; +} + +.datepicker-blank { + padding: 4px; +} + +.datepicker-day { + background: none; + border: 1px solid transparent; + color: var(--color-text); + font-family: var(--font-body); + font-size: 11px; + padding: 4px; + cursor: pointer; + text-align: center; +} + +.datepicker-day:hover { + background: var(--color-surface-hover); + border-color: var(--color-border); +} + +.datepicker-day-selected { + background: var(--color-purple); + color: var(--color-white); +} + +.datepicker-day-selected:hover { + background: var(--color-purple-hover); + border-color: transparent; +} + +.datepicker-footer { + display: flex; + justify-content: flex-end; + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid var(--color-border); +} + +.datepicker-clear { + background: none; + border: none; + color: var(--color-text-muted); + font-family: var(--font-body); + font-size: 11px; + cursor: pointer; + padding: 2px 4px; +} + +.datepicker-clear:hover { + color: var(--color-red); +} + +.mdp { + position: relative; + display: inline-block; +} + +.mdp-trigger { + background: var(--color-bg); + border: 1px solid var(--color-border); + padding: 4px 8px; + font-family: var(--font-body); + font-size: 12px; + color: var(--color-text); + cursor: pointer; + min-width: 80px; + text-align: left; +} + +.mdp-trigger:hover { + border-color: var(--color-pink); +} + +.mdp-popup { + position: absolute; + top: 100%; + left: 0; + z-index: 50; + background: var(--color-panel); + border: 1px solid var(--color-border); + margin-top: 2px; +} + +.mdp-body { + display: flex; +} + +.mdp-month-col { + width: 64px; + max-height: 220px; + overflow-y: auto; + border-right: 1px solid var(--color-border); + padding: 2px 0; +} + +.mdp-wheel-item { + display: block; + width: 100%; + background: none; + border: none; + padding: 5px 8px; + font-family: var(--font-body); + font-size: 11px; + color: var(--color-text); + cursor: pointer; + text-align: left; + white-space: nowrap; +} + +.mdp-wheel-item:hover { + background: var(--color-surface-hover); +} + +.mdp-wheel-item-selected { + background: var(--color-pink); + color: var(--color-white); +} + +.mdp-wheel-item-selected:hover { + background: var(--color-pink); +} + +.mdp-day-col { + padding: 4px; +} + +.mdp-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 1px; +} + +.mdp-day { + background: none; + border: 1px solid transparent; + color: var(--color-text); + font-family: var(--font-body); + font-size: 11px; + padding: 3px; + cursor: pointer; + text-align: center; + min-width: 24px; +} + +.mdp-day:hover { + background: var(--color-surface-hover); + border-color: var(--color-border); +} + +.mdp-day-selected { + background: var(--color-pink); + color: var(--color-white); +} + +.mdp-day-selected:hover { + background: var(--color-pink); + border-color: transparent; +} + +.mdp-footer { + border-top: 1px solid var(--color-border); + padding: 4px 8px; + display: flex; + justify-content: flex-end; +} + +.mdp-clear { + background: none; + border: none; + color: var(--color-text-muted); + font-family: var(--font-body); + font-size: 11px; + cursor: pointer; + padding: 2px 4px; +} + +.mdp-clear:hover { + color: var(--color-red); +}
\ No newline at end of file diff --git a/garden/src/types/admin.ts b/garden/src/types/admin.ts index a67298c..d3230c1 100644 --- a/garden/src/types/admin.ts +++ b/garden/src/types/admin.ts @@ -23,7 +23,6 @@ export interface AdminUser { disabled_at: string | null; disabled_until: string | null; last_seen_at: string | null; - registration_ip: string; created_at: string; } diff --git a/shrine/config/functions.go b/shrine/config/functions.go index dc0e6c9..ba55c75 100644 --- a/shrine/config/functions.go +++ b/shrine/config/functions.go @@ -3,19 +3,20 @@ package config import ( "fmt" "shrine/enums" + "shrine/messages" ) func verifyConfig() error { if Server.Port <= 0 || Server.Port > 65535 { - return fmt.Errorf("invalid server port: %d", Server.Port) + return fmt.Errorf(messages.InvalidServerPort, Server.Port) } if !verifyDatabaseDriver(enums.DatabaseDriver(Database.Driver)) { - return fmt.Errorf("invalid database driver: %s", Database.Driver) + return fmt.Errorf(messages.InvalidDatabaseDriver, Database.Driver) } if Database.DSN == "" { - return fmt.Errorf("data source name (DSN) cannot be empty") + return fmt.Errorf(messages.DSNCannotBeEmpty) } return nil diff --git a/shrine/controllers/auth.go b/shrine/controllers/auth.go index 8d27f3b..97b3bb8 100644 --- a/shrine/controllers/auth.go +++ b/shrine/controllers/auth.go @@ -16,7 +16,7 @@ func RegisterController(context *fiber.Ctx) error { return shortcuts.BadRequest(context, err) } - result, serviceErr := services.Register(body) + result, serviceErr := services.Register(body, meta.Request(context).IP) if serviceErr != nil { return shortcuts.HandleError(context, serviceErr) } @@ -30,7 +30,7 @@ func LoginController(context *fiber.Ctx) error { return shortcuts.BadRequest(context, err) } - citizen, serviceErr := services.Authenticate(body) + citizen, serviceErr := services.Authenticate(body, meta.Request(context).IP) if serviceErr != nil { return shortcuts.HandleError(context, serviceErr) } diff --git a/shrine/controllers/council.go b/shrine/controllers/council.go index 43f2459..f1a508e 100644 --- a/shrine/controllers/council.go +++ b/shrine/controllers/council.go @@ -1,11 +1,20 @@ package controllers import ( + "fmt" + "strconv" + "strings" + + "shrine/config" + "shrine/messages" + "shrine/repositories" "shrine/services" "shrine/types/council" "shrine/utils/auth" + "shrine/utils/crypto" "shrine/utils/meta" "shrine/utils/shortcuts" + "shrine/utils/storage" "github.com/gofiber/fiber/v2" ) @@ -134,4 +143,51 @@ func EditUserController(context *fiber.Ctx) error { } return shortcuts.Success(context, result) +} + +func ListIPBansController(context *fiber.Ctx) error { + pagination := meta.Paginate(context) + sorting := meta.Sort(context, []string{"ip", "reason", "created_at"}, "created_at") + items, total := repositories.ListIPBans(pagination, sorting) + return shortcuts.Success(context, pagination.Response(items, total)) +} + +func DeleteIPBanController(context *fiber.Ctx) error { + id, err := strconv.ParseUint(meta.Request(context).MustHave().Param("id"), 10, 64) + if err != nil { + return shortcuts.BadRequest(context, err) + } + repositories.DeleteIPBanByID(uint(id)) + return shortcuts.NoContent(context) +} + +func UploadImageController(context *fiber.Ctx) error { + file, err := context.FormFile("file") + if err != nil { + return shortcuts.BadRequest(context, fiber.NewError(fiber.StatusBadRequest, messages.FileRequired)) + } + + contentType := file.Header.Get("Content-Type") + if !strings.HasPrefix(contentType, "image/") { + return shortcuts.BadRequest(context, fiber.NewError(fiber.StatusBadRequest, messages.OnlyImagesAllowed)) + } + + if file.Size > int64(config.Storage.MaxFileSize) { + return shortcuts.BadRequest(context, fiber.NewError(fiber.StatusBadRequest, messages.FileTooLarge)) + } + + source, err := file.Open() + if err != nil { + return shortcuts.InternalServerError(context, err) + } + defer source.Close() + + ref := crypto.Ref() + path := fmt.Sprintf("citizens/signatures/%s/%s", ref, file.Filename) + + if err := storage.Upload(path, source, file.Size, contentType); err != nil { + return shortcuts.InternalServerError(context, err) + } + + return shortcuts.Created(context, fiber.Map{"url": storage.ResolveCDN(path)}) }
\ No newline at end of file diff --git a/shrine/database/migrate.go b/shrine/database/migrate.go index ca76520..b7ab59d 100644 --- a/shrine/database/migrate.go +++ b/shrine/database/migrate.go @@ -18,6 +18,7 @@ func migrate() { &models.TicketCategory{}, &models.Ticket{}, &models.TicketMessage{}, + &models.IPBan{}, ) if err != nil { logger.Fatalf("Database", "Error during database migration: %v", err) diff --git a/shrine/messages/auth.go b/shrine/messages/auth.go index 0586fdc..1c5c839 100644 --- a/shrine/messages/auth.go +++ b/shrine/messages/auth.go @@ -4,6 +4,7 @@ const ( InvalidRequestBody = "Invalid request body." InvalidUsernameOrPassword = "Invalid username or password." AccountBannedOrDisabled = "Your account has been banned or disabled." + IPBanned = "Access from this IP address has been restricted." EmailNotVerified = "Your email address has not been verified. Please check your inbox." VerificationTokenRequired = "Verification token is required." VerificationLinkInvalid = "Your verification link is invalid or has expired." @@ -21,4 +22,7 @@ const ( FailedCreateSession = "Failed to create session." FailedVerifyAccount = "Failed to verify your account." FailedEndSession = "Failed to end your session." + EmailSubjectVerify = "Verify your Pagoda account" + EmailSubjectDisabled = "Your Pagoda account has been disabled" + EmailSubjectBanned = "Your Pagoda account has been banned" )
\ No newline at end of file diff --git a/shrine/messages/council.go b/shrine/messages/council.go index e871782..201f5e1 100644 --- a/shrine/messages/council.go +++ b/shrine/messages/council.go @@ -23,4 +23,20 @@ const ( FailedEnableUser = "Failed to enable user." FailedChangeRole = "Failed to change role." FailedUpdateUser = "Failed to update user." + OnlyImagesAllowed = "Only image files are allowed." + FileTooLarge = "File exceeds the maximum allowed size." + JadeExceedsMax = "Jade cannot exceed 99,999." + InvalidBirthdayFormat = "Invalid birthday format. Use MM-DD." + AuditBannedIP = "Banned with user @%s" + AuditBannedUser = "Banned user @%s" + AuditUnbannedUser = "Unbanned user @%s" + AuditDisabledUser = "Disabled user @%s" + AuditEnabledUser = "Enabled user @%s" + AuditChangedRole = "Changed role of @%s from %s to %s" + AuditEditedUser = "Edited user @%s" + AuditWarnedUser = "Warned user @%s" + AuditDeactivatedWarn = "Deactivated warning %s" + AuditUpdatedTicket = "Updated ticket %s" + SystemLetterBanned = "Account Banned [%s]" + SystemLetterDisabled = "Account Disabled [%s]" )
\ No newline at end of file diff --git a/shrine/messages/letter.go b/shrine/messages/letter.go index 2492c63..49d01f9 100644 --- a/shrine/messages/letter.go +++ b/shrine/messages/letter.go @@ -29,4 +29,11 @@ const ( FailedUploadFile = "Failed to upload file." LetterRenamed = "Letter renamed." LeftConversation = "You have left the conversation." + RecipientNotFound = "User '%s' not found." + ParticipantRemoved = "%s has been removed." + FileExceedsMaxSize = "File exceeds the maximum size of %d MB." + SystemMessageTitle = "System Message" + EmptyConversationTitle = "Empty Conversation" + LetterTitleTwo = "%s and %s" + LetterTitleMany = "%s, %s, and %d others" )
\ No newline at end of file diff --git a/shrine/messages/system.go b/shrine/messages/system.go new file mode 100644 index 0000000..e6fe442 --- /dev/null +++ b/shrine/messages/system.go @@ -0,0 +1,13 @@ +package messages + +const ( + StorageNotConfigured = "Storage not configured." + InvalidServerPort = "Invalid server port: %d." + InvalidDatabaseDriver = "Invalid database driver: %s." + DSNCannotBeEmpty = "Data source name (DSN) cannot be empty." + ConfigMustBePointer = "Config must be a pointer to struct." + FailedLoadTemplate = "Failed to load %s template: %v." + CannotActionSelf = "You cannot %s yourself." + CannotActionOwner = "You cannot %s the owner." + OnlyOwnerCanActionAdmin = "Only the owner can %s an administrator." +)
\ No newline at end of file diff --git a/shrine/messages/validation.go b/shrine/messages/validation.go new file mode 100644 index 0000000..c277033 --- /dev/null +++ b/shrine/messages/validation.go @@ -0,0 +1,9 @@ +package messages + +const ( + PasswordTooShort = "Password must be at least 8 characters." + PasswordTooLong = "Password must be at most 255 characters." + PasswordRequired = "Password is required." + InvalidEmail = "Please enter a valid email address." + InvalidDisplayName = "Display name must be between 1 and 50 characters." +)
\ No newline at end of file diff --git a/shrine/models/ipban.go b/shrine/models/ipban.go new file mode 100644 index 0000000..cf18e6c --- /dev/null +++ b/shrine/models/ipban.go @@ -0,0 +1,12 @@ +package models + +import ( + "time" +) + +type IPBan struct { + ID uint `gorm:"primaryKey;autoIncrement"` + IP string `gorm:"size:45;uniqueIndex;not null"` + Reason string `gorm:"size:500"` + CreatedAt time.Time `gorm:"index"` +}
\ No newline at end of file diff --git a/shrine/models/user.go b/shrine/models/user.go index a4311e2..503c61a 100644 --- a/shrine/models/user.go +++ b/shrine/models/user.go @@ -3,6 +3,7 @@ package models import ( "errors" "shrine/enums" + "shrine/messages" "shrine/types/user" "shrine/utils/storage" "shrine/utils/validators" @@ -45,15 +46,15 @@ type User struct { DisabledUntil *time.Time WarningCount uint `gorm:"not null;default:0"` LastSeenAt *time.Time - RegistrationIP string `gorm:"size:45"` + IP string `gorm:"size:45"` } func (self *User) SetPassword(password string) error { if len(password) < 8 { - return errors.New("Password must be at least 8 characters.") + return errors.New(messages.PasswordTooShort) } if len(password) > 255 { - return errors.New("Password must be at most 255 characters.") + return errors.New(messages.PasswordTooLong) } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { @@ -150,7 +151,6 @@ func (self *User) ToAdminResponse() user.AdminUserResponse { DisabledAt: self.DisabledAt, DisabledUntil: self.DisabledUntil, LastSeenAt: self.LastSeenAt, - RegistrationIP: self.RegistrationIP, } } @@ -167,23 +167,23 @@ func (self *User) BeforeCreate(tx *gorm.DB) error { if !bypassUsername { if !validators.IsValidUsername(self.Username, 3) { - return errors.New("Username must be 3-32 characters and can only contain letters, numbers, and underscores.") + return errors.New(messages.InvalidUsername) } if validators.IsReservedUsername(self.Username) { - return errors.New("This username is not available.") + return errors.New(messages.UsernameNotAvailable) } } if !validators.IsValidEmail(self.Email) { - return errors.New("Please enter a valid email address.") + return errors.New(messages.InvalidEmail) } if len(strings.TrimSpace(self.DisplayName)) < 1 || len(strings.TrimSpace(self.DisplayName)) > 50 { - return errors.New("Display name must be between 1 and 50 characters.") + return errors.New(messages.InvalidDisplayName) } if self.PasswordHash == "" { - return errors.New("Password is required.") + return errors.New(messages.PasswordRequired) } self.Email = strings.ToLower(strings.TrimSpace(self.Email)) @@ -195,11 +195,15 @@ func (self *User) BeforeCreate(tx *gorm.DB) error { func (self *User) BeforeUpdate(tx *gorm.DB) error { if !validators.IsValidEmail(self.Email) { - return errors.New("Please enter a valid email address.") + return errors.New(messages.InvalidEmail) } if len(strings.TrimSpace(self.DisplayName)) < 1 || len(strings.TrimSpace(self.DisplayName)) > 50 { - return errors.New("Display name must be between 1 and 50 characters.") + return errors.New(messages.InvalidDisplayName) + } + + if self.Jade > validators.MaxJade { + return errors.New(messages.JadeExceedsMax) } self.Email = strings.ToLower(strings.TrimSpace(self.Email)) diff --git a/shrine/repositories/ipban.go b/shrine/repositories/ipban.go new file mode 100644 index 0000000..9c89dc7 --- /dev/null +++ b/shrine/repositories/ipban.go @@ -0,0 +1,39 @@ +package repositories + +import ( + "shrine/database" + "shrine/models" + "shrine/utils/meta" +) + +func CreateIPBan(ip string, reason string) error { + return database.DB.Create(&models.IPBan{ + IP: ip, + Reason: reason, + }).Error +} + +func DeleteIPBan(ip string) { + database.DB.Where("ip = ?", ip).Delete(&models.IPBan{}) +} + +func IsIPBanned(ip string) bool { + var count int64 + database.DB.Model(&models.IPBan{}).Where("ip = ?", ip).Count(&count) + return count > 0 +} + +func ListIPBans(pagination meta.Pagination, sorting meta.Sorting) ([]models.IPBan, int64) { + var ipBans []models.IPBan + var total int64 + + query := database.DB.Model(&models.IPBan{}) + query.Count(&total) + pagination.Apply(sorting.Apply(query)).Find(&ipBans) + + return ipBans, total +} + +func DeleteIPBanByID(id uint) { + database.DB.Delete(&models.IPBan{}, id) +}
\ No newline at end of file diff --git a/shrine/repositories/token.go b/shrine/repositories/token.go index ef671f8..92e01c6 100644 --- a/shrine/repositories/token.go +++ b/shrine/repositories/token.go @@ -20,4 +20,8 @@ func FindValidToken(tokenHash string) (*models.Token, error) { func DeleteToken(tokenHash string) error { return database.DB.Where("token_hash = ?", tokenHash).Delete(&models.Token{}).Error +} + +func DeleteUserTokens(userID uint) { + database.DB.Where("user_id = ?", userID).Delete(&models.Token{}) }
\ No newline at end of file diff --git a/shrine/router/council.go b/shrine/router/council.go index 2eb96b4..af6815a 100644 --- a/shrine/router/council.go +++ b/shrine/router/council.go @@ -34,4 +34,9 @@ func init() { urls.Path(enums.GET, "/audit", auth.RequireStaff(controllers.ListAuditLogsController), "audit") urls.Path(enums.GET, "/audit/:ref", auth.RequireStaff(controllers.GetAuditLogController), "auditdetail") + + urls.Path(enums.GET, "/bannedips", auth.RequireAdmin(controllers.ListIPBansController), "bannedips") + urls.Path(enums.DELETE, "/bannedips/:id", auth.RequireAdmin(controllers.DeleteIPBanController), "bannedipdelete") + + urls.Path(enums.POST, "/upload", auth.RequireAdmin(controllers.UploadImageController), "upload") }
\ No newline at end of file diff --git a/shrine/services/auth.go b/shrine/services/auth.go index 28e63c4..e1e3bc4 100644 --- a/shrine/services/auth.go +++ b/shrine/services/auth.go @@ -11,11 +11,16 @@ import ( "shrine/utils/crypto" ) -func Register(request account.RegisterRequest) (*common.MessageResponse, *hypertext.ServiceError) { +func Register(request account.RegisterRequest, ip string) (*common.MessageResponse, *hypertext.ServiceError) { + if repositories.IsIPBanned(ip) { + return nil, fail(enums.Forbidden, messages.IPBanned) + } + citizen := models.User{ Username: request.Username, Email: request.Email, DisplayName: request.DisplayName, + IP: ip, } if err := citizen.SetPassword(request.Password); err != nil { @@ -33,12 +38,16 @@ func Register(request account.RegisterRequest) (*common.MessageResponse, *hypert return &common.MessageResponse{Message: messages.AccountCreated}, nil } -func Authenticate(request account.LoginRequest) (*models.User, *hypertext.ServiceError) { +func Authenticate(request account.LoginRequest, ip string) (*models.User, *hypertext.ServiceError) { citizen, err := repositories.FindUserByUsername(request.Username) if err != nil { return nil, fail(enums.Unauthorized, messages.InvalidUsernameOrPassword) } + if !citizen.IsStaff() && repositories.IsIPBanned(ip) { + return nil, fail(enums.Forbidden, messages.IPBanned) + } + if citizen.ClearExpiredDisable() { repositories.UpdateUser(citizen) } @@ -55,6 +64,9 @@ func Authenticate(request account.LoginRequest) (*models.User, *hypertext.Servic return nil, fail(enums.Forbidden, messages.EmailNotVerified) } + citizen.IP = ip + repositories.UpdateUser(citizen) + return citizen, nil } diff --git a/shrine/services/council.go b/shrine/services/council.go index 7c19b72..fca4312 100644 --- a/shrine/services/council.go +++ b/shrine/services/council.go @@ -16,6 +16,7 @@ import ( "shrine/utils/emails" "shrine/utils/logger" "shrine/utils/sanitize" + "shrine/utils/storage" "shrine/utils/validators" "strings" "time" @@ -60,11 +61,17 @@ func BanUser(admin *models.User, target *models.User, request council.BanRequest return nil, fail(enums.Internal, messages.FailedBanUser) } + repositories.DeleteUserTokens(target.ID) + + if target.IP != "" { + repositories.CreateIPBan(target.IP, fmt.Sprintf(messages.AuditBannedIP, target.Username)) + } + if sanitizedMessage != "" { - repositories.CreateSystemLetter(target.ID, fmt.Sprintf("Account Banned [%s]", ref), sanitizedMessage, ref) + repositories.CreateSystemLetter(target.ID, fmt.Sprintf(messages.SystemLetterBanned, ref), sanitizedMessage, ref) } - repositories.LogAction(admin.ID, "user.ban", "user", target.Username, fmt.Sprintf("Banned user @%s", target.Username), audit.BanDetails{ + repositories.LogAction(admin.ID, "user.ban", "user", target.Username, fmt.Sprintf(messages.AuditBannedUser, target.Username), audit.BanDetails{ Reason: request.Reason, SystemRef: ref, }) @@ -87,7 +94,11 @@ func UnbanUser(admin *models.User, target *models.User) (*user.AdminUserResponse return nil, fail(enums.Internal, messages.FailedUnbanUser) } - repositories.LogAction(admin.ID, "user.unban", "user", target.Username, fmt.Sprintf("Unbanned user @%s", target.Username), nil) + if target.IP != "" { + repositories.DeleteIPBan(target.IP) + } + + repositories.LogAction(admin.ID, "user.unban", "user", target.Username, fmt.Sprintf(messages.AuditUnbannedUser, target.Username), nil) response := target.ToAdminResponse() return &response, nil @@ -121,11 +132,13 @@ func DisableUser(admin *models.User, target *models.User, request council.Disabl return nil, fail(enums.Internal, messages.FailedDisableUser) } + repositories.DeleteUserTokens(target.ID) + if sanitizedMessage != "" { - repositories.CreateSystemLetter(target.ID, fmt.Sprintf("Account Disabled [%s]", ref), sanitizedMessage, ref) + repositories.CreateSystemLetter(target.ID, fmt.Sprintf(messages.SystemLetterDisabled, ref), sanitizedMessage, ref) } - repositories.LogAction(admin.ID, "user.disable", "user", target.Username, fmt.Sprintf("Disabled user @%s", target.Username), audit.DisableDetails{ + repositories.LogAction(admin.ID, "user.disable", "user", target.Username, fmt.Sprintf(messages.AuditDisabledUser, target.Username), audit.DisableDetails{ Reason: request.Reason, DisabledUntil: request.DisabledUntil, SystemRef: ref, @@ -150,7 +163,7 @@ func EnableUser(admin *models.User, target *models.User) (*user.AdminUserRespons return nil, fail(enums.Internal, messages.FailedEnableUser) } - repositories.LogAction(admin.ID, "user.enable", "user", target.Username, fmt.Sprintf("Enabled user @%s", target.Username), nil) + repositories.LogAction(admin.ID, "user.enable", "user", target.Username, fmt.Sprintf(messages.AuditEnabledUser, target.Username), nil) response := target.ToAdminResponse() return &response, nil @@ -184,7 +197,7 @@ func ChangeRole(admin *models.User, target *models.User, request council.ChangeR return nil, fail(enums.Internal, messages.FailedChangeRole) } - repositories.LogAction(admin.ID, "user.role_change", "user", target.Username, fmt.Sprintf("Changed role of @%s from %s to %s", target.Username, oldRole, request.Role), audit.RoleChangeDetails{ + repositories.LogAction(admin.ID, "user.role_change", "user", target.Username, fmt.Sprintf(messages.AuditChangedRole, target.Username, oldRole, request.Role), audit.RoleChangeDetails{ OldRole: oldRole, NewRole: request.Role, }) @@ -231,10 +244,16 @@ func EditUser(admin *models.User, target *models.User, request council.EditUserR if *request.Birthday == "" { target.Birthday = nil } else { - parsed, err := time.Parse("2006-01-02", *request.Birthday) + var parsed time.Time + var err error + parsed, err = time.Parse("01-02", *request.Birthday) if err != nil { - return nil, fail(enums.BadRequest, "Invalid birthday format. Use YYYY-MM-DD.") + parsed, err = time.Parse("2006-01-02", *request.Birthday) + if err != nil { + return nil, fail(enums.BadRequest, messages.InvalidBirthdayFormat) + } } + parsed = time.Date(1904, parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.UTC) target.Birthday = &parsed } changes = append(changes, audit.FieldChange{Field: "birthday", Old: nil, New: nil}) @@ -266,11 +285,22 @@ func EditUser(admin *models.User, target *models.User, request council.EditUserR } if request.Signature != nil { + oldImageURL := extractImageURL(target.Signature) + newImageURL := extractImageURL(*request.Signature) + if oldImageURL != "" && oldImageURL != newImageURL { + oldPath := storage.PathFromCDN(oldImageURL) + if oldPath != "" { + storage.Delete(oldPath) + } + } target.Signature = *request.Signature changes = append(changes, audit.FieldChange{Field: "signature", Old: nil, New: nil}) } if request.Jade != nil { + if !validators.IsValidJade(*request.Jade) { + return nil, fail(enums.BadRequest, messages.JadeExceedsMax) + } changes = append(changes, audit.FieldChange{Field: "jade", Old: target.Jade, New: *request.Jade}) target.Jade = *request.Jade } @@ -285,10 +315,10 @@ func EditUser(admin *models.User, target *models.User, request council.EditUserR } if err := repositories.UpdateUser(target); err != nil { - return nil, fail(enums.Internal, messages.FailedUpdateUser) + return nil, mapUserError(err) } - repositories.LogAction(admin.ID, "user.edit", "user", target.Username, fmt.Sprintf("Edited user @%s", target.Username), audit.EditUserDetails{ + repositories.LogAction(admin.ID, "user.edit", "user", target.Username, fmt.Sprintf(messages.AuditEditedUser, target.Username), audit.EditUserDetails{ Changes: changes, }) diff --git a/shrine/services/functions.go b/shrine/services/functions.go index 3836064..d28d76f 100644 --- a/shrine/services/functions.go +++ b/shrine/services/functions.go @@ -2,6 +2,7 @@ package services import ( "fmt" + "regexp" "shrine/enums" "shrine/messages" "shrine/models" @@ -23,6 +24,10 @@ func ResolveUser(username string) (*models.User, *hypertext.ServiceError) { } func mapRegistrationError(err error) *hypertext.ServiceError { + return mapUserError(err) +} + +func mapUserError(err error) *hypertext.ServiceError { if strings.Contains(err.Error(), "users.username") { return fail(enums.Conflict, messages.UsernameAlreadyExists) } @@ -66,7 +71,7 @@ func computeTitle(record *models.Letter, participants []models.LetterParticipant } if record.IsSystem { - return "System Message" + return messages.SystemMessageTitle } var others []string @@ -78,13 +83,13 @@ func computeTitle(record *models.Letter, participants []models.LetterParticipant switch len(others) { case 0: - return "Empty Conversation" + return messages.EmptyConversationTitle case 1: return others[0] case 2: - return fmt.Sprintf("%s and %s", others[0], others[1]) + return fmt.Sprintf(messages.LetterTitleTwo, others[0], others[1]) default: - return fmt.Sprintf("%s, %s, and %d others", others[0], others[1], len(others)-2) + return fmt.Sprintf(messages.LetterTitleMany, others[0], others[1], len(others)-2) } } @@ -147,4 +152,14 @@ func buildCitizenSummaries(citizens []models.User) []user.CitizenSummaryResponse summaries[index] = citizen.ToSummary() } return summaries +} + +var imgSrcPattern = regexp.MustCompile(`<img\s+[^>]*src="([^"]+)"`) + +func extractImageURL(html string) string { + match := imgSrcPattern.FindStringSubmatch(html) + if len(match) < 2 { + return "" + } + return match[1] }
\ No newline at end of file diff --git a/shrine/services/letter.go b/shrine/services/letter.go index 940e9c7..20a4f93 100644 --- a/shrine/services/letter.go +++ b/shrine/services/letter.go @@ -70,7 +70,7 @@ func CreateLetter(userID uint, request letter.CreateRequest) (*common.MessageRes for _, username := range request.Recipients { recipient, err := repositories.FindUserByUsername(username) if err != nil { - return nil, fail(enums.BadRequest, fmt.Sprintf("User '%s' not found.", username)) + return nil, fail(enums.BadRequest, fmt.Sprintf(messages.RecipientNotFound, username)) } if recipient.ID == userID { continue @@ -245,12 +245,12 @@ func RemoveLetterParticipant(ref string, ownerID uint, request letter.RemovePart return nil, fail(enums.Internal, messages.FailedRemoveParticipant) } - return &common.MessageResponse{Message: fmt.Sprintf("%s has been removed.", target.DisplayName)}, nil + return &common.MessageResponse{Message: fmt.Sprintf(messages.ParticipantRemoved, target.DisplayName)}, nil } func UploadLetterAttachment(userID uint, fileName string, fileSize int64, contentType string, reader io.Reader) (*letter.AttachmentResponse, *hypertext.ServiceError) { if fileSize > config.Storage.MaxFileSize { - return nil, fail(enums.BadRequest, fmt.Sprintf("File exceeds the maximum size of %d MB.", config.Storage.MaxFileSize/1024/1024)) + return nil, fail(enums.BadRequest, fmt.Sprintf(messages.FileExceedsMaxSize, config.Storage.MaxFileSize/1024/1024)) } attachment := models.LetterAttachment{ diff --git a/shrine/services/ticket.go b/shrine/services/ticket.go index eff662b..337d51e 100644 --- a/shrine/services/ticket.go +++ b/shrine/services/ticket.go @@ -195,7 +195,7 @@ func UpdateTicket(adminID uint, ref string, request ticket.UpdateRequest) (*tick return nil, fail(enums.Internal, messages.FailedUpdateTicket) } - repositories.LogAction(adminID, "ticket.update", "ticket", record.Ref, fmt.Sprintf("Updated ticket %s", record.Ref), request) + repositories.LogAction(adminID, "ticket.update", "ticket", record.Ref, fmt.Sprintf(messages.AuditUpdatedTicket, record.Ref), request) record, _ = repositories.FindTicketByRef(record.Ref) response := record.ToResponse() diff --git a/shrine/services/warning.go b/shrine/services/warning.go index 34e38eb..0b27591 100644 --- a/shrine/services/warning.go +++ b/shrine/services/warning.go @@ -35,7 +35,7 @@ func WarnUser(admin *models.User, target *models.User, request warning.WarnReque return nil, fail(enums.Internal, messages.FailedCreateWarning) } - repositories.LogAction(admin.ID, "user.warn", "user", target.Username, fmt.Sprintf("Warned user @%s", target.Username), audit.WarningDetails{ + repositories.LogAction(admin.ID, "user.warn", "user", target.Username, fmt.Sprintf(messages.AuditWarnedUser, target.Username), audit.WarningDetails{ WarningRef: record.SystemRef, Title: title, Message: sanitizedMessage, @@ -59,7 +59,7 @@ func DeactivateWarning(admin *models.User, ref string) (*warning.WarningResponse return nil, fail(enums.Internal, messages.FailedDeactivateWarn) } - repositories.LogAction(admin.ID, "user.unwarn", "user", "", fmt.Sprintf("Deactivated warning %s", record.SystemRef), audit.DeactivateWarningDetails{ + repositories.LogAction(admin.ID, "user.unwarn", "user", "", fmt.Sprintf(messages.AuditDeactivatedWarn, record.SystemRef), audit.DeactivateWarningDetails{ WarningRef: record.SystemRef, }) diff --git a/shrine/types/user/user.go b/shrine/types/user/user.go index 4126bfc..f28f2f3 100644 --- a/shrine/types/user/user.go +++ b/shrine/types/user/user.go @@ -38,7 +38,6 @@ type AdminUserResponse struct { DisabledAt *time.Time `json:"disabled_at"` DisabledUntil *time.Time `json:"disabled_until"` LastSeenAt *time.Time `json:"last_seen_at"` - RegistrationIP string `json:"registration_ip"` } type StatsResponse struct { diff --git a/shrine/utils/auth/hierarchy.go b/shrine/utils/auth/hierarchy.go index 2fa6583..a1262b3 100644 --- a/shrine/utils/auth/hierarchy.go +++ b/shrine/utils/auth/hierarchy.go @@ -2,20 +2,21 @@ package auth import ( "fmt" + "shrine/messages" "shrine/models" ) func ValidateHierarchy(admin *models.User, target *models.User, action string) error { if target.ID == admin.ID { - return fmt.Errorf("You cannot %s yourself.", action) + return fmt.Errorf(messages.CannotActionSelf, action) } if target.IsOwner() { - return fmt.Errorf("You cannot %s the owner.", action) + return fmt.Errorf(messages.CannotActionOwner, action) } if target.IsAdmin() && !admin.IsOwner() { - return fmt.Errorf("Only the owner can %s an administrator.", action) + return fmt.Errorf(messages.OnlyOwnerCanActionAdmin, action) } return nil diff --git a/shrine/utils/emails/emails.go b/shrine/utils/emails/emails.go index 7b11327..3b38905 100644 --- a/shrine/utils/emails/emails.go +++ b/shrine/utils/emails/emails.go @@ -4,6 +4,7 @@ import ( "fmt" "net/smtp" "shrine/config" + "shrine/messages" "strconv" "github.com/flosch/pongo2/v6" @@ -19,15 +20,15 @@ func init() { var err error activationTemplate, err = pongo2.FromFile("templates/activation.html") if err != nil { - panic(fmt.Sprintf("failed to load activation template: %v", err)) + panic(fmt.Sprintf(messages.FailedLoadTemplate, "activation", err)) } disabledTemplate, err = pongo2.FromFile("templates/account_disabled.html") if err != nil { - panic(fmt.Sprintf("failed to load disabled template: %v", err)) + panic(fmt.Sprintf(messages.FailedLoadTemplate, "disabled", err)) } bannedTemplate, err = pongo2.FromFile("templates/account_banned.html") if err != nil { - panic(fmt.Sprintf("failed to load banned template: %v", err)) + panic(fmt.Sprintf(messages.FailedLoadTemplate, "banned", err)) } } @@ -60,7 +61,7 @@ func SendActivation(to string, username string, token string) error { return err } - return Send(to, "Verify your Pagoda account", html) + return Send(to, messages.EmailSubjectVerify, html) } func SendDisabledNotification(to string, username string, reason string, disabledUntil string) error { @@ -73,7 +74,7 @@ func SendDisabledNotification(to string, username string, reason string, disable return err } - return Send(to, "Your Pagoda account has been disabled", html) + return Send(to, messages.EmailSubjectDisabled, html) } func SendBannedNotification(to string, username string, reason string) error { @@ -85,5 +86,5 @@ func SendBannedNotification(to string, username string, reason string) error { return err } - return Send(to, "Your Pagoda account has been banned", html) + return Send(to, messages.EmailSubjectBanned, html) }
\ No newline at end of file diff --git a/shrine/utils/env/validator.go b/shrine/utils/env/validator.go index fa9b17f..4c654ca 100644 --- a/shrine/utils/env/validator.go +++ b/shrine/utils/env/validator.go @@ -1,14 +1,15 @@ package env import ( - "fmt" + "errors" "reflect" + "shrine/messages" ) func validateConfigInput(config any) (reflect.Value, reflect.Type, error) { v := reflect.ValueOf(config) if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct { - return reflect.Value{}, nil, fmt.Errorf("config must be a pointer to struct") + return reflect.Value{}, nil, errors.New(messages.ConfigMustBePointer) } elem := v.Elem() return elem, elem.Type(), nil diff --git a/shrine/utils/storage/storage.go b/shrine/utils/storage/storage.go index 3dcaebe..d071149 100644 --- a/shrine/utils/storage/storage.go +++ b/shrine/utils/storage/storage.go @@ -2,9 +2,10 @@ package storage import ( "context" - "fmt" + "errors" "io" "shrine/config" + "shrine/messages" "shrine/utils/logger" "strings" @@ -34,7 +35,7 @@ func init() { func Upload(path string, reader io.Reader, size int64, contentType string) error { if Client == nil { - return fmt.Errorf("storage not configured") + return errors.New(messages.StorageNotConfigured) } _, err := Client.PutObject(context.Background(), config.Storage.Bucket, path, reader, size, minio.PutObjectOptions{ ContentType: contentType, @@ -44,7 +45,7 @@ func Upload(path string, reader io.Reader, size int64, contentType string) error func Delete(path string) error { if Client == nil { - return fmt.Errorf("storage not configured") + return errors.New(messages.StorageNotConfigured) } return Client.RemoveObject(context.Background(), config.Storage.Bucket, path, minio.RemoveObjectOptions{}) } @@ -54,4 +55,12 @@ func ResolveCDN(path string) string { return "" } return strings.TrimRight(config.Storage.CDN, "/") + "/" + config.Storage.Bucket + "/" + path +} + +func PathFromCDN(cdnURL string) string { + prefix := strings.TrimRight(config.Storage.CDN, "/") + "/" + config.Storage.Bucket + "/" + if !strings.HasPrefix(cdnURL, prefix) { + return "" + } + return strings.TrimPrefix(cdnURL, prefix) }
\ No newline at end of file diff --git a/shrine/utils/validators/jade.go b/shrine/utils/validators/jade.go new file mode 100644 index 0000000..5e69f8e --- /dev/null +++ b/shrine/utils/validators/jade.go @@ -0,0 +1,7 @@ +package validators + +const MaxJade uint64 = 99999 + +func IsValidJade(amount uint64) bool { + return amount <= MaxJade +}
\ No newline at end of file |
