OPC
This commit is contained in:
68
lib/auth.ts
Normal file
68
lib/auth.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const COOKIE_NAME = "admin_session";
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function getSecret() {
|
||||
const secret = process.env.SESSION_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("SESSION_SECRET is required");
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
export type SessionPayload = {
|
||||
role: "admin";
|
||||
iat: number;
|
||||
};
|
||||
|
||||
async function hmacSha256(data: string, secret: string): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
||||
return Buffer.from(sig).toString("base64url");
|
||||
}
|
||||
|
||||
export async function signSession(payload: SessionPayload): Promise<string> {
|
||||
const secret = getSecret();
|
||||
const base = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const sig = await hmacSha256(base, secret);
|
||||
return `${base}.${sig}`;
|
||||
}
|
||||
|
||||
export async function verifySession(token?: string): Promise<SessionPayload | null> {
|
||||
if (!token) return null;
|
||||
const secret = getSecret();
|
||||
const [base, sig] = token.split(".");
|
||||
if (!base || !sig) return null;
|
||||
const check = await hmacSha256(base, secret);
|
||||
if (check !== sig) return null;
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(base, "base64url").toString());
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAdminFromRequest(req: NextRequest): Promise<boolean> {
|
||||
const token = req.cookies.get(COOKIE_NAME)?.value;
|
||||
return Boolean(await verifySession(token));
|
||||
}
|
||||
|
||||
export function setAdminCookie(token: string) {
|
||||
cookies().set(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: true,
|
||||
maxAge: 60 * 60 * 24 * 30
|
||||
});
|
||||
}
|
||||
|
||||
export const cookieName = COOKIE_NAME;
|
||||
24
lib/mongo.ts
Normal file
24
lib/mongo.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Db, MongoClient } from "mongodb";
|
||||
|
||||
const uri = process.env.MONGODB_URI;
|
||||
const dbName = process.env.MONGODB_DB || "pushinfo";
|
||||
|
||||
if (!uri) {
|
||||
throw new Error("Missing MONGODB_URI in environment variables");
|
||||
}
|
||||
|
||||
let client: MongoClient | null = null;
|
||||
let db: Db | null = null;
|
||||
|
||||
export async function getDb(): Promise<Db> {
|
||||
if (db) return db;
|
||||
|
||||
if (!client) {
|
||||
client = new MongoClient(uri, { serverSelectionTimeoutMS: 5000 });
|
||||
}
|
||||
if (!client.topology?.isConnected()) {
|
||||
await client.connect();
|
||||
}
|
||||
db = client.db(dbName);
|
||||
return db;
|
||||
}
|
||||
14
lib/normalize.ts
Normal file
14
lib/normalize.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export function normalizeImageUrl(input?: string): string | undefined {
|
||||
if (!input) return undefined;
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (trimmed.startsWith("data:") || trimmed.startsWith("blob:")) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.startsWith("https://")) return trimmed;
|
||||
if (trimmed.startsWith("http://")) return `https://${trimmed.slice(7)}`;
|
||||
if (trimmed.startsWith("//")) return `https:${trimmed}`;
|
||||
if (trimmed.startsWith("img.020417.xyz/")) return `https://${trimmed}`;
|
||||
if (trimmed.startsWith("/")) return `https://img.020417.xyz${trimmed}`;
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
14
lib/search.ts
Normal file
14
lib/search.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export function escapeRegExp(input: string): string {
|
||||
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export function buildSearchFilter(query?: string) {
|
||||
if (!query) return null;
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return null;
|
||||
const safe = escapeRegExp(trimmed);
|
||||
const regex = new RegExp(safe, "i");
|
||||
return {
|
||||
$or: [{ title: { $regex: regex } }, { markdown: { $regex: regex } }]
|
||||
};
|
||||
}
|
||||
6
lib/site.ts
Normal file
6
lib/site.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function getSiteUrl(): string {
|
||||
const raw = process.env.NEXT_PUBLIC_SITE_URL || process.env.SITE_URL;
|
||||
const fallback = "http://localhost:3000";
|
||||
const base = raw && raw.trim().length > 0 ? raw.trim() : fallback;
|
||||
return base.replace(/\/+$/, "");
|
||||
}
|
||||
22
lib/slug.ts
Normal file
22
lib/slug.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export function generateSlug(): string {
|
||||
const cryptoApi = globalThis.crypto;
|
||||
if (!cryptoApi?.getRandomValues) {
|
||||
throw new Error("Web Crypto API is not available");
|
||||
}
|
||||
|
||||
const timestamp = Date.now().toString().padStart(13, "0").slice(-13);
|
||||
const prefixLength = 64 - 13;
|
||||
const bytes = new Uint8Array(Math.ceil(prefixLength / 8));
|
||||
cryptoApi.getRandomValues(bytes);
|
||||
|
||||
let prefix = "";
|
||||
for (const byte of bytes) {
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
prefix += (byte >> bit) & 1 ? "O" : "o";
|
||||
if (prefix.length === prefixLength) break;
|
||||
}
|
||||
if (prefix.length === prefixLength) break;
|
||||
}
|
||||
|
||||
return `${prefix}${timestamp}`;
|
||||
}
|
||||
Reference in New Issue
Block a user