@@ -2,8 +2,9 @@ import { Elysia, t } from 'elysia';
22import { logger } from '../lib/logger' ;
33import { processExport , type ExportRequest } from '../lib/export' ;
44import { 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' ;
78import dayjs from 'dayjs' ;
89import 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+
1891export 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
0 commit comments