@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 +99 -4
- package/dist/lib/fileUpload.d.ts +5 -0
- package/dist/lib/fileUpload.d.ts.map +1 -1
- package/dist/lib/fileUpload.js +19 -3
- package/dist/lib/googleCloudStorage.d.ts +2 -1
- package/dist/lib/googleCloudStorage.d.ts.map +1 -1
- package/dist/lib/googleCloudStorage.js +12 -5
- package/dist/routers/_app.d.ts +78 -22
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/user.d.ts +39 -11
- package/dist/routers/user.d.ts.map +1 -1
- package/dist/routers/user.js +185 -25
- package/package.json +1 -1
- package/prisma/migrations/20250920143543_add_profile_fields/migration.sql +15 -0
- package/prisma/schema.prisma +10 -1
- package/src/index.ts +111 -4
- package/src/lib/fileUpload.ts +30 -4
- package/src/lib/googleCloudStorage.ts +14 -5
- package/src/routers/user.ts +200 -25
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: [
|
|
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:
|
|
38
|
-
|
|
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
|
+
});
|
package/dist/lib/fileUpload.d.ts
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/lib/fileUpload.js
CHANGED
|
@@ -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
|
-
//
|
|
29
|
-
|
|
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":"
|
|
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
|
|
55
|
+
const options = {
|
|
54
56
|
version: 'v4',
|
|
55
|
-
action:
|
|
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) {
|
package/dist/routers/_app.d.ts
CHANGED
|
@@ -2164,29 +2164,57 @@ export declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
|
|
|
2164
2164
|
id: string;
|
|
2165
2165
|
username: string;
|
|
2166
2166
|
profile: {
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
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
|
|
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
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
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
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
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
|
-
|
|
5464
|
-
|
|
5465
|
-
|
|
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
|
|
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
|
-
|
|
5474
|
-
|
|
5475
|
-
|
|
5476
|
-
|
|
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
|
-
|
|
5484
|
-
|
|
5485
|
-
|
|
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
|
|
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"}
|
package/dist/routers/user.d.ts
CHANGED
|
@@ -22,29 +22,57 @@ export declare const userRouter: import("@trpc/server").TRPCBuiltRouter<{
|
|
|
22
22
|
id: string;
|
|
23
23
|
username: string;
|
|
24
24
|
profile: {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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;
|
|
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"}
|
package/dist/routers/user.js
CHANGED
|
@@ -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 {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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:
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
profile: true,
|
|
169
|
+
update: {
|
|
170
|
+
...updateData,
|
|
171
|
+
updatedAt: new Date(),
|
|
67
172
|
},
|
|
68
173
|
});
|
|
69
|
-
|
|
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
|
@@ -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;
|
package/prisma/schema.prisma
CHANGED
|
@@ -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: [
|
|
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:
|
|
45
|
-
|
|
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
|
});
|
package/src/lib/fileUpload.ts
CHANGED
|
@@ -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
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
67
|
+
const options: any = {
|
|
66
68
|
version: 'v4',
|
|
67
|
-
action:
|
|
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);
|
package/src/routers/user.ts
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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:
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
profile: true,
|
|
189
|
+
update: {
|
|
190
|
+
...updateData,
|
|
191
|
+
updatedAt: new Date(),
|
|
77
192
|
},
|
|
78
193
|
});
|
|
79
194
|
|
|
80
|
-
|
|
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
|
});
|