summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-03-10 14:00:28 +0530
committerBobby <[email protected]>2026-03-10 14:00:28 +0530
commit03a9b16a0fd7cc5a293a1289646817e21663f4bd (patch)
tree3d11cd3cd5c61c32e4db1e9a6d1df527353789bc
parented6c3bc61c02a5ca6998b39781dc60877f1cfe82 (diff)
downloadpagoda-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.
-rw-r--r--garden/src/api.ts6
-rw-r--r--garden/src/components/DatePicker.tsx150
-rw-r--r--garden/src/components/Editor.tsx2
-rw-r--r--garden/src/components/Layout.tsx41
-rw-r--r--garden/src/components/MiniEditor.tsx288
-rw-r--r--garden/src/components/MonthDayPicker.tsx147
-rw-r--r--garden/src/components/StaffGuard.tsx28
-rw-r--r--garden/src/index.css3
-rw-r--r--garden/src/pages/council/bannedips.tsx116
-rw-r--r--garden/src/pages/council/user.tsx313
-rw-r--r--garden/src/pages/council/users.tsx3
-rw-r--r--garden/src/routes.ts1
-rw-r--r--garden/src/styles/council.css106
-rw-r--r--garden/src/styles/datepicker.css271
-rw-r--r--garden/src/types/admin.ts1
-rw-r--r--shrine/config/functions.go7
-rw-r--r--shrine/controllers/auth.go4
-rw-r--r--shrine/controllers/council.go56
-rw-r--r--shrine/database/migrate.go1
-rw-r--r--shrine/messages/auth.go4
-rw-r--r--shrine/messages/council.go16
-rw-r--r--shrine/messages/letter.go7
-rw-r--r--shrine/messages/system.go13
-rw-r--r--shrine/messages/validation.go9
-rw-r--r--shrine/models/ipban.go12
-rw-r--r--shrine/models/user.go26
-rw-r--r--shrine/repositories/ipban.go39
-rw-r--r--shrine/repositories/token.go4
-rw-r--r--shrine/router/council.go5
-rw-r--r--shrine/services/auth.go16
-rw-r--r--shrine/services/council.go52
-rw-r--r--shrine/services/functions.go23
-rw-r--r--shrine/services/letter.go6
-rw-r--r--shrine/services/ticket.go2
-rw-r--r--shrine/services/warning.go4
-rw-r--r--shrine/types/user/user.go1
-rw-r--r--shrine/utils/auth/hierarchy.go7
-rw-r--r--shrine/utils/emails/emails.go13
-rw-r--r--shrine/utils/env/validator.go5
-rw-r--r--shrine/utils/storage/storage.go15
-rw-r--r--shrine/utils/validators/jade.go7
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}>&lsaquo;</button>
+ <span class="datepicker-nav-title">{MONTHS[viewMonth()]} {viewYear()}</span>
+ <button type="button" class="datepicker-nav-btn" onClick={nextMonth}>&rsaquo;</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">&larr; 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