create-nara 1.0.16 → 1.0.18
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.
Potentially problematic release.
This version of create-nara might be problematic. Click here for more details.
package/package.json
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
|
-
import { BaseController, jsonSuccess, ValidationError } from '@nara-web/core';
|
|
1
|
+
import { BaseController, jsonSuccess, jsonError, ValidationError } from '@nara-web/core';
|
|
2
2
|
import type { NaraRequest, NaraResponse } from '@nara-web/core';
|
|
3
3
|
import bcrypt from 'bcrypt';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import crypto from 'crypto';
|
|
4
8
|
|
|
5
9
|
export class ProfileController extends BaseController {
|
|
10
|
+
private avatarDir = './uploads/avatars';
|
|
11
|
+
|
|
12
|
+
constructor() {
|
|
13
|
+
super();
|
|
14
|
+
// Ensure avatar directory exists
|
|
15
|
+
if (!fs.existsSync(this.avatarDir)) {
|
|
16
|
+
fs.mkdirSync(this.avatarDir, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
async update(req: NaraRequest, res: NaraResponse) {
|
|
7
21
|
const user = req.user;
|
|
8
22
|
if (!user) {
|
|
@@ -64,19 +78,58 @@ export class ProfileController extends BaseController {
|
|
|
64
78
|
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
|
65
79
|
}
|
|
66
80
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
81
|
+
try {
|
|
82
|
+
// Get raw body as buffer
|
|
83
|
+
const buffer = await req.buffer();
|
|
70
84
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
// await saveFile(file, filename);
|
|
75
|
-
// await UserModel.updateAvatar(user.id, `/uploads/${filename}`);
|
|
76
|
-
// return jsonSuccess(res, { url: `/uploads/${filename}` }, 'Avatar uploaded successfully');
|
|
85
|
+
if (!buffer || buffer.length === 0) {
|
|
86
|
+
return jsonError(res, 'No file uploaded', 400);
|
|
87
|
+
}
|
|
77
88
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
89
|
+
// Extract image from multipart form data
|
|
90
|
+
const boundary = req.headers['content-type']?.split('boundary=')[1];
|
|
91
|
+
if (!boundary) {
|
|
92
|
+
return jsonError(res, 'Invalid multipart form data', 400);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Find image data in multipart
|
|
96
|
+
const parts = buffer.toString('binary').split(`--${boundary}`);
|
|
97
|
+
let imageBuffer: Buffer | null = null;
|
|
98
|
+
|
|
99
|
+
for (const part of parts) {
|
|
100
|
+
if (part.includes('Content-Type: image/')) {
|
|
101
|
+
const dataStart = part.indexOf('\r\n\r\n') + 4;
|
|
102
|
+
const dataEnd = part.lastIndexOf('\r\n');
|
|
103
|
+
if (dataStart > 4 && dataEnd > dataStart) {
|
|
104
|
+
imageBuffer = Buffer.from(part.slice(dataStart, dataEnd), 'binary');
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!imageBuffer) {
|
|
111
|
+
return jsonError(res, 'No image found in upload', 400);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Generate unique filename
|
|
115
|
+
const filename = `${crypto.randomUUID()}.webp`;
|
|
116
|
+
const filepath = path.join(this.avatarDir, filename);
|
|
117
|
+
|
|
118
|
+
// Process and save image with sharp
|
|
119
|
+
await sharp(imageBuffer)
|
|
120
|
+
.resize(400, 400, { fit: 'cover' })
|
|
121
|
+
.webp({ quality: 80 })
|
|
122
|
+
.toFile(filepath);
|
|
123
|
+
|
|
124
|
+
const avatarUrl = `/uploads/avatars/${filename}`;
|
|
125
|
+
|
|
126
|
+
// TODO: Update user avatar in database
|
|
127
|
+
// await UserModel.updateAvatar(user.id, avatarUrl);
|
|
128
|
+
|
|
129
|
+
return jsonSuccess(res, { url: avatarUrl }, 'Avatar uploaded successfully');
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error('Avatar upload error:', error);
|
|
132
|
+
return jsonError(res, 'Failed to upload avatar', 500);
|
|
133
|
+
}
|
|
81
134
|
}
|
|
82
135
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { fly } from 'svelte/transition';
|
|
3
3
|
import axios from "axios";
|
|
4
|
-
import Header from "../
|
|
5
|
-
import { api, Toast } from "../
|
|
4
|
+
import Header from "../components/Header.svelte";
|
|
5
|
+
import { api, Toast } from "../components/helper";
|
|
6
6
|
|
|
7
7
|
interface User {
|
|
8
8
|
id: string;
|
|
@@ -25,26 +25,21 @@
|
|
|
25
25
|
function handleAvatarChange(event: Event): void {
|
|
26
26
|
const target = event.target as HTMLInputElement;
|
|
27
27
|
const file = target.files?.[0];
|
|
28
|
-
if (file) console.log('[AVATAR DEBUG] Uploading file:', file.name, 'Size:', file.size, 'Type:', file.type);
|
|
29
28
|
if (file) {
|
|
30
29
|
const formData = new FormData();
|
|
31
30
|
formData.append("file", file);
|
|
32
31
|
isLoading = true;
|
|
33
|
-
console.log('[AVATAR DEBUG] Sending request to /assets/avatar');
|
|
34
32
|
axios
|
|
35
|
-
.post("/
|
|
33
|
+
.post("/api/profile/avatar", formData)
|
|
36
34
|
.then((response) => {
|
|
37
|
-
console.log('[AVATAR DEBUG] Upload response:', response.data);
|
|
38
35
|
setTimeout(() => {
|
|
39
36
|
isLoading = false;
|
|
40
|
-
previewUrl = response.data.data
|
|
41
|
-
console.log('[AVATAR DEBUG] previewUrl set to:', previewUrl);
|
|
37
|
+
previewUrl = response.data.data?.url + "?v=" + Date.now();
|
|
42
38
|
}, 500);
|
|
43
|
-
user.avatar = response.data.data
|
|
39
|
+
user.avatar = response.data.data?.url;
|
|
44
40
|
Toast("Avatar berhasil diupload", "success");
|
|
45
41
|
})
|
|
46
|
-
.catch((
|
|
47
|
-
console.log('[AVATAR DEBUG] Upload failed:', error);
|
|
42
|
+
.catch(() => {
|
|
48
43
|
isLoading = false;
|
|
49
44
|
Toast("Gagal mengupload avatar", "error");
|
|
50
45
|
});
|
|
@@ -1,7 +1,31 @@
|
|
|
1
1
|
import type { NaraApp } from '@nara-web/core';
|
|
2
2
|
import { webAuthMiddleware, guestMiddleware } from '../app/middlewares/auth.js';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
3
5
|
|
|
4
6
|
export function registerRoutes(app: NaraApp) {
|
|
7
|
+
// Serve uploaded files
|
|
8
|
+
app.get('/uploads/*', (req, res) => {
|
|
9
|
+
const requestPath = req.path?.replace('/uploads/', '') || '';
|
|
10
|
+
const filePath = path.join(process.cwd(), 'uploads', requestPath);
|
|
11
|
+
|
|
12
|
+
if (fs.existsSync(filePath)) {
|
|
13
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
14
|
+
const mimeTypes: Record<string, string> = {
|
|
15
|
+
'.webp': 'image/webp',
|
|
16
|
+
'.png': 'image/png',
|
|
17
|
+
'.jpg': 'image/jpeg',
|
|
18
|
+
'.jpeg': 'image/jpeg',
|
|
19
|
+
'.gif': 'image/gif',
|
|
20
|
+
};
|
|
21
|
+
res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream');
|
|
22
|
+
res.setHeader('Cache-Control', 'public, max-age=31536000');
|
|
23
|
+
res.send(fs.readFileSync(filePath));
|
|
24
|
+
} else {
|
|
25
|
+
res.status(404).send('Not found');
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
5
29
|
// Public routes
|
|
6
30
|
app.get('/', (req, res) => {
|
|
7
31
|
res.inertia?.('landing', {
|
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "crypto";
|
|
2
|
-
import type { NaraRequest, NaraResponse } from "@core";
|
|
3
|
-
import { BaseController, jsonError, jsonServerError, jsonSuccess } from "@core";
|
|
4
|
-
import fs from "fs";
|
|
5
|
-
import path from "path";
|
|
6
|
-
import sharp from "sharp";
|
|
7
|
-
import { Asset, User } from "@models";
|
|
8
|
-
import Logger from "@services/Logger";
|
|
9
|
-
import { Storage } from '@services';
|
|
10
|
-
import { UPLOAD } from '@config/constants';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
// Cache object to store file contents in memory
|
|
15
|
-
let cache: { [key: string]: Buffer } = {};
|
|
16
|
-
|
|
17
|
-
class AssetController extends BaseController {
|
|
18
|
-
/**
|
|
19
|
-
* Serves assets from the dist folder (compiled assets)
|
|
20
|
-
* - Handles CSS and JS files with proper content types
|
|
21
|
-
* - Implements file caching for better performance
|
|
22
|
-
* - Sets appropriate cache headers for browser caching
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
public async uploadAsset(request: NaraRequest, response: NaraResponse) {
|
|
26
|
-
this.requireAuth(request);
|
|
27
|
-
|
|
28
|
-
// Store user reference for use in nested callbacks
|
|
29
|
-
const userId = request.user.id;
|
|
30
|
-
console.log('[AVATAR DEBUG] Upload started for user:', userId);
|
|
31
|
-
|
|
32
|
-
try {
|
|
33
|
-
let isValidFile = true;
|
|
34
|
-
|
|
35
|
-
await request.multipart(async (field: any) => {
|
|
36
|
-
if (field.file) {
|
|
37
|
-
if (!field.mime_type.includes("image")) {
|
|
38
|
-
console.log('[AVATAR DEBUG] File mime_type:', field.mime_type);
|
|
39
|
-
isValidFile = false;
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const id = randomUUID();
|
|
44
|
-
const fileName = `${id}.webp`;
|
|
45
|
-
|
|
46
|
-
// Create a buffer to store the image data
|
|
47
|
-
const chunks: Buffer[] = [];
|
|
48
|
-
const readable = field.file.stream;
|
|
49
|
-
|
|
50
|
-
readable.on('data', (chunk: Buffer) => {
|
|
51
|
-
chunks.push(chunk);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
readable.on('end', async () => {
|
|
55
|
-
const buffer = Buffer.concat(chunks);
|
|
56
|
-
console.log('[AVATAR DEBUG] Total buffer length received:', buffer.length);
|
|
57
|
-
|
|
58
|
-
try {
|
|
59
|
-
// Process image with Sharp and get buffer
|
|
60
|
-
const processedBuffer = await sharp(buffer)
|
|
61
|
-
.webp({ quality: 80 }) // Convert to WebP with 80% quality
|
|
62
|
-
.resize(1200, 1200, { // Resize to max 1200x1200 while maintaining aspect ratio
|
|
63
|
-
fit: 'inside',
|
|
64
|
-
withoutEnlargement: true
|
|
65
|
-
})
|
|
66
|
-
.toBuffer();
|
|
67
|
-
console.log('[AVATAR DEBUG] Processed buffer length (WebP):', processedBuffer.length);
|
|
68
|
-
|
|
69
|
-
// Store processed image using Storage service
|
|
70
|
-
const storedFile = await Storage.put(processedBuffer, {
|
|
71
|
-
directory: UPLOAD.AVATAR_DIR,
|
|
72
|
-
name: id,
|
|
73
|
-
extension: 'webp'
|
|
74
|
-
console.log('[AVATAR DEBUG] File stored at:', storedFile.url);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
// Build public URL for the saved file
|
|
78
|
-
const publicUrl = storedFile.url;
|
|
79
|
-
|
|
80
|
-
// Save to assets table with local file reference
|
|
81
|
-
await Asset.create({
|
|
82
|
-
id,
|
|
83
|
-
type: 'image',
|
|
84
|
-
url: publicUrl,
|
|
85
|
-
mime_type: 'image/webp',
|
|
86
|
-
name: fileName,
|
|
87
|
-
size: processedBuffer.length,
|
|
88
|
-
user_id: userId,
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
// Update user avatar in users table
|
|
92
|
-
await User.updateAvatar(userId, publicUrl);
|
|
93
|
-
|
|
94
|
-
// Return success response with public URL
|
|
95
|
-
console.log('[AVATAR DEBUG] Returning public URL:', publicUrl);
|
|
96
|
-
jsonSuccess(response, 'Avatar berhasil diupload', { url: publicUrl });
|
|
97
|
-
} catch (err) {
|
|
98
|
-
Logger.error('Error processing and uploading image', err as Error);
|
|
99
|
-
jsonServerError(response, 'Gagal memproses dan mengupload gambar');
|
|
100
|
-
}
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
if (!isValidFile) {
|
|
106
|
-
return jsonError(response, "Invalid file type. Only images are allowed.", 400, "INVALID_FILE_TYPE");
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
} catch (error) {
|
|
110
|
-
Logger.error('Error uploading asset', error as Error);
|
|
111
|
-
return jsonServerError(response, "Internal server error");
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
public async distFolder(request: NaraRequest, response: NaraResponse) {
|
|
116
|
-
const file = request.params.file;
|
|
117
|
-
|
|
118
|
-
try {
|
|
119
|
-
const filePath = `dist/assets/${file}`;
|
|
120
|
-
|
|
121
|
-
// Set appropriate content type based on file extension
|
|
122
|
-
if (file.endsWith(".css")) {
|
|
123
|
-
response.setHeader("Content-Type", "text/css");
|
|
124
|
-
} else if (file.endsWith(".js")) {
|
|
125
|
-
response.setHeader("Content-Type", "application/javascript");
|
|
126
|
-
} else {
|
|
127
|
-
response.setHeader("Content-Type", "application/octet-stream");
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Set cache control header for browser caching (1 year)
|
|
131
|
-
response.setHeader("Cache-Control", "public, max-age=31536000");
|
|
132
|
-
|
|
133
|
-
// Return cached content if available
|
|
134
|
-
if (cache[file]) {
|
|
135
|
-
return response.send(cache[file]);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Check if file exists and serve it
|
|
139
|
-
if (await fs.promises.access(filePath).then(() => true).catch(() => false)) {
|
|
140
|
-
const fileContent = await fs.promises.readFile(filePath);
|
|
141
|
-
|
|
142
|
-
// Cache the file content
|
|
143
|
-
cache[file] = fileContent;
|
|
144
|
-
|
|
145
|
-
return response.send(fileContent);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return response.status(404).send("File not found");
|
|
149
|
-
} catch (error) {
|
|
150
|
-
Logger.error('Error serving dist file', error as Error);
|
|
151
|
-
return response.status(500).send("Internal server error");
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Serves static files from the public folder
|
|
157
|
-
* - Implements security by checking allowed file extensions
|
|
158
|
-
* - Prevents directory traversal attacks
|
|
159
|
-
* - Handles various file types (images, fonts, documents, etc.)
|
|
160
|
-
*/
|
|
161
|
-
public async publicFolder(request: NaraRequest, response: NaraResponse) {
|
|
162
|
-
// List of allowed file extensions for security
|
|
163
|
-
const allowedExtensions = [
|
|
164
|
-
'.ico', '.png', '.jpeg', '.jpg', '.gif', '.svg', '.webp',
|
|
165
|
-
'.txt', '.pdf', '.css', '.js',
|
|
166
|
-
'.woff', '.woff2', '.ttf', '.eot',
|
|
167
|
-
'.mp4', '.webm', '.mp3', '.wav'
|
|
168
|
-
];
|
|
169
|
-
|
|
170
|
-
// Get the requested path and decode URL encoding
|
|
171
|
-
const requestedPath = decodeURIComponent(request.path);
|
|
172
|
-
|
|
173
|
-
// Security: Remove leading slash and normalize
|
|
174
|
-
const relativePath = requestedPath.replace(/^\/+/, '');
|
|
175
|
-
|
|
176
|
-
// Security: Check for path traversal attempts BEFORE any path operations
|
|
177
|
-
// Block any path containing .. or encoded variants
|
|
178
|
-
if (relativePath.includes('..') ||
|
|
179
|
-
relativePath.includes('%2e') ||
|
|
180
|
-
relativePath.includes('%2E') ||
|
|
181
|
-
relativePath.includes('\0')) {
|
|
182
|
-
Logger.logSecurity('Path traversal attempt blocked', {
|
|
183
|
-
requestedPath,
|
|
184
|
-
ip: request.ip
|
|
185
|
-
});
|
|
186
|
-
return response.status(403).send('Access denied');
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Check if the path has any extension
|
|
190
|
-
if (!relativePath.includes('.')) {
|
|
191
|
-
return response.status(404).send('Page not found');
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Security check: validate file extension
|
|
195
|
-
if (!allowedExtensions.some(ext => relativePath.toLowerCase().endsWith(ext))) {
|
|
196
|
-
return response.status(403).send('File type not allowed');
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Resolve the absolute path and verify it's within public directory
|
|
200
|
-
const publicDir = path.resolve(process.cwd(), 'public');
|
|
201
|
-
const storageDir = path.resolve(process.cwd(), 'storage');
|
|
202
|
-
const resolvedPath = path.resolve(process.cwd(), relativePath);
|
|
203
|
-
|
|
204
|
-
// Security: Ensure the resolved path is within the public or storage directory
|
|
205
|
-
if (!resolvedPath.startsWith(publicDir) && !resolvedPath.startsWith(storageDir)) {
|
|
206
|
-
Logger.logSecurity('Path traversal attempt blocked (resolved path escape)', {
|
|
207
|
-
requestedPath,
|
|
208
|
-
resolvedPath,
|
|
209
|
-
publicDir,
|
|
210
|
-
storageDir,
|
|
211
|
-
ip: request.ip
|
|
212
|
-
});
|
|
213
|
-
return response.status(403).send('Access denied');
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Check if file exists
|
|
217
|
-
if (!fs.existsSync(resolvedPath)) {
|
|
218
|
-
return response.status(404).send('File not found');
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Serve the file
|
|
222
|
-
return response.download(resolvedPath);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
export default new AssetController();
|