feat: rebrand Hemmelig to paste.es for cloudhost.es
- Set Spanish as default language with ephemeral/encrypted privacy focus - Translate all user-facing strings and legal pages to Spanish - Replace Norwegian flag with Spanish flag in footer - Remove Hemmelig/terces.cloud links, add cloudhost.es sponsorship - Rewrite PrivacyPage: zero data collection, ephemeral design emphasis - Rewrite TermsPage: Spanish law, RGPD, paste.es/CloudHost.es references - Update PWA manifest, HTML meta tags, package.json branding - Rename webhook headers to X-Paste-Event / X-Paste-Signature - Update API docs title and contact to paste.es / cloudhost.es Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
130
api/routes/account.ts
Normal file
130
api/routes/account.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { auth } from '../auth';
|
||||
import prisma from '../lib/db';
|
||||
import { handleNotFound } from '../lib/utils';
|
||||
import { authMiddleware } from '../middlewares/auth';
|
||||
import { updateAccountSchema, updatePasswordSchema } from '../validations/account';
|
||||
|
||||
const app = new Hono<{
|
||||
Variables: {
|
||||
user: typeof auth.$Infer.Session.user | null;
|
||||
};
|
||||
}>();
|
||||
|
||||
// Get user account information
|
||||
app.get('/', authMiddleware, async (c) => {
|
||||
const user = c.get('user');
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
});
|
||||
});
|
||||
|
||||
// Update user account information
|
||||
app.put('/', authMiddleware, zValidator('json', updateAccountSchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
const { username, email } = c.req.valid('json');
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if username is taken by another user
|
||||
if (username) {
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingUser && existingUser.id !== user.id) {
|
||||
return c.json({ error: 'Username is already taken' }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if email is taken by another user
|
||||
if (email) {
|
||||
const existingEmail = await prisma.user.findFirst({
|
||||
where: { email },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingEmail && existingEmail.id !== user.id) {
|
||||
return c.json({ error: 'Email is already taken' }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
username,
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({
|
||||
username: updatedUser.username,
|
||||
email: updatedUser.email,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update account:', error);
|
||||
return handleNotFound(error as Error & { code?: string }, c);
|
||||
}
|
||||
});
|
||||
|
||||
// Update user password
|
||||
app.put('/password', authMiddleware, zValidator('json', updatePasswordSchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
const { currentPassword, newPassword } = c.req.valid('json');
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use better-auth's changePassword API
|
||||
const result = await auth.api.changePassword({
|
||||
body: {
|
||||
currentPassword,
|
||||
newPassword,
|
||||
},
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return c.json({ error: 'Failed to change password' }, 500);
|
||||
}
|
||||
|
||||
return c.json({ message: 'Password updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to update password:', error);
|
||||
const message = error instanceof Error ? error.message : 'Failed to update password';
|
||||
return c.json({ error: message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete user account
|
||||
app.delete('/', authMiddleware, async (c) => {
|
||||
const user = c.get('user');
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.user.delete({
|
||||
where: { id: user.id },
|
||||
});
|
||||
|
||||
return c.json({ message: 'Account deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete account:', error);
|
||||
return handleNotFound(error as Error & { code?: string }, c);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
253
api/routes/analytics.ts
Normal file
253
api/routes/analytics.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { isbot } from 'isbot';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
calculatePercentage,
|
||||
createVisitorId,
|
||||
getStartDateForTimeRange,
|
||||
isAnalyticsEnabled,
|
||||
isValidAnalyticsPath,
|
||||
} from '../lib/analytics';
|
||||
import prisma from '../lib/db';
|
||||
import { getClientIp } from '../lib/utils';
|
||||
import { authMiddleware, checkAdmin } from '../middlewares/auth';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
const trackSchema = z.object({
|
||||
path: z.string().max(255),
|
||||
});
|
||||
|
||||
const timeRangeSchema = z.object({
|
||||
timeRange: z.enum(['7d', '14d', '30d']).default('30d'),
|
||||
});
|
||||
|
||||
// POST /api/analytics/track - Public endpoint for visitor tracking
|
||||
app.post('/track', zValidator('json', trackSchema), async (c) => {
|
||||
if (!isAnalyticsEnabled()) {
|
||||
return c.json({ success: false }, 403);
|
||||
}
|
||||
|
||||
const userAgent = c.req.header('user-agent') || '';
|
||||
|
||||
if (isbot(userAgent)) {
|
||||
return c.json({ success: false }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
const { path } = c.req.valid('json');
|
||||
|
||||
if (!isValidAnalyticsPath(path)) {
|
||||
return c.json({ error: 'Invalid path format' }, 400);
|
||||
}
|
||||
|
||||
const uniqueId = createVisitorId(getClientIp(c), userAgent);
|
||||
|
||||
await prisma.visitorAnalytics.create({
|
||||
data: { path, uniqueId },
|
||||
});
|
||||
|
||||
return c.json({ success: true }, 201);
|
||||
} catch (error) {
|
||||
console.error('Analytics tracking error:', error);
|
||||
return c.json({ error: 'Failed to track analytics' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/analytics - Secret analytics (admin only)
|
||||
app.get('/', authMiddleware, checkAdmin, zValidator('query', timeRangeSchema), async (c) => {
|
||||
const { timeRange } = c.req.valid('query');
|
||||
const now = new Date();
|
||||
const startDate = getStartDateForTimeRange(timeRange);
|
||||
|
||||
try {
|
||||
// Use aggregations for basic counts - much more efficient than loading all records
|
||||
const [aggregates, activeCount, typesCounts, dailyStats, secretRequestStats] =
|
||||
await Promise.all([
|
||||
// Get total count and sum of views
|
||||
prisma.secrets.aggregate({
|
||||
where: { createdAt: { gte: startDate } },
|
||||
_count: true,
|
||||
_sum: { views: true },
|
||||
}),
|
||||
// Count active (non-expired) secrets
|
||||
prisma.secrets.count({
|
||||
where: {
|
||||
createdAt: { gte: startDate },
|
||||
expiresAt: { gt: now },
|
||||
},
|
||||
}),
|
||||
// Get counts for secret types in parallel
|
||||
Promise.all([
|
||||
prisma.secrets.count({
|
||||
where: { createdAt: { gte: startDate }, password: { not: null } },
|
||||
}),
|
||||
prisma.secrets.count({
|
||||
where: {
|
||||
createdAt: { gte: startDate },
|
||||
ipRange: { not: null },
|
||||
NOT: { ipRange: '' },
|
||||
},
|
||||
}),
|
||||
prisma.secrets.count({
|
||||
where: { createdAt: { gte: startDate }, isBurnable: true },
|
||||
}),
|
||||
]),
|
||||
// For daily stats, we still need individual records but only select minimal fields
|
||||
prisma.secrets.findMany({
|
||||
where: { createdAt: { gte: startDate } },
|
||||
select: {
|
||||
createdAt: true,
|
||||
views: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
}),
|
||||
// Secret request statistics
|
||||
Promise.all([
|
||||
prisma.secretRequest.count({
|
||||
where: { createdAt: { gte: startDate } },
|
||||
}),
|
||||
prisma.secretRequest.count({
|
||||
where: { createdAt: { gte: startDate }, status: 'fulfilled' },
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
|
||||
const totalSecrets = aggregates._count;
|
||||
const totalViews = aggregates._sum.views || 0;
|
||||
const activeSecrets = activeCount;
|
||||
const expiredSecrets = totalSecrets - activeSecrets;
|
||||
const averageViews = totalSecrets > 0 ? totalViews / totalSecrets : 0;
|
||||
|
||||
const [passwordProtected, ipRestricted, burnable] = typesCounts;
|
||||
const [totalSecretRequests, fulfilledSecretRequests] = secretRequestStats;
|
||||
|
||||
// Process daily stats from minimal data
|
||||
const dailyStatsMap = dailyStats.reduce(
|
||||
(acc, secret) => {
|
||||
const date = secret.createdAt.toISOString().split('T')[0];
|
||||
if (!acc[date]) {
|
||||
acc[date] = { date, secrets: 0, views: 0 };
|
||||
}
|
||||
acc[date].secrets++;
|
||||
acc[date].views += secret.views || 0;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { date: string; secrets: number; views: number }>
|
||||
);
|
||||
|
||||
// Calculate expiration stats from minimal data
|
||||
const expirationDurations = dailyStats.map(
|
||||
(s) => (s.expiresAt.getTime() - s.createdAt.getTime()) / (1000 * 60 * 60)
|
||||
);
|
||||
const oneHour = expirationDurations.filter((d) => d <= 1).length;
|
||||
const oneDay = expirationDurations.filter((d) => d > 1 && d <= 24).length;
|
||||
const oneWeekPlus = expirationDurations.filter((d) => d > 24).length;
|
||||
|
||||
return c.json({
|
||||
totalSecrets,
|
||||
totalViews,
|
||||
activeSecrets,
|
||||
expiredSecrets,
|
||||
averageViews: parseFloat(averageViews.toFixed(2)),
|
||||
dailyStats: Object.values(dailyStatsMap),
|
||||
secretTypes: {
|
||||
passwordProtected: calculatePercentage(passwordProtected, totalSecrets),
|
||||
ipRestricted: calculatePercentage(ipRestricted, totalSecrets),
|
||||
burnable: calculatePercentage(burnable, totalSecrets),
|
||||
},
|
||||
expirationStats: {
|
||||
oneHour: calculatePercentage(oneHour, totalSecrets),
|
||||
oneDay: calculatePercentage(oneDay, totalSecrets),
|
||||
oneWeekPlus: calculatePercentage(oneWeekPlus, totalSecrets),
|
||||
},
|
||||
secretRequests: {
|
||||
total: totalSecretRequests,
|
||||
fulfilled: fulfilledSecretRequests,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch analytics data:', error);
|
||||
return c.json({ error: 'Failed to fetch analytics data' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/analytics/visitors - Visitor analytics data (admin only)
|
||||
app.get('/visitors', authMiddleware, checkAdmin, async (c) => {
|
||||
try {
|
||||
const analytics = await prisma.visitorAnalytics.findMany({
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: 1000,
|
||||
});
|
||||
return c.json(analytics);
|
||||
} catch (error) {
|
||||
console.error('Analytics retrieval error:', error);
|
||||
return c.json({ error: 'Failed to retrieve analytics' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/analytics/visitors/unique - Aggregated unique visitor data (admin only)
|
||||
app.get('/visitors/unique', authMiddleware, checkAdmin, async (c) => {
|
||||
try {
|
||||
const aggregatedData = await prisma.visitorAnalytics.groupBy({
|
||||
by: ['uniqueId', 'path'],
|
||||
_count: { uniqueId: true },
|
||||
orderBy: { _count: { uniqueId: 'desc' } },
|
||||
});
|
||||
return c.json(aggregatedData);
|
||||
} catch (error) {
|
||||
console.error('Aggregated analytics retrieval error:', error);
|
||||
return c.json({ error: 'Failed to retrieve aggregated analytics' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/analytics/visitors/daily - Daily visitor statistics (admin only)
|
||||
app.get(
|
||||
'/visitors/daily',
|
||||
authMiddleware,
|
||||
checkAdmin,
|
||||
zValidator('query', timeRangeSchema),
|
||||
async (c) => {
|
||||
try {
|
||||
const { timeRange } = c.req.valid('query');
|
||||
const startDate = getStartDateForTimeRange(timeRange);
|
||||
|
||||
// Use raw SQL for efficient database-level aggregation
|
||||
// This avoids loading all records into memory for high-traffic instances
|
||||
const aggregatedData = await prisma.$queryRaw<
|
||||
Array<{
|
||||
date: string;
|
||||
unique_visitors: bigint;
|
||||
total_visits: bigint;
|
||||
paths: string;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
DATE(timestamp) as date,
|
||||
COUNT(DISTINCT uniqueId) as unique_visitors,
|
||||
COUNT(*) as total_visits,
|
||||
GROUP_CONCAT(DISTINCT path) as paths
|
||||
FROM visitor_analytics
|
||||
WHERE timestamp >= ${startDate}
|
||||
GROUP BY DATE(timestamp)
|
||||
ORDER BY date ASC
|
||||
`;
|
||||
|
||||
// Convert BigInt to number for JSON serialization
|
||||
const result = aggregatedData.map((row) => ({
|
||||
date: row.date,
|
||||
unique_visitors: Number(row.unique_visitors),
|
||||
total_visits: Number(row.total_visits),
|
||||
paths: row.paths || '',
|
||||
}));
|
||||
|
||||
return c.json(result);
|
||||
} catch (error) {
|
||||
console.error('Daily analytics retrieval error:', error);
|
||||
return c.json({ error: 'Failed to retrieve daily analytics' }, 500);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default app;
|
||||
156
api/routes/api-keys.ts
Normal file
156
api/routes/api-keys.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import { auth } from '../auth';
|
||||
import prisma from '../lib/db';
|
||||
import { handleNotFound } from '../lib/utils';
|
||||
import { sendWebhook } from '../lib/webhook';
|
||||
import { authMiddleware } from '../middlewares/auth';
|
||||
|
||||
const createApiKeySchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
expiresInDays: z.number().int().min(1).max(365).optional(),
|
||||
});
|
||||
|
||||
const deleteApiKeySchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
function hashApiKey(key: string): string {
|
||||
return createHash('sha256').update(key).digest('hex');
|
||||
}
|
||||
|
||||
function generateApiKey(): string {
|
||||
const prefix = 'hemmelig';
|
||||
const key = randomBytes(24).toString('base64url');
|
||||
return `${prefix}_${key}`;
|
||||
}
|
||||
|
||||
const app = new Hono<{
|
||||
Variables: {
|
||||
user: typeof auth.$Infer.Session.user | null;
|
||||
};
|
||||
}>()
|
||||
.use(authMiddleware)
|
||||
.get('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKeys = await prisma.apiKey.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
keyPrefix: true,
|
||||
lastUsedAt: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return c.json(apiKeys);
|
||||
} catch (error) {
|
||||
console.error('Failed to list API keys:', error);
|
||||
return c.json({ error: 'Failed to list API keys' }, 500);
|
||||
}
|
||||
})
|
||||
.post('/', zValidator('json', createApiKeySchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const { name, expiresInDays } = c.req.valid('json');
|
||||
|
||||
try {
|
||||
// Check API key limit (max 5 per user)
|
||||
const existingCount = await prisma.apiKey.count({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
if (existingCount >= 5) {
|
||||
return c.json({ error: 'Maximum API key limit reached (5)' }, 400);
|
||||
}
|
||||
|
||||
const rawKey = generateApiKey();
|
||||
const keyHash = hashApiKey(rawKey);
|
||||
const keyPrefix = rawKey.substring(0, 16);
|
||||
|
||||
const expiresAt = expiresInDays
|
||||
? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000)
|
||||
: null;
|
||||
|
||||
const apiKey = await prisma.apiKey.create({
|
||||
data: {
|
||||
name,
|
||||
keyHash,
|
||||
keyPrefix,
|
||||
userId: user.id,
|
||||
expiresAt,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
keyPrefix: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Send webhook for API key creation
|
||||
sendWebhook('apikey.created', {
|
||||
apiKeyId: apiKey.id,
|
||||
name: apiKey.name,
|
||||
expiresAt: apiKey.expiresAt?.toISOString() || null,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// Return the raw key only once - it cannot be retrieved again
|
||||
return c.json(
|
||||
{
|
||||
...apiKey,
|
||||
key: rawKey,
|
||||
},
|
||||
201
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to create API key:', error);
|
||||
return c.json({ error: 'Failed to create API key' }, 500);
|
||||
}
|
||||
})
|
||||
.delete('/:id', zValidator('param', deleteApiKeySchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const { id } = c.req.valid('param');
|
||||
|
||||
try {
|
||||
// Ensure the API key belongs to the user
|
||||
const apiKey = await prisma.apiKey.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
return c.json({ error: 'API key not found' }, 404);
|
||||
}
|
||||
|
||||
await prisma.apiKey.delete({ where: { id } });
|
||||
|
||||
return c.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete API key:', error);
|
||||
return handleNotFound(error as Error & { code?: string }, c);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
// Export helper for middleware
|
||||
export { hashApiKey };
|
||||
134
api/routes/files.ts
Normal file
134
api/routes/files.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { createReadStream, createWriteStream } from 'fs';
|
||||
import { Hono } from 'hono';
|
||||
import { stream } from 'hono/streaming';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Readable } from 'stream';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { z } from 'zod';
|
||||
import config from '../config';
|
||||
import prisma from '../lib/db';
|
||||
import { generateSafeFilePath, getMaxFileSize, isPathSafe } from '../lib/files';
|
||||
import { getInstanceSettings } from '../lib/settings';
|
||||
|
||||
const files = new Hono();
|
||||
|
||||
const fileIdParamSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
files.get('/:id', zValidator('param', fileIdParamSchema), async (c) => {
|
||||
const { id } = c.req.valid('param');
|
||||
|
||||
try {
|
||||
// Fetch file with its associated secrets to verify access
|
||||
const file = await prisma.file.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
secrets: {
|
||||
select: {
|
||||
id: true,
|
||||
views: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!file) {
|
||||
return c.json({ error: 'File not found' }, 404);
|
||||
}
|
||||
|
||||
// Security: Verify the file is associated with at least one valid (non-expired, has views) secret
|
||||
// This prevents direct file access without going through the secret viewing flow
|
||||
const hasValidSecret = file.secrets.some((secret) => {
|
||||
const now = new Date();
|
||||
const hasViewsRemaining = secret.views === null || secret.views > 0;
|
||||
const notExpired = secret.expiresAt > now;
|
||||
return hasViewsRemaining && notExpired;
|
||||
});
|
||||
|
||||
if (!hasValidSecret) {
|
||||
return c.json({ error: 'File not found' }, 404);
|
||||
}
|
||||
|
||||
// Validate path is within upload directory to prevent path traversal
|
||||
if (!isPathSafe(file.path)) {
|
||||
console.error(`Path traversal attempt detected: ${file.path}`);
|
||||
return c.json({ error: 'File not found' }, 404);
|
||||
}
|
||||
|
||||
// Stream the file instead of loading it entirely into memory
|
||||
const nodeStream = createReadStream(file.path);
|
||||
const webStream = Readable.toWeb(nodeStream) as ReadableStream;
|
||||
|
||||
return stream(c, async (s) => {
|
||||
s.onAbort(() => {
|
||||
nodeStream.destroy();
|
||||
});
|
||||
await s.pipe(webStream);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to download file:', error);
|
||||
return c.json({ error: 'Failed to download file' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
files.post('/', async (c) => {
|
||||
try {
|
||||
// Check if file uploads are allowed
|
||||
let allowFileUploads = true;
|
||||
if (config.isManaged()) {
|
||||
const managedSettings = config.getManagedSettings();
|
||||
allowFileUploads = managedSettings?.allowFileUploads ?? true;
|
||||
} else {
|
||||
const instanceSettings = await getInstanceSettings();
|
||||
allowFileUploads = instanceSettings?.allowFileUploads ?? true;
|
||||
}
|
||||
|
||||
if (!allowFileUploads) {
|
||||
return c.json({ error: 'File uploads are disabled on this instance.' }, 403);
|
||||
}
|
||||
|
||||
const body = await c.req.parseBody();
|
||||
const file = body['file'];
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
return c.json({ error: 'File is required and must be a file.' }, 400);
|
||||
}
|
||||
|
||||
const maxFileSize = getMaxFileSize();
|
||||
if (file.size > maxFileSize) {
|
||||
return c.json(
|
||||
{ error: `File size exceeds the limit of ${maxFileSize / 1024 / 1024}MB.` },
|
||||
413
|
||||
);
|
||||
}
|
||||
|
||||
const id = nanoid();
|
||||
const safePath = generateSafeFilePath(id, file.name);
|
||||
|
||||
if (!safePath) {
|
||||
console.error(`Path traversal attempt in upload: ${file.name}`);
|
||||
return c.json({ error: 'Invalid filename' }, 400);
|
||||
}
|
||||
|
||||
// Stream the file to disk instead of loading it entirely into memory
|
||||
const webStream = file.stream();
|
||||
const nodeStream = Readable.fromWeb(webStream as import('stream/web').ReadableStream);
|
||||
const writeStream = createWriteStream(safePath.path);
|
||||
|
||||
await pipeline(nodeStream, writeStream);
|
||||
|
||||
const newFile = await prisma.file.create({
|
||||
data: { id, filename: safePath.filename, path: safePath.path },
|
||||
});
|
||||
|
||||
return c.json({ id: newFile.id }, 201);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload file:', error);
|
||||
return c.json({ error: 'Failed to upload file' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default files;
|
||||
131
api/routes/health.ts
Normal file
131
api/routes/health.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { constants } from 'fs';
|
||||
import { access, unlink, writeFile } from 'fs/promises';
|
||||
import { Hono } from 'hono';
|
||||
import { join } from 'path';
|
||||
import prisma from '../lib/db';
|
||||
import { UPLOAD_DIR } from '../lib/files';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
type CheckStatus = 'healthy' | 'unhealthy';
|
||||
|
||||
type CheckResult = {
|
||||
status: CheckStatus;
|
||||
latency_ms?: number;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type HealthResponse = {
|
||||
status: CheckStatus;
|
||||
timestamp: string;
|
||||
checks: {
|
||||
database: CheckResult;
|
||||
storage: CheckResult;
|
||||
memory: CheckResult;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check database connectivity by executing a simple query
|
||||
*/
|
||||
async function checkDatabase(): Promise<CheckResult> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return {
|
||||
status: 'healthy',
|
||||
latency_ms: Date.now() - start,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
latency_ms: Date.now() - start,
|
||||
error: error instanceof Error ? error.message : 'Database connection failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check file storage is accessible and writable
|
||||
*/
|
||||
async function checkStorage(): Promise<CheckResult> {
|
||||
const testFile = join(UPLOAD_DIR, `.health-check-${Date.now()}`);
|
||||
try {
|
||||
// Check directory exists and is accessible
|
||||
await access(UPLOAD_DIR, constants.R_OK | constants.W_OK);
|
||||
|
||||
// Try to write and delete a test file
|
||||
await writeFile(testFile, 'health-check');
|
||||
await unlink(testFile);
|
||||
|
||||
return {
|
||||
status: 'healthy',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
error: error instanceof Error ? error.message : 'Storage check failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check memory usage is within acceptable bounds
|
||||
* Note: heapUsed/heapTotal ratio is often high (90%+) in normal Node.js operation
|
||||
* since the heap grows dynamically. We use RSS-based threshold instead.
|
||||
*/
|
||||
function checkMemory(): CheckResult {
|
||||
const memUsage = process.memoryUsage();
|
||||
const heapUsedMB = Math.round(memUsage.heapUsed / 1024 / 1024);
|
||||
const heapTotalMB = Math.round(memUsage.heapTotal / 1024 / 1024);
|
||||
const rssMB = Math.round(memUsage.rss / 1024 / 1024);
|
||||
|
||||
// Consider unhealthy if RSS exceeds 1GB (reasonable default for most deployments)
|
||||
const RSS_THRESHOLD_MB = 1024;
|
||||
const isHealthy = rssMB < RSS_THRESHOLD_MB;
|
||||
|
||||
return {
|
||||
status: isHealthy ? 'healthy' : 'unhealthy',
|
||||
heap_used_mb: heapUsedMB,
|
||||
heap_total_mb: heapTotalMB,
|
||||
rss_mb: rssMB,
|
||||
rss_threshold_mb: RSS_THRESHOLD_MB,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /health/live - Liveness probe
|
||||
* Simple check to verify the process is running
|
||||
*/
|
||||
app.get('/live', (c) => {
|
||||
return c.json({ status: 'healthy', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /health/ready - Readiness probe
|
||||
* Comprehensive check of all dependencies
|
||||
*/
|
||||
app.get('/ready', async (c) => {
|
||||
const [database, storage] = await Promise.all([checkDatabase(), checkStorage()]);
|
||||
|
||||
const memory = checkMemory();
|
||||
|
||||
const checks = { database, storage, memory };
|
||||
|
||||
const overallStatus: CheckStatus = Object.values(checks).every(
|
||||
(check) => check.status === 'healthy'
|
||||
)
|
||||
? 'healthy'
|
||||
: 'unhealthy';
|
||||
|
||||
const response: HealthResponse = {
|
||||
status: overallStatus,
|
||||
timestamp: new Date().toISOString(),
|
||||
checks,
|
||||
};
|
||||
|
||||
return c.json(response, overallStatus === 'healthy' ? 200 : 503);
|
||||
});
|
||||
|
||||
export default app;
|
||||
169
api/routes/instance.ts
Normal file
169
api/routes/instance.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { Hono } from 'hono';
|
||||
import config from '../config';
|
||||
import { ADMIN_SETTINGS_FIELDS, PUBLIC_SETTINGS_FIELDS } from '../lib/constants';
|
||||
import prisma from '../lib/db';
|
||||
import settingsCache, { setCachedInstanceSettings } from '../lib/settings';
|
||||
import { handleNotFound, isPublicUrl } from '../lib/utils';
|
||||
import { authMiddleware, checkAdmin } from '../middlewares/auth';
|
||||
import { instanceSettingsSchema } from '../validations/instance';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// GET /api/instance/managed - check if instance is in managed mode
|
||||
app.get('/managed', async (c) => {
|
||||
return c.json({ managed: config.isManaged() });
|
||||
});
|
||||
|
||||
// GET /api/instance/settings/public - public settings for all users
|
||||
app.get('/settings/public', async (c) => {
|
||||
try {
|
||||
// In managed mode, return settings from environment variables
|
||||
if (config.isManaged()) {
|
||||
const managedSettings = config.getManagedSettings();
|
||||
const publicSettings = Object.fromEntries(
|
||||
Object.entries(managedSettings || {}).filter(
|
||||
([key]) => key in PUBLIC_SETTINGS_FIELDS
|
||||
)
|
||||
);
|
||||
return c.json(publicSettings);
|
||||
}
|
||||
|
||||
let dbSettings = await prisma.instanceSettings.findFirst({
|
||||
select: PUBLIC_SETTINGS_FIELDS,
|
||||
});
|
||||
|
||||
if (!dbSettings) {
|
||||
const initialData = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(config.get('general')).filter(([, v]) => v !== undefined)
|
||||
),
|
||||
...Object.fromEntries(
|
||||
Object.entries(config.get('security')).filter(([, v]) => v !== undefined)
|
||||
),
|
||||
};
|
||||
|
||||
dbSettings = await prisma.instanceSettings.create({
|
||||
data: initialData,
|
||||
select: PUBLIC_SETTINGS_FIELDS,
|
||||
});
|
||||
}
|
||||
|
||||
const configSettings = {
|
||||
...config.get('general'),
|
||||
...config.get('security'),
|
||||
};
|
||||
const filteredConfigSettings = Object.fromEntries(
|
||||
Object.entries(configSettings).filter(
|
||||
([key, value]) => value !== undefined && key in PUBLIC_SETTINGS_FIELDS
|
||||
)
|
||||
);
|
||||
|
||||
const finalSettings = {
|
||||
...dbSettings,
|
||||
...filteredConfigSettings,
|
||||
};
|
||||
|
||||
return c.json(finalSettings);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch public instance settings:', error);
|
||||
return c.json({ error: 'Failed to fetch instance settings' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/instance/settings - admin only
|
||||
app.get('/settings', authMiddleware, checkAdmin, async (c) => {
|
||||
try {
|
||||
// In managed mode, return settings from environment variables
|
||||
if (config.isManaged()) {
|
||||
const managedSettings = config.getManagedSettings();
|
||||
return c.json(managedSettings);
|
||||
}
|
||||
|
||||
let dbSettings = await prisma.instanceSettings.findFirst({ select: ADMIN_SETTINGS_FIELDS });
|
||||
|
||||
if (!dbSettings) {
|
||||
const initialData = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(config.get('general')).filter(([, v]) => v !== undefined)
|
||||
),
|
||||
...Object.fromEntries(
|
||||
Object.entries(config.get('security')).filter(([, v]) => v !== undefined)
|
||||
),
|
||||
};
|
||||
|
||||
dbSettings = await prisma.instanceSettings.create({
|
||||
data: initialData,
|
||||
select: ADMIN_SETTINGS_FIELDS,
|
||||
});
|
||||
}
|
||||
|
||||
const configSettings = {
|
||||
...config.get('general'),
|
||||
...config.get('security'),
|
||||
};
|
||||
const filteredConfigSettings = Object.fromEntries(
|
||||
Object.entries(configSettings).filter(([, value]) => value !== undefined)
|
||||
);
|
||||
|
||||
const finalSettings = {
|
||||
...dbSettings,
|
||||
...filteredConfigSettings,
|
||||
};
|
||||
|
||||
return c.json(finalSettings);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch instance settings:', error);
|
||||
return c.json({ error: 'Failed to fetch instance settings' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/instance/settings
|
||||
app.put(
|
||||
'/settings',
|
||||
authMiddleware,
|
||||
checkAdmin,
|
||||
zValidator('json', instanceSettingsSchema),
|
||||
async (c) => {
|
||||
// Block updates in managed mode
|
||||
if (config.isManaged()) {
|
||||
return c.json(
|
||||
{ error: 'Instance is in managed mode. Settings cannot be modified.' },
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
const body = c.req.valid('json');
|
||||
|
||||
if (body.webhookUrl && body.webhookUrl !== '' && !(await isPublicUrl(body.webhookUrl))) {
|
||||
return c.json({ error: 'Webhook URL cannot point to private/internal addresses' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await prisma.instanceSettings.findFirst();
|
||||
|
||||
if (!settings) {
|
||||
return c.json({ error: 'Instance settings not found' }, 404);
|
||||
}
|
||||
|
||||
const updatedSettings = await prisma.instanceSettings.update({
|
||||
where: { id: settings.id },
|
||||
data: body,
|
||||
select: ADMIN_SETTINGS_FIELDS,
|
||||
});
|
||||
|
||||
const currentSettings = settingsCache.get('instanceSettings');
|
||||
setCachedInstanceSettings({
|
||||
...currentSettings,
|
||||
...updatedSettings,
|
||||
});
|
||||
|
||||
return c.json(updatedSettings);
|
||||
} catch (error) {
|
||||
console.error('Failed to update instance settings:', error);
|
||||
return handleNotFound(error as Error & { code?: string }, c);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default app;
|
||||
151
api/routes/invites.ts
Normal file
151
api/routes/invites.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
import { auth } from '../auth';
|
||||
import { TIME } from '../lib/constants';
|
||||
import prisma from '../lib/db';
|
||||
import { handleNotFound } from '../lib/utils';
|
||||
import { authMiddleware, checkAdmin } from '../middlewares/auth';
|
||||
|
||||
const createInviteSchema = z.object({
|
||||
maxUses: z.number().int().min(1).max(100).optional().default(1),
|
||||
expiresInDays: z.number().int().min(1).max(365).optional(),
|
||||
});
|
||||
|
||||
const codeSchema = z.object({ code: z.string() });
|
||||
|
||||
// Public route for validating invite codes (no auth required)
|
||||
export const invitePublicRoute = new Hono()
|
||||
.post('/validate', zValidator('json', codeSchema), async (c) => {
|
||||
const { code } = c.req.valid('json');
|
||||
|
||||
try {
|
||||
const invite = await prisma.inviteCode.findUnique({
|
||||
where: { code: code.toUpperCase() },
|
||||
});
|
||||
|
||||
if (!invite || !invite.isActive) {
|
||||
return c.json({ error: 'Invalid invite code' }, 400);
|
||||
}
|
||||
|
||||
if (invite.expiresAt && new Date() > invite.expiresAt) {
|
||||
return c.json({ error: 'Invite code has expired' }, 400);
|
||||
}
|
||||
|
||||
if (invite.maxUses && invite.uses >= invite.maxUses) {
|
||||
return c.json({ error: 'Invite code has reached maximum uses' }, 400);
|
||||
}
|
||||
|
||||
return c.json({ valid: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to validate invite code:', error);
|
||||
return c.json({ error: 'Failed to validate invite code' }, 500);
|
||||
}
|
||||
})
|
||||
.post('/use', zValidator('json', z.object({ code: z.string() })), async (c) => {
|
||||
const { code } = c.req.valid('json');
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
const userId = user.id;
|
||||
|
||||
try {
|
||||
const invite = await prisma.inviteCode.findUnique({
|
||||
where: { code: code.toUpperCase() },
|
||||
});
|
||||
|
||||
if (!invite || !invite.isActive) {
|
||||
return c.json({ error: 'Invalid invite code' }, 400);
|
||||
}
|
||||
|
||||
if (invite.expiresAt && new Date() > invite.expiresAt) {
|
||||
return c.json({ error: 'Invite code has expired' }, 400);
|
||||
}
|
||||
|
||||
if (invite.maxUses && invite.uses >= invite.maxUses) {
|
||||
return c.json({ error: 'Invite code has reached maximum uses' }, 400);
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.inviteCode.update({
|
||||
where: { id: invite.id },
|
||||
data: { uses: { increment: 1 } },
|
||||
}),
|
||||
prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { inviteCodeUsed: code.toUpperCase() },
|
||||
}),
|
||||
]);
|
||||
|
||||
return c.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to use invite code:', error);
|
||||
return c.json({ error: 'Failed to use invite code' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Protected routes for admin invite management
|
||||
export const inviteRoute = new Hono<{
|
||||
Variables: {
|
||||
user: typeof auth.$Infer.Session.user | null;
|
||||
};
|
||||
}>()
|
||||
.use(authMiddleware)
|
||||
.use(checkAdmin)
|
||||
.get('/', async (c) => {
|
||||
try {
|
||||
const invites = await prisma.inviteCode.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return c.json(invites);
|
||||
} catch (error) {
|
||||
console.error('Failed to list invite codes:', error);
|
||||
return c.json({ error: 'Failed to list invite codes' }, 500);
|
||||
}
|
||||
})
|
||||
.post('/', zValidator('json', createInviteSchema), async (c) => {
|
||||
const { maxUses, expiresInDays } = c.req.valid('json');
|
||||
const user = c.get('user');
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const code = nanoid(12).toUpperCase();
|
||||
const expiresAt = expiresInDays
|
||||
? new Date(Date.now() + expiresInDays * TIME.DAY_MS)
|
||||
: null;
|
||||
|
||||
const invite = await prisma.inviteCode.create({
|
||||
data: {
|
||||
code,
|
||||
maxUses,
|
||||
expiresAt,
|
||||
createdBy: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json(invite, 201);
|
||||
} catch (error) {
|
||||
console.error('Failed to create invite code:', error);
|
||||
return c.json({ error: 'Failed to create invite code' }, 500);
|
||||
}
|
||||
})
|
||||
.delete('/:id', zValidator('param', z.object({ id: z.string() })), async (c) => {
|
||||
const { id } = c.req.valid('param');
|
||||
|
||||
try {
|
||||
await prisma.inviteCode.update({
|
||||
where: { id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
return c.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete invite code ${id}:`, error);
|
||||
return handleNotFound(error as Error & { code?: string }, c);
|
||||
}
|
||||
});
|
||||
159
api/routes/metrics.ts
Normal file
159
api/routes/metrics.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
import { Hono } from 'hono';
|
||||
import { collectDefaultMetrics, Gauge, Histogram, register, Registry } from 'prom-client';
|
||||
import config from '../config';
|
||||
import prisma from '../lib/db';
|
||||
import { getInstanceSettings } from '../lib/settings';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// Create a custom registry
|
||||
const metricsRegistry = new Registry();
|
||||
|
||||
// Collect default Node.js metrics (memory, CPU, event loop, etc.)
|
||||
collectDefaultMetrics({ register: metricsRegistry });
|
||||
|
||||
// Custom application metrics
|
||||
const activeSecretsGauge = new Gauge({
|
||||
name: 'hemmelig_secrets_active_count',
|
||||
help: 'Current number of active (unexpired) secrets',
|
||||
registers: [metricsRegistry],
|
||||
});
|
||||
|
||||
const totalUsersGauge = new Gauge({
|
||||
name: 'hemmelig_users_total',
|
||||
help: 'Total number of registered users',
|
||||
registers: [metricsRegistry],
|
||||
});
|
||||
|
||||
const visitorsUnique30dGauge = new Gauge({
|
||||
name: 'hemmelig_visitors_unique_30d',
|
||||
help: 'Unique visitors in the last 30 days',
|
||||
registers: [metricsRegistry],
|
||||
});
|
||||
|
||||
const visitorsViews30dGauge = new Gauge({
|
||||
name: 'hemmelig_visitors_views_30d',
|
||||
help: 'Total page views in the last 30 days',
|
||||
registers: [metricsRegistry],
|
||||
});
|
||||
|
||||
const httpRequestDuration = new Histogram({
|
||||
name: 'hemmelig_http_request_duration_seconds',
|
||||
help: 'Duration of HTTP requests in seconds',
|
||||
labelNames: ['method', 'route', 'status_code'],
|
||||
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
|
||||
registers: [metricsRegistry],
|
||||
});
|
||||
|
||||
// Function to update gauge metrics from database
|
||||
async function updateGaugeMetrics() {
|
||||
try {
|
||||
const now = new Date();
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Count active secrets (not expired)
|
||||
const activeSecrets = await prisma.secrets.count({
|
||||
where: {
|
||||
expiresAt: {
|
||||
gt: now,
|
||||
},
|
||||
},
|
||||
});
|
||||
activeSecretsGauge.set(activeSecrets);
|
||||
|
||||
// Count total users
|
||||
const totalUsers = await prisma.user.count();
|
||||
totalUsersGauge.set(totalUsers);
|
||||
|
||||
// Get visitor stats for the last 30 days
|
||||
const visitorStats = await prisma.$queryRaw<
|
||||
Array<{ unique_visitors: bigint; total_views: bigint }>
|
||||
>`
|
||||
SELECT
|
||||
COUNT(DISTINCT uniqueId) as unique_visitors,
|
||||
COUNT(*) as total_views
|
||||
FROM visitor_analytics
|
||||
WHERE timestamp >= ${thirtyDaysAgo}
|
||||
`;
|
||||
|
||||
if (visitorStats.length > 0) {
|
||||
visitorsUnique30dGauge.set(Number(visitorStats[0].unique_visitors));
|
||||
visitorsViews30dGauge.set(Number(visitorStats[0].total_views));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update metrics gauges:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to verify Bearer token using constant-time comparison
|
||||
function verifyBearerToken(authHeader: string | undefined, expectedSecret: string): boolean {
|
||||
if (!authHeader || !expectedSecret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const provided = Buffer.from(parts[1]);
|
||||
const expected = Buffer.from(expectedSecret);
|
||||
|
||||
// Pad to same length to prevent timing leaks on token length
|
||||
const maxLen = Math.max(provided.length, expected.length);
|
||||
const paddedProvided = Buffer.alloc(maxLen);
|
||||
const paddedExpected = Buffer.alloc(maxLen);
|
||||
provided.copy(paddedProvided);
|
||||
expected.copy(paddedExpected);
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
return timingSafeEqual(paddedProvided, paddedExpected) && provided.length === expected.length;
|
||||
}
|
||||
|
||||
// GET /api/metrics - Prometheus metrics endpoint
|
||||
app.get('/', async (c) => {
|
||||
try {
|
||||
// In managed mode, use environment-based settings; otherwise use database
|
||||
const settings = config.isManaged()
|
||||
? config.getManagedSettings()
|
||||
: await getInstanceSettings();
|
||||
|
||||
// Check if metrics are enabled
|
||||
if (!settings?.metricsEnabled) {
|
||||
return c.json({ error: 'Metrics endpoint is disabled' }, 404);
|
||||
}
|
||||
|
||||
// Verify authentication if secret is configured
|
||||
if (settings.metricsSecret) {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!verifyBearerToken(authHeader, settings.metricsSecret)) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
// Update gauge metrics before returning
|
||||
await updateGaugeMetrics();
|
||||
|
||||
// Get metrics in Prometheus format
|
||||
const metrics = await metricsRegistry.metrics();
|
||||
|
||||
return c.text(metrics, 200, {
|
||||
'Content-Type': register.contentType,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate metrics:', error);
|
||||
return c.json({ error: 'Failed to generate metrics' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export function observeHttpRequest(
|
||||
method: string,
|
||||
route: string,
|
||||
statusCode: number,
|
||||
duration: number
|
||||
) {
|
||||
httpRequestDuration.labels(method, route, String(statusCode)).observe(duration);
|
||||
}
|
||||
|
||||
export default app;
|
||||
455
api/routes/secret-requests.ts
Normal file
455
api/routes/secret-requests.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
|
||||
import { Hono } from 'hono';
|
||||
import { auth } from '../auth';
|
||||
import prisma from '../lib/db';
|
||||
import { isPublicUrl } from '../lib/utils';
|
||||
import { authMiddleware } from '../middlewares/auth';
|
||||
import {
|
||||
createSecretRequestSchema,
|
||||
processSecretRequestsQueryParams,
|
||||
secretRequestIdParamSchema,
|
||||
secretRequestsQuerySchema,
|
||||
secretRequestTokenQuerySchema,
|
||||
submitSecretRequestSchema,
|
||||
} from '../validations/secret-requests';
|
||||
|
||||
// Webhook payload for secret request fulfillment
|
||||
interface SecretRequestWebhookPayload {
|
||||
event: 'secret_request.fulfilled';
|
||||
timestamp: string;
|
||||
request: {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
fulfilledAt: string;
|
||||
};
|
||||
secret: {
|
||||
id: string;
|
||||
maxViews: number;
|
||||
expiresAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Send webhook notification when a secret request is fulfilled
|
||||
async function sendSecretRequestWebhook(
|
||||
webhookUrl: string,
|
||||
webhookSecret: string,
|
||||
payload: SecretRequestWebhookPayload
|
||||
): Promise<void> {
|
||||
try {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const payloadString = JSON.stringify(payload);
|
||||
const signedPayload = `${timestamp}.${payloadString}`;
|
||||
const signature = createHmac('sha256', webhookSecret).update(signedPayload).digest('hex');
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Hemmelig-Event': 'secret_request.fulfilled',
|
||||
'X-Hemmelig-Signature': `sha256=${signature}`,
|
||||
'X-Hemmelig-Timestamp': timestamp.toString(),
|
||||
'X-Hemmelig-Request-Id': payload.request.id,
|
||||
'User-Agent': 'Hemmelig-Webhook/1.0',
|
||||
};
|
||||
|
||||
// Retry with exponential backoff
|
||||
const maxRetries = 3;
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: payloadString,
|
||||
signal: AbortSignal.timeout(5000), // 5 second timeout to prevent slow-loris
|
||||
redirect: 'error', // Prevent SSRF via open redirects
|
||||
});
|
||||
|
||||
if (response.ok) return;
|
||||
|
||||
// Don't retry for client errors (4xx)
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
console.error(`Secret request webhook delivery failed: ${response.status}`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (attempt === maxRetries - 1) {
|
||||
console.error('Secret request webhook delivery failed after retries:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Exponential backoff: 1s, 2s, 4s
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error preparing secret request webhook:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Secure token comparison - constant time for all inputs
|
||||
function validateToken(provided: string, stored: string): boolean {
|
||||
try {
|
||||
// Pad to same length to prevent timing leaks from length comparison
|
||||
const providedBuf = Buffer.alloc(32);
|
||||
const storedBuf = Buffer.alloc(32);
|
||||
|
||||
const providedBytes = Buffer.from(provided, 'hex');
|
||||
const storedBytes = Buffer.from(stored, 'hex');
|
||||
|
||||
// Only copy valid bytes, rest stays as zeros
|
||||
if (providedBytes.length === 32) providedBytes.copy(providedBuf);
|
||||
if (storedBytes.length === 32) storedBytes.copy(storedBuf);
|
||||
|
||||
// Always do the comparison, even if lengths were wrong
|
||||
const match = timingSafeEqual(providedBuf, storedBuf);
|
||||
|
||||
// Only return true if lengths were correct AND content matches
|
||||
return providedBytes.length === 32 && storedBytes.length === 32 && match;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const app = new Hono<{
|
||||
Variables: {
|
||||
user: typeof auth.$Infer.Session.user | null;
|
||||
};
|
||||
}>()
|
||||
// List user's secret requests (authenticated)
|
||||
.get('/', authMiddleware, zValidator('query', secretRequestsQuerySchema), async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!; // authMiddleware guarantees user exists
|
||||
|
||||
const validatedQuery = c.req.valid('query');
|
||||
const { skip, take, status } = processSecretRequestsQueryParams(validatedQuery);
|
||||
|
||||
const whereClause: { userId: string; status?: string } = { userId: user.id };
|
||||
if (status && status !== 'all') {
|
||||
whereClause.status = status;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.secretRequest.findMany({
|
||||
where: whereClause,
|
||||
skip,
|
||||
take,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
status: true,
|
||||
maxViews: true,
|
||||
expiresIn: true,
|
||||
webhookUrl: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
fulfilledAt: true,
|
||||
secretId: true,
|
||||
},
|
||||
}),
|
||||
prisma.secretRequest.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
return c.json({
|
||||
data: items,
|
||||
meta: {
|
||||
total,
|
||||
skip,
|
||||
take,
|
||||
page: Math.floor(skip / take) + 1,
|
||||
totalPages: Math.ceil(total / take),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to retrieve secret requests:', error);
|
||||
return c.json({ error: 'Failed to retrieve secret requests' }, 500);
|
||||
}
|
||||
})
|
||||
// Create new secret request (authenticated)
|
||||
.post('/', authMiddleware, zValidator('json', createSecretRequestSchema), async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!; // authMiddleware guarantees user exists
|
||||
|
||||
const data = c.req.valid('json');
|
||||
|
||||
if (data.webhookUrl && !(await isPublicUrl(data.webhookUrl))) {
|
||||
return c.json(
|
||||
{ error: 'Webhook URL cannot point to private/internal addresses' },
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
// Generate secure token (64 hex chars = 32 bytes)
|
||||
const token = randomBytes(32).toString('hex');
|
||||
|
||||
// Generate webhook secret if webhook URL is provided
|
||||
const webhookSecret = data.webhookUrl ? randomBytes(32).toString('hex') : null;
|
||||
|
||||
const request = await prisma.secretRequest.create({
|
||||
data: {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
maxViews: data.maxViews,
|
||||
expiresIn: data.expiresIn,
|
||||
allowedIp: data.allowedIp,
|
||||
preventBurn: data.preventBurn,
|
||||
webhookUrl: data.webhookUrl,
|
||||
webhookSecret,
|
||||
token,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + data.validFor * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
// Get the base URL from the request
|
||||
const origin = c.req.header('origin') || process.env.HEMMELIG_BASE_URL || '';
|
||||
|
||||
return c.json(
|
||||
{
|
||||
id: request.id,
|
||||
creatorLink: `${origin}/request/${request.id}?token=${token}`,
|
||||
webhookSecret, // Return once so requester can configure their webhook receiver
|
||||
expiresAt: request.expiresAt,
|
||||
},
|
||||
201
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to create secret request:', error);
|
||||
return c.json({ error: 'Failed to create secret request' }, 500);
|
||||
}
|
||||
})
|
||||
// Get single secret request details (authenticated, owner only)
|
||||
.get('/:id', authMiddleware, zValidator('param', secretRequestIdParamSchema), async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { id } = c.req.valid('param');
|
||||
|
||||
const request = await prisma.secretRequest.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
status: true,
|
||||
maxViews: true,
|
||||
expiresIn: true,
|
||||
preventBurn: true,
|
||||
webhookUrl: true,
|
||||
token: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
fulfilledAt: true,
|
||||
secretId: true,
|
||||
userId: true,
|
||||
allowedIp: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
return c.json({ error: 'Secret request not found' }, 404);
|
||||
}
|
||||
|
||||
if (request.userId !== user.id) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
// Get the base URL from the request
|
||||
const origin = c.req.header('origin') || process.env.HEMMELIG_BASE_URL || '';
|
||||
|
||||
return c.json({
|
||||
...request,
|
||||
creatorLink: `${origin}/request/${request.id}?token=${request.token}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to retrieve secret request:', error);
|
||||
return c.json({ error: 'Failed to retrieve secret request' }, 500);
|
||||
}
|
||||
})
|
||||
// Cancel/delete secret request (authenticated, owner only)
|
||||
.delete('/:id', authMiddleware, zValidator('param', secretRequestIdParamSchema), async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { id } = c.req.valid('param');
|
||||
|
||||
const request = await prisma.secretRequest.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true, status: true },
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
return c.json({ error: 'Secret request not found' }, 404);
|
||||
}
|
||||
|
||||
if (request.userId !== user.id) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
// Only allow cancellation of pending requests
|
||||
if (request.status !== 'pending') {
|
||||
return c.json({ error: 'Can only cancel pending requests' }, 400);
|
||||
}
|
||||
|
||||
await prisma.secretRequest.update({
|
||||
where: { id },
|
||||
data: { status: 'cancelled' },
|
||||
});
|
||||
|
||||
return c.json({ success: true, message: 'Secret request cancelled' });
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel secret request:', error);
|
||||
return c.json({ error: 'Failed to cancel secret request' }, 500);
|
||||
}
|
||||
})
|
||||
// Get request info for Creator (public, requires token)
|
||||
.get(
|
||||
'/:id/info',
|
||||
zValidator('param', secretRequestIdParamSchema),
|
||||
zValidator('query', secretRequestTokenQuerySchema),
|
||||
async (c) => {
|
||||
try {
|
||||
const { id } = c.req.valid('param');
|
||||
const { token } = c.req.valid('query');
|
||||
|
||||
const request = await prisma.secretRequest.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
status: true,
|
||||
expiresAt: true,
|
||||
token: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!request || !validateToken(token, request.token)) {
|
||||
return c.json({ error: 'Invalid or expired request' }, 404);
|
||||
}
|
||||
|
||||
if (request.status !== 'pending') {
|
||||
return c.json({ error: 'Request already fulfilled or expired' }, 410);
|
||||
}
|
||||
|
||||
if (new Date() > request.expiresAt) {
|
||||
// Update status to expired
|
||||
await prisma.secretRequest.update({
|
||||
where: { id },
|
||||
data: { status: 'expired' },
|
||||
});
|
||||
return c.json({ error: 'Request has expired' }, 410);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: request.id,
|
||||
title: request.title,
|
||||
description: request.description,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to retrieve secret request info:', error);
|
||||
return c.json({ error: 'Failed to retrieve request info' }, 500);
|
||||
}
|
||||
}
|
||||
)
|
||||
// Submit encrypted secret for request (public, requires token)
|
||||
.post(
|
||||
'/:id/submit',
|
||||
zValidator('param', secretRequestIdParamSchema),
|
||||
zValidator('query', secretRequestTokenQuerySchema),
|
||||
zValidator('json', submitSecretRequestSchema),
|
||||
async (c) => {
|
||||
try {
|
||||
const { id } = c.req.valid('param');
|
||||
const { token } = c.req.valid('query');
|
||||
const { secret, title, salt } = c.req.valid('json');
|
||||
|
||||
// Use interactive transaction to prevent race conditions
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const request = await tx.secretRequest.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!request || !validateToken(token, request.token)) {
|
||||
return { error: 'Invalid request', status: 404 };
|
||||
}
|
||||
|
||||
if (request.status !== 'pending') {
|
||||
return { error: 'Request already fulfilled', status: 410 };
|
||||
}
|
||||
|
||||
if (new Date() > request.expiresAt) {
|
||||
await tx.secretRequest.update({
|
||||
where: { id },
|
||||
data: { status: 'expired' },
|
||||
});
|
||||
return { error: 'Request has expired', status: 410 };
|
||||
}
|
||||
|
||||
// Calculate expiration time for the secret
|
||||
const secretExpiresAt = new Date(Date.now() + request.expiresIn * 1000);
|
||||
|
||||
// Create secret and update request atomically
|
||||
const createdSecret = await tx.secrets.create({
|
||||
data: {
|
||||
secret: Buffer.from(secret),
|
||||
title: title ? Buffer.from(title) : Buffer.from([]),
|
||||
salt,
|
||||
views: request.maxViews,
|
||||
ipRange: request.allowedIp,
|
||||
isBurnable: !request.preventBurn,
|
||||
expiresAt: secretExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.secretRequest.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'fulfilled',
|
||||
fulfilledAt: new Date(),
|
||||
secretId: createdSecret.id,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, createdSecret, request, secretExpiresAt };
|
||||
});
|
||||
|
||||
if ('error' in result) {
|
||||
return c.json({ error: result.error }, result.status as 404 | 410);
|
||||
}
|
||||
|
||||
const { createdSecret, request, secretExpiresAt } = result;
|
||||
|
||||
// Send webhook notification (async, don't block response)
|
||||
if (request.webhookUrl && request.webhookSecret) {
|
||||
const webhookPayload: SecretRequestWebhookPayload = {
|
||||
event: 'secret_request.fulfilled',
|
||||
timestamp: new Date().toISOString(),
|
||||
request: {
|
||||
id: request.id,
|
||||
title: request.title,
|
||||
createdAt: request.createdAt.toISOString(),
|
||||
fulfilledAt: new Date().toISOString(),
|
||||
},
|
||||
secret: {
|
||||
id: createdSecret.id,
|
||||
maxViews: request.maxViews,
|
||||
expiresAt: secretExpiresAt.toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
sendSecretRequestWebhook(
|
||||
request.webhookUrl,
|
||||
request.webhookSecret,
|
||||
webhookPayload
|
||||
).catch(console.error);
|
||||
}
|
||||
|
||||
// Return secret ID (client will construct full URL with decryption key)
|
||||
return c.json({ secretId: createdSecret.id }, 201);
|
||||
} catch (error) {
|
||||
console.error('Failed to submit secret for request:', error);
|
||||
return c.json({ error: 'Failed to submit secret' }, 500);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default app;
|
||||
343
api/routes/secrets.ts
Normal file
343
api/routes/secrets.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { auth } from '../auth';
|
||||
import config from '../config';
|
||||
import prisma from '../lib/db';
|
||||
import { compare, hash } from '../lib/password';
|
||||
import { getInstanceSettings } from '../lib/settings';
|
||||
import { handleNotFound } from '../lib/utils';
|
||||
import { sendWebhook } from '../lib/webhook';
|
||||
import { apiKeyOrAuthMiddleware } from '../middlewares/auth';
|
||||
import { ipRestriction } from '../middlewares/ip-restriction';
|
||||
import {
|
||||
createSecretsSchema,
|
||||
getSecretSchema,
|
||||
processSecretsQueryParams,
|
||||
secretsIdParamSchema,
|
||||
secretsQuerySchema,
|
||||
} from '../validations/secrets';
|
||||
|
||||
interface SecretCreateData {
|
||||
salt: string;
|
||||
secret: Uint8Array;
|
||||
title?: Uint8Array | null;
|
||||
password: string | null;
|
||||
expiresAt: Date;
|
||||
views?: number;
|
||||
isBurnable?: boolean;
|
||||
ipRange?: string | null;
|
||||
files?: { connect: { id: string }[] };
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
const app = new Hono<{
|
||||
Variables: {
|
||||
user: typeof auth.$Infer.Session.user | null;
|
||||
};
|
||||
}>()
|
||||
.get('/', apiKeyOrAuthMiddleware, zValidator('query', secretsQuerySchema), async (c) => {
|
||||
try {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const validatedQuery = c.req.valid('query');
|
||||
const options = processSecretsQueryParams(validatedQuery);
|
||||
const whereClause = { userId: user.id };
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.secrets.findMany({
|
||||
where: whereClause,
|
||||
skip: options.skip,
|
||||
take: options.take,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
views: true,
|
||||
password: true,
|
||||
ipRange: true,
|
||||
isBurnable: true,
|
||||
_count: {
|
||||
select: { files: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.secrets.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
const formattedItems = items.map((item) => ({
|
||||
id: item.id,
|
||||
createdAt: item.createdAt,
|
||||
expiresAt: item.expiresAt,
|
||||
views: item.views,
|
||||
isPasswordProtected: !!item.password,
|
||||
ipRange: item.ipRange,
|
||||
isBurnable: item.isBurnable,
|
||||
fileCount: item._count.files,
|
||||
}));
|
||||
|
||||
return c.json({
|
||||
data: formattedItems,
|
||||
meta: {
|
||||
total,
|
||||
skip: options.skip,
|
||||
take: options.take,
|
||||
page: Math.floor(options.skip / options.take) + 1,
|
||||
totalPages: Math.ceil(total / options.take),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to retrieve secrets:', error);
|
||||
return c.json(
|
||||
{
|
||||
error: 'Failed to retrieve secrets',
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
})
|
||||
.post(
|
||||
'/:id',
|
||||
zValidator('param', secretsIdParamSchema),
|
||||
zValidator('json', getSecretSchema),
|
||||
ipRestriction,
|
||||
async (c) => {
|
||||
try {
|
||||
const { id } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
|
||||
// Atomically retrieve secret and consume view in a single transaction
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const item = await tx.secrets.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
secret: true,
|
||||
title: true,
|
||||
ipRange: true,
|
||||
views: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
isBurnable: true,
|
||||
password: true,
|
||||
salt: true,
|
||||
files: {
|
||||
select: { id: true, filename: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return { error: 'Secret not found', status: 404 as const };
|
||||
}
|
||||
|
||||
// Check if secret has no views remaining (already consumed)
|
||||
if (item.views !== null && item.views <= 0) {
|
||||
return { error: 'Secret not found', status: 404 as const };
|
||||
}
|
||||
|
||||
// Verify password if required
|
||||
if (item.password) {
|
||||
const isValidPassword = await compare(data.password!, item.password);
|
||||
if (!isValidPassword) {
|
||||
return { error: 'Invalid password', status: 401 as const };
|
||||
}
|
||||
}
|
||||
|
||||
// Consume the view atomically with retrieval
|
||||
const newViews = item.views! - 1;
|
||||
|
||||
// If burnable and last view, delete the secret after returning data
|
||||
if (item.isBurnable && newViews <= 0) {
|
||||
await tx.secrets.delete({ where: { id } });
|
||||
|
||||
// Send webhook for burned secret
|
||||
sendWebhook('secret.burned', {
|
||||
secretId: id,
|
||||
hasPassword: !!item.password,
|
||||
hasIpRestriction: !!item.ipRange,
|
||||
});
|
||||
} else {
|
||||
// Decrement views
|
||||
await tx.secrets.update({
|
||||
where: { id },
|
||||
data: { views: newViews },
|
||||
});
|
||||
|
||||
// Send webhook for viewed secret
|
||||
sendWebhook('secret.viewed', {
|
||||
secretId: id,
|
||||
hasPassword: !!item.password,
|
||||
hasIpRestriction: !!item.ipRange,
|
||||
viewsRemaining: newViews,
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { password: _password, ...itemWithoutPassword } = item;
|
||||
return {
|
||||
...itemWithoutPassword,
|
||||
views: newViews,
|
||||
burned: item.isBurnable && newViews <= 0,
|
||||
};
|
||||
});
|
||||
|
||||
if ('error' in result) {
|
||||
return c.json({ error: result.error }, result.status);
|
||||
}
|
||||
|
||||
return c.json(result);
|
||||
} catch (error) {
|
||||
console.error(`Failed to retrieve item ${c.req.param('id')}:`, error);
|
||||
return c.json(
|
||||
{
|
||||
error: 'Failed to retrieve item',
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
)
|
||||
.get('/:id/check', zValidator('param', secretsIdParamSchema), ipRestriction, async (c) => {
|
||||
try {
|
||||
const { id } = c.req.valid('param');
|
||||
|
||||
const item = await prisma.secrets.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
views: true,
|
||||
title: true,
|
||||
password: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return c.json({ error: 'Secret not found' }, 404);
|
||||
}
|
||||
|
||||
// Check if secret has no views remaining (already consumed)
|
||||
if (item.views !== null && item.views <= 0) {
|
||||
return c.json({ error: 'Secret not found' }, 404);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
views: item.views,
|
||||
title: item.title,
|
||||
isPasswordProtected: !!item.password,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to check secret ${c.req.param('id')}:`, error);
|
||||
return c.json(
|
||||
{
|
||||
error: 'Failed to check secret',
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
})
|
||||
.post('/', zValidator('json', createSecretsSchema), async (c) => {
|
||||
try {
|
||||
const user = c.get('user');
|
||||
|
||||
// Check if only registered users can create secrets
|
||||
// In managed mode, use environment-based settings; otherwise use database
|
||||
const settings = config.isManaged()
|
||||
? config.getManagedSettings()
|
||||
: await getInstanceSettings();
|
||||
if (settings?.requireRegisteredUser && !user) {
|
||||
return c.json({ error: 'Only registered users can create secrets' }, 401);
|
||||
}
|
||||
|
||||
const validatedData = c.req.valid('json');
|
||||
|
||||
// Enforce dynamic maxSecretSize from instance settings (in KB)
|
||||
const maxSizeKB = settings?.maxSecretSize ?? 1024;
|
||||
const maxSizeBytes = maxSizeKB * 1024;
|
||||
if (validatedData.secret.length > maxSizeBytes) {
|
||||
return c.json({ error: `Secret exceeds maximum size of ${maxSizeKB} KB` }, 413);
|
||||
}
|
||||
|
||||
const { expiresAt, password, fileIds, salt, title, ...rest } = validatedData;
|
||||
|
||||
const data: SecretCreateData = {
|
||||
...rest,
|
||||
salt,
|
||||
// Title is required by the database, default to empty Uint8Array if not provided
|
||||
title: title ?? new Uint8Array(0),
|
||||
password: password ? await hash(password) : null,
|
||||
expiresAt: new Date(Date.now() + expiresAt * 1000),
|
||||
...(fileIds && {
|
||||
files: { connect: fileIds.map((id: string) => ({ id })) },
|
||||
}),
|
||||
};
|
||||
|
||||
if (user) {
|
||||
data.userId = user.id;
|
||||
}
|
||||
|
||||
const item = await prisma.secrets.create({ data });
|
||||
|
||||
return c.json({ id: item.id }, 201);
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to create secrets:', error);
|
||||
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') {
|
||||
const prismaError = error as { meta?: { target?: string } };
|
||||
return c.json(
|
||||
{
|
||||
error: 'Could not create secrets',
|
||||
details: prismaError.meta?.target,
|
||||
},
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
error: 'Failed to create secret',
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
})
|
||||
.delete('/:id', zValidator('param', secretsIdParamSchema), async (c) => {
|
||||
try {
|
||||
const { id } = c.req.valid('param');
|
||||
|
||||
// Use transaction to prevent race conditions
|
||||
const secret = await prisma.$transaction(async (tx) => {
|
||||
// Get secret info before deleting for webhook
|
||||
const secretData = await tx.secrets.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, password: true, ipRange: true },
|
||||
});
|
||||
|
||||
await tx.secrets.delete({ where: { id } });
|
||||
|
||||
return secretData;
|
||||
});
|
||||
|
||||
// Send webhook for manually burned secret
|
||||
if (secret) {
|
||||
sendWebhook('secret.burned', {
|
||||
secretId: id,
|
||||
hasPassword: !!secret.password,
|
||||
hasIpRestriction: !!secret.ipRange,
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: 'Secret deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete secret ${c.req.param('id')}:`, error);
|
||||
return handleNotFound(error as Error & { code?: string }, c);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
82
api/routes/setup.ts
Normal file
82
api/routes/setup.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import { auth } from '../auth';
|
||||
import prisma from '../lib/db';
|
||||
import { passwordSchema } from '../validations/password';
|
||||
|
||||
const setupSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: passwordSchema,
|
||||
username: z.string().min(3).max(32),
|
||||
name: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
const app = new Hono()
|
||||
// Check if setup is needed (no users exist)
|
||||
.get('/status', async (c) => {
|
||||
try {
|
||||
const userCount = await prisma.user.count();
|
||||
return c.json({
|
||||
needsSetup: userCount === 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to check setup status:', error);
|
||||
return c.json({ error: 'Failed to check setup status' }, 500);
|
||||
}
|
||||
})
|
||||
// Complete initial setup - create first admin user
|
||||
.post('/complete', zValidator('json', setupSchema), async (c) => {
|
||||
try {
|
||||
// Check if any users already exist
|
||||
const userCount = await prisma.user.count();
|
||||
if (userCount > 0) {
|
||||
return c.json({ error: 'Setup already completed' }, 403);
|
||||
}
|
||||
|
||||
const { email, password, username, name } = c.req.valid('json');
|
||||
|
||||
// Create the admin user using better-auth
|
||||
const result = await auth.api.signUpEmail({
|
||||
body: {
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
username,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.user) {
|
||||
return c.json({ error: 'Failed to create admin user' }, 500);
|
||||
}
|
||||
|
||||
// Update user to be admin
|
||||
await prisma.user.update({
|
||||
where: { id: result.user.id },
|
||||
data: { role: 'admin' },
|
||||
});
|
||||
|
||||
// Create initial instance settings if not exists
|
||||
const existingSettings = await prisma.instanceSettings.findFirst();
|
||||
if (!existingSettings) {
|
||||
await prisma.instanceSettings.create({
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: 'Setup completed successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to complete setup:', error);
|
||||
return c.json(
|
||||
{
|
||||
error: 'Failed to complete setup',
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
89
api/routes/user.ts
Normal file
89
api/routes/user.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import prisma from '../lib/db';
|
||||
import { checkAdmin } from '../middlewares/auth';
|
||||
import { updateUserSchema } from '../validations/user';
|
||||
|
||||
export const userRoute = new Hono()
|
||||
.use(checkAdmin)
|
||||
.get(
|
||||
'/',
|
||||
zValidator(
|
||||
'query',
|
||||
z.object({
|
||||
page: z.coerce.number().min(1).default(1),
|
||||
pageSize: z.coerce.number().min(1).max(100).default(10),
|
||||
search: z.string().max(100).optional(),
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { page, pageSize, search } = c.req.valid('query');
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where = search
|
||||
? {
|
||||
OR: [
|
||||
{ username: { contains: search } },
|
||||
{ email: { contains: search } },
|
||||
{ name: { contains: search } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
banned: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
return c.json({
|
||||
users,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
});
|
||||
}
|
||||
)
|
||||
.put(
|
||||
'/:id',
|
||||
zValidator('param', z.object({ id: z.string() })),
|
||||
zValidator('json', updateUserSchema),
|
||||
async (c) => {
|
||||
const { id } = c.req.valid('param');
|
||||
const { username, email } = c.req.valid('json');
|
||||
|
||||
const data = {
|
||||
...(username && { username }),
|
||||
...(email && { email }),
|
||||
};
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
banned: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json(user);
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user