added admin dashboard
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { readJson, writeJson } from './storage';
|
||||
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
|
||||
const ADMIN_FILE = 'admin.json';
|
||||
const SESSION_FILE = 'sessions.json';
|
||||
const SESSION_COOKIE = 'gds_admin';
|
||||
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7; // 7 days
|
||||
|
||||
type AdminRecord = {
|
||||
username: string;
|
||||
salt: string;
|
||||
hash: string;
|
||||
};
|
||||
|
||||
type SessionRecord = {
|
||||
token: string;
|
||||
username: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
function hashPassword(password: string, salt: string): string {
|
||||
return scryptSync(password, salt, 64).toString('hex');
|
||||
}
|
||||
|
||||
export function seedDefaultAdminIfMissing(): { username: string; tempPassword?: string } {
|
||||
const existing = readJson<AdminRecord | null>(ADMIN_FILE, null);
|
||||
if (existing) return { username: existing.username };
|
||||
const username = process.env.ADMIN_USERNAME || 'admin';
|
||||
const password = process.env.ADMIN_PASSWORD || 'changeme';
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const record: AdminRecord = { username, salt, hash: hashPassword(password, salt) };
|
||||
writeJson(ADMIN_FILE, record);
|
||||
return { username, tempPassword: password };
|
||||
}
|
||||
|
||||
export function verifyCredentials(username: string, password: string): boolean {
|
||||
const record = readJson<AdminRecord | null>(ADMIN_FILE, null);
|
||||
if (!record) return false;
|
||||
if (record.username !== username) return false;
|
||||
const candidate = Buffer.from(hashPassword(password, record.salt), 'hex');
|
||||
const stored = Buffer.from(record.hash, 'hex');
|
||||
if (candidate.length !== stored.length) return false;
|
||||
return timingSafeEqual(candidate, stored);
|
||||
}
|
||||
|
||||
export function changePassword(currentPassword: string, newPassword: string): boolean {
|
||||
const record = readJson<AdminRecord | null>(ADMIN_FILE, null);
|
||||
if (!record) return false;
|
||||
if (!verifyCredentials(record.username, currentPassword)) return false;
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
writeJson(ADMIN_FILE, {
|
||||
username: record.username,
|
||||
salt,
|
||||
hash: hashPassword(newPassword, salt)
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function loadSessions(): SessionRecord[] {
|
||||
const now = Date.now();
|
||||
const all = readJson<SessionRecord[]>(SESSION_FILE, []);
|
||||
const live = all.filter((s) => s.expiresAt > now);
|
||||
if (live.length !== all.length) writeJson(SESSION_FILE, live);
|
||||
return live;
|
||||
}
|
||||
|
||||
export function createSession(username: string, cookies: Cookies): string {
|
||||
const sessions = loadSessions();
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const record: SessionRecord = { token, username, expiresAt: Date.now() + SESSION_TTL_MS };
|
||||
sessions.push(record);
|
||||
writeJson(SESSION_FILE, sessions);
|
||||
cookies.set(SESSION_COOKIE, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: SESSION_TTL_MS / 1000
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
export function destroySession(cookies: Cookies): void {
|
||||
const token = cookies.get(SESSION_COOKIE);
|
||||
if (token) {
|
||||
const sessions = loadSessions().filter((s) => s.token !== token);
|
||||
writeJson(SESSION_FILE, sessions);
|
||||
}
|
||||
cookies.delete(SESSION_COOKIE, { path: '/' });
|
||||
}
|
||||
|
||||
export function getSessionUser(cookies: Cookies): { username: string } | null {
|
||||
const token = cookies.get(SESSION_COOKIE);
|
||||
if (!token) return null;
|
||||
const session = loadSessions().find((s) => s.token === token);
|
||||
if (!session) return null;
|
||||
return { username: session.username };
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { readJson, writeJson } from './storage';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
buildDefaultContent,
|
||||
DEFAULT_IMAGES,
|
||||
DEFAULT_SLOTS,
|
||||
type SiteContent,
|
||||
type ImageSlot,
|
||||
type Testimonial
|
||||
} from '$lib/content/defaults';
|
||||
|
||||
const FILE = 'content.json';
|
||||
|
||||
export function getContent(): SiteContent {
|
||||
const seed = buildDefaultContent();
|
||||
const stored = readJson<Partial<SiteContent>>(FILE, seed);
|
||||
return {
|
||||
copy: { ...seed.copy, ...(stored.copy ?? {}) },
|
||||
images: { ...seed.images, ...(stored.images ?? {}) },
|
||||
testimonials: stored.testimonials ?? seed.testimonials,
|
||||
seo: { ...seed.seo, ...(stored.seo ?? {}) }
|
||||
};
|
||||
}
|
||||
|
||||
export function setCopy(id: string, value: string): void {
|
||||
const c = getContent();
|
||||
c.copy[id] = value;
|
||||
writeJson(FILE, c);
|
||||
}
|
||||
|
||||
export function setCopyBulk(updates: Record<string, string>): void {
|
||||
const c = getContent();
|
||||
for (const [k, v] of Object.entries(updates)) c.copy[k] = v;
|
||||
writeJson(FILE, c);
|
||||
}
|
||||
|
||||
export function setImage(id: string, patch: Partial<ImageSlot>): ImageSlot {
|
||||
const c = getContent();
|
||||
const current = c.images[id] ?? DEFAULT_IMAGES[id];
|
||||
if (!current) throw new Error(`Unknown image slot: ${id}`);
|
||||
const next: ImageSlot = {
|
||||
id,
|
||||
label: current.label,
|
||||
src: patch.src ?? current.src,
|
||||
alt: patch.alt ?? current.alt
|
||||
};
|
||||
c.images[id] = next;
|
||||
writeJson(FILE, c);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function resetImage(id: string): ImageSlot {
|
||||
const c = getContent();
|
||||
const fallback = DEFAULT_IMAGES[id];
|
||||
if (!fallback) throw new Error(`Unknown image slot: ${id}`);
|
||||
c.images[id] = { ...fallback };
|
||||
writeJson(FILE, c);
|
||||
return c.images[id];
|
||||
}
|
||||
|
||||
export function setSeo(patch: Partial<SiteContent['seo']>): void {
|
||||
const c = getContent();
|
||||
c.seo = { ...c.seo, ...patch };
|
||||
writeJson(FILE, c);
|
||||
}
|
||||
|
||||
export function listSlots() {
|
||||
return DEFAULT_SLOTS;
|
||||
}
|
||||
|
||||
export function addTestimonial(input: Omit<Testimonial, 'id'>): Testimonial {
|
||||
const c = getContent();
|
||||
const entry: Testimonial = { id: `t_${randomUUID().slice(0, 8)}`, ...input };
|
||||
c.testimonials = [...c.testimonials, entry];
|
||||
writeJson(FILE, c);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function updateTestimonial(id: string, patch: Partial<Omit<Testimonial, 'id'>>): Testimonial | null {
|
||||
const c = getContent();
|
||||
let updated: Testimonial | null = null;
|
||||
c.testimonials = c.testimonials.map((t) => {
|
||||
if (t.id !== id) return t;
|
||||
updated = { ...t, ...patch };
|
||||
return updated;
|
||||
});
|
||||
if (updated) writeJson(FILE, c);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function deleteTestimonial(id: string): void {
|
||||
const c = getContent();
|
||||
c.testimonials = c.testimonials.filter((t) => t.id !== id);
|
||||
writeJson(FILE, c);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { DEFAULT_SLOTS } from '$lib/content/defaults';
|
||||
import { analyzeCopy, aggregateScore } from '$lib/seo';
|
||||
import { getContent } from './content';
|
||||
|
||||
export function computeHealth(): { seo: number; geo: number } {
|
||||
const content = getContent();
|
||||
const analyses = DEFAULT_SLOTS
|
||||
.filter((s) => s.kind === 'multiline' || s.id.endsWith('_text'))
|
||||
.slice(0, 30)
|
||||
.map((s) => analyzeCopy(content.copy[s.id] ?? s.value, content.seo.primaryKeywords));
|
||||
return aggregateScore(analyses);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { readJson, writeJson } from './storage';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export type NotificationType = 'submission' | 'seo_drop' | 'geo_drop';
|
||||
|
||||
export type Notification = {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
link?: string;
|
||||
createdAt: string;
|
||||
read: boolean;
|
||||
};
|
||||
|
||||
const FILE = 'notifications.json';
|
||||
const MAX_KEEP = 200;
|
||||
|
||||
export function listNotifications(): Notification[] {
|
||||
const all = readJson<Notification[]>(FILE, []);
|
||||
return [...all].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
export function unreadCount(): number {
|
||||
return readJson<Notification[]>(FILE, []).filter((n) => !n.read).length;
|
||||
}
|
||||
|
||||
export function addNotification(input: Omit<Notification, 'id' | 'createdAt' | 'read'>): Notification {
|
||||
const all = readJson<Notification[]>(FILE, []);
|
||||
const entry: Notification = {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
read: false,
|
||||
...input
|
||||
};
|
||||
all.push(entry);
|
||||
// Keep only the most recent MAX_KEEP
|
||||
const sorted = all.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, MAX_KEEP);
|
||||
writeJson(FILE, sorted);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function markRead(id: string): void {
|
||||
const all = readJson<Notification[]>(FILE, []);
|
||||
writeJson(FILE, all.map((n) => (n.id === id ? { ...n, read: true } : n)));
|
||||
}
|
||||
|
||||
export function markAllRead(): void {
|
||||
const all = readJson<Notification[]>(FILE, []);
|
||||
writeJson(FILE, all.map((n) => ({ ...n, read: true })));
|
||||
}
|
||||
|
||||
export function deleteNotification(id: string): void {
|
||||
const all = readJson<Notification[]>(FILE, []);
|
||||
writeJson(FILE, all.filter((n) => n.id !== id));
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const DATA_DIR = join(process.cwd(), 'data');
|
||||
|
||||
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
export function dataPath(name: string): string {
|
||||
return join(DATA_DIR, name);
|
||||
}
|
||||
|
||||
export function readJson<T>(name: string, fallback: T): T {
|
||||
const p = dataPath(name);
|
||||
if (!existsSync(p)) return fallback;
|
||||
try {
|
||||
return JSON.parse(readFileSync(p, 'utf8')) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJson<T>(name: string, value: T): void {
|
||||
const p = dataPath(name);
|
||||
const dir = dirname(p);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
const tmp = `${p}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8');
|
||||
renameSync(tmp, p);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readJson, writeJson } from './storage';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export type Submission = {
|
||||
id: string;
|
||||
name: string;
|
||||
dog: string;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
ip?: string;
|
||||
};
|
||||
|
||||
const FILE = 'submissions.json';
|
||||
|
||||
export function listSubmissions(): Submission[] {
|
||||
const items = readJson<Submission[]>(FILE, []);
|
||||
return [...items].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
export function addSubmission(input: Omit<Submission, 'id' | 'createdAt'>): Submission {
|
||||
const items = readJson<Submission[]>(FILE, []);
|
||||
const entry: Submission = {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
...input
|
||||
};
|
||||
items.push(entry);
|
||||
writeJson(FILE, items);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function deleteSubmission(id: string): void {
|
||||
const items = readJson<Submission[]>(FILE, []);
|
||||
writeJson(FILE, items.filter((s) => s.id !== id));
|
||||
}
|
||||
Reference in New Issue
Block a user