1c0a7cb850
Phase 4 — New Widget Types: - Clock/Weather, System Stats, RSS/Feed, Calendar, Markdown, Metric/Counter, Link Group, Camera/Stream widgets - Backend services with caching for each data source - Full creation form with dynamic config fields per type Phase 5 — Visual & Styling Enhancements: - Glassmorphism card style (solid/glass/outline) - Board-level themes with per-board hue/saturation - Animated SVG status rings replacing static dots - Card size options (compact/medium/large) - Custom CSS injection (admin + per-board, sanitized) - Wallpaper backgrounds with blur/overlay/parallax Phase 6 — Functional Features: - Favorites bar with drag-and-drop reordering - Recent apps tracking with privacy toggle - Uptime dashboard page (/status, guest-accessible) - Notifications system (Discord/Slack/Telegram/HTTP webhooks) - App tags with filtering in board view - Multi-URL app cards with expandable sub-links - Personal API tokens with scoped permissions - Audit log with retention and admin viewer Phase 7 — Quality of Life: - Onboarding wizard (5-step first-launch setup) - App URL health preview with favicon/title detection - Board templates (4 built-in + custom import/export) - Keyboard shortcut overlay (j/k nav, 1-9 boards, ? help) 212 files changed, 15641 insertions, 980 deletions. Build, lint, type check, and 222 tests all pass.
128 lines
3.0 KiB
TypeScript
128 lines
3.0 KiB
TypeScript
import { randomBytes, createHash } from 'crypto';
|
|
import bcrypt from 'bcryptjs';
|
|
import { prisma } from '../prisma.js';
|
|
|
|
const BCRYPT_ROUNDS = 10;
|
|
|
|
/**
|
|
* Hash a token string using SHA-256 for fast lookup, then bcrypt for storage.
|
|
* We use SHA-256 as an intermediate to create a fixed-length input for bcrypt
|
|
* (bcrypt has a 72-byte limit).
|
|
*/
|
|
function sha256(token: string): string {
|
|
return createHash('sha256').update(token).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* Generate a new API token. Returns the plaintext token (shown once) and the DB record.
|
|
*/
|
|
export async function generateToken(
|
|
userId: string,
|
|
name: string,
|
|
scope: string,
|
|
expiresAt?: string
|
|
) {
|
|
const plainToken = randomBytes(32).toString('hex');
|
|
const tokenHash = await bcrypt.hash(sha256(plainToken), BCRYPT_ROUNDS);
|
|
|
|
const token = await prisma.apiToken.create({
|
|
data: {
|
|
userId,
|
|
name,
|
|
tokenHash,
|
|
scope,
|
|
expiresAt: expiresAt ? new Date(expiresAt) : null
|
|
}
|
|
});
|
|
|
|
return {
|
|
id: token.id,
|
|
name: token.name,
|
|
scope: token.scope,
|
|
expiresAt: token.expiresAt,
|
|
createdAt: token.createdAt,
|
|
token: plainToken // Only returned once at creation time
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Revoke (delete) an API token.
|
|
*/
|
|
export async function revokeToken(tokenId: string, userId: string) {
|
|
const token = await prisma.apiToken.findUnique({ where: { id: tokenId } });
|
|
if (!token || token.userId !== userId) {
|
|
throw new Error('API token not found');
|
|
}
|
|
|
|
await prisma.apiToken.delete({ where: { id: tokenId } });
|
|
}
|
|
|
|
/**
|
|
* List all tokens for a user (without the hash).
|
|
*/
|
|
export async function listTokens(userId: string) {
|
|
const tokens = await prisma.apiToken.findMany({
|
|
where: { userId },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
scope: true,
|
|
lastUsedAt: true,
|
|
expiresAt: true,
|
|
createdAt: true
|
|
},
|
|
orderBy: { createdAt: 'desc' }
|
|
});
|
|
return tokens;
|
|
}
|
|
|
|
/**
|
|
* Validate a plaintext token string. Returns the user info if valid, null otherwise.
|
|
* Updates lastUsedAt on successful validation.
|
|
*/
|
|
export async function validateToken(tokenString: string): Promise<{
|
|
readonly userId: string;
|
|
readonly scope: string;
|
|
} | null> {
|
|
const tokenSha = sha256(tokenString);
|
|
|
|
// We need to check against all tokens since bcrypt hashes are unique per-hash.
|
|
// For better performance at scale, consider indexing on a prefix or using a different scheme.
|
|
const allTokens = await prisma.apiToken.findMany({
|
|
select: {
|
|
id: true,
|
|
userId: true,
|
|
tokenHash: true,
|
|
scope: true,
|
|
expiresAt: true
|
|
}
|
|
});
|
|
|
|
for (const token of allTokens) {
|
|
const isMatch = await bcrypt.compare(tokenSha, token.tokenHash);
|
|
if (isMatch) {
|
|
// Check expiry
|
|
if (token.expiresAt && token.expiresAt < new Date()) {
|
|
return null; // Token expired
|
|
}
|
|
|
|
// Update lastUsedAt (fire-and-forget)
|
|
prisma.apiToken
|
|
.update({
|
|
where: { id: token.id },
|
|
data: { lastUsedAt: new Date() }
|
|
})
|
|
.catch(() => {
|
|
// Swallow errors from lastUsedAt update
|
|
});
|
|
|
|
return {
|
|
userId: token.userId,
|
|
scope: token.scope
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|