@studious-lms/server 1.1.3 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -8,13 +8,56 @@ import { appRouter } from './routers/_app.js';
8
8
  import { createTRPCContext, createCallerFactory } from './trpc.js';
9
9
  import { logger } from './utils/logger.js';
10
10
  import { setupSocketHandlers } from './socket/handlers.js';
11
+ import { bucket } from './lib/googleCloudStorage.js';
11
12
  dotenv.config();
12
13
  const app = express();
13
14
  // CORS middleware
14
15
  app.use(cors({
15
- origin: [process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000', 'http://localhost:3000'],
16
+ origin: [
17
+ 'http://localhost:3000', // Frontend development server
18
+ 'http://localhost:3001', // Server port
19
+ 'http://127.0.0.1:3000', // Alternative localhost
20
+ 'http://127.0.0.1:3001', // Alternative localhost
21
+ process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
22
+ ],
16
23
  credentials: true,
24
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
25
+ allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'x-user'],
26
+ optionsSuccessStatus: 200
17
27
  }));
28
+ // Handle preflight OPTIONS requests
29
+ app.options('*', (req, res) => {
30
+ const allowedOrigins = [
31
+ 'http://localhost:3000',
32
+ 'http://localhost:3001',
33
+ 'http://127.0.0.1:3000',
34
+ 'http://127.0.0.1:3001',
35
+ process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
36
+ ];
37
+ const origin = req.headers.origin;
38
+ if (origin && allowedOrigins.includes(origin)) {
39
+ res.header('Access-Control-Allow-Origin', origin);
40
+ }
41
+ else {
42
+ res.header('Access-Control-Allow-Origin', 'http://localhost:3000');
43
+ }
44
+ res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
45
+ res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, x-user');
46
+ res.header('Access-Control-Allow-Credentials', 'true');
47
+ res.sendStatus(200);
48
+ });
49
+ // CORS debugging middleware
50
+ app.use((req, res, next) => {
51
+ if (req.method === 'OPTIONS' || req.path.includes('trpc')) {
52
+ logger.info('CORS Request', {
53
+ method: req.method,
54
+ path: req.path,
55
+ origin: req.headers.origin,
56
+ userAgent: req.headers['user-agent']
57
+ });
58
+ }
59
+ next();
60
+ });
18
61
  // Response time logging middleware
