feat: add community publishing and news syndication
This commit is contained in:
+195
-117
@@ -8,109 +8,166 @@ const COOKIE_NAME = 'shd_session';
|
||||
const SESSION_SECONDS = 60 * 60 * 24 * 30;
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
loginUsername: string | null;
|
||||
displayName: string;
|
||||
publicSlug: string | null;
|
||||
role: string;
|
||||
discordLinked: boolean;
|
||||
avatarUrl: string;
|
||||
id: string;
|
||||
username: string;
|
||||
loginUsername: string | null;
|
||||
displayName: string;
|
||||
publicSlug: string | null;
|
||||
role: string;
|
||||
discordLinked: boolean;
|
||||
avatarUrl: string;
|
||||
};
|
||||
|
||||
type LoginRequestContext = {
|
||||
request?: Request;
|
||||
getClientAddress?: () => string;
|
||||
failureReason?: string | null;
|
||||
credentialId?: string | null;
|
||||
sessionId?: string | null;
|
||||
riskFlags?: string[] | null;
|
||||
request?: Request;
|
||||
getClientAddress?: () => string;
|
||||
failureReason?: string | null;
|
||||
credentialId?: string | null;
|
||||
sessionId?: string | null;
|
||||
riskFlags?: string[] | null;
|
||||
};
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export async function createSession(cookies: Cookies, userId: string): Promise<string> {
|
||||
const database = await getDatabase();
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const sessionId = randomUUID();
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000);
|
||||
const insert = await database.prepare(
|
||||
'INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
export async function createSession(
|
||||
cookies: Cookies,
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
const database = await getDatabase();
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const sessionId = randomUUID();
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000);
|
||||
const insert = await database.prepare(
|
||||
'INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||
);
|
||||
|
||||
await insert.run(
|
||||
sessionId,
|
||||
userId,
|
||||
hashToken(token),
|
||||
expiresAt.toISOString(),
|
||||
now.toISOString()
|
||||
);
|
||||
await insert.run(
|
||||
sessionId,
|
||||
userId,
|
||||
hashToken(token),
|
||||
expiresAt.toISOString(),
|
||||
now.toISOString(),
|
||||
);
|
||||
|
||||
cookies.set(COOKIE_NAME, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: !dev,
|
||||
maxAge: SESSION_SECONDS
|
||||
});
|
||||
return sessionId;
|
||||
cookies.set(COOKIE_NAME, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: !dev,
|
||||
maxAge: SESSION_SECONDS,
|
||||
});
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function describeClient(userAgent: string) {
|
||||
const clientName = /Edg\//.test(userAgent) ? 'Edge' : /Firefox\//.test(userAgent) ? 'Firefox' :
|
||||
/Chrome\//.test(userAgent) ? 'Chrome' : /Safari\//.test(userAgent) ? 'Safari' : 'Other';
|
||||
const operatingSystem = /Windows/.test(userAgent) ? 'Windows' : /Android/.test(userAgent) ? 'Android' :
|
||||
/iPhone|iPad/.test(userAgent) ? 'iOS/iPadOS' : /Mac OS X/.test(userAgent) ? 'macOS' :
|
||||
/Linux/.test(userAgent) ? 'Linux' : 'Other';
|
||||
const deviceType = /Mobile|Android|iPhone/.test(userAgent) ? 'Mobile' : /iPad|Tablet/.test(userAgent) ? 'Tablet' : 'Desktop';
|
||||
return { clientName, operatingSystem, deviceType };
|
||||
const clientName = /Edg\//.test(userAgent)
|
||||
? 'Edge'
|
||||
: /Firefox\//.test(userAgent)
|
||||
? 'Firefox'
|
||||
: /Chrome\//.test(userAgent)
|
||||
? 'Chrome'
|
||||
: /Safari\//.test(userAgent)
|
||||
? 'Safari'
|
||||
: 'Other';
|
||||
const operatingSystem = /Windows/.test(userAgent)
|
||||
? 'Windows'
|
||||
: /Android/.test(userAgent)
|
||||
? 'Android'
|
||||
: /iPhone|iPad/.test(userAgent)
|
||||
? 'iOS/iPadOS'
|
||||
: /Mac OS X/.test(userAgent)
|
||||
? 'macOS'
|
||||
: /Linux/.test(userAgent)
|
||||
? 'Linux'
|
||||
: 'Other';
|
||||
const deviceType = /Mobile|Android|iPhone/.test(userAgent)
|
||||
? 'Mobile'
|
||||
: /iPad|Tablet/.test(userAgent)
|
||||
? 'Tablet'
|
||||
: 'Desktop';
|
||||
return { clientName, operatingSystem, deviceType };
|
||||
}
|
||||
|
||||
export async function purgeExpiredLoginMetadata(): Promise<void> {
|
||||
const database = await getDatabase();
|
||||
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare(`UPDATE login_events SET ip_address=NULL,user_agent=NULL,client_name=NULL,
|
||||
const database = await getDatabase();
|
||||
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare(`UPDATE login_events SET ip_address=NULL,user_agent=NULL,client_name=NULL,
|
||||
operating_system=NULL,device_type=NULL,location_country=NULL,location_region=NULL,location_city=NULL,
|
||||
credential_id=NULL,session_id=NULL,sensitive_purged_at=?
|
||||
WHERE created_at<? AND sensitive_purged_at IS NULL`)).run(now, cutoff);
|
||||
WHERE created_at<? AND sensitive_purged_at IS NULL`)
|
||||
).run(now, cutoff);
|
||||
}
|
||||
|
||||
export async function recordLogin(userId: string | null, provider: 'password' | 'discord' | 'magic_link' | 'passkey', succeeded: boolean, context: LoginRequestContext = {}): Promise<void> {
|
||||
const database = await getDatabase();
|
||||
const userAgent = context.request?.headers.get('user-agent')?.slice(0, 1000) ?? null;
|
||||
const headers = context.request?.headers;
|
||||
const locationCountry = headers?.get('cf-ipcountry') ?? headers?.get('x-vercel-ip-country') ?? headers?.get('x-geo-country');
|
||||
const locationRegion = headers?.get('x-vercel-ip-country-region') ?? headers?.get('x-geo-region');
|
||||
const locationCity = headers?.get('x-vercel-ip-city') ?? headers?.get('x-geo-city');
|
||||
const client = describeClient(userAgent ?? '');
|
||||
const riskFlags = context.riskFlags ?? (!succeeded
|
||||
? [userId ? 'failed_authentication' : 'unknown_account_attempt']
|
||||
: []);
|
||||
let ipAddress: string | null = null;
|
||||
try { ipAddress = context.getClientAddress?.() ?? null; } catch { /* unavailable in some development adapters */ }
|
||||
await (await database.prepare(`INSERT INTO login_events(id,user_id,provider,succeeded,ip_address,user_agent,
|
||||
export async function recordLogin(
|
||||
userId: string | null,
|
||||
provider: 'password' | 'discord' | 'magic_link' | 'passkey',
|
||||
succeeded: boolean,
|
||||
context: LoginRequestContext = {},
|
||||
): Promise<void> {
|
||||
const database = await getDatabase();
|
||||
const userAgent =
|
||||
context.request?.headers.get('user-agent')?.slice(0, 1000) ?? null;
|
||||
const headers = context.request?.headers;
|
||||
const locationCountry =
|
||||
headers?.get('cf-ipcountry') ??
|
||||
headers?.get('x-vercel-ip-country') ??
|
||||
headers?.get('x-geo-country');
|
||||
const locationRegion =
|
||||
headers?.get('x-vercel-ip-country-region') ?? headers?.get('x-geo-region');
|
||||
const locationCity =
|
||||
headers?.get('x-vercel-ip-city') ?? headers?.get('x-geo-city');
|
||||
const client = describeClient(userAgent ?? '');
|
||||
const riskFlags =
|
||||
context.riskFlags ??
|
||||
(!succeeded
|
||||
? [userId ? 'failed_authentication' : 'unknown_account_attempt']
|
||||
: []);
|
||||
let ipAddress: string | null = null;
|
||||
try {
|
||||
ipAddress = context.getClientAddress?.() ?? null;
|
||||
} catch {
|
||||
/* unavailable in some development adapters */
|
||||
}
|
||||
await (
|
||||
await database.prepare(`INSERT INTO login_events(id,user_id,provider,succeeded,ip_address,user_agent,
|
||||
client_name,operating_system,device_type,location_country,location_region,location_city,
|
||||
failure_reason,risk_flags,credential_id,session_id,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
|
||||
randomUUID(), userId, provider, succeeded ? 1 : 0, ipAddress, userAgent,
|
||||
client.clientName, client.operatingSystem, client.deviceType,
|
||||
locationCountry?.slice(0, 100) ?? null, locationRegion?.slice(0, 100) ?? null, locationCity?.slice(0, 100) ?? null,
|
||||
context.failureReason ?? null, riskFlags.length ? riskFlags.join(',') : null,
|
||||
context.credentialId ?? null, context.sessionId ?? null, new Date().toISOString()
|
||||
);
|
||||
await purgeExpiredLoginMetadata();
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
).run(
|
||||
randomUUID(),
|
||||
userId,
|
||||
provider,
|
||||
succeeded ? 1 : 0,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
client.clientName,
|
||||
client.operatingSystem,
|
||||
client.deviceType,
|
||||
locationCountry?.slice(0, 100) ?? null,
|
||||
locationRegion?.slice(0, 100) ?? null,
|
||||
locationCity?.slice(0, 100) ?? null,
|
||||
context.failureReason ?? null,
|
||||
riskFlags.length ? riskFlags.join(',') : null,
|
||||
context.credentialId ?? null,
|
||||
context.sessionId ?? null,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
await purgeExpiredLoginMetadata();
|
||||
}
|
||||
|
||||
export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null> {
|
||||
const token = cookies.get(COOKIE_NAME);
|
||||
if (!token) return null;
|
||||
export async function getSessionUser(
|
||||
cookies: Cookies,
|
||||
): Promise<AuthUser | null> {
|
||||
const token = cookies.get(COOKIE_NAME);
|
||||
if (!token) return null;
|
||||
|
||||
const database = await getDatabase();
|
||||
const statement = await database.prepare(`
|
||||
const database = await getDatabase();
|
||||
const statement = await database.prepare(`
|
||||
SELECT users.id, users.username, users.login_username, COALESCE(user_profiles.display_name,users.username) AS display_name,
|
||||
user_profiles.public_slug,
|
||||
users.role, users.discord_id, user_avatars.source AS avatar_source,
|
||||
@@ -125,50 +182,71 @@ export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null>
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE sessions.token_hash = ? AND sessions.expires_at > ?
|
||||
`);
|
||||
const user = await statement.get(hashToken(token), new Date().toISOString());
|
||||
const user = await statement.get(hashToken(token), new Date().toISOString());
|
||||
|
||||
if (!user) {
|
||||
cookies.delete(COOKIE_NAME, { path: '/' });
|
||||
return null;
|
||||
}
|
||||
const now = new Date();
|
||||
const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > now;
|
||||
if (user.deleted_at || user.blocked_at || banned) {
|
||||
await (await database.prepare('DELETE FROM sessions WHERE token_hash=?')).run(hashToken(token));
|
||||
cookies.delete(COOKIE_NAME, { path: '/' });
|
||||
return null;
|
||||
}
|
||||
if (!user.last_active_at || now.getTime() - new Date(String(user.last_active_at)).getTime() > 5 * 60 * 1000) {
|
||||
await (await database.prepare(`INSERT INTO user_status(user_id,last_active_at,updated_at) VALUES(?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET last_active_at=excluded.last_active_at,updated_at=excluded.updated_at`))
|
||||
.run(String(user.id), now.toISOString(), now.toISOString());
|
||||
}
|
||||
if (!user) {
|
||||
cookies.delete(COOKIE_NAME, { path: '/' });
|
||||
return null;
|
||||
}
|
||||
const now = new Date();
|
||||
const banned =
|
||||
typeof user.banned_until === 'string' && new Date(user.banned_until) > now;
|
||||
if (user.deleted_at || user.blocked_at || banned) {
|
||||
await (
|
||||
await database.prepare('DELETE FROM sessions WHERE token_hash=?')
|
||||
).run(hashToken(token));
|
||||
cookies.delete(COOKIE_NAME, { path: '/' });
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!user.last_active_at ||
|
||||
now.getTime() - new Date(String(user.last_active_at)).getTime() >
|
||||
5 * 60 * 1000
|
||||
) {
|
||||
await (
|
||||
await database.prepare(`INSERT INTO user_status(user_id,last_active_at,updated_at) VALUES(?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET last_active_at=excluded.last_active_at,updated_at=excluded.updated_at`)
|
||||
).run(String(user.id), now.toISOString(), now.toISOString());
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(user.id),
|
||||
username: String(user.username),
|
||||
loginUsername: typeof user.login_username === 'string' ? user.login_username : null,
|
||||
displayName: String(user.display_name),
|
||||
publicSlug: typeof user.public_slug === 'string' ? user.public_slug : null,
|
||||
role: String(user.role),
|
||||
discordLinked: typeof user.discord_id === 'string',
|
||||
avatarUrl: avatarUrl(user.avatar_source, user.avatar_value, String(user.id), user.avatar_updated_at)
|
||||
};
|
||||
return {
|
||||
id: String(user.id),
|
||||
username: String(user.username),
|
||||
loginUsername:
|
||||
typeof user.login_username === 'string' ? user.login_username : null,
|
||||
displayName: String(user.display_name),
|
||||
publicSlug: typeof user.public_slug === 'string' ? user.public_slug : null,
|
||||
role: String(user.role),
|
||||
discordLinked: typeof user.discord_id === 'string',
|
||||
avatarUrl: avatarUrl(
|
||||
user.avatar_source,
|
||||
user.avatar_value,
|
||||
String(user.id),
|
||||
user.avatar_updated_at,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function destroySession(cookies: Cookies): Promise<void> {
|
||||
const token = cookies.get(COOKIE_NAME);
|
||||
const token = cookies.get(COOKIE_NAME);
|
||||
|
||||
if (token) {
|
||||
const database = await getDatabase();
|
||||
const session = await (await database.prepare('SELECT id FROM sessions WHERE token_hash=?')).get(hashToken(token));
|
||||
if (session?.id) {
|
||||
await (await database.prepare('UPDATE login_events SET session_ended_at=?,session_end_reason=? WHERE session_id=?'))
|
||||
.run(new Date().toISOString(), 'user_logout', session.id);
|
||||
}
|
||||
const statement = await database.prepare('DELETE FROM sessions WHERE token_hash = ?');
|
||||
await statement.run(hashToken(token));
|
||||
}
|
||||
if (token) {
|
||||
const database = await getDatabase();
|
||||
const session = await (
|
||||
await database.prepare('SELECT id FROM sessions WHERE token_hash=?')
|
||||
).get(hashToken(token));
|
||||
if (session?.id) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE login_events SET session_ended_at=?,session_end_reason=? WHERE session_id=?',
|
||||
)
|
||||
).run(new Date().toISOString(), 'user_logout', session.id);
|
||||
}
|
||||
const statement = await database.prepare(
|
||||
'DELETE FROM sessions WHERE token_hash = ?',
|
||||
);
|
||||
await statement.run(hashToken(token));
|
||||
}
|
||||
|
||||
cookies.delete(COOKIE_NAME, { path: '/' });
|
||||
cookies.delete(COOKIE_NAME, { path: '/' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user