create-nara 1.0.14 → 1.0.16
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
|
@@ -0,0 +1,226 @@
|
|
|
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();
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { fly } from 'svelte/transition';
|
|
3
|
-
import
|
|
4
|
-
import
|
|
3
|
+
import axios from "axios";
|
|
4
|
+
import Header from "../Components/Header.svelte";
|
|
5
|
+
import { api, Toast } from "../Components/helper";
|
|
5
6
|
|
|
6
7
|
interface User {
|
|
7
8
|
id: string;
|
|
@@ -21,104 +22,64 @@
|
|
|
21
22
|
let isLoading: boolean = false;
|
|
22
23
|
let previewUrl: string | null = user.avatar || null;
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
function handleAvatarChange(event: Event): void {
|
|
25
26
|
const target = event.target as HTMLInputElement;
|
|
26
27
|
const file = target.files?.[0];
|
|
28
|
+
if (file) console.log('[AVATAR DEBUG] Uploading file:', file.name, 'Size:', file.size, 'Type:', file.type);
|
|
27
29
|
if (file) {
|
|
28
30
|
const formData = new FormData();
|
|
29
31
|
formData.append("file", file);
|
|
30
32
|
isLoading = true;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
console.log('[AVATAR DEBUG] Sending request to /assets/avatar');
|
|
34
|
+
axios
|
|
35
|
+
.post("/assets/avatar", formData)
|
|
36
|
+
.then((response) => {
|
|
37
|
+
console.log('[AVATAR DEBUG] Upload response:', response.data);
|
|
38
|
+
setTimeout(() => {
|
|
39
|
+
isLoading = false;
|
|
40
|
+
previewUrl = response.data.data.url + "?v=" + Date.now();
|
|
41
|
+
console.log('[AVATAR DEBUG] previewUrl set to:', previewUrl);
|
|
42
|
+
}, 500);
|
|
43
|
+
user.avatar = response.data.data.url + "?v=" + Date.now();
|
|
44
|
+
Toast("Avatar berhasil diupload", "success");
|
|
45
|
+
})
|
|
46
|
+
.catch((error) => {
|
|
47
|
+
console.log('[AVATAR DEBUG] Upload failed:', error);
|
|
48
|
+
isLoading = false;
|
|
49
|
+
Toast("Gagal mengupload avatar", "error");
|
|
36
50
|
});
|
|
37
|
-
const result = await response.json();
|
|
38
|
-
|
|
39
|
-
if (result.success) {
|
|
40
|
-
previewUrl = result.data?.url + "?v=" + Date.now();
|
|
41
|
-
user.avatar = result.data?.url;
|
|
42
|
-
Toast(result.message || "Avatar uploaded successfully", "success");
|
|
43
|
-
} else {
|
|
44
|
-
Toast(result.message || "Failed to upload avatar", "error");
|
|
45
|
-
}
|
|
46
|
-
} catch (err: unknown) {
|
|
47
|
-
const errorMessage = err instanceof Error ? err.message : 'Network error';
|
|
48
|
-
Toast(errorMessage, "error");
|
|
49
|
-
} finally {
|
|
50
|
-
isLoading = false;
|
|
51
|
-
}
|
|
52
51
|
}
|
|
53
52
|
}
|
|
54
53
|
|
|
55
54
|
async function changeProfile(): Promise<void> {
|
|
56
55
|
isLoading = true;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
method: 'POST',
|
|
60
|
-
headers: { 'Content-Type': 'application/json' },
|
|
61
|
-
body: JSON.stringify({ name: user.name, email: user.email, phone: user.phone })
|
|
62
|
-
});
|
|
63
|
-
const result = await response.json();
|
|
64
|
-
|
|
65
|
-
if (result.success) {
|
|
66
|
-
Toast(result.message || 'Profile updated successfully', 'success');
|
|
67
|
-
} else {
|
|
68
|
-
if (result.errors) {
|
|
69
|
-
const errorMessages = Object.values(result.errors).flat() as string[];
|
|
70
|
-
Toast(errorMessages[0] || result.message || 'Failed to update profile', 'error');
|
|
71
|
-
} else {
|
|
72
|
-
Toast(result.message || 'Failed to update profile', 'error');
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
} catch (err: unknown) {
|
|
76
|
-
const errorMessage = err instanceof Error ? err.message : 'Network error';
|
|
77
|
-
Toast(errorMessage, 'error');
|
|
78
|
-
} finally {
|
|
79
|
-
isLoading = false;
|
|
80
|
-
}
|
|
56
|
+
await api(() => axios.post("/change-profile", user));
|
|
57
|
+
isLoading = false;
|
|
81
58
|
}
|
|
82
59
|
|
|
83
60
|
async function changePassword(): Promise<void> {
|
|
84
|
-
if (new_password
|
|
85
|
-
Toast("
|
|
61
|
+
if (new_password != confirm_password) {
|
|
62
|
+
Toast("Password tidak cocok", "error");
|
|
86
63
|
return;
|
|
87
64
|
}
|
|
88
65
|
|
|
89
66
|
if (!current_password || !new_password || !confirm_password) {
|
|
90
|
-
Toast("
|
|
67
|
+
Toast("Mohon isi semua field", "error");
|
|
91
68
|
return;
|
|
92
69
|
}
|
|
93
70
|
|
|
94
71
|
isLoading = true;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
Toast(result.message || 'Password changed successfully', 'success');
|
|
105
|
-
current_password = "";
|
|
106
|
-
new_password = "";
|
|
107
|
-
confirm_password = "";
|
|
108
|
-
} else {
|
|
109
|
-
if (result.errors) {
|
|
110
|
-
const errorMessages = Object.values(result.errors).flat() as string[];
|
|
111
|
-
Toast(errorMessages[0] || result.message || 'Failed to change password', 'error');
|
|
112
|
-
} else {
|
|
113
|
-
Toast(result.message || 'Failed to change password', 'error');
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
} catch (err: unknown) {
|
|
117
|
-
const errorMessage = err instanceof Error ? err.message : 'Network error';
|
|
118
|
-
Toast(errorMessage, 'error');
|
|
119
|
-
} finally {
|
|
120
|
-
isLoading = false;
|
|
72
|
+
const result = await api(() => axios.post("/change-password", {
|
|
73
|
+
current_password,
|
|
74
|
+
new_password,
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
if (result.success) {
|
|
78
|
+
current_password = "";
|
|
79
|
+
new_password = "";
|
|
80
|
+
confirm_password = "";
|
|
121
81
|
}
|
|
82
|
+
isLoading = false;
|
|
122
83
|
}
|
|
123
84
|
</script>
|
|
124
85
|
|