removed excess comments
This commit is contained in:
@@ -7,7 +7,6 @@ import { CACHE_CONFIG } from "~/config";
|
||||
|
||||
const BLOG_CACHE_TTL = CACHE_CONFIG.BLOG_CACHE_TTL_MS;
|
||||
|
||||
// Shared cache function for all blog posts
|
||||
const getAllPostsData = async (privilegeLevel: string) => {
|
||||
return withCacheAndStale(
|
||||
`blog-posts-${privilegeLevel}`,
|
||||
@@ -15,7 +14,6 @@ const getAllPostsData = async (privilegeLevel: string) => {
|
||||
async () => {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Fetch all posts with aggregated data
|
||||
let postsQuery = `
|
||||
SELECT
|
||||
p.id,
|
||||
@@ -73,10 +71,8 @@ const getAllPostsData = async (privilegeLevel: string) => {
|
||||
|
||||
export const blogRouter = createTRPCRouter({
|
||||
getRecentPosts: publicProcedure.query(async ({ ctx }) => {
|
||||
// Always use public privilege level for recent posts (only show published)
|
||||
const allPostsData = await getAllPostsData("public");
|
||||
|
||||
// Return only the 3 most recent posts (already sorted DESC by date)
|
||||
return allPostsData.posts.slice(0, 3);
|
||||
}),
|
||||
|
||||
|
||||
@@ -442,7 +442,6 @@ export const databaseRouter = createTRPCRouter({
|
||||
try {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Check if post is being published for the first time
|
||||
let shouldSetPublishDate = false;
|
||||
if (input.published !== undefined && input.published !== null) {
|
||||
const currentPostQuery = await conn.execute({
|
||||
@@ -451,7 +450,6 @@ export const databaseRouter = createTRPCRouter({
|
||||
});
|
||||
const currentPost = currentPostQuery.rows[0] as any;
|
||||
|
||||
// Set publish date if transitioning from unpublished to published and date is null
|
||||
if (
|
||||
currentPost &&
|
||||
!currentPost.published &&
|
||||
@@ -500,14 +498,12 @@ export const databaseRouter = createTRPCRouter({
|
||||
first = false;
|
||||
}
|
||||
|
||||
// Set date if publishing for the first time
|
||||
if (shouldSetPublishDate) {
|
||||
query += first ? "date = ?" : ", date = ?";
|
||||
params.push(new Date().toISOString());
|
||||
first = false;
|
||||
}
|
||||
|
||||
// Always update last_edited_date
|
||||
query += first ? "last_edited_date = ?" : ", last_edited_date = ?";
|
||||
params.push(new Date().toISOString());
|
||||
first = false;
|
||||
@@ -581,10 +577,6 @@ export const databaseRouter = createTRPCRouter({
|
||||
}
|
||||
}),
|
||||
|
||||
// ============================================================
|
||||
// Post Likes Routes
|
||||
// ============================================================
|
||||
|
||||
addPostLike: publicProcedure
|
||||
.input(togglePostLikeMutationSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
@@ -640,10 +632,6 @@ export const databaseRouter = createTRPCRouter({
|
||||
}
|
||||
}),
|
||||
|
||||
// ============================================================
|
||||
// User Routes
|
||||
// ============================================================
|
||||
|
||||
getUserById: publicProcedure
|
||||
.input(getUserByIdSchema)
|
||||
.query(async ({ input }) => {
|
||||
|
||||
@@ -226,7 +226,6 @@ export const gitActivityRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
|
||||
// Get GitHub contribution activity (for heatmap)
|
||||
getGitHubActivity: publicProcedure.query(async () => {
|
||||
return withCacheAndStale(
|
||||
"github-activity",
|
||||
@@ -306,7 +305,6 @@ export const gitActivityRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
|
||||
// Get Gitea contribution activity (for heatmap)
|
||||
getGiteaActivity: publicProcedure.query(async () => {
|
||||
return withCacheAndStale(
|
||||
"gitea-activity",
|
||||
|
||||
@@ -3,13 +3,10 @@ import { env } from "~/env/server";
|
||||
|
||||
export const infillRouter = createTRPCRouter({
|
||||
getConfig: publicProcedure.query(({ ctx }) => {
|
||||
// Only admins get the config
|
||||
if (ctx.privilegeLevel !== "admin") {
|
||||
return { endpoint: null, token: null };
|
||||
}
|
||||
|
||||
// Return endpoint and token (or null if not configured)
|
||||
// Now supports both desktop and mobile (fullscreen mode)
|
||||
return {
|
||||
endpoint: env.VITE_INFILL_ENDPOINT || null,
|
||||
token: env.INFILL_BEARER_TOKEN || null
|
||||
|
||||
@@ -7,21 +7,15 @@ import { lineagePvpRouter } from "./lineage/pvp";
|
||||
import { lineageMaintenanceRouter } from "./lineage/maintenance";
|
||||
|
||||
export const lineageRouter = createTRPCRouter({
|
||||
// Authentication
|
||||
auth: lineageAuthRouter,
|
||||
|
||||
// Database Management
|
||||
database: lineageDatabaseRouter,
|
||||
|
||||
// PvP
|
||||
pvp: lineagePvpRouter,
|
||||
|
||||
// JSON Service
|
||||
jsonService: lineageJsonServiceRouter,
|
||||
|
||||
// Misc (Analytics, Tokens, etc.)
|
||||
misc: lineageMiscRouter,
|
||||
|
||||
// Maintenance (Protected)
|
||||
maintenance: lineageMaintenanceRouter,
|
||||
});
|
||||
maintenance: lineageMaintenanceRouter
|
||||
});
|
||||
|
||||
@@ -427,7 +427,6 @@ export const lineageDatabaseRouter = createTRPCRouter({
|
||||
`Failed to delete database ${db_name} in cron job:`,
|
||||
error
|
||||
);
|
||||
// Continue with other deletions even if one fails
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -455,7 +454,6 @@ export const lineageDatabaseRouter = createTRPCRouter({
|
||||
`Failed to delete database ${db_name} in cron job:`,
|
||||
error
|
||||
);
|
||||
// Continue with other deletions even if one fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createTRPCRouter, publicProcedure } from "../../utils";
|
||||
|
||||
// Attack data imports
|
||||
import playerAttacks from "~/lineage-json/attack-route/playerAttacks.json";
|
||||
import mageBooks from "~/lineage-json/attack-route/mageBooks.json";
|
||||
import mageSpells from "~/lineage-json/attack-route/mageSpells.json";
|
||||
@@ -12,21 +11,17 @@ import paladinBooks from "~/lineage-json/attack-route/paladinBooks.json";
|
||||
import paladinSpells from "~/lineage-json/attack-route/paladinSpells.json";
|
||||
import summons from "~/lineage-json/attack-route/summons.json";
|
||||
|
||||
// Conditions data imports
|
||||
import conditions from "~/lineage-json/conditions-route/conditions.json";
|
||||
import debilitations from "~/lineage-json/conditions-route/debilitations.json";
|
||||
import sanityDebuffs from "~/lineage-json/conditions-route/sanityDebuffs.json";
|
||||
|
||||
// Dungeon data imports
|
||||
import dungeons from "~/lineage-json/dungeon-route/dungeons.json";
|
||||
import specialEncounters from "~/lineage-json/dungeon-route/specialEncounters.json";
|
||||
|
||||
// Enemy data imports
|
||||
import bosses from "~/lineage-json/enemy-route/bosses.json";
|
||||
import enemies from "~/lineage-json/enemy-route/enemy.json";
|
||||
import enemyAttacks from "~/lineage-json/enemy-route/enemyAttacks.json";
|
||||
|
||||
// Item data imports
|
||||
import arrows from "~/lineage-json/item-route/arrows.json";
|
||||
import bows from "~/lineage-json/item-route/bows.json";
|
||||
import foci from "~/lineage-json/item-route/foci.json";
|
||||
@@ -69,7 +64,7 @@ export const lineageJsonServiceRouter = createTRPCRouter({
|
||||
rangerSpells,
|
||||
paladinBooks,
|
||||
paladinSpells,
|
||||
summons,
|
||||
summons
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -78,7 +73,7 @@ export const lineageJsonServiceRouter = createTRPCRouter({
|
||||
ok: true,
|
||||
conditions,
|
||||
debilitations,
|
||||
sanityDebuffs,
|
||||
sanityDebuffs
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -86,7 +81,7 @@ export const lineageJsonServiceRouter = createTRPCRouter({
|
||||
return {
|
||||
ok: true,
|
||||
dungeons,
|
||||
specialEncounters,
|
||||
specialEncounters
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -95,7 +90,7 @@ export const lineageJsonServiceRouter = createTRPCRouter({
|
||||
ok: true,
|
||||
bosses,
|
||||
enemies,
|
||||
enemyAttacks,
|
||||
enemyAttacks
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -120,7 +115,7 @@ export const lineageJsonServiceRouter = createTRPCRouter({
|
||||
prefix,
|
||||
potions,
|
||||
poison,
|
||||
staves,
|
||||
staves
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -134,7 +129,7 @@ export const lineageJsonServiceRouter = createTRPCRouter({
|
||||
otherOptions,
|
||||
healthOptions,
|
||||
sanityOptions,
|
||||
pvpRewards,
|
||||
pvpRewards
|
||||
};
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
@@ -28,10 +28,6 @@ const assets: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const miscRouter = createTRPCRouter({
|
||||
// ============================================================
|
||||
// Downloads endpoint
|
||||
// ============================================================
|
||||
|
||||
getDownloadUrl: publicProcedure
|
||||
.input(z.object({ asset_name: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
@@ -73,10 +69,6 @@ export const miscRouter = createTRPCRouter({
|
||||
}
|
||||
}),
|
||||
|
||||
// ============================================================
|
||||
// S3 Operations
|
||||
// ============================================================
|
||||
|
||||
getPreSignedURL: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -99,10 +91,10 @@ export const miscRouter = createTRPCRouter({
|
||||
|
||||
const sanitizeForS3 = (str: string) => {
|
||||
return str
|
||||
.replace(/\s+/g, "-") // Replace spaces with hyphens
|
||||
.replace(/[^\w\-\.]/g, "") // Remove special characters except hyphens, dots, and word chars
|
||||
.replace(/\-+/g, "-") // Replace multiple hyphens with single hyphen
|
||||
.replace(/^-+|-+$/g, ""); // Remove leading/trailing hyphens
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^\w\-\.]/g, "")
|
||||
.replace(/\-+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
};
|
||||
|
||||
const sanitizedTitle = sanitizeForS3(input.title);
|
||||
@@ -210,10 +202,6 @@ export const miscRouter = createTRPCRouter({
|
||||
}
|
||||
}),
|
||||
|
||||
// ============================================================
|
||||
// Password Hashing
|
||||
// ============================================================
|
||||
|
||||
hashPassword: publicProcedure
|
||||
.input(z.object({ password: z.string().min(8) }))
|
||||
.mutation(async ({ input }) => {
|
||||
@@ -249,10 +237,6 @@ export const miscRouter = createTRPCRouter({
|
||||
}
|
||||
}),
|
||||
|
||||
// ============================================================
|
||||
// Contact Form
|
||||
// ============================================================
|
||||
|
||||
sendContactRequest: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -324,7 +308,6 @@ export const miscRouter = createTRPCRouter({
|
||||
|
||||
return { message: "email sent" };
|
||||
} catch (error) {
|
||||
// Provide specific error messages for different failure types
|
||||
if (error instanceof TimeoutError) {
|
||||
console.error("Contact form email timeout:", error.message);
|
||||
throw new TRPCError({
|
||||
@@ -359,10 +342,6 @@ export const miscRouter = createTRPCRouter({
|
||||
}
|
||||
}),
|
||||
|
||||
// ============================================================
|
||||
// Account Deletion Request
|
||||
// ============================================================
|
||||
|
||||
sendDeletionRequestEmail: publicProcedure
|
||||
.input(z.object({ email: z.string().email() }))
|
||||
.mutation(async ({ input }) => {
|
||||
@@ -384,7 +363,6 @@ export const miscRouter = createTRPCRouter({
|
||||
const apiKey = env.SENDINBLUE_KEY;
|
||||
const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
|
||||
|
||||
// Email to admin
|
||||
const sendinblueMyData = {
|
||||
sender: {
|
||||
name: "freno.me",
|
||||
@@ -395,7 +373,6 @@ export const miscRouter = createTRPCRouter({
|
||||
subject: "Life and Lineage Acct Deletion"
|
||||
};
|
||||
|
||||
// Email to user
|
||||
const sendinblueUserData = {
|
||||
sender: {
|
||||
name: "freno.me",
|
||||
@@ -407,7 +384,6 @@ export const miscRouter = createTRPCRouter({
|
||||
};
|
||||
|
||||
try {
|
||||
// Send both emails with retry logic
|
||||
await Promise.all([
|
||||
fetchWithRetry(
|
||||
async () => {
|
||||
@@ -459,7 +435,6 @@ export const miscRouter = createTRPCRouter({
|
||||
|
||||
return { message: "request sent" };
|
||||
} catch (error) {
|
||||
// Provide specific error messages
|
||||
if (error instanceof TimeoutError) {
|
||||
console.error("Deletion request email timeout:", error.message);
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getUserID } from "~/server/auth";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import diff from "fast-diff";
|
||||
|
||||
// Helper to create diff patch between two HTML strings
|
||||
export function createDiffPatch(
|
||||
oldContent: string,
|
||||
newContent: string
|
||||
@@ -14,7 +13,6 @@ export function createDiffPatch(
|
||||
return JSON.stringify(changes);
|
||||
}
|
||||
|
||||
// Helper to apply diff patch to content
|
||||
export function applyDiffPatch(baseContent: string, patchJson: string): string {
|
||||
const changes = JSON.parse(patchJson);
|
||||
let result = "";
|
||||
@@ -34,12 +32,10 @@ export function applyDiffPatch(baseContent: string, patchJson: string): string {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Helper to reconstruct content from history chain
|
||||
async function reconstructContent(
|
||||
conn: ReturnType<typeof ConnectionFactory>,
|
||||
historyId: number
|
||||
): Promise<string> {
|
||||
// Get the full chain from root to this history entry
|
||||
const chain: Array<{
|
||||
id: number;
|
||||
parent_id: number | null;
|
||||
@@ -70,7 +66,6 @@ async function reconstructContent(
|
||||
currentId = row.parent_id;
|
||||
}
|
||||
|
||||
// Apply patches in order
|
||||
let content = "";
|
||||
for (const entry of chain) {
|
||||
content = applyDiffPatch(content, entry.content);
|
||||
@@ -80,7 +75,6 @@ async function reconstructContent(
|
||||
}
|
||||
|
||||
export const postHistoryRouter = createTRPCRouter({
|
||||
// Save a new history entry
|
||||
save: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -124,10 +118,8 @@ export const postHistoryRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Create diff patch
|
||||
const diffPatch = createDiffPatch(input.previousContent, input.content);
|
||||
|
||||
// Insert history entry
|
||||
const result = await conn.execute({
|
||||
sql: `
|
||||
INSERT INTO PostHistory (post_id, parent_id, content, is_saved)
|
||||
@@ -141,7 +133,6 @@ export const postHistoryRouter = createTRPCRouter({
|
||||
]
|
||||
});
|
||||
|
||||
// Prune old history entries if we exceed 100
|
||||
const countResult = await conn.execute({
|
||||
sql: "SELECT COUNT(*) as count FROM PostHistory WHERE post_id = ?",
|
||||
args: [input.postId]
|
||||
@@ -149,7 +140,6 @@ export const postHistoryRouter = createTRPCRouter({
|
||||
|
||||
const count = (countResult.rows[0] as { count: number }).count;
|
||||
if (count > 100) {
|
||||
// Get the oldest entries to delete (keep most recent 100)
|
||||
const toDelete = await conn.execute({
|
||||
sql: `
|
||||
SELECT id FROM PostHistory
|
||||
@@ -160,7 +150,6 @@ export const postHistoryRouter = createTRPCRouter({
|
||||
args: [input.postId, count - 100]
|
||||
});
|
||||
|
||||
// Delete old entries
|
||||
for (const row of toDelete.rows) {
|
||||
const entry = row as { id: number };
|
||||
await conn.execute({
|
||||
@@ -176,7 +165,6 @@ export const postHistoryRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
// Get history for a post with reconstructed content
|
||||
getHistory: publicProcedure
|
||||
.input(z.object({ postId: z.number() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
@@ -191,7 +179,6 @@ export const postHistoryRouter = createTRPCRouter({
|
||||
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Verify post exists and user is author
|
||||
const postCheck = await conn.execute({
|
||||
sql: "SELECT author_id FROM Post WHERE id = ?",
|
||||
args: [input.postId]
|
||||
@@ -212,7 +199,6 @@ export const postHistoryRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Get all history entries for this post
|
||||
const result = await conn.execute({
|
||||
sql: `
|
||||
SELECT id, parent_id, content, created_at, is_saved
|
||||
@@ -231,7 +217,6 @@ export const postHistoryRouter = createTRPCRouter({
|
||||
is_saved: number;
|
||||
}>;
|
||||
|
||||
// Reconstruct content for each entry by applying diffs sequentially
|
||||
const historyWithContent: Array<{
|
||||
id: number;
|
||||
parent_id: number | null;
|
||||
@@ -255,7 +240,6 @@ export const postHistoryRouter = createTRPCRouter({
|
||||
return historyWithContent;
|
||||
}),
|
||||
|
||||
// Restore content from a history entry
|
||||
restore: publicProcedure
|
||||
.input(z.object({ historyId: z.number() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
@@ -270,7 +254,6 @@ export const postHistoryRouter = createTRPCRouter({
|
||||
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Get history entry and verify ownership
|
||||
const historyResult = await conn.execute({
|
||||
sql: `
|
||||
SELECT ph.post_id
|
||||
@@ -288,12 +271,9 @@ export const postHistoryRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
const historyEntry = historyResult.rows[0] as { post_id: number };
|
||||
|
||||
// Verify user is post author
|
||||
const postCheck = await conn.execute({
|
||||
sql: "SELECT author_id FROM Post WHERE id = ?",
|
||||
args: [historyEntry.post_id]
|
||||
args: [historyResult.post_id]
|
||||
});
|
||||
|
||||
const post = postCheck.rows[0] as { author_id: string };
|
||||
@@ -304,7 +284,6 @@ export const postHistoryRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Reconstruct content from history chain
|
||||
const content = await reconstructContent(conn, input.historyId);
|
||||
|
||||
return { content };
|
||||
|
||||
@@ -152,7 +152,6 @@ export const userRouter = createTRPCRouter({
|
||||
|
||||
const { oldPassword, newPassword, newPasswordConfirmation } = input;
|
||||
|
||||
// Schema already validates password match, but double check
|
||||
if (newPassword !== newPasswordConfirmation) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
@@ -226,7 +225,6 @@ export const userRouter = createTRPCRouter({
|
||||
|
||||
const { newPassword, newPasswordConfirmation } = input;
|
||||
|
||||
// Schema already validates password match, but double check
|
||||
if (newPassword !== newPasswordConfirmation) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
|
||||
@@ -26,7 +26,6 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
||||
privilegeLevel = payload.id === env.ADMIN_ID ? "admin" : "user";
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently clear invalid token (401s are expected for non-authenticated users)
|
||||
setCookie(event.nativeEvent, "userIDToken", "", {
|
||||
maxAge: 0,
|
||||
expires: new Date("2016-10-05")
|
||||
@@ -57,7 +56,7 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
userId: ctx.userId // userId is non-null here
|
||||
userId: ctx.userId
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -72,11 +71,10 @@ const enforceUserIsAdmin = t.middleware(({ ctx, next }) => {
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
userId: ctx.userId! // userId is non-null for admins
|
||||
userId: ctx.userId!
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Protected procedures
|
||||
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
|
||||
export const adminProcedure = t.procedure.use(enforceUserIsAdmin);
|
||||
|
||||
@@ -10,32 +10,25 @@ import { v4 as uuid } from "uuid";
|
||||
* Audit event types for security tracking
|
||||
*/
|
||||
export type AuditEventType =
|
||||
// Authentication events
|
||||
| "auth.login.success"
|
||||
| "auth.login.failed"
|
||||
| "auth.logout"
|
||||
| "auth.register.success"
|
||||
| "auth.register.failed"
|
||||
// Password events
|
||||
| "auth.password.change"
|
||||
| "auth.password.reset.request"
|
||||
| "auth.password.reset.complete"
|
||||
// Email verification
|
||||
| "auth.email.verify.request"
|
||||
| "auth.email.verify.complete"
|
||||
// OAuth events
|
||||
| "auth.oauth.github.success"
|
||||
| "auth.oauth.github.failed"
|
||||
| "auth.oauth.google.success"
|
||||
| "auth.oauth.google.failed"
|
||||
// Session management
|
||||
| "auth.session.revoke"
|
||||
| "auth.session.revokeAll"
|
||||
// Security events
|
||||
| "security.rate_limit.exceeded"
|
||||
| "security.csrf.failed"
|
||||
| "security.suspicious.activity"
|
||||
// Admin actions
|
||||
| "admin.action";
|
||||
|
||||
/**
|
||||
@@ -74,7 +67,6 @@ export async function logAuditEvent(entry: AuditLogEntry): Promise<void> {
|
||||
]
|
||||
});
|
||||
} catch (error) {
|
||||
// Never throw - logging failures shouldn't break auth flows
|
||||
console.error("Failed to write audit log:", error, entry);
|
||||
}
|
||||
}
|
||||
@@ -186,7 +178,6 @@ export async function getFailedLoginAttempts(
|
||||
): Promise<number | Array<Record<string, any>>> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Aggregate query: getFailedLoginAttempts(24, 100) - get all failed logins in last 24 hours
|
||||
if (
|
||||
typeof identifierOrHours === "number" &&
|
||||
typeof identifierTypeOrLimit === "number"
|
||||
@@ -216,7 +207,6 @@ export async function getFailedLoginAttempts(
|
||||
}));
|
||||
}
|
||||
|
||||
// Specific identifier query: getFailedLoginAttempts("user-123", "user_id", 15)
|
||||
const identifier = identifierOrHours as string;
|
||||
const identifierType = identifierTypeOrLimit as "user_id" | "ip_address";
|
||||
const column = identifierType === "user_id" ? "user_id" : "ip_address";
|
||||
@@ -258,7 +248,6 @@ export async function getUserSecuritySummary(
|
||||
}> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Get total events for user in time period
|
||||
const totalEventsResult = await conn.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM AuditLog
|
||||
WHERE user_id = ?
|
||||
@@ -267,7 +256,6 @@ export async function getUserSecuritySummary(
|
||||
});
|
||||
const totalEvents = (totalEventsResult.rows[0]?.count as number) || 0;
|
||||
|
||||
// Get successful events
|
||||
const successfulEventsResult = await conn.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM AuditLog
|
||||
WHERE user_id = ?
|
||||
@@ -278,7 +266,6 @@ export async function getUserSecuritySummary(
|
||||
const successfulEvents =
|
||||
(successfulEventsResult.rows[0]?.count as number) || 0;
|
||||
|
||||
// Get failed events
|
||||
const failedEventsResult = await conn.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM AuditLog
|
||||
WHERE user_id = ?
|
||||
@@ -288,7 +275,6 @@ export async function getUserSecuritySummary(
|
||||
});
|
||||
const failedEvents = (failedEventsResult.rows[0]?.count as number) || 0;
|
||||
|
||||
// Get unique event types
|
||||
const eventTypesResult = await conn.execute({
|
||||
sql: `SELECT DISTINCT event_type FROM AuditLog
|
||||
WHERE user_id = ?
|
||||
@@ -299,7 +285,6 @@ export async function getUserSecuritySummary(
|
||||
(row) => row.event_type as string
|
||||
);
|
||||
|
||||
// Get unique IPs
|
||||
const uniqueIPsResult = await conn.execute({
|
||||
sql: `SELECT DISTINCT ip_address FROM AuditLog
|
||||
WHERE user_id = ?
|
||||
@@ -309,7 +294,6 @@ export async function getUserSecuritySummary(
|
||||
});
|
||||
const uniqueIPs = uniqueIPsResult.rows.map((row) => row.ip_address as string);
|
||||
|
||||
// Get total successful logins
|
||||
const loginResult = await conn.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM AuditLog
|
||||
WHERE user_id = ?
|
||||
@@ -319,7 +303,6 @@ export async function getUserSecuritySummary(
|
||||
});
|
||||
const totalLogins = (loginResult.rows[0]?.count as number) || 0;
|
||||
|
||||
// Get failed login attempts
|
||||
const failedResult = await conn.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM AuditLog
|
||||
WHERE user_id = ?
|
||||
@@ -330,7 +313,6 @@ export async function getUserSecuritySummary(
|
||||
});
|
||||
const failedLogins = (failedResult.rows[0]?.count as number) || 0;
|
||||
|
||||
// Get last login info
|
||||
const lastLoginResult = await conn.execute({
|
||||
sql: `SELECT created_at, ip_address FROM AuditLog
|
||||
WHERE user_id = ?
|
||||
@@ -342,7 +324,6 @@ export async function getUserSecuritySummary(
|
||||
});
|
||||
const lastLogin = lastLoginResult.rows[0];
|
||||
|
||||
// Get unique IP count
|
||||
const ipResult = await conn.execute({
|
||||
sql: `SELECT COUNT(DISTINCT ip_address) as count FROM AuditLog
|
||||
WHERE user_id = ?
|
||||
@@ -353,7 +334,6 @@ export async function getUserSecuritySummary(
|
||||
});
|
||||
const uniqueIpCount = (ipResult.rows[0]?.count as number) || 0;
|
||||
|
||||
// Get recent sessions (last 24 hours)
|
||||
const sessionResult = await conn.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM AuditLog
|
||||
WHERE user_id = ?
|
||||
|
||||
@@ -4,7 +4,6 @@ export interface FeatureFlags {
|
||||
|
||||
export function getFeatureFlags(): FeatureFlags {
|
||||
return {
|
||||
// TODO: Add feature flags here
|
||||
"beta-features": process.env.ENABLE_BETA_FEATURES === "true",
|
||||
"new-editor": false,
|
||||
"premium-content": true,
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
/**
|
||||
* Database Initialization for Audit Logging
|
||||
* Run this script to create the AuditLog table in your database
|
||||
*
|
||||
* Usage: bun run src/server/init-audit-table.ts
|
||||
*/
|
||||
|
||||
import { ConnectionFactory } from "./database";
|
||||
|
||||
async function initAuditTable() {
|
||||
@@ -13,7 +6,6 @@ async function initAuditTable() {
|
||||
try {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Create AuditLog table
|
||||
await conn.execute({
|
||||
sql: `CREATE TABLE IF NOT EXISTS AuditLog (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -30,7 +22,6 @@ async function initAuditTable() {
|
||||
|
||||
console.log("✅ AuditLog table created (or already exists)");
|
||||
|
||||
// Create indexes for performance
|
||||
console.log("🔧 Creating indexes...");
|
||||
|
||||
await conn.execute({
|
||||
@@ -51,7 +42,6 @@ async function initAuditTable() {
|
||||
|
||||
console.log("✅ Indexes created");
|
||||
|
||||
// Verify table exists
|
||||
const result = await conn.execute({
|
||||
sql: `SELECT name FROM sqlite_master WHERE type='table' AND name='AuditLog'`
|
||||
});
|
||||
@@ -59,7 +49,6 @@ async function initAuditTable() {
|
||||
if (result.rows.length > 0) {
|
||||
console.log("✅ AuditLog table verified - ready for use!");
|
||||
|
||||
// Check row count
|
||||
const countResult = await conn.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM AuditLog`
|
||||
});
|
||||
@@ -82,7 +71,6 @@ async function initAuditTable() {
|
||||
}
|
||||
}
|
||||
|
||||
// Run if executed directly
|
||||
if (import.meta.main) {
|
||||
initAuditTable()
|
||||
.then(() => process.exit(0))
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import * as bcrypt from "bcrypt";
|
||||
|
||||
/**
|
||||
* Dummy hash for timing attack prevention
|
||||
* This is a pre-computed bcrypt hash that will be used when a user doesn't exist
|
||||
* to maintain constant-time behavior
|
||||
*/
|
||||
const DUMMY_HASH =
|
||||
"$2b$10$YxVvS6L6HhS1pVBP6nZK0.9r0xwN8xvvzX7GwL5xvKJ6xvS6L6HhS1";
|
||||
|
||||
@@ -25,16 +20,13 @@ export async function checkPassword(
|
||||
|
||||
/**
|
||||
* Check password with timing attack protection
|
||||
* Always runs bcrypt comparison even if user doesn't exist
|
||||
*/
|
||||
export async function checkPasswordSafe(
|
||||
password: string,
|
||||
hash: string | null | undefined
|
||||
): Promise<boolean> {
|
||||
// If no hash provided, use dummy hash to maintain constant timing
|
||||
const hashToCompare = hash || DUMMY_HASH;
|
||||
const match = await bcrypt.compare(password, hashToCompare);
|
||||
|
||||
// Return false if no real hash was provided
|
||||
return hash ? match : false;
|
||||
}
|
||||
|
||||
@@ -17,14 +17,10 @@ import {
|
||||
*/
|
||||
function getCookieValue(event: H3Event, name: string): string | undefined {
|
||||
try {
|
||||
// Try vinxi's getCookie first
|
||||
const value = getCookie(event, name);
|
||||
if (value) return value;
|
||||
} catch (e) {
|
||||
// vinxi's getCookie failed, will use fallback
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// Fallback for tests: parse cookie header manually
|
||||
try {
|
||||
const cookieHeader =
|
||||
event.headers?.get?.("cookie") ||
|
||||
@@ -60,7 +56,6 @@ function setCookieValue(
|
||||
try {
|
||||
setCookie(event, name, value, options);
|
||||
} catch (e) {
|
||||
// In tests, setCookie might fail - store in mock object
|
||||
if (!event.node) event.node = { req: { headers: {} } } as any;
|
||||
if (!event.node.res) event.node.res = {} as any;
|
||||
if (!event.node.res.cookies) event.node.res.cookies = {} as any;
|
||||
@@ -73,18 +68,15 @@ function setCookieValue(
|
||||
*/
|
||||
function getHeaderValue(event: H3Event, name: string): string | null {
|
||||
try {
|
||||
// Try various header access patterns
|
||||
if (event.request?.headers?.get) {
|
||||
const val = event.request.headers.get(name);
|
||||
if (val !== null && val !== undefined) return val;
|
||||
}
|
||||
if (event.headers) {
|
||||
// Check if it's a Headers object with .get method
|
||||
if (typeof (event.headers as any).get === "function") {
|
||||
const val = (event.headers as any).get(name);
|
||||
if (val !== null && val !== undefined) return val;
|
||||
}
|
||||
// Or a plain object
|
||||
if (typeof event.headers === "object") {
|
||||
const val = (event.headers as any)[name];
|
||||
if (val !== undefined) return val;
|
||||
@@ -115,7 +107,7 @@ export function setCSRFToken(event: H3Event): string {
|
||||
setCookieValue(event, "csrf-token", token, {
|
||||
maxAge: AUTH_CONFIG.CSRF_TOKEN_MAX_AGE,
|
||||
path: "/",
|
||||
httpOnly: false, // Must be readable by client JS
|
||||
httpOnly: false,
|
||||
secure: env.NODE_ENV === "production",
|
||||
sameSite: "lax"
|
||||
});
|
||||
@@ -133,7 +125,6 @@ export function validateCSRFToken(event: H3Event): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
return timingSafeEqual(headerToken, cookieToken);
|
||||
}
|
||||
|
||||
@@ -160,7 +151,6 @@ export const csrfProtection = t.middleware(async ({ ctx, next }) => {
|
||||
const isValid = validateCSRFToken(ctx.event.nativeEvent);
|
||||
|
||||
if (!isValid) {
|
||||
// Log CSRF failure
|
||||
const { ipAddress, userAgent } = getAuditContext(ctx.event.nativeEvent);
|
||||
await logAuditEvent({
|
||||
eventType: "security.csrf.failed",
|
||||
@@ -191,8 +181,6 @@ export const csrfProtection = t.middleware(async ({ ctx, next }) => {
|
||||
*/
|
||||
export const csrfProtectedProcedure = t.procedure.use(csrfProtection);
|
||||
|
||||
// ========== Rate Limiting ==========
|
||||
|
||||
interface RateLimitRecord {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
@@ -213,7 +201,6 @@ export async function clearRateLimitStore(): Promise<void> {
|
||||
|
||||
/**
|
||||
* Cleanup expired rate limit entries every 5 minutes
|
||||
* Runs in background to prevent database bloat
|
||||
*/
|
||||
setInterval(async () => {
|
||||
try {
|
||||
@@ -255,7 +242,6 @@ export function getUserAgent(event: H3Event): string {
|
||||
|
||||
/**
|
||||
* Extract audit context from H3Event
|
||||
* Convenience function for logging
|
||||
*/
|
||||
export function getAuditContext(event: H3Event): {
|
||||
ipAddress: string;
|
||||
@@ -288,14 +274,12 @@ export async function checkRateLimit(
|
||||
const now = Date.now();
|
||||
const resetAt = new Date(now + windowMs);
|
||||
|
||||
// Try to get existing record
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT id, count, reset_at FROM RateLimit WHERE identifier = ?",
|
||||
args: [identifier]
|
||||
});
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
// Create new record
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO RateLimit (id, identifier, count, reset_at) VALUES (?, ?, ?, ?)",
|
||||
args: [uuid(), identifier, 1, resetAt.toISOString()]
|
||||
@@ -306,9 +290,7 @@ export async function checkRateLimit(
|
||||
const record = result.rows[0];
|
||||
const recordResetAt = new Date(record.reset_at as string);
|
||||
|
||||
// Check if window has expired
|
||||
if (now > recordResetAt.getTime()) {
|
||||
// Reset the record
|
||||
await conn.execute({
|
||||
sql: "UPDATE RateLimit SET count = 1, reset_at = ?, updated_at = datetime('now') WHERE identifier = ?",
|
||||
args: [resetAt.toISOString(), identifier]
|
||||
@@ -318,12 +300,10 @@ export async function checkRateLimit(
|
||||
|
||||
const count = record.count as number;
|
||||
|
||||
// Check if limit exceeded
|
||||
if (count >= maxAttempts) {
|
||||
const remainingMs = recordResetAt.getTime() - now;
|
||||
const remainingSec = Math.ceil(remainingMs / 1000);
|
||||
|
||||
// Log rate limit exceeded (fire-and-forget)
|
||||
if (event) {
|
||||
const { ipAddress, userAgent } = getAuditContext(event);
|
||||
logAuditEvent({
|
||||
@@ -337,9 +317,7 @@ export async function checkRateLimit(
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: false
|
||||
}).catch(() => {
|
||||
// Ignore logging errors
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
@@ -348,7 +326,6 @@ export async function checkRateLimit(
|
||||
});
|
||||
}
|
||||
|
||||
// Increment count
|
||||
await conn.execute({
|
||||
sql: "UPDATE RateLimit SET count = count + 1, updated_at = datetime('now') WHERE identifier = ?",
|
||||
args: [identifier]
|
||||
@@ -359,7 +336,6 @@ export async function checkRateLimit(
|
||||
|
||||
/**
|
||||
* Rate limit configuration for different operations
|
||||
* Re-exported from config for backward compatibility
|
||||
*/
|
||||
export const RATE_LIMITS = CONFIG_RATE_LIMITS;
|
||||
|
||||
@@ -371,7 +347,6 @@ export async function rateLimitLogin(
|
||||
clientIP: string,
|
||||
event?: H3Event
|
||||
): Promise<void> {
|
||||
// Rate limit by IP
|
||||
await checkRateLimit(
|
||||
`login:ip:${clientIP}`,
|
||||
RATE_LIMITS.LOGIN_IP.maxAttempts,
|
||||
@@ -379,7 +354,6 @@ export async function rateLimitLogin(
|
||||
event
|
||||
);
|
||||
|
||||
// Rate limit by email
|
||||
await checkRateLimit(
|
||||
`login:email:${email}`,
|
||||
RATE_LIMITS.LOGIN_EMAIL.maxAttempts,
|
||||
@@ -433,19 +407,7 @@ export async function rateLimitEmailVerification(
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Account Lockout ==========
|
||||
|
||||
/**
|
||||
* Account lockout configuration
|
||||
* Re-exported from config for backward compatibility
|
||||
*/
|
||||
export const ACCOUNT_LOCKOUT = CONFIG_ACCOUNT_LOCKOUT;
|
||||
|
||||
/**
|
||||
* Check if an account is locked
|
||||
* @param userId - User ID to check
|
||||
* @returns Object with isLocked status and remaining time if locked
|
||||
*/
|
||||
export async function checkAccountLockout(userId: string): Promise<{
|
||||
isLocked: boolean;
|
||||
remainingMs?: number;
|
||||
@@ -482,7 +444,6 @@ export async function checkAccountLockout(userId: string): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
// Lockout expired, clear it
|
||||
await conn.execute({
|
||||
sql: "UPDATE User SET locked_until = NULL, failed_attempts = 0 WHERE id = ?",
|
||||
args: [userId]
|
||||
@@ -491,11 +452,6 @@ export async function checkAccountLockout(userId: string): Promise<{
|
||||
return { isLocked: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed login attempt and lock account if threshold exceeded
|
||||
* @param userId - User ID
|
||||
* @returns Object with isLocked status and remaining time if locked
|
||||
*/
|
||||
export async function recordFailedLogin(userId: string): Promise<{
|
||||
isLocked: boolean;
|
||||
remainingMs?: number;
|
||||
@@ -504,7 +460,6 @@ export async function recordFailedLogin(userId: string): Promise<{
|
||||
const { ConnectionFactory } = await import("./database");
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Increment failed attempts
|
||||
const result = await conn.execute({
|
||||
sql: `UPDATE User
|
||||
SET failed_attempts = COALESCE(failed_attempts, 0) + 1
|
||||
@@ -515,7 +470,6 @@ export async function recordFailedLogin(userId: string): Promise<{
|
||||
|
||||
const failedAttempts = (result.rows[0]?.failed_attempts as number) || 0;
|
||||
|
||||
// Check if we should lock the account
|
||||
if (failedAttempts >= ACCOUNT_LOCKOUT.MAX_FAILED_ATTEMPTS) {
|
||||
const lockedUntil = new Date(
|
||||
Date.now() + ACCOUNT_LOCKOUT.LOCKOUT_DURATION_MS
|
||||
@@ -541,7 +495,6 @@ export async function recordFailedLogin(userId: string): Promise<{
|
||||
|
||||
/**
|
||||
* Reset failed login attempts on successful login
|
||||
* @param userId - User ID
|
||||
*/
|
||||
export async function resetFailedAttempts(userId: string): Promise<void> {
|
||||
const { ConnectionFactory } = await import("./database");
|
||||
@@ -553,18 +506,10 @@ export async function resetFailedAttempts(userId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Password Reset Token Management ==========
|
||||
|
||||
/**
|
||||
* Password reset token configuration
|
||||
* Re-exported from config for backward compatibility
|
||||
*/
|
||||
export const PASSWORD_RESET_CONFIG = CONFIG_PASSWORD_RESET;
|
||||
|
||||
/**
|
||||
* Create a password reset token
|
||||
* @param userId - User ID
|
||||
* @returns The reset token and token ID
|
||||
*/
|
||||
export async function createPasswordResetToken(userId: string): Promise<{
|
||||
token: string;
|
||||
@@ -575,20 +520,17 @@ export async function createPasswordResetToken(userId: string): Promise<{
|
||||
const { v4: uuid } = await import("uuid");
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Generate cryptographically secure token
|
||||
const token = crypto.randomUUID();
|
||||
const tokenId = uuid();
|
||||
const expiresAt = new Date(
|
||||
Date.now() + PASSWORD_RESET_CONFIG.TOKEN_EXPIRY_MS
|
||||
);
|
||||
|
||||
// Invalidate any existing unused tokens for this user
|
||||
await conn.execute({
|
||||
sql: "UPDATE PasswordResetToken SET used_at = datetime('now') WHERE user_id = ? AND used_at IS NULL",
|
||||
args: [userId]
|
||||
});
|
||||
|
||||
// Create new token
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO PasswordResetToken (id, token, user_id, expires_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
@@ -604,8 +546,6 @@ export async function createPasswordResetToken(userId: string): Promise<{
|
||||
|
||||
/**
|
||||
* Validate and consume a password reset token
|
||||
* @param token - Reset token
|
||||
* @returns User ID if valid, null otherwise
|
||||
*/
|
||||
export async function validatePasswordResetToken(
|
||||
token: string
|
||||
@@ -626,12 +566,10 @@ export async function validatePasswordResetToken(
|
||||
|
||||
const tokenRecord = result.rows[0];
|
||||
|
||||
// Check if already used
|
||||
if (tokenRecord.used_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
const expiresAt = new Date(tokenRecord.expires_at as string);
|
||||
if (expiresAt < new Date()) {
|
||||
return null;
|
||||
@@ -645,7 +583,6 @@ export async function validatePasswordResetToken(
|
||||
|
||||
/**
|
||||
* Mark a password reset token as used
|
||||
* @param tokenId - Token ID
|
||||
*/
|
||||
export async function markPasswordResetTokenUsed(
|
||||
tokenId: string
|
||||
@@ -661,7 +598,6 @@ export async function markPasswordResetTokenUsed(
|
||||
|
||||
/**
|
||||
* Clean up expired password reset tokens
|
||||
* Should be run periodically (e.g., via cron job)
|
||||
*/
|
||||
export async function cleanupExpiredPasswordResetTokens(): Promise<number> {
|
||||
const { ConnectionFactory } = await import("./database");
|
||||
|
||||
Reference in New Issue
Block a user