Skip to content

Commit 4f13594

Browse files
committed
feat: data export in settings
1 parent a059441 commit 4f13594

File tree

4 files changed

+451
-171
lines changed

4 files changed

+451
-171
lines changed

apps/api/src/routes/export.ts

Lines changed: 115 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import { Elysia, t } from 'elysia';
22
import { logger } from '../lib/logger';
33
import { processExport, type ExportRequest } from '../lib/export';
44
import { createRateLimitMiddleware } from '../middleware/rate-limit';
5-
import { auth } from '@databuddy/auth';
6-
import { getCachedWebsite } from '../lib/website-utils';
5+
import { websitesApi, auth } from '@databuddy/auth';
6+
import { db, eq, websites } from '@databuddy/db';
7+
import { cacheable } from '@databuddy/redis';
78
import dayjs from 'dayjs';
89
import utc from 'dayjs/plugin/utc';
910

@@ -15,6 +16,78 @@ const exportRateLimit = createRateLimitMiddleware({
1516
skipAuth: false,
1617
});
1718

19+
// Cached website lookup (same as in RPC utils)
20+
const getWebsiteById = cacheable(
21+
async (id: string) => {
22+
try {
23+
if (!id) {
24+
return null;
25+
}
26+
return await db.query.websites.findFirst({
27+
where: eq(websites.id, id),
28+
});
29+
} catch (error) {
30+
console.error('Error fetching website by ID:', error, { id });
31+
return null;
32+
}
33+
},
34+
{
35+
expireInSec: 600,
36+
prefix: 'website_by_id',
37+
staleWhileRevalidate: true,
38+
staleTime: 60,
39+
}
40+
);
41+
42+
/**
43+
* Authorize website access using the same pattern as RPC routers
44+
*/
45+
async function authorizeWebsiteAccess(
46+
headers: Headers,
47+
websiteId: string,
48+
permission: 'read' | 'update' | 'delete' | 'transfer'
49+
) {
50+
const website = await getWebsiteById(websiteId);
51+
52+
if (!website) {
53+
throw new Error('Website not found');
54+
}
55+
56+
// Public websites allow read access
57+
if (permission === 'read' && website.isPublic) {
58+
return website;
59+
}
60+
61+
// Get user session
62+
const session = await auth.api.getSession({ headers });
63+
const user = session?.user;
64+
65+
if (!user) {
66+
throw new Error('Authentication is required for this action');
67+
}
68+
69+
// Admin users have full access
70+
if (user.role === 'ADMIN') {
71+
return website;
72+
}
73+
74+
// Check organization permissions
75+
if (website.organizationId) {
76+
const { success } = await websitesApi.hasPermission({
77+
headers,
78+
body: { permissions: { website: [permission] } },
79+
});
80+
if (!success) {
81+
throw new Error('You do not have permission to perform this action');
82+
}
83+
} else if (website.userId !== user.id) {
84+
// Check direct ownership
85+
throw new Error('You are not the owner of this website');
86+
}
87+
88+
return website;
89+
}
90+
1891
export const exportRoute = new Elysia({ prefix: '/v1/export' })
1992
.use(exportRateLimit)
2093
.post(
@@ -27,93 +100,15 @@ export const exportRoute = new Elysia({ prefix: '/v1/export' })
27100
const websiteId = body.website_id;
28101

29102
if (!websiteId) {
30-
logger.warn('Export request missing website_id', {
31-
requestId,
32-
userAgent: request.headers.get('user-agent'),
33-
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
34-
});
35-
return new Response(
36-
JSON.stringify({
37-
success: false,
38-
error: 'Website ID is required',
39-
code: 'MISSING_WEBSITE_ID',
40-
}),
41-
{
42-
status: 400,
43-
headers: { 'Content-Type': 'application/json' }
44-
}
45-
);
103+
return createErrorResponse(400, 'MISSING_WEBSITE_ID', 'Website ID is required');
46104
}
47105

48-
// Get user session
49-
const session = await auth.api.getSession({ headers: request.headers });
50-
const user = session?.user;
51-
52-
if (!user) {
53-
logger.warn('Export request without authentication', {
54-
requestId,
55-
websiteId,
56-
userAgent: request.headers.get('user-agent'),
57-
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
58-
});
59-
return new Response(
60-
JSON.stringify({
61-
success: false,
62-
error: 'Authentication required',
63-
code: 'AUTH_REQUIRED',
64-
}),
65-
{
66-
status: 401,
67-
headers: { 'Content-Type': 'application/json' }
68-
}
69-
);
70-
}
71-
72-
// Get website and verify ownership
73-
const website = await getCachedWebsite(websiteId);
74-
if (!website) {
75-
logger.warn('Export request for non-existent website', {
76-
requestId,
77-
websiteId,
78-
userId: user.id,
79-
userAgent: request.headers.get('user-agent'),
80-
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
81-
});
82-
return new Response(
83-
JSON.stringify({
84-
success: false,
85-
error: 'Website not found',
86-
code: 'WEBSITE_NOT_FOUND',
87-
}),
88-
{
89-
status: 404,
90-
headers: { 'Content-Type': 'application/json' }
91-
}
92-
);
93-
}
94-
95-
// Check if user owns the website (assuming website has userId field)
96-
if (website.userId !== user.id) {
97-
logger.warn('Export request for unauthorized website', {
98-
requestId,
99-
websiteId,
100-
userId: user.id,
101-
websiteOwnerId: website.userId,
102-
userAgent: request.headers.get('user-agent'),
103-
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
104-
});
105-
return new Response(
106-
JSON.stringify({
107-
success: false,
108-
error: 'Access denied. You may not have permission to export data for this website.',
109-
code: 'ACCESS_DENIED',
110-
}),
111-
{
112-
status: 403,
113-
headers: { 'Content-Type': 'application/json' }
114-
}
115-
);
116-
}
106+
// Use the same authorization pattern as RPC routers
107+
const website = await authorizeWebsiteAccess(
108+
request.headers,
109+
websiteId,
110+
'read'
111+
);
117112

118113
// Validate and sanitize date inputs
119114
const { validatedDates, error: dateError } = validateDateRange(
@@ -125,22 +120,11 @@ export const exportRoute = new Elysia({ prefix: '/v1/export' })
125120
logger.warn('Export request with invalid dates', {
126121
requestId,
127122
websiteId,
128-
userId: user.id,
129123
startDate: body.start_date,
130124
endDate: body.end_date,
131125
error: dateError,
132126
});
133-
return new Response(
134-
JSON.stringify({
135-
success: false,
136-
error: dateError,
137-
code: 'INVALID_DATE_RANGE',
138-
}),
139-
{
140-
status: 400,
141-
headers: { 'Content-Type': 'application/json' }
142-
}
143-
);
127+
return createErrorResponse(400, 'INVALID_DATE_RANGE', dateError);
144128
}
145129

146130
// Validate export format
@@ -149,28 +133,15 @@ export const exportRoute = new Elysia({ prefix: '/v1/export' })
149133
logger.warn('Export request with invalid format', {
150134
requestId,
151135
websiteId,
152-
userId: user.id,
153136
format,
154137
});
155-
return new Response(
156-
JSON.stringify({
157-
success: false,
158-
error: 'Invalid export format. Supported formats: csv, json, txt, proto',
159-
code: 'INVALID_FORMAT',
160-
}),
161-
{
162-
status: 400,
163-
headers: { 'Content-Type': 'application/json' }
164-
}
165-
);
138+
return createErrorResponse(400, 'INVALID_FORMAT', 'Invalid export format. Supported formats: csv, json, txt, proto');
166139
}
167140

