feat: Phases 4-7 — Full Feature Expansion (26 features)
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.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user