pulp-image 0.1.0

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.
@@ -0,0 +1,398 @@
1
+ import express from 'express';
2
+ import { fileURLToPath } from 'url';
3
+ import { dirname, join, resolve } from 'path';
4
+ import { mkdirSync, writeFileSync, unlinkSync, rmSync, existsSync, readFileSync } from 'fs';
5
+ import { tmpdir, homedir } from 'os';
6
+ import { randomUUID } from 'crypto';
7
+ import multer from 'multer';
8
+ import open from 'open';
9
+ import { runJob } from './runJob.js';
10
+ import { exec, spawn } from 'child_process';
11
+ import { promisify } from 'util';
12
+
13
+ const execAsync = promisify(exec);
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+
18
+ /**
19
+ * Starts the UI server and opens browser
20
+ * @param {number} port - Port to run server on (default: 3000)
21
+ * @returns {Promise<object>} Server instance and URL
22
+ */
23
+ export async function startUIServer(port = 3000) {
24
+ const app = express();
25
+
26
+ // Get UI directory path (ui/ folder in project root)
27
+ const projectRoot = join(__dirname, '..');
28
+ const uiDir = join(projectRoot, 'ui');
29
+
30
+ // JSON body parser for API
31
+ app.use(express.json());
32
+
33
+ // Configure multer for file uploads with filename preservation
34
+ const uploadDir = join(tmpdir(), 'pulp-image-uploads');
35
+ if (!existsSync(uploadDir)) {
36
+ mkdirSync(uploadDir, { recursive: true });
37
+ }
38
+
39
+ const storage = multer.diskStorage({
40
+ destination: (req, file, cb) => {
41
+ cb(null, uploadDir);
42
+ },
43
+ filename: (req, file, cb) => {
44
+ // Preserve original filename, sanitize for security
45
+ const originalName = file.originalname || 'file';
46
+ // Remove path traversal attempts (../)
47
+ let sanitized = originalName.replace(/\.\./g, '_');
48
+ // Remove leading/trailing dots and slashes
49
+ sanitized = sanitized.replace(/^[.\/]+|[.\/]+$/g, '');
50
+ // Remove null bytes and other dangerous characters
51
+ sanitized = sanitized.replace(/\0/g, '');
52
+ // Keep most characters but remove truly dangerous ones
53
+ // Allow: letters, numbers, dots, hyphens, underscores, spaces
54
+ sanitized = sanitized.replace(/[<>:"|?*\x00-\x1f]/g, '_');
55
+ // Ensure we have a valid filename
56
+ const safeName = sanitized || `file_${Date.now()}`;
57
+ cb(null, safeName);
58
+ }
59
+ });
60
+
61
+ const upload = multer({
62
+ storage: storage,
63
+ limits: { fileSize: 100 * 1024 * 1024 } // 100MB limit
64
+ });
65
+
66
+ // API endpoint for processing images
67
+ app.post('/api/run', upload.array('files'), async (req, res) => {
68
+ try {
69
+ if (!req.files || req.files.length === 0) {
70
+ return res.status(400).json({ error: 'No files uploaded' });
71
+ }
72
+
73
+ // Parse config from form data
74
+ let config;
75
+ try {
76
+ config = JSON.parse(req.body.config || '{}');
77
+ } catch (e) {
78
+ return res.status(400).json({ error: 'Invalid config JSON' });
79
+ }
80
+
81
+ // Handle default output directory
82
+ if (config.useDefaultOutput !== false) {
83
+ // Generate timestamp-based output directory
84
+ const now = new Date();
85
+ const year = now.getFullYear();
86
+ const month = String(now.getMonth() + 1).padStart(2, '0');
87
+ const day = String(now.getDate()).padStart(2, '0');
88
+ const hours = String(now.getHours()).padStart(2, '0');
89
+ const minutes = String(now.getMinutes()).padStart(2, '0');
90
+ const seconds = String(now.getSeconds()).padStart(2, '0');
91
+ const timestamp = `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`;
92
+ config.out = join(homedir(), 'pulp-image-results', timestamp);
93
+ } else if (config.out) {
94
+ // Expand ~ to home directory
95
+ if (config.out.startsWith('~')) {
96
+ config.out = join(homedir(), config.out.slice(1));
97
+ }
98
+ // Resolve to absolute path
99
+ config.out = resolve(config.out);
100
+ }
101
+
102
+ // Determine if single file or directory
103
+ // For browser uploads, we treat multiple files as a directory
104
+ const isDirectory = req.files.length > 1;
105
+ let inputPath;
106
+ const tempPaths = [];
107
+
108
+ try {
109
+ if (isDirectory) {
110
+ // Create a temp directory and move all files there
111
+ const tempDir = join(tmpdir(), 'pulp-image-temp', randomUUID());
112
+ mkdirSync(tempDir, { recursive: true });
113
+
114
+ for (const file of req.files) {
115
+ // Use originalname from multer (which we sanitized but preserved)
116
+ const originalName = file.originalname || file.filename;
117
+ const destPath = join(tempDir, originalName);
118
+ writeFileSync(destPath, readFileSync(file.path));
119
+ unlinkSync(file.path); // Remove from upload dir
120
+ tempPaths.push(destPath);
121
+ }
122
+
123
+ inputPath = tempDir;
124
+ } else {
125
+ // Single file - preserve original filename
126
+ const file = req.files[0];
127
+ const originalName = file.originalname || file.filename;
128
+ // If multer saved with a different name, rename it
129
+ if (file.filename !== originalName) {
130
+ const newPath = join(uploadDir, originalName);
131
+ writeFileSync(newPath, readFileSync(file.path));
132
+ unlinkSync(file.path);
133
+ inputPath = newPath;
134
+ tempPaths.push(newPath);
135
+ } else {
136
+ inputPath = file.path;
137
+ tempPaths.push(file.path);
138
+ }
139
+ }
140
+
141
+ // Run the job
142
+ const results = await runJob(inputPath, config);
143
+
144
+ // Add resolved output path to results
145
+ results.outputPath = config.out;
146
+
147
+ // Always clean up temp files in UI mode
148
+ // Browser security prevents UI from deleting original user files,
149
+ // so temp upload files must always be cleaned up after processing
150
+ try {
151
+ if (isDirectory) {
152
+ // Remove entire temp directory
153
+ rmSync(inputPath, { recursive: true, force: true });
154
+ } else {
155
+ // Remove single temp file
156
+ tempPaths.forEach(path => {
157
+ if (existsSync(path)) {
158
+ unlinkSync(path);
159
+ }
160
+ });
161
+ }
162
+ } catch (cleanupError) {
163
+ console.warn('Warning: Failed to clean up temp files:', cleanupError);
164
+ }
165
+
166
+ // Return results
167
+ res.json(results);
168
+
169
+ } catch (error) {
170
+ // Clean up on error
171
+ try {
172
+ if (isDirectory && inputPath) {
173
+ rmSync(inputPath, { recursive: true, force: true });
174
+ } else {
175
+ tempPaths.forEach(path => {
176
+ if (existsSync(path)) {
177
+ unlinkSync(path);
178
+ }
179
+ });
180
+ }
181
+ } catch (cleanupError) {
182
+ console.warn('Warning: Failed to clean up temp files after error:', cleanupError);
183
+ }
184
+
185
+ throw error;
186
+ }
187
+
188
+ } catch (error) {
189
+ console.error('API Error:', error);
190
+ res.status(500).json({
191
+ error: error.message || 'Processing failed',
192
+ message: error.message
193
+ });
194
+ }
195
+ });
196
+
197
+ // API endpoint to resolve output path
198
+ app.post('/api/resolve-output-path', (req, res) => {
199
+ try {
200
+ const { useDefault, timestamp, customPath } = req.body;
201
+
202
+ if (useDefault && timestamp) {
203
+ const resolvedPath = join(homedir(), 'pulp-image-results', timestamp);
204
+ res.json({ path: resolvedPath });
205
+ } else if (customPath) {
206
+ let resolved = customPath;
207
+ if (resolved.startsWith('~')) {
208
+ resolved = join(homedir(), resolved.slice(1));
209
+ }
210
+ resolved = resolve(resolved);
211
+ res.json({ path: resolved });
212
+ } else {
213
+ res.status(400).json({ error: 'Invalid request' });
214
+ }
215
+ } catch (error) {
216
+ res.status(500).json({ error: error.message });
217
+ }
218
+ });
219
+
220
+ // API endpoint to validate output path
221
+ app.post('/api/validate-output-path', (req, res) => {
222
+ try {
223
+ const { path } = req.body;
224
+ if (!path) {
225
+ return res.status(400).json({ error: 'Path required' });
226
+ }
227
+
228
+ let resolvedPath = path;
229
+ if (resolvedPath.startsWith('~')) {
230
+ resolvedPath = join(homedir(), resolvedPath.slice(1));
231
+ }
232
+ resolvedPath = resolve(resolvedPath);
233
+
234
+ const exists = existsSync(resolvedPath);
235
+ const willCreate = !exists; // Will be created if it doesn't exist
236
+
237
+ res.json({ exists, willCreate, path: resolvedPath });
238
+ } catch (error) {
239
+ res.status(500).json({ error: error.message });
240
+ }
241
+ });
242
+
243
+ // API endpoint to open folder in file manager
244
+ app.post('/api/open-folder', async (req, res) => {
245
+ try {
246
+ const { path } = req.body;
247
+ if (!path) {
248
+ return res.status(400).json({ error: 'Path required' });
249
+ }
250
+
251
+ // Determine OS and use appropriate command
252
+ const platform = process.platform;
253
+ let command;
254
+ let options = {};
255
+
256
+ if (platform === 'darwin') {
257
+ // macOS
258
+ command = `open "${path}"`;
259
+ } else if (platform === 'win32') {
260
+ // Windows - use explorer to open in foreground
261
+ command = `explorer "${path}"`;
262
+ } else {
263
+ // Linux and others - suppress stderr to avoid Wayland/Gnome noise
264
+ command = `xdg-open "${path}" 2>/dev/null || true`;
265
+ // Use shell to properly handle stderr redirection
266
+ options = { shell: '/bin/bash' };
267
+ }
268
+
269
+ try {
270
+ // Suppress stderr on Linux to avoid noisy output
271
+ if (platform === 'linux' || (platform !== 'darwin' && platform !== 'win32')) {
272
+ // Use spawn with stderr redirected instead of execAsync for better control
273
+ await new Promise((resolve, reject) => {
274
+ const child = spawn('xdg-open', [path], {
275
+ stdio: ['ignore', 'ignore', 'ignore'], // Suppress all output
276
+ detached: true
277
+ });
278
+ child.unref(); // Allow parent to exit independently
279
+ // Don't wait for child to exit - xdg-open may spawn other processes
280
+ setTimeout(resolve, 100); // Give it a moment to start
281
+ });
282
+ } else {
283
+ await execAsync(command, options);
284
+ }
285
+ res.json({ success: true });
286
+ } catch (execError) {
287
+ // Don't expose raw OS errors to user
288
+ console.error('Error opening folder:', execError);
289
+ res.status(500).json({
290
+ error: 'Could not open folder automatically.',
291
+ path: path // Include path so UI can display it
292
+ });
293
+ }
294
+
295
+ } catch (error) {
296
+ // Don't expose raw errors to user
297
+ console.error('Error opening folder:', error);
298
+ const { path } = req.body;
299
+ res.status(500).json({
300
+ error: 'Could not open folder automatically.',
301
+ path: path || null // Include path if available
302
+ });
303
+ }
304
+ });
305
+
306
+ // Version endpoint - reads from package.json (single source of truth)
307
+ // Must be before static middleware to avoid conflicts
308
+ app.get('/api/version', (req, res) => {
309
+ try {
310
+ const packageJsonPath = join(projectRoot, 'package.json');
311
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
312
+ res.json({ version: packageJson.version });
313
+ } catch (error) {
314
+ console.error('Error reading version from package.json:', error);
315
+ res.status(500).json({ error: 'Failed to read version' });
316
+ }
317
+ });
318
+
319
+ // Serve static files from ui directory
320
+ app.use(express.static(uiDir));
321
+
322
+ // Fallback: serve placeholder if ui/index.html doesn't exist
323
+ // (static middleware serves it if it exists, so this only runs if missing)
324
+ app.get('/', (req, res) => {
325
+ // Placeholder page
326
+ res.send(`<!DOCTYPE html>
327
+ <html lang="en">
328
+ <head>
329
+ <meta charset="UTF-8">
330
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
331
+ <title>Pulp Image UI</title>
332
+ <style>
333
+ body {
334
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
335
+ display: flex;
336
+ justify-content: center;
337
+ align-items: center;
338
+ min-height: 100vh;
339
+ margin: 0;
340
+ background: linear-gradient(135deg, #fff5e6 0%, #ffe6cc 100%);
341
+ color: #333;
342
+ }
343
+ .container {
344
+ text-align: center;
345
+ padding: 2rem;
346
+ background: white;
347
+ border-radius: 12px;
348
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
349
+ max-width: 600px;
350
+ }
351
+ h1 {
352
+ color: #ff8c42;
353
+ margin-top: 0;
354
+ }
355
+ .brand {
356
+ font-size: 0.9em;
357
+ color: #666;
358
+ margin-top: 1rem;
359
+ }
360
+ </style>
361
+ </head>
362
+ <body>
363
+ <div class="container">
364
+ <h1>šŸŠ Pulp Image UI</h1>
365
+ <p>UI is starting up...</p>
366
+ <p class="brand">Pulp Image by Rebellion Geeks</p>
367
+ </div>
368
+ </body>
369
+ </html>`);
370
+ });
371
+
372
+ // Start server
373
+ return new Promise((resolve, reject) => {
374
+ const server = app.listen(port, 'localhost', () => {
375
+ const url = `http://localhost:${port}`;
376
+ console.log(`\n🌐 Pulp Image UI is running\n`);
377
+ console.log(`Open in your browser:\n${url}\n`);
378
+ console.log('Tip: Ctrl + Click the link above if it doesn\'t open automatically.\n');
379
+ console.log('Press Ctrl+C to stop\n');
380
+
381
+ // Open browser automatically (best-effort)
382
+ open(url).catch(() => {
383
+ // Silently fail - user can use Ctrl+Click or copy URL
384
+ });
385
+
386
+ resolve({ server, url, app });
387
+ });
388
+
389
+ server.on('error', (err) => {
390
+ if (err.code === 'EADDRINUSE') {
391
+ reject(new Error(`Port ${port} is already in use. Please try a different port.`));
392
+ } else {
393
+ reject(err);
394
+ }
395
+ });
396
+ });
397
+ }
398
+