repoimage 0.2.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.
- package/.claude/settings.local.json +8 -0
- package/AGENTS.md +28 -0
- package/PROJECT-AGENTS.md +55 -0
- package/README.md +153 -0
- package/TODO.md +132 -0
- package/client/index.html +12 -0
- package/client/package.json +23 -0
- package/client/src/App.tsx +599 -0
- package/client/src/components/FsBrowser.tsx +210 -0
- package/client/src/components/Settings.tsx +81 -0
- package/client/src/index.css +797 -0
- package/client/src/lib/api.ts +69 -0
- package/client/src/lib/collect.ts +204 -0
- package/client/src/lib/format.ts +96 -0
- package/client/src/lib/session.ts +58 -0
- package/client/src/main.tsx +10 -0
- package/client/src/vite-env.d.ts +1 -0
- package/client/tsconfig.json +18 -0
- package/client/vite.config.ts +27 -0
- package/docs/README.md +28 -0
- package/docs/api/overview.md +65 -0
- package/docs/api/scan.md +188 -0
- package/docs/architecture.md +155 -0
- package/docs/design/invariants.md +19 -0
- package/docs/design/role-system.md +50 -0
- package/docs/development/README.md +94 -0
- package/docs/features/README.md +21 -0
- package/docs/features/compression-score.md +75 -0
- package/docs/features/exclusions.md +63 -0
- package/docs/features/session.md +64 -0
- package/package.json +37 -0
- package/server/dist/cli.d.ts +3 -0
- package/server/dist/cli.d.ts.map +1 -0
- package/server/dist/cli.js +54 -0
- package/server/dist/cli.js.map +1 -0
- package/server/dist/fs-list.d.ts +3 -0
- package/server/dist/fs-list.d.ts.map +1 -0
- package/server/dist/fs-list.js +73 -0
- package/server/dist/fs-list.js.map +1 -0
- package/server/dist/paths.d.ts +3 -0
- package/server/dist/paths.d.ts.map +1 -0
- package/server/dist/paths.js +12 -0
- package/server/dist/paths.js.map +1 -0
- package/server/dist/scan.d.ts +16 -0
- package/server/dist/scan.d.ts.map +1 -0
- package/server/dist/scan.js +158 -0
- package/server/dist/scan.js.map +1 -0
- package/server/dist/server.d.ts +6 -0
- package/server/dist/server.d.ts.map +1 -0
- package/server/dist/server.js +313 -0
- package/server/dist/server.js.map +1 -0
- package/server/package.json +22 -0
- package/server/src/cli.ts +63 -0
- package/server/src/fs-list.ts +70 -0
- package/server/src/paths.ts +6 -0
- package/server/src/scan.ts +203 -0
- package/server/src/server.ts +356 -0
- package/server/tsconfig.json +9 -0
- package/shared/package.json +10 -0
- package/shared/src/constants.ts +37 -0
- package/shared/src/index.ts +4 -0
- package/shared/src/role-guess.ts +103 -0
- package/shared/src/schema.ts +18 -0
- package/shared/src/types.ts +36 -0
- package/shared/tsconfig.json +9 -0
- package/test/cli.test.js +56 -0
- package/test/fs-list.test.js +39 -0
- package/test/role-guess.test.js +50 -0
- package/test/scan.test.js +150 -0
- package/test/server.test.js +308 -0
- package/tsconfig.base.json +14 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import sizeOf from 'image-size';
|
|
4
|
+
import {
|
|
5
|
+
IMAGE_EXTENSIONS,
|
|
6
|
+
COMMON_EXCLUDE_PATHS,
|
|
7
|
+
guessRole,
|
|
8
|
+
type ImageRow,
|
|
9
|
+
type RawImageEntry,
|
|
10
|
+
type ScanIssue
|
|
11
|
+
} from '@repoimage/shared';
|
|
12
|
+
|
|
13
|
+
export { IMAGE_EXTENSIONS };
|
|
14
|
+
|
|
15
|
+
export function computeCompressionFields(
|
|
16
|
+
ext: string,
|
|
17
|
+
fileSize: number,
|
|
18
|
+
width: number | null | undefined,
|
|
19
|
+
height: number | null | undefined
|
|
20
|
+
): { uncompressedSize: number | null; compressionRatio: string | null } {
|
|
21
|
+
let uncompressedSize: number | null = null;
|
|
22
|
+
let compressionRatio: string | null = null;
|
|
23
|
+
|
|
24
|
+
if (width && height && !['.svg'].includes(ext)) {
|
|
25
|
+
const bytesPerPixel = ['.png', '.gif'].includes(ext) ? 4 : 3;
|
|
26
|
+
uncompressedSize = width * height * bytesPerPixel;
|
|
27
|
+
const linearRatio = fileSize / uncompressedSize;
|
|
28
|
+
const logScore = Math.max(0, Math.min(10, -Math.log2(linearRatio)));
|
|
29
|
+
compressionRatio = logScore.toFixed(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { uncompressedSize, compressionRatio };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function enrichImageEntry(entry: RawImageEntry): ImageRow | null {
|
|
36
|
+
const relativePath = String(entry.path || '').replace(/\\/g, '/').trim();
|
|
37
|
+
if (!relativePath || relativePath.split('/').includes('..')) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const size = Number(entry.size);
|
|
42
|
+
if (!Number.isFinite(size) || size < 0) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let width: number | null = entry.width != null ? Number(entry.width) : null;
|
|
47
|
+
let height: number | null = entry.height != null ? Number(entry.height) : null;
|
|
48
|
+
if (!Number.isFinite(width)) width = null;
|
|
49
|
+
if (!Number.isFinite(height)) height = null;
|
|
50
|
+
|
|
51
|
+
const ext = path.extname(relativePath).toLowerCase();
|
|
52
|
+
const { uncompressedSize, compressionRatio } = computeCompressionFields(
|
|
53
|
+
ext,
|
|
54
|
+
size,
|
|
55
|
+
width,
|
|
56
|
+
height
|
|
57
|
+
);
|
|
58
|
+
const { roleGuess, roleGuessReason } = guessRole({
|
|
59
|
+
path: relativePath,
|
|
60
|
+
size,
|
|
61
|
+
width,
|
|
62
|
+
height
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const row: ImageRow = {
|
|
66
|
+
path: relativePath,
|
|
67
|
+
size: Math.round(size),
|
|
68
|
+
width,
|
|
69
|
+
height,
|
|
70
|
+
uncompressedSize,
|
|
71
|
+
compressionRatio,
|
|
72
|
+
role: null,
|
|
73
|
+
roleGuess
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
if (roleGuessReason) {
|
|
77
|
+
row.roleGuessReason = roleGuessReason;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return row;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isImageFile(filename: string): boolean {
|
|
84
|
+
const ext = path.extname(filename).toLowerCase();
|
|
85
|
+
return (IMAGE_EXTENSIONS as readonly string[]).includes(ext);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function shouldExcludePath(dirName: string, excludeCommon: boolean): boolean {
|
|
89
|
+
if (!excludeCommon) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return (COMMON_EXCLUDE_PATHS as readonly string[]).includes(dirName);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface WalkRow {
|
|
96
|
+
path: string;
|
|
97
|
+
size: number;
|
|
98
|
+
width: number | null;
|
|
99
|
+
height: number | null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface WalkContext {
|
|
103
|
+
issues: ScanIssue[];
|
|
104
|
+
verbose: boolean;
|
|
105
|
+
excludeCommon: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function walkDirectory(
|
|
109
|
+
dirPath: string,
|
|
110
|
+
imageFiles: WalkRow[],
|
|
111
|
+
ctx: WalkContext
|
|
112
|
+
): WalkRow[] {
|
|
113
|
+
try {
|
|
114
|
+
const items = fs.readdirSync(dirPath);
|
|
115
|
+
|
|
116
|
+
for (const item of items) {
|
|
117
|
+
const fullPath = path.join(dirPath, item);
|
|
118
|
+
const stat = fs.statSync(fullPath);
|
|
119
|
+
|
|
120
|
+
if (stat.isDirectory()) {
|
|
121
|
+
if (!shouldExcludePath(item, ctx.excludeCommon)) {
|
|
122
|
+
walkDirectory(fullPath, imageFiles, ctx);
|
|
123
|
+
}
|
|
124
|
+
} else if (stat.isFile() && isImageFile(item)) {
|
|
125
|
+
try {
|
|
126
|
+
const dimensions = sizeOf(fullPath);
|
|
127
|
+
imageFiles.push({
|
|
128
|
+
path: fullPath,
|
|
129
|
+
size: stat.size,
|
|
130
|
+
width: dimensions.width ?? null,
|
|
131
|
+
height: dimensions.height ?? null
|
|
132
|
+
});
|
|
133
|
+
} catch {
|
|
134
|
+
imageFiles.push({
|
|
135
|
+
path: fullPath,
|
|
136
|
+
size: stat.size,
|
|
137
|
+
width: null,
|
|
138
|
+
height: null
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} catch (error) {
|
|
144
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
145
|
+
ctx.issues.push({ code: 'DIR_READ_ERROR', path: dirPath, message });
|
|
146
|
+
if (ctx.verbose) {
|
|
147
|
+
console.error(`Error reading directory ${dirPath}:`, message);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return imageFiles;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function findImagesInFolders(
|
|
155
|
+
folders: string[],
|
|
156
|
+
options: { verbose?: boolean; excludeCommon?: boolean } = {}
|
|
157
|
+
): { images: ImageRow[]; issues: ScanIssue[] } {
|
|
158
|
+
const verbose = Boolean(options.verbose);
|
|
159
|
+
const excludeCommon = Boolean(options.excludeCommon);
|
|
160
|
+
const issues: ScanIssue[] = [];
|
|
161
|
+
const ctx = { issues, verbose, excludeCommon };
|
|
162
|
+
const allImages: ImageRow[] = [];
|
|
163
|
+
|
|
164
|
+
for (const folder of folders) {
|
|
165
|
+
if (!fs.existsSync(folder)) {
|
|
166
|
+
const message = `Folder does not exist: ${folder}`;
|
|
167
|
+
issues.push({ code: 'MISSING_FOLDER', path: folder, message });
|
|
168
|
+
if (verbose) {
|
|
169
|
+
console.error(message);
|
|
170
|
+
}
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (verbose) {
|
|
175
|
+
console.log(`Scanning folder: ${folder}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const images = walkDirectory(folder, [], ctx);
|
|
179
|
+
const relativeImages = images
|
|
180
|
+
.map(image => {
|
|
181
|
+
const relativePath = path.relative(folder, image.path).split(path.sep).join('/');
|
|
182
|
+
return enrichImageEntry({
|
|
183
|
+
path: relativePath,
|
|
184
|
+
size: image.size,
|
|
185
|
+
width: image.width,
|
|
186
|
+
height: image.height
|
|
187
|
+
});
|
|
188
|
+
})
|
|
189
|
+
.filter((row): row is ImageRow => row != null);
|
|
190
|
+
|
|
191
|
+
allImages.push(...relativeImages);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { images: allImages, issues };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function sortByCompressionScore(images: ImageRow[]): ImageRow[] {
|
|
198
|
+
return [...images].sort((a, b) => {
|
|
199
|
+
const scoreA = parseFloat(a.compressionRatio ?? '') || 0;
|
|
200
|
+
const scoreB = parseFloat(b.compressionRatio ?? '') || 0;
|
|
201
|
+
return scoreB - scoreA;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { IMAGE_EXTENSIONS } from '@repoimage/shared';
|
|
6
|
+
import { listDirectory } from './fs-list';
|
|
7
|
+
import {
|
|
8
|
+
enrichImageEntry,
|
|
9
|
+
findImagesInFolders,
|
|
10
|
+
sortByCompressionScore
|
|
11
|
+
} from './scan';
|
|
12
|
+
import { defaultClientRoot } from './paths';
|
|
13
|
+
|
|
14
|
+
const MAX_SCAN_ENTRIES = 100_000;
|
|
15
|
+
|
|
16
|
+
function isLoopbackAddress(addr: string | undefined): boolean {
|
|
17
|
+
if (addr == null) return true;
|
|
18
|
+
const a = String(addr);
|
|
19
|
+
return a === '127.0.0.1' || a === '::1' || a === '::ffff:127.0.0.1';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function assertLoopbackForFsApi(
|
|
23
|
+
req: http.IncomingMessage,
|
|
24
|
+
res: http.ServerResponse
|
|
25
|
+
): boolean {
|
|
26
|
+
if (process.env.REPOIMAGE_FS_TRUST_REMOTE === '1') {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
const addr = req.socket.remoteAddress;
|
|
30
|
+
if (!isLoopbackAddress(addr)) {
|
|
31
|
+
sendJson(res, 403, {
|
|
32
|
+
error:
|
|
33
|
+
'Filesystem API is only available to loopback clients. Set REPOIMAGE_FS_TRUST_REMOTE=1 to disable this check (not recommended).'
|
|
34
|
+
});
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveScanFolders(body: Record<string, unknown>): string[] {
|
|
41
|
+
if (typeof body.folder === 'string' && body.folder.trim()) {
|
|
42
|
+
return [body.folder.trim()];
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(body.folders)) {
|
|
45
|
+
return body.folders
|
|
46
|
+
.map(f => (typeof f === 'string' ? f.trim() : ''))
|
|
47
|
+
.filter(Boolean);
|
|
48
|
+
}
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const IMAGE_FILE_EXTS = new Set<string>(IMAGE_EXTENSIONS);
|
|
53
|
+
|
|
54
|
+
const MIME: Record<string, string> = {
|
|
55
|
+
'.html': 'text/html; charset=utf-8',
|
|
56
|
+
'.css': 'text/css; charset=utf-8',
|
|
57
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
58
|
+
'.json': 'application/json; charset=utf-8',
|
|
59
|
+
'.ico': 'image/x-icon',
|
|
60
|
+
'.png': 'image/png',
|
|
61
|
+
'.jpg': 'image/jpeg',
|
|
62
|
+
'.jpeg': 'image/jpeg',
|
|
63
|
+
'.gif': 'image/gif',
|
|
64
|
+
'.bmp': 'image/bmp',
|
|
65
|
+
'.tif': 'image/tiff',
|
|
66
|
+
'.tiff': 'image/tiff',
|
|
67
|
+
'.webp': 'image/webp',
|
|
68
|
+
'.svg': 'image/svg+xml',
|
|
69
|
+
'.avif': 'image/avif',
|
|
70
|
+
'.heic': 'image/heic',
|
|
71
|
+
'.heif': 'image/heif'
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function readJsonBody(req: http.IncomingMessage): Promise<Record<string, unknown>> {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const chunks: Buffer[] = [];
|
|
77
|
+
req.on('data', chunk => chunks.push(chunk));
|
|
78
|
+
req.on('end', () => {
|
|
79
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
80
|
+
if (!raw.trim()) {
|
|
81
|
+
resolve({});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
resolve(JSON.parse(raw) as Record<string, unknown>);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
reject(e);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
req.on('error', reject);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function send(
|
|
95
|
+
res: http.ServerResponse,
|
|
96
|
+
status: number,
|
|
97
|
+
body: string | Buffer,
|
|
98
|
+
headers: Record<string, string> = {}
|
|
99
|
+
): void {
|
|
100
|
+
const buf = Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8');
|
|
101
|
+
res.writeHead(status, {
|
|
102
|
+
'Content-Length': buf.length,
|
|
103
|
+
...headers
|
|
104
|
+
});
|
|
105
|
+
res.end(buf);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sendJson(res: http.ServerResponse, status: number, obj: unknown): void {
|
|
109
|
+
send(res, status, JSON.stringify(obj), {
|
|
110
|
+
'Content-Type': 'application/json; charset=utf-8'
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface RepoimageServerOptions {
|
|
115
|
+
clientRoot?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function createRepoimageServer(options: RepoimageServerOptions = {}): http.Server {
|
|
119
|
+
const clientRoot = path.resolve(options.clientRoot ?? defaultClientRoot());
|
|
120
|
+
|
|
121
|
+
function safeStaticFile(urlPath: string): string | null {
|
|
122
|
+
const decoded = decodeURIComponent((urlPath || '').split('?')[0]);
|
|
123
|
+
if (decoded.includes('..')) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const rel = decoded === '/' || decoded === '' ? 'index.html' : decoded.replace(/^\/+/, '');
|
|
127
|
+
const full = path.resolve(path.join(clientRoot, rel));
|
|
128
|
+
const root = path.resolve(clientRoot);
|
|
129
|
+
if (full !== root && !full.startsWith(root + path.sep)) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return full;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function serveSpaIndex(res: http.ServerResponse): void {
|
|
136
|
+
const indexPath = path.join(clientRoot, 'index.html');
|
|
137
|
+
if (!fs.existsSync(indexPath)) {
|
|
138
|
+
send(
|
|
139
|
+
res,
|
|
140
|
+
503,
|
|
141
|
+
'Client not built. Run npm run build from the repo root.',
|
|
142
|
+
{ 'Content-Type': 'text/plain; charset=utf-8' }
|
|
143
|
+
);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
send(res, 200, fs.readFileSync(indexPath), {
|
|
147
|
+
'Content-Type': 'text/html; charset=utf-8'
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return http.createServer(async (req, res) => {
|
|
152
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
153
|
+
|
|
154
|
+
if (req.method === 'POST' && url.pathname === '/api/scan') {
|
|
155
|
+
try {
|
|
156
|
+
const body = await readJsonBody(req);
|
|
157
|
+
const rawFolders = resolveScanFolders(body);
|
|
158
|
+
const normalized = rawFolders.map(f => path.resolve(f)).filter(Boolean);
|
|
159
|
+
|
|
160
|
+
if (normalized.length === 0) {
|
|
161
|
+
sendJson(res, 400, {
|
|
162
|
+
error: 'Provide a project root: non-empty string "folder" or "folders" array.'
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const sortByScore = Boolean(body.sortByScore);
|
|
168
|
+
const excludeCommon = Boolean(body.excludeCommonFolders);
|
|
169
|
+
const { images, issues } = findImagesInFolders(normalized, {
|
|
170
|
+
verbose: false,
|
|
171
|
+
excludeCommon
|
|
172
|
+
});
|
|
173
|
+
const imagesOut = sortByScore ? sortByCompressionScore(images) : images;
|
|
174
|
+
|
|
175
|
+
sendJson(res, 200, { images: imagesOut, issues });
|
|
176
|
+
} catch (e) {
|
|
177
|
+
sendJson(res, 400, {
|
|
178
|
+
error: 'Invalid JSON body',
|
|
179
|
+
detail: String(e instanceof Error ? e.message : e)
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (req.method === 'POST' && url.pathname === '/api/scan-entries') {
|
|
186
|
+
try {
|
|
187
|
+
const body = await readJsonBody(req);
|
|
188
|
+
const entries = Array.isArray(body.entries) ? body.entries : [];
|
|
189
|
+
|
|
190
|
+
if (entries.length === 0) {
|
|
191
|
+
sendJson(res, 400, { error: 'Provide a non-empty "entries" array.' });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (entries.length > MAX_SCAN_ENTRIES) {
|
|
196
|
+
sendJson(res, 400, {
|
|
197
|
+
error: `Too many entries (max ${MAX_SCAN_ENTRIES}).`
|
|
198
|
+
});
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const images = [];
|
|
203
|
+
for (const raw of entries) {
|
|
204
|
+
const row = enrichImageEntry(raw as Parameters<typeof enrichImageEntry>[0]);
|
|
205
|
+
if (row) {
|
|
206
|
+
images.push(row);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const sortByScore = Boolean(body.sortByScore);
|
|
211
|
+
const imagesOut = sortByScore ? sortByCompressionScore(images) : images;
|
|
212
|
+
|
|
213
|
+
sendJson(res, 200, { images: imagesOut, issues: [] });
|
|
214
|
+
} catch (e) {
|
|
215
|
+
sendJson(res, 400, {
|
|
216
|
+
error: 'Invalid JSON body',
|
|
217
|
+
detail: String(e instanceof Error ? e.message : e)
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (url.pathname === '/api/fs/list') {
|
|
224
|
+
if (!assertLoopbackForFsApi(req, res)) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let rawPath = '';
|
|
229
|
+
if (req.method === 'GET') {
|
|
230
|
+
rawPath = url.searchParams.get('path') ?? '';
|
|
231
|
+
} else if (req.method === 'POST') {
|
|
232
|
+
try {
|
|
233
|
+
const body = await readJsonBody(req);
|
|
234
|
+
rawPath = typeof body.path === 'string' ? body.path : '';
|
|
235
|
+
} catch {
|
|
236
|
+
sendJson(res, 400, { error: 'Invalid JSON body' });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
send(res, 405, 'Method not allowed', { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const trimmed = rawPath.trim();
|
|
245
|
+
if (!trimmed) {
|
|
246
|
+
sendJson(res, 400, {
|
|
247
|
+
error:
|
|
248
|
+
'Missing path. Use GET ?path=/absolute/path or POST JSON { "path": "/absolute/path" }.'
|
|
249
|
+
});
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const result = listDirectory(trimmed);
|
|
254
|
+
if ('error' in result) {
|
|
255
|
+
let code = 400;
|
|
256
|
+
if (result.error === 'ENOENT') code = 404;
|
|
257
|
+
else if (result.error === 'ENOTDIR') code = 400;
|
|
258
|
+
else if (result.error === 'EACCES' || result.error === 'ESTAT') code = 403;
|
|
259
|
+
sendJson(res, code, result);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
sendJson(res, 200, result);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (req.method === 'GET' && url.pathname === '/api/fs/home') {
|
|
268
|
+
if (!assertLoopbackForFsApi(req, res)) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const home = os.homedir();
|
|
272
|
+
sendJson(res, 200, { path: home.split(path.sep).join('/') });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (req.method === 'GET' && url.pathname === '/api/file') {
|
|
277
|
+
if (!assertLoopbackForFsApi(req, res)) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const raw = url.searchParams.get('path') ?? '';
|
|
282
|
+
const trimmed = raw.trim();
|
|
283
|
+
if (!trimmed) {
|
|
284
|
+
sendJson(res, 400, { error: 'Missing path. Use ?path=/absolute/path/to/image.png' });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const resolved = path.resolve(trimmed);
|
|
289
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
290
|
+
if (!IMAGE_FILE_EXTS.has(ext)) {
|
|
291
|
+
sendJson(res, 403, { error: 'Not an allowed image type.' });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let st: fs.Stats;
|
|
296
|
+
try {
|
|
297
|
+
st = fs.statSync(resolved);
|
|
298
|
+
} catch (e) {
|
|
299
|
+
const err = e as NodeJS.ErrnoException;
|
|
300
|
+
const code = err.code === 'ENOENT' ? 404 : 403;
|
|
301
|
+
sendJson(res, code, {
|
|
302
|
+
error: err.code ?? 'ESTAT',
|
|
303
|
+
message: String(err.message || e)
|
|
304
|
+
});
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (!st.isFile()) {
|
|
309
|
+
sendJson(res, 400, { error: 'ENOTFILE', message: 'Path is not a file.' });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const type = MIME[ext] || 'application/octet-stream';
|
|
314
|
+
send(res, 200, fs.readFileSync(resolved), { 'Content-Type': type });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (req.method === 'GET') {
|
|
319
|
+
let filePath = safeStaticFile(url.pathname);
|
|
320
|
+
if (!filePath) {
|
|
321
|
+
send(res, 403, 'Forbidden', { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
|
|
325
|
+
filePath = path.join(filePath, 'index.html');
|
|
326
|
+
}
|
|
327
|
+
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
|
328
|
+
if (!url.pathname.startsWith('/api')) {
|
|
329
|
+
serveSpaIndex(res);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
send(res, 404, 'Not found', { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
336
|
+
const type = MIME[ext] || 'application/octet-stream';
|
|
337
|
+
send(res, 200, fs.readFileSync(filePath), { 'Content-Type': type });
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
send(res, 405, 'Method not allowed', { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (require.main === module) {
|
|
346
|
+
const requested = process.env.PORT;
|
|
347
|
+
const port = requested !== undefined && requested !== '' ? Number(requested) : 3847;
|
|
348
|
+
const host = process.env.REPOIMAGE_BIND || '127.0.0.1';
|
|
349
|
+
const server = createRepoimageServer();
|
|
350
|
+
server.listen(port, host, () => {
|
|
351
|
+
const addr = server.address();
|
|
352
|
+
const p = typeof addr === 'object' && addr ? addr.port : port;
|
|
353
|
+
const h = typeof addr === 'object' && addr ? addr.address : host;
|
|
354
|
+
process.stderr.write(`RepoImage GUI: http://${h}:${p}/\n`);
|
|
355
|
+
});
|
|
356
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const IMAGE_EXTENSIONS = [
|
|
2
|
+
'.jpg',
|
|
3
|
+
'.jpeg',
|
|
4
|
+
'.png',
|
|
5
|
+
'.gif',
|
|
6
|
+
'.bmp',
|
|
7
|
+
'.tiff',
|
|
8
|
+
'.tif',
|
|
9
|
+
'.webp',
|
|
10
|
+
'.svg',
|
|
11
|
+
'.ico',
|
|
12
|
+
'.raw',
|
|
13
|
+
'.heic',
|
|
14
|
+
'.heif',
|
|
15
|
+
'.avif'
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export const COMMON_EXCLUDE_PATHS = [
|
|
19
|
+
'node_modules',
|
|
20
|
+
'.next',
|
|
21
|
+
'dist',
|
|
22
|
+
'.git',
|
|
23
|
+
'build',
|
|
24
|
+
'.bundle',
|
|
25
|
+
'.cache',
|
|
26
|
+
'target',
|
|
27
|
+
'vendor',
|
|
28
|
+
'.idea',
|
|
29
|
+
'.vscode',
|
|
30
|
+
'__pycache__',
|
|
31
|
+
'.pytest_cache',
|
|
32
|
+
'.tox',
|
|
33
|
+
'venv',
|
|
34
|
+
'.venv',
|
|
35
|
+
'env',
|
|
36
|
+
'.env'
|
|
37
|
+
] as const;
|