168141
// Log export initiation for audit trail
169142
logger.info('Data export initiated', {
170143
requestId,
171144
websiteId,
172-
userId: user.id,
173-
userEmail: user.email,
174145
startDate: validatedDates.startDate,
175146
endDate: validatedDates.endDate,
176147
format,
@@ -193,8 +164,6 @@ export const exportRoute = new Elysia({ prefix: '/v1/export' })
193164
logger.info('Data export completed successfully', {
194165
requestId,
195166
websiteId,
196-
userId: user.id,
197-
userEmail: user.email,
198167
filename: result.filename,
199168
fileSize: result.buffer.length,
200169
totalRecords: result.metadata.totalRecords,
@@ -223,18 +192,20 @@ export const exportRoute = new Elysia({ prefix: '/v1/export' })
223192
timestamp: new Date().toISOString(),
224193
});
225194

226-
return new Response(
227-
JSON.stringify({
228-
success: false,
229-
error: 'Export failed. Please try again later.',
230-
code: 'EXPORT_FAILED',
231-
requestId,
232-
}),
233-
{
234-
status: 500,
235-
headers: { 'Content-Type': 'application/json' }
195+
// Handle authorization errors specifically
196+
if (error instanceof Error) {
197+
if (error.message.includes('not found')) {
198+
return createErrorResponse(404, 'WEBSITE_NOT_FOUND', 'Website not found', requestId);
236199
}
237-
);
200+
if (error.message.includes('Authentication is required')) {
201+
return createErrorResponse(401, 'AUTH_REQUIRED', 'Authentication required', requestId);
202+
}
203+
if (error.message.includes('permission') || error.message.includes('owner')) {
204+
return createErrorResponse(403, 'ACCESS_DENIED', 'Access denied. You may not have permission to export data for this website.', requestId);
205+
}
206+
}
207+
208+
return createErrorResponse(500, 'EXPORT_FAILED', 'Export failed. Please try again later.', requestId);
238209
}
239210
},
240211
{
@@ -265,6 +236,24 @@ export const exportRoute = new Elysia({ prefix: '/v1/export' })
265236
}
266237
);
267238

239+
/**
240+
* Creates a standardized error response
241+
*/
242+
function createErrorResponse(status: number, code: string, message: string, requestId?: string) {
243+
return new Response(
244+
JSON.stringify({
245+
success: false,
246+
error: message,
247+
code,
248+
...(requestId && { requestId }),
249+
}),
250+
{
251+
status,
252+
headers: { 'Content-Type': 'application/json' }
253+
}
254+
);
255+
}
256+
268257
/**
269258
* Validates and sanitizes date range inputs
270259
* Prevents SQL injection and ensures reasonable date ranges

apps/dashboard/app/(main)/websites/[id]/_components/constants/settings-constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const SETTINGS_TABS = {
1212
ADVANCED: 'advanced',
1313
OPTIMIZATION: 'optimization',
1414
PRIVACY: 'privacy',
15+
EXPORT: 'export',
1516
} as const;
1617

1718
export type SettingsTab = (typeof SETTINGS_TABS)[keyof typeof SETTINGS_TABS];

0 commit comments

Comments
 (0)