19
62
  app.use((req, res, next) => {
20
63
  const start = Date.now();
@@ -34,10 +77,16 @@ const httpServer = createServer(app);
34
77
  // Setup Socket.IO
35
78
  const io = new Server(httpServer, {
36
79
  cors: {
37
- origin: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
38
- methods: ['GET', 'POST'],
80
+ origin: [
81
+ 'http://localhost:3000', // Frontend development server
82
+ 'http://localhost:3001', // Server port
83
+ 'http://127.0.0.1:3000', // Alternative localhost
84
+ 'http://127.0.0.1:3001', // Alternative localhost
85
+ process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
86
+ ],
87
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
39
88
  credentials: true,
40
- allowedHeaders: ['Access-Control-Allow-Origin']
89
+ allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Access-Control-Allow-Origin', 'x-user']
41
90
  },
42
91
  transports: ['websocket', 'polling'],
43
92
  pingTimeout: 60000,
@@ -52,6 +101,42 @@ io.engine.on('connection_error', (err) => {
52
101
  });
53
102
  // Setup socket handlers
54
103
  setupSocketHandlers(io);
104
+ // File serving endpoint for secure file access
105
+ app.get('/api/files/:filePath', async (req, res) => {
106
+ try {
107
+ const filePath = decodeURIComponent(req.params.filePath);
108
+ console.log('File request:', { filePath, originalPath: req.params.filePath });
109
+ // Get file from Google Cloud Storage
110
+ const file = bucket.file(filePath);
111
+ const [exists] = await file.exists();
112
+ console.log('File exists:', exists, 'for path:', filePath);
113
+ if (!exists) {
114
+ return res.status(404).json({ error: 'File not found', filePath });
115
+ }
116
+ // Get file metadata
117
+ const [metadata] = await file.getMetadata();
118
+ // Set appropriate headers
119
+ res.set({
120
+ 'Content-Type': metadata.contentType || 'application/octet-stream',
121
+ 'Content-Length': metadata.size,
122
+ 'Cache-Control': 'public, max-age=31536000', // 1 year cache
123
+ 'ETag': metadata.etag,
124
+ });
125
+ // Stream file to response
126
+ const stream = file.createReadStream();
127
+ stream.pipe(res);
128
+ stream.on('error', (error) => {
129
+ console.error('Error streaming file:', error);
130
+ if (!res.headersSent) {
131
+ res.status(500).json({ error: 'Error streaming file' });
132
+ }
133
+ });
134
+ }
135
+ catch (error) {
136
+ console.error('Error serving file:', error);
137
+ res.status(500).json({ error: 'Internal server error' });
138
+ }
139
+ });
55
140
  // Create caller
56
141
  const createCaller = createCallerFactory(appRouter);
57
142
  // Setup tRPC middleware
@@ -75,3 +160,13 @@ logger.info('Configurations', {
75
160
  NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
76
161
  LOG_MODE: process.env.LOG_MODE,
77
162
  });
163
+ // Log CORS configuration
164
+ logger.info('CORS Configuration', {
165
+ allowedOrigins: [
166
+ 'http://localhost:3000',
167
+ 'http://localhost:3001',
168
+ 'http://127.0.0.1:3000',
169
+ 'http://127.0.0.1:3001',
170
+ process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
171
+ ]
172
+ });
@@ -4,6 +4,11 @@ export interface FileData {
4
4
  size: number;
5
5
  data: string;
6
6
  }
7
+ export interface DirectFileData {
8
+ name: string;
9
+ type: string;
10
+ size: number;
11
+ }
7
12
  export interface UploadedFile {
8
13
  id: string;
9
14
  name: string;
@@ -1 +1 @@
1
- {"version":3,"file":"fileUpload.d.ts","sourceRoot":"","sources":["../../src/lib/fileUpload.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAC9B,IAAI,EAAE,QAAQ,EACd,MAAM,EAAE,MAAM,EACd,SAAS,CAAC,EAAE,MAAM,EAClB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,YAAY,CAAC,CA+FvB;AAED;;;;;;GAMG;AACH,wBAAsB,WAAW,CAC/B,KAAK,EAAE,QAAQ,EAAE,EACjB,MAAM,EAAE,MAAM,EACd,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,YAAY,EAAE,CAAC,CAWzB;AAED;;;;GAIG;AACH,wBAAsB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAUlE"}
1
+ {"version":3,"file":"fileUpload.d.ts","sourceRoot":"","sources":["../../src/lib/fileUpload.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CAEd;AAED,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAC9B,IAAI,EAAE,QAAQ,EACd,MAAM,EAAE,MAAM,EACd,SAAS,CAAC,EAAE,MAAM,EAClB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,YAAY,CAAC,CAkHvB;AAED;;;;;;GAMG;AACH,wBAAsB,WAAW,CAC/B,KAAK,EAAE,QAAQ,EAAE,EACjB,MAAM,EAAE,MAAM,EACd,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,YAAY,EAAE,CAAC,CAWzB;AAED;;;;GAIG;AACH,wBAAsB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAUlE"}
@@ -13,8 +13,22 @@ import { prisma } from "./prisma.js";
13
13
  */
14
14
  export async function uploadFile(file, userId, directory, assignmentId) {
15
15
  try {
16
+ // Validate file extension matches MIME type
17
+ const fileExtension = file.name.split('.').pop()?.toLowerCase();
18
+ const mimeType = file.type.toLowerCase();
19
+ const extensionMimeMap = {
20
+ 'jpg': ['image/jpeg'],
21
+ 'jpeg': ['image/jpeg'],
22
+ 'png': ['image/png'],
23
+ 'gif': ['image/gif'],
24
+ 'webp': ['image/webp']
25
+ };
26
+ if (fileExtension && extensionMimeMap[fileExtension]) {
27
+ if (!extensionMimeMap[fileExtension].includes(mimeType)) {
28
+ throw new Error(`File extension .${fileExtension} does not match MIME type ${mimeType}`);
29
+ }
30
+ }
16
31
  // Create a unique filename
17
- const fileExtension = file.name.split('.').pop();
18
32
  const uniqueFilename = `${uuidv4()}.${fileExtension}`;
19
33
  // // Construct the full path
20
34
  const filePath = directory
@@ -25,8 +39,9 @@ export async function uploadFile(file, userId, directory, assignmentId) {
25
39
  // // Generate and store thumbnail if supported
26
40
  let thumbnailId;
27
41
  try {
28
- // // Convert base64 to buffer for thumbnail generation
29
- const base64Data = file.data.split(',')[1];
42
+ // // Convert base64 to buffer for thumbnail generation
43
+ // Handle both data URI format (data:image/jpeg;base64,...) and raw base64
44
+ const base64Data = file.data.includes(',') ? file.data.split(',')[1] : file.data;
30
45
  const fileBuffer = Buffer.from(base64Data, 'base64');
31
46
  // // Generate thumbnail directly from buffer
32
47
  const thumbnailBuffer = await generateMediaThumbnail(fileBuffer, file.type);
@@ -52,6 +67,7 @@ export async function uploadFile(file, userId, directory, assignmentId) {
52
67
  }
53
68
  catch (error) {
54
69
  console.warn('Failed to generate thumbnail:', error);
70
+ // Continue without thumbnail - this is not a critical failure
55
71
  }
56
72
  // Create file record in database
57
73
  // const uploadedPath = '/dummyPath' + Math.random().toString(36).substring(2, 15);
@@ -1,3 +1,4 @@
1
+ export declare const bucket: import("@google-cloud/storage").Bucket;
1
2
  /**
2
3
  * Uploads a file to Google Cloud Storage
3
4
  * @param base64Data Base64 encoded file data
@@ -11,7 +12,7 @@ export declare function uploadFile(base64Data: string, filePath: string, content
11
12
  * @param filePath The path of the file in the bucket
12
13
  * @returns The signed URL
13
14
  */
14
- export declare function getSignedUrl(filePath: string): Promise<string>;
15
+ export declare function getSignedUrl(filePath: string, action?: 'read' | 'write', contentType?: string): Promise<string>;
15
16
  /**
16
17
  * Deletes a file from Google Cloud Storage
17
18
  * @param filePath The path of the file to delete
@@ -1 +1 @@
1
- {"version":3,"file":"googleCloudStorage.d.ts","sourceRoot":"","sources":["../../src/lib/googleCloudStorage.ts"],"names":[],"mappings":"AAgBA;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC9B,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,MAAM,CAAC,CA4BjB;AAED;;;;GAIG;AACH,wBAAsB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAepE;AAED;;;GAGG;AACH,wBAAsB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAUhE"}
1
+ {"version":3,"file":"googleCloudStorage.d.ts","sourceRoot":"","sources":["../../src/lib/googleCloudStorage.ts"],"names":[],"mappings":"AAaA,eAAO,MAAM,MAAM,wCAAwD,CAAC;AAK5E;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC9B,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,MAAM,CAAC,CA4BjB;AAED;;;;GAIG;AACH,wBAAsB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,GAAE,MAAM,GAAG,OAAgB,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAsB7H;AAED;;;GAGG;AACH,wBAAsB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAUhE"}
@@ -1,3 +1,5 @@
1
+ import dotenv from 'dotenv';
2
+ dotenv.config();
1
3
  import { Storage } from '@google-cloud/storage';
2
4
  import { TRPCError } from '@trpc/server';
3
5
  const storage = new Storage({
@@ -7,7 +9,7 @@ const storage = new Storage({
7
9
  private_key: process.env.GOOGLE_CLOUD_PRIVATE_KEY?.replace(/\\n/g, '\n'),
8
10
  },
9
11
  });
10
- const bucket = storage.bucket(process.env.GOOGLE_CLOUD_BUCKET_NAME || '');
12
+ export const bucket = storage.bucket(process.env.GOOGLE_CLOUD_BUCKET_NAME);
11
13
  // Short expiration time for signed URLs (5 minutes)
12
14
  const SIGNED_URL_EXPIRATION = 5 * 60 * 1000;
13
15
  /**
@@ -48,13 +50,18 @@ export async function uploadFile(base64Data, filePath, contentType) {
48
50
  * @param filePath The path of the file in the bucket
49
51
  * @returns The signed URL
50
52
  */
51
- export async function getSignedUrl(filePath) {
53
+ export async function getSignedUrl(filePath, action = 'read', contentType) {
52
54
  try {
53
- const [url] = await bucket.file(filePath).getSignedUrl({
55
+ const options = {
54
56
  version: 'v4',
55
- action: 'read',
57
+ action: action,
56
58
  expires: Date.now() + SIGNED_URL_EXPIRATION,
57
- });
59
+ };
60
+ // For write operations, add content type if provided
61
+ if (action === 'write' && contentType) {
62
+ options.contentType = contentType;
63
+ }
64
+ const [url] = await bucket.file(filePath).getSignedUrl(options);
58
65
  return url;
59
66
  }
60
67
  catch (error) {
@@ -2164,29 +2164,57 @@ export declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
2164
2164
  id: string;
2165
2165
  username: string;
2166
2166
  profile: {
2167
- id: string;
2168
- userId: string;
2169
- } | null;
2167
+ displayName: any;
2168
+ bio: any;
2169
+ location: any;
2170
+ website: any;
2171
+ profilePicture: string | null;
2172
+ profilePictureThumbnail: string | null;
2173
+ };
2170
2174
  };
2171
2175
  meta: object;
2172
2176
  }>;
2173
2177
  updateProfile: import("@trpc/server").TRPCMutationProcedure<{
2174
2178
  input: {
2175
- profile: Record<string, any>;
2179
+ profile?: {
2180
+ location?: string | null | undefined;
2181
+ displayName?: string | null | undefined;
2182
+ bio?: string | null | undefined;
2183
+ website?: string | null | undefined;
2184
+ } | undefined;
2176
2185
  profilePicture?: {
2177
- type: string;
2178
- name: string;
2179
- size: number;
2180
- data: string;
2186
+ filePath: string;
2187
+ fileName: string;
2188
+ fileType: string;
2189
+ fileSize: number;
2190
+ } | undefined;
2191
+ dicebearAvatar?: {
2192
+ url: string;
2181
2193
  } | undefined;
2182
2194
  };
2183
2195
  output: {
2184
2196
  id: string;
2185
2197
  username: string;
2186
2198
  profile: {
2187
- id: string;
2188
- userId: string;
2189
- } | null;
2199
+ displayName: any;
2200
+ bio: any;
2201
+ location: any;
2202
+ website: any;
2203
+ profilePicture: string | null;
2204
+ profilePictureThumbnail: string | null;
2205
+ };
2206
+ };
2207
+ meta: object;
2208
+ }>;
2209
+ getUploadUrl: import("@trpc/server").TRPCMutationProcedure<{
2210
+ input: {
2211
+ fileName: string;
2212
+ fileType: string;
2213
+ };
2214
+ output: {
2215
+ uploadUrl: string;
2216
+ filePath: string;
2217
+ fileName: string;
2190
2218
  };
2191
2219
  meta: object;
2192
2220
  }>;
@@ -5460,29 +5488,57 @@ export declare const createCaller: import("@trpc/server").TRPCRouterCaller<{
5460
5488
  id: string;
5461
5489
  username: string;
5462
5490
  profile: {
5463
- id: string;
5464
- userId: string;
5465
- } | null;
5491
+ displayName: any;
5492
+ bio: any;
5493
+ location: any;
5494
+ website: any;
5495
+ profilePicture: string | null;
5496
+ profilePictureThumbnail: string | null;
5497
+ };
5466
5498
  };
5467
5499
  meta: object;
5468
5500
  }>;
5469
5501
  updateProfile: import("@trpc/server").TRPCMutationProcedure<{
5470
5502
  input: {
5471
- profile: Record<string, any>;
5503
+ profile?: {
5504
+ location?: string | null | undefined;
5505
+ displayName?: string | null | undefined;
5506
+ bio?: string | null | undefined;
5507
+ website?: string | null | undefined;
5508
+ } | undefined;
5472
5509
  profilePicture?: {
5473
- type: string;
5474
- name: string;
5475
- size: number;
5476
- data: string;
5510
+ filePath: string;
5511
+ fileName: string;
5512
+ fileType: string;
5513
+ fileSize: number;
5514
+ } | undefined;
5515
+ dicebearAvatar?: {
5516
+ url: string;
5477
5517
  } | undefined;
5478
5518
  };
5479
5519
  output: {
5480
5520
  id: string;
5481
5521
  username: string;
5482
5522
  profile: {
5483
- id: string;
5484
- userId: string;
5485
- } | null;
5523
+ displayName: any;
5524
+ bio: any;
5525
+ location: any;
5526
+ website: any;
5527
+ profilePicture: string | null;
5528
+ profilePictureThumbnail: string | null;
5529
+ };
5530
+ };
5531
+ meta: object;
5532
+ }>;
5533
+ getUploadUrl: import("@trpc/server").TRPCMutationProcedure<{
5534
+ input: {
5535
+ fileName: string;
5536
+ fileType: string;
5537
+ };
5538
+ output: {
5539
+ uploadUrl: string;
5540
+ filePath: string;
5541
+ fileName: string;
5486
5542
  };
5487
5543
  meta: object;
5488
5544
  }>;
@@ -1 +1 @@
1
- {"version":3,"file":"_app.d.ts","sourceRoot":"","sources":["../../src/routers/_app.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAU1E,eAAO,MAAM,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAapB,CAAC;AAGH,MAAM,MAAM,SAAS,GAAG,OAAO,SAAS,CAAC;AACzC,MAAM,MAAM,YAAY,GAAG,iBAAiB,CAAC,SAAS,CAAC,CAAC;AACxD,MAAM,MAAM,aAAa,GAAG,kBAAkB,CAAC,SAAS,CAAC,CAAC;AAG1D,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAAiC,CAAC"}
1
+ {"version":3,"file":"_app.d.ts","sourceRoot":"","sources":["../../src/routers/_app.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAU1E,eAAO,MAAM,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAapB,CAAC;AAGH,MAAM,MAAM,SAAS,GAAG,OAAO,SAAS,CAAC;AACzC,MAAM,MAAM,YAAY,GAAG,iBAAiB,CAAC,SAAS,CAAC,CAAC;AACxD,MAAM,MAAM,aAAa,GAAG,kBAAkB,CAAC,SAAS,CAAC,CAAC;AAG1D,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAAiC,CAAC"}
@@ -22,29 +22,57 @@ export declare const userRouter: import("@trpc/server").TRPCBuiltRouter<{
22
22
  id: string;
23
23
  username: string;
24
24
  profile: {
25
- id: string;
26
- userId: string;
27
- } | null;
25
+ displayName: any;
26
+ bio: any;
27
+ location: any;
28
+ website: any;
29
+ profilePicture: string | null;
30
+ profilePictureThumbnail: string | null;
31
+ };
28
32
  };
29
33
  meta: object;
30
34
  }>;
31
35
  updateProfile: import("@trpc/server").TRPCMutationProcedure<{
32
36
  input: {
33
- profile: Record<string, any>;
37
+ profile?: {
38
+ location?: string | null | undefined;
39
+ displayName?: string | null | undefined;
40
+ bio?: string | null | undefined;
41
+ website?: string | null | undefined;
42
+ } | undefined;
34
43
  profilePicture?: {
35
- type: string;
36
- name: string;
37
- size: number;
38
- data: string;
44
+ filePath: string;
45
+ fileName: string;
46
+ fileType: string;
47
+ fileSize: number;
48
+ } | undefined;
49
+ dicebearAvatar?: {
50
+ url: string;
39
51
  } | undefined;
40
52
  };
41
53
  output: {
42
54
  id: string;
43
55
  username: string;
44
56
  profile: {
45
- id: string;
46
- userId: string;
47
- } | null;
57
+ displayName: any;
58
+ bio: any;
59
+ location: any;
60
+ website: any;
61
+ profilePicture: string | null;
62
+ profilePictureThumbnail: string | null;
63
+ };
64
+ };
65
+ meta: object;
66
+ }>;
67
+ getUploadUrl: import("@trpc/server").TRPCMutationProcedure<{
68
+ input: {
69
+ fileName: string;
70
+ fileType: string;
71
+ };
72
+ output: {
73
+ uploadUrl: string;
74
+ filePath: string;
75
+ fileName: string;
48
76
  };
49
77
  meta: object;
50
78
  }>;
@@ -1 +1 @@
1
- {"version":3,"file":"user.d.ts","sourceRoot":"","sources":["../../src/routers/user.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAkBxB,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DrB,CAAC"}
1
+ {"version":3,"file":"user.d.ts","sourceRoot":"","sources":["../../src/routers/user.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AA2DxB,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqMrB,CAAC"}
@@ -2,16 +2,50 @@ import { z } from "zod";
2
2
  import { createTRPCRouter, protectedProcedure } from "../trpc.js";
3
3
  import { TRPCError } from "@trpc/server";
4
4
  import { prisma } from "../lib/prisma.js";
5
- import { uploadFiles } from "../lib/fileUpload.js";
6
- const fileSchema = z.object({
7
- name: z.string(),
8
- type: z.string(),
9
- size: z.number(),
10
- data: z.string(), // base64 encoded file data
5
+ import { getSignedUrl } from "../lib/googleCloudStorage.js";
6
+ import { logger } from "../utils/logger.js";
7
+ // Helper function to convert file path to backend proxy URL
8
+ function getFileUrl(filePath) {
9
+ if (!filePath)
10
+ return null;
11
+ // If it's already a full URL (DiceBear or external), return as is
12
+ if (filePath.startsWith('http')) {
13
+ return filePath;
14
+ }
15
+ // Convert GCS path to full backend proxy URL
16
+ const backendUrl = process.env.BACKEND_URL || 'http://localhost:3001';
17
+ return `${backendUrl}/api/files/${encodeURIComponent(filePath)}`;
18
+ }
19
+ // For direct file uploads (file already uploaded to GCS)
20
+ const fileUploadSchema = z.object({
21
+ filePath: z.string().min(1, "File path is required"),
22
+ fileName: z.string().min(1, "File name is required"),
23
+ fileType: z.string().regex(/^image\/(jpeg|jpg|png|gif|webp)$/i, "Only image files (JPEG, PNG, GIF, WebP) are allowed"),
24
+ fileSize: z.number().max(5 * 1024 * 1024, "File size must be less than 5MB"),
25
+ });
26
+ // For DiceBear avatar URL
27
+ const dicebearSchema = z.object({
28
+ url: z.string().url("Invalid DiceBear avatar URL"),
29
+ });
30
+ const profileSchema = z.object({
31
+ displayName: z.string().nullable().optional().transform(val => val === null ? undefined : val),
32
+ bio: z.string().nullable().optional().transform(val => val === null ? undefined : val),
33
+ location: z.string().nullable().optional().transform(val => val === null ? undefined : val),
34
+ website: z.union([
35
+ z.string().url(),
36
+ z.literal(""),
37
+ z.null().transform(() => undefined)
38
+ ]).optional(),
11
39
  });
12
40
  const updateProfileSchema = z.object({
13
- profile: z.record(z.any()),
14
- profilePicture: fileSchema.optional(),
41
+ profile: profileSchema.optional(),
42
+ // Support both custom file upload and DiceBear avatar
43
+ profilePicture: fileUploadSchema.optional(),
44
+ dicebearAvatar: dicebearSchema.optional(),
45
+ });
46
+ const getUploadUrlSchema = z.object({
47
+ fileName: z.string().min(1, "File name is required"),
48
+ fileType: z.string().regex(/^image\/(jpeg|jpg|png|gif|webp)$/i, "Only image files are allowed"),
15
49
  });
16
50
  export const userRouter = createTRPCRouter({
17
51
  getProfile: protectedProcedure
@@ -27,7 +61,6 @@ export const userRouter = createTRPCRouter({
27
61
  select: {
28
62
  id: true,
29
63
  username: true,
30
- profile: true,
31
64
  },
32
65
  });
33
66
  if (!user) {
@@ -36,7 +69,29 @@ export const userRouter = createTRPCRouter({
36
69
  message: "User not found",
37
70
  });
38
71
  }
39
- return user;
72
+ // Get user profile separately
73
+ const userProfile = await prisma.userProfile.findUnique({
74
+ where: { userId: ctx.user.id },
75
+ });
76
+ return {
77
+ id: user.id,
78
+ username: user.username,
79
+ profile: userProfile ? {
80
+ displayName: userProfile.displayName || null,
81
+ bio: userProfile.bio || null,
82
+ location: userProfile.location || null,
83
+ website: userProfile.website || null,
84
+ profilePicture: getFileUrl(userProfile.profilePicture),
85
+ profilePictureThumbnail: getFileUrl(userProfile.profilePictureThumbnail),
86
+ } : {
87
+ displayName: null,
88
+ bio: null,
89
+ location: null,
90
+ website: null,
91
+ profilePicture: null,
92
+ profilePictureThumbnail: null,
93
+ },
94
+ };
40
95
  }),
41
96
  updateProfile: protectedProcedure
42
97
  .input(updateProfileSchema)
@@ -47,25 +102,130 @@ export const userRouter = createTRPCRouter({
47
102
  message: "User must be authenticated",
48
103
  });
49
104
  }
50
- let uploadedFiles = [];
105
+ // Get current profile to clean up old profile picture
106
+ const currentProfile = await prisma.userProfile.findUnique({
107
+ where: { userId: ctx.user.id },
108
+ });
109
+ let profilePictureUrl = null;
110
+ let profilePictureThumbnail = null;
111
+ // Handle custom profile picture (already uploaded to GCS)
51
112
  if (input.profilePicture) {
52
- // Store profile picture in a user-specific directory
53
- uploadedFiles = await uploadFiles([input.profilePicture], ctx.user.id, `users/${ctx.user.id}/profile`);
54
- // Add profile picture path to profile data
55
- input.profile.profilePicture = uploadedFiles[0].path;
56
- input.profile.profilePictureThumbnail = uploadedFiles[0].thumbnailId;
113
+ try {
114
+ // File is already uploaded to GCS, just use the path
115
+ profilePictureUrl = input.profilePicture.filePath;
116
+ // Generate thumbnail for the uploaded file
117
+ // TODO: Implement thumbnail generation for direct uploads
118
+ profilePictureThumbnail = null;
119
+ // Clean up old profile picture if it exists
120
+ if (currentProfile?.profilePicture) {
121
+ // TODO: Implement file deletion logic here
122
+ // await deleteFile((currentProfile as any).profilePicture);
123
+ }
124
+ }
125
+ catch (error) {
126
+ logger.error('Profile picture processing failed', {
127
+ userId: ctx.user.id,
128
+ error: error instanceof Error ? error.message : 'Unknown error'
129
+ });
130
+ throw new TRPCError({
131
+ code: "INTERNAL_SERVER_ERROR",
132
+ message: "Failed to process profile picture. Please try again.",
133
+ });
134
+ }
57
135
  }
58
- const updatedUser = await prisma.user.update({
59
- where: { id: ctx.user.id },
60
- data: {
61
- profile: input.profile,
136
+ // Handle DiceBear avatar URL
137
+ if (input.dicebearAvatar) {
138
+ profilePictureUrl = input.dicebearAvatar.url;
139
+ // No thumbnail for DiceBear avatars since they're SVG URLs
140
+ profilePictureThumbnail = null;
141
+ }
142
+ // Prepare update data
143
+ const updateData = {};
144
+ if (input.profile) {
145
+ if (input.profile.displayName !== undefined && input.profile.displayName !== null) {
146
+ updateData.displayName = input.profile.displayName;
147
+ }
148
+ if (input.profile.bio !== undefined && input.profile.bio !== null) {
149
+ updateData.bio = input.profile.bio;
150
+ }
151
+ if (input.profile.location !== undefined && input.profile.location !== null) {
152
+ updateData.location = input.profile.location;
153
+ }
154
+ if (input.profile.website !== undefined && input.profile.website !== null) {
155
+ updateData.website = input.profile.website;
156
+ }
157
+ }
158
+ if (profilePictureUrl !== null)
159
+ updateData.profilePicture = profilePictureUrl;
160
+ if (profilePictureThumbnail !== null)
161
+ updateData.profilePictureThumbnail = profilePictureThumbnail;
162
+ // Upsert user profile with structured data
163
+ const updatedProfile = await prisma.userProfile.upsert({
164
+ where: { userId: ctx.user.id },
165
+ create: {
166
+ userId: ctx.user.id,
167
+ ...updateData,
62
168
  },
63
- select: {
64
- id: true,
65
- username: true,
66
- profile: true,
169
+ update: {
170
+ ...updateData,
171
+ updatedAt: new Date(),
67
172
  },
68
173
  });
69
- return updatedUser;
174
+ // Get username for response
175
+ const user = await prisma.user.findUnique({
176
+ where: { id: ctx.user.id },
177
+ select: { username: true },
178
+ });
179
+ return {
180
+ id: ctx.user.id,
181
+ username: user?.username || '',
182
+ profile: {
183
+ displayName: updatedProfile.displayName || null,
184
+ bio: updatedProfile.bio || null,
185
+ location: updatedProfile.location || null,
186
+ website: updatedProfile.website || null,
187
+ profilePicture: getFileUrl(updatedProfile.profilePicture),
188
+ profilePictureThumbnail: getFileUrl(updatedProfile.profilePictureThumbnail),
189
+ },
190
+ };
191
+ }),
192
+ getUploadUrl: protectedProcedure
193
+ .input(getUploadUrlSchema)
194
+ .mutation(async ({ ctx, input }) => {
195
+ if (!ctx.user) {
196
+ throw new TRPCError({
197
+ code: "UNAUTHORIZED",
198
+ message: "User must be authenticated",
199
+ });
200
+ }
201
+ try {
202
+ // Generate unique filename
203
+ const fileExtension = input.fileName.split('.').pop();
204
+ const uniqueFilename = `${ctx.user.id}-${Date.now()}.${fileExtension}`;
205
+ const filePath = `users/${ctx.user.id}/profile/${uniqueFilename}`;
206
+ // Generate signed URL for direct upload (write permission)
207
+ const uploadUrl = await getSignedUrl(filePath, 'write', input.fileType);
208
+ logger.info('Generated upload URL', {
209
+ userId: ctx.user.id,
210
+ filePath,
211
+ fileName: uniqueFilename,
212
+ fileType: input.fileType
213
+ });
214
+ return {
215
+ uploadUrl,
216
+ filePath,
217
+ fileName: uniqueFilename,
218
+ };
219
+ }
220
+ catch (error) {
221
+ logger.error('Failed to generate upload URL', {
222
+ userId: ctx.user.id,
223
+ error: error instanceof Error ? error.message : 'Unknown error'
224
+ });
225
+ throw new TRPCError({
226
+ code: "INTERNAL_SERVER_ERROR",
227
+ message: "Failed to generate upload URL",
228
+ });
229
+ }
70
230
  }),
71
231
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studious-lms/server",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "description": "Backend server for Studious application",
5
5
  "main": "dist/exportType.js",
6
6
  "types": "dist/exportType.d.ts",
@@ -0,0 +1,15 @@
1
+ /*
2
+ Warnings:
3
+
4
+ - Added the required column `updatedAt` to the `UserProfile` table without a default value. This is not possible if the table is not empty.
5
+
6
+ */
7
+ -- AlterTable
8
+ ALTER TABLE "public"."UserProfile" ADD COLUMN "bio" TEXT,
9
+ ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10
+ ADD COLUMN "displayName" TEXT,
11
+ ADD COLUMN "location" TEXT,
12
+ ADD COLUMN "profilePicture" TEXT,
13
+ ADD COLUMN "profilePictureThumbnail" TEXT,
14
+ ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL,
15
+ ADD COLUMN "website" TEXT;
@@ -82,7 +82,16 @@ model UserProfile {
82
82
  id String @id @default(uuid())
83
83
  userId String @unique
84
84
  user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
85
-
85
+
86
+ displayName String?
87
+ bio String?
88
+ location String?
89
+ website String?
90
+ profilePicture String?
91
+ profilePictureThumbnail String?
92
+
93
+ createdAt DateTime @default(now())
94
+ updatedAt DateTime @updatedAt
86
95
  }
87
96
 
88
97
  model Class {
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import { appRouter } from './routers/_app.js';
9
9
  import { createTRPCContext, createCallerFactory } from './trpc.js';
10
10
  import { logger } from './utils/logger.js';
11
11
  import { setupSocketHandlers } from './socket/handlers.js';
12
+ import { bucket } from './lib/googleCloudStorage.js';
12
13
 
13
14
  dotenv.config();
14
15
 
@@ -16,10 +17,55 @@ const app = express();
16
17
 
17
18
  // CORS middleware
18
19
  app.use(cors({
19
- origin: [process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000', 'http://localhost:3000'],
20
+ origin: [
21
+ 'http://localhost:3000', // Frontend development server
22
+ 'http://localhost:3001', // Server port
23
+ 'http://127.0.0.1:3000', // Alternative localhost
24
+ 'http://127.0.0.1:3001', // Alternative localhost
25
+ process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
26
+ ],
20
27
  credentials: true,
28
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
29
+ allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'x-user'],
30
+ optionsSuccessStatus: 200
21
31
  }));
22
32
 
33
+ // Handle preflight OPTIONS requests
34
+ app.options('*', (req, res) => {
35
+ const allowedOrigins = [
36
+ 'http://localhost:3000',
37
+ 'http://localhost:3001',
38
+ 'http://127.0.0.1:3000',
39
+ 'http://127.0.0.1:3001',
40
+ process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
41
+ ];
42
+
43
+ const origin = req.headers.origin;
44
+ if (origin && allowedOrigins.includes(origin)) {
45
+ res.header('Access-Control-Allow-Origin', origin);
46
+ } else {
47
+ res.header('Access-Control-Allow-Origin', 'http://localhost:3000');
48
+ }
49
+
50
+ res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
51
+ res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, x-user');
52
+ res.header('Access-Control-Allow-Credentials', 'true');
53
+ res.sendStatus(200);
54
+ });
55
+
56
+ // CORS debugging middleware
57
+ app.use((req, res, next) => {
58
+ if (req.method === 'OPTIONS' || req.path.includes('trpc')) {
59
+ logger.info('CORS Request', {
60
+ method: req.method,
61
+ path: req.path,
62
+ origin: req.headers.origin,
63
+ userAgent: req.headers['user-agent']
64
+ });
65
+ }
66
+ next();
67
+ });
68
+
23
69
  // Response time logging middleware
24
70
  app.use((req, res, next) => {
25
71
  const start = Date.now();
@@ -41,10 +87,16 @@ const httpServer = createServer(app);
41
87
  // Setup Socket.IO
42
88
  const io = new Server(httpServer, {
43
89
  cors: {
44
- origin: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
45
- methods: ['GET', 'POST'],
90
+ origin: [
91
+ 'http://localhost:3000', // Frontend development server
92
+ 'http://localhost:3001', // Server port
93
+ 'http://127.0.0.1:3000', // Alternative localhost
94
+ 'http://127.0.0.1:3001', // Alternative localhost
95
+ process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
96
+ ],
97
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
46
98
  credentials: true,
47
- allowedHeaders: ['Access-Control-Allow-Origin']
99
+ allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Access-Control-Allow-Origin', 'x-user']
48
100
  },
49
101
  transports: ['websocket', 'polling'],
50
102
  pingTimeout: 60000,
@@ -62,6 +114,50 @@ io.engine.on('connection_error', (err: Error) => {
62
114
  // Setup socket handlers
63
115
  setupSocketHandlers(io);
64
116
 
117
+ // File serving endpoint for secure file access
118
+ app.get('/api/files/:filePath', async (req, res) => {
119
+ try {
120
+ const filePath = decodeURIComponent(req.params.filePath);
121
+ console.log('File request:', { filePath, originalPath: req.params.filePath });
122
+
123
+ // Get file from Google Cloud Storage
124
+ const file = bucket.file(filePath);
125
+ const [exists] = await file.exists();
126
+
127
+ console.log('File exists:', exists, 'for path:', filePath);
128
+
129
+ if (!exists) {
130
+ return res.status(404).json({ error: 'File not found', filePath });
131
+ }
132
+
133
+ // Get file metadata
134
+ const [metadata] = await file.getMetadata();
135
+
136
+ // Set appropriate headers
137
+ res.set({
138
+ 'Content-Type': metadata.contentType || 'application/octet-stream',
139
+ 'Content-Length': metadata.size,
140
+ 'Cache-Control': 'public, max-age=31536000', // 1 year cache
141
+ 'ETag': metadata.etag,
142
+ });
143
+
144
+ // Stream file to response
145
+ const stream = file.createReadStream();
146
+ stream.pipe(res);
147
+
148
+ stream.on('error', (error) => {
149
+ console.error('Error streaming file:', error);
150
+ if (!res.headersSent) {
151
+ res.status(500).json({ error: 'Error streaming file' });
152
+ }
153
+ });
154
+
155
+ } catch (error) {
156
+ console.error('Error serving file:', error);
157
+ res.status(500).json({ error: 'Internal server error' });
158
+ }
159
+ });
160
+
65
161
  // Create caller
66
162
  const createCaller = createCallerFactory(appRouter);
67
163
 
@@ -91,4 +187,15 @@ logger.info('Configurations', {
91
187
  PORT: process.env.PORT,
92
188
  NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
93
189
  LOG_MODE: process.env.LOG_MODE,
190
+ });
191
+
192
+ // Log CORS configuration
193
+ logger.info('CORS Configuration', {
194
+ allowedOrigins: [
195
+ 'http://localhost:3000',
196
+ 'http://localhost:3001',
197
+ 'http://127.0.0.1:3000',
198
+ 'http://127.0.0.1:3001',
199
+ process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
200
+ ]
94
201
  });
@@ -11,6 +11,13 @@ export interface FileData {
11
11
  data: string; // base64 encoded file data
12
12
  }
13
13
 
14
+ export interface DirectFileData {
15
+ name: string;
16
+ type: string;
17
+ size: number;
18
+ // No data field - for direct file uploads
19
+ }
20
+
14
21
  export interface UploadedFile {
15
22
  id: string;
16
23
  name: string;
@@ -35,8 +42,25 @@ export async function uploadFile(
35
42
  assignmentId?: string
36
43
  ): Promise<UploadedFile> {
37
44
  try {
45
+ // Validate file extension matches MIME type
46
+ const fileExtension = file.name.split('.').pop()?.toLowerCase();
47
+ const mimeType = file.type.toLowerCase();
48
+
49
+ const extensionMimeMap: Record<string, string[]> = {
50
+ 'jpg': ['image/jpeg'],
51
+ 'jpeg': ['image/jpeg'],
52
+ 'png': ['image/png'],
53
+ 'gif': ['image/gif'],
54
+ 'webp': ['image/webp']
55
+ };
56
+
57
+ if (fileExtension && extensionMimeMap[fileExtension]) {
58
+ if (!extensionMimeMap[fileExtension].includes(mimeType)) {
59
+ throw new Error(`File extension .${fileExtension} does not match MIME type ${mimeType}`);
60
+ }
61
+ }
62
+
38
63
  // Create a unique filename
39
- const fileExtension = file.name.split('.').pop();
40
64
  const uniqueFilename = `${uuidv4()}.${fileExtension}`;
41
65
 
42
66
  // // Construct the full path
@@ -50,9 +74,10 @@ export async function uploadFile(
50
74
  // // Generate and store thumbnail if supported
51
75
  let thumbnailId: string | undefined;
52
76
  try {
53
- // // Convert base64 to buffer for thumbnail generation
54
- const base64Data = file.data.split(',')[1];
55
- const fileBuffer = Buffer.from(base64Data, 'base64');
77
+ // // Convert base64 to buffer for thumbnail generation
78
+ // Handle both data URI format (data:image/jpeg;base64,...) and raw base64
79
+ const base64Data = file.data.includes(',') ? file.data.split(',')[1] : file.data;
80
+ const fileBuffer = Buffer.from(base64Data, 'base64');
56
81
 
57
82
  // // Generate thumbnail directly from buffer
58
83
  const thumbnailBuffer = await generateMediaThumbnail(fileBuffer, file.type);
@@ -79,6 +104,7 @@ export async function uploadFile(
79
104
  }
80
105
  } catch (error) {
81
106
  console.warn('Failed to generate thumbnail:', error);
107
+ // Continue without thumbnail - this is not a critical failure
82
108
  }
83
109
 
84
110
  // Create file record in database
@@ -1,3 +1,5 @@
1
+ import dotenv from 'dotenv';
2
+ dotenv.config();
1
3
  import { Storage } from '@google-cloud/storage';
2
4
  import { TRPCError } from '@trpc/server';
3
5
 
@@ -9,7 +11,7 @@ const storage = new Storage({
9
11
  },
10
12
  });
11
13
 
12
- const bucket = storage.bucket(process.env.GOOGLE_CLOUD_BUCKET_NAME || '');
14
+ export const bucket = storage.bucket(process.env.GOOGLE_CLOUD_BUCKET_NAME!);
13
15
 
14
16
  // Short expiration time for signed URLs (5 minutes)
15
17
  const SIGNED_URL_EXPIRATION = 5 * 60 * 1000;
@@ -60,13 +62,20 @@ export async function uploadFile(
60
62
  * @param filePath The path of the file in the bucket
61
63
  * @returns The signed URL
62
64
  */
63
- export async function getSignedUrl(filePath: string): Promise<string> {
65
+ export async function getSignedUrl(filePath: string, action: 'read' | 'write' = 'read', contentType?: string): Promise<string> {
64
66
  try {
65
- const [url] = await bucket.file(filePath).getSignedUrl({
67
+ const options: any = {
66
68
  version: 'v4',
67
- action: 'read',
69
+ action: action,
68
70
  expires: Date.now() + SIGNED_URL_EXPIRATION,
69
- });
71
+ };
72
+
73
+ // For write operations, add content type if provided
74
+ if (action === 'write' && contentType) {
75
+ options.contentType = contentType;
76
+ }
77
+
78
+ const [url] = await bucket.file(filePath).getSignedUrl(options);
70
79
  return url;
71
80
  } catch (error) {
72
81
  console.error('Error getting signed URL:', error);
@@ -3,17 +3,58 @@ import { createTRPCRouter, protectedProcedure } from "../trpc.js";
3
3
  import { TRPCError } from "@trpc/server";
4
4
  import { prisma } from "../lib/prisma.js";
5
5
  import { uploadFiles, type UploadedFile } from "../lib/fileUpload.js";
6
+ import { getSignedUrl } from "../lib/googleCloudStorage.js";
7
+ import { logger } from "../utils/logger.js";
8
+ import { bucket } from "../lib/googleCloudStorage.js";
6
9
 
7
- const fileSchema = z.object({
8
- name: z.string(),
9
- type: z.string(),
10
- size: z.number(),
11
- data: z.string(), // base64 encoded file data
10
+ // Helper function to convert file path to backend proxy URL
11
+ function getFileUrl(filePath: string | null): string | null {
12
+ if (!filePath) return null;
13
+
14
+ // If it's already a full URL (DiceBear or external), return as is
15
+ if (filePath.startsWith('http')) {
16
+ return filePath;
17
+ }
18
+
19
+ // Convert GCS path to full backend proxy URL
20
+ const backendUrl = process.env.BACKEND_URL || 'http://localhost:3001';
21
+ return `${backendUrl}/api/files/${encodeURIComponent(filePath)}`;
22
+ }
23
+
24
+ // For direct file uploads (file already uploaded to GCS)
25
+ const fileUploadSchema = z.object({
26
+ filePath: z.string().min(1, "File path is required"),
27
+ fileName: z.string().min(1, "File name is required"),
28
+ fileType: z.string().regex(/^image\/(jpeg|jpg|png|gif|webp)$/i, "Only image files (JPEG, PNG, GIF, WebP) are allowed"),
29
+ fileSize: z.number().max(5 * 1024 * 1024, "File size must be less than 5MB"),
30
+ });
31
+
32
+ // For DiceBear avatar URL
33
+ const dicebearSchema = z.object({
34
+ url: z.string().url("Invalid DiceBear avatar URL"),
35
+ });
36
+
37
+ const profileSchema = z.object({
38
+ displayName: z.string().nullable().optional().transform(val => val === null ? undefined : val),
39
+ bio: z.string().nullable().optional().transform(val => val === null ? undefined : val),
40
+ location: z.string().nullable().optional().transform(val => val === null ? undefined : val),
41
+ website: z.union([
42
+ z.string().url(),
43
+ z.literal(""),
44
+ z.null().transform(() => undefined)
45
+ ]).optional(),
12
46
  });
13
47
 
14
48
  const updateProfileSchema = z.object({
15
- profile: z.record(z.any()),
16
- profilePicture: fileSchema.optional(),
49
+ profile: profileSchema.optional(),
50
+ // Support both custom file upload and DiceBear avatar
51
+ profilePicture: fileUploadSchema.optional(),
52
+ dicebearAvatar: dicebearSchema.optional(),
53
+ });
54
+
55
+ const getUploadUrlSchema = z.object({
56
+ fileName: z.string().min(1, "File name is required"),
57
+ fileType: z.string().regex(/^image\/(jpeg|jpg|png|gif|webp)$/i, "Only image files are allowed"),
17
58
  });
18
59
 
19
60
  export const userRouter = createTRPCRouter({
@@ -31,7 +72,6 @@ export const userRouter = createTRPCRouter({
31
72
  select: {
32
73
  id: true,
33
74
  username: true,
34
- profile: true,
35
75
  },
36
76
  });
37
77
 
@@ -42,7 +82,30 @@ export const userRouter = createTRPCRouter({
42
82
  });
43
83
  }
44
84
 
45
- return user;
85
+ // Get user profile separately
86
+ const userProfile = await prisma.userProfile.findUnique({
87
+ where: { userId: ctx.user.id },
88
+ });
89
+
90
+ return {
91
+ id: user.id,
92
+ username: user.username,
93
+ profile: userProfile ? {
94
+ displayName: (userProfile as any).displayName || null,
95
+ bio: (userProfile as any).bio || null,
96
+ location: (userProfile as any).location || null,
97
+ website: (userProfile as any).website || null,
98
+ profilePicture: getFileUrl((userProfile as any).profilePicture),
99
+ profilePictureThumbnail: getFileUrl((userProfile as any).profilePictureThumbnail),
100
+ } : {
101
+ displayName: null,
102
+ bio: null,
103
+ location: null,
104
+ website: null,
105
+ profilePicture: null,
106
+ profilePictureThumbnail: null,
107
+ },
108
+ };
46
109
  }),
47
110
 
48
111
  updateProfile: protectedProcedure
@@ -55,28 +118,140 @@ export const userRouter = createTRPCRouter({
55
118
  });
56
119
  }
57
120
 
58
- let uploadedFiles: UploadedFile[] = [];
121
+ // Get current profile to clean up old profile picture
122
+ const currentProfile = await prisma.userProfile.findUnique({
123
+ where: { userId: ctx.user.id },
124
+ });
125
+
126
+ let profilePictureUrl: string | null = null;
127
+ let profilePictureThumbnail: string | null = null;
128
+
129
+ // Handle custom profile picture (already uploaded to GCS)
59
130
  if (input.profilePicture) {
60
- // Store profile picture in a user-specific directory
61
- uploadedFiles = await uploadFiles([input.profilePicture], ctx.user.id, `users/${ctx.user.id}/profile`);
62
-
63
- // Add profile picture path to profile data
64
- input.profile.profilePicture = uploadedFiles[0].path;
65
- input.profile.profilePictureThumbnail = uploadedFiles[0].thumbnailId;
131
+ try {
132
+ // File is already uploaded to GCS, just use the path
133
+ profilePictureUrl = input.profilePicture.filePath;
134
+
135
+ // Generate thumbnail for the uploaded file
136
+ // TODO: Implement thumbnail generation for direct uploads
137
+ profilePictureThumbnail = null;
138
+
139
+ // Clean up old profile picture if it exists
140
+ if ((currentProfile as any)?.profilePicture) {
141
+ // TODO: Implement file deletion logic here
142
+ // await deleteFile((currentProfile as any).profilePicture);
143
+ }
144
+ } catch (error) {
145
+ logger.error('Profile picture processing failed', {
146
+ userId: ctx.user.id,
147
+ error: error instanceof Error ? error.message : 'Unknown error'
148
+ });
149
+ throw new TRPCError({
150
+ code: "INTERNAL_SERVER_ERROR",
151
+ message: "Failed to process profile picture. Please try again.",
152
+ });
153
+ }
66
154
  }
67
155
 
68
- const updatedUser = await prisma.user.update({
69
- where: { id: ctx.user.id },
70
- data: {
71
- profile: input.profile,
156
+ // Handle DiceBear avatar URL
157
+ if (input.dicebearAvatar) {
158
+ profilePictureUrl = input.dicebearAvatar.url;
159
+ // No thumbnail for DiceBear avatars since they're SVG URLs
160
+ profilePictureThumbnail = null;
161
+ }
162
+
163
+ // Prepare update data
164
+ const updateData: any = {};
165
+ if (input.profile) {
166
+ if (input.profile.displayName !== undefined && input.profile.displayName !== null) {
167
+ updateData.displayName = input.profile.displayName;
168
+ }
169
+ if (input.profile.bio !== undefined && input.profile.bio !== null) {
170
+ updateData.bio = input.profile.bio;
171
+ }
172
+ if (input.profile.location !== undefined && input.profile.location !== null) {
173
+ updateData.location = input.profile.location;
174
+ }
175
+ if (input.profile.website !== undefined && input.profile.website !== null) {
176
+ updateData.website = input.profile.website;
177
+ }
178
+ }
179
+ if (profilePictureUrl !== null) updateData.profilePicture = profilePictureUrl;
180
+ if (profilePictureThumbnail !== null) updateData.profilePictureThumbnail = profilePictureThumbnail;
181
+
182
+ // Upsert user profile with structured data
183
+ const updatedProfile = await prisma.userProfile.upsert({
184
+ where: { userId: ctx.user.id },
185
+ create: {
186
+ userId: ctx.user.id,
187
+ ...updateData,
72
188
  },
73
- select: {
74
- id: true,
75
- username: true,
76
- profile: true,
189
+ update: {
190
+ ...updateData,
191
+ updatedAt: new Date(),
77
192
  },
78
193
  });
79
194
 
80
- return updatedUser;
195
+ // Get username for response
196
+ const user = await prisma.user.findUnique({
197
+ where: { id: ctx.user.id },
198
+ select: { username: true },
199
+ });
200
+
201
+ return {
202
+ id: ctx.user.id,
203
+ username: user?.username || '',
204
+ profile: {
205
+ displayName: (updatedProfile as any).displayName || null,
206
+ bio: (updatedProfile as any).bio || null,
207
+ location: (updatedProfile as any).location || null,
208
+ website: (updatedProfile as any).website || null,
209
+ profilePicture: getFileUrl((updatedProfile as any).profilePicture),
210
+ profilePictureThumbnail: getFileUrl((updatedProfile as any).profilePictureThumbnail),
211
+ },
212
+ };
213
+ }),
214
+
215
+ getUploadUrl: protectedProcedure
216
+ .input(getUploadUrlSchema)
217
+ .mutation(async ({ ctx, input }) => {
218
+ if (!ctx.user) {
219
+ throw new TRPCError({
220
+ code: "UNAUTHORIZED",
221
+ message: "User must be authenticated",
222
+ });
223
+ }
224
+
225
+ try {
226
+ // Generate unique filename
227
+ const fileExtension = input.fileName.split('.').pop();
228
+ const uniqueFilename = `${ctx.user.id}-${Date.now()}.${fileExtension}`;
229
+ const filePath = `users/${ctx.user.id}/profile/${uniqueFilename}`;
230
+
231
+ // Generate signed URL for direct upload (write permission)
232
+ const uploadUrl = await getSignedUrl(filePath, 'write', input.fileType);
233
+
234
+ logger.info('Generated upload URL', {
235
+ userId: ctx.user.id,
236
+ filePath,
237
+ fileName: uniqueFilename,
238
+ fileType: input.fileType
239
+ });
240
+
241
+ return {
242
+ uploadUrl,
243
+ filePath,
244
+ fileName: uniqueFilename,
245
+ };
246
+ } catch (error) {
247
+ logger.error('Failed to generate upload URL', {
248
+ userId: ctx.user.id,
249
+ error: error instanceof Error ? error.message : 'Unknown error'
250
+ });
251
+ throw new TRPCError({
252
+ code: "INTERNAL_SERVER_ERROR",
253
+ message: "Failed to generate upload URL",
254
+ });
255
+ }
81
256
  }),
82
257
  });