File size: 9,494 Bytes
96bdf6c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
import { GET, POST } from './route';
import { createAccessToken } from '@/lib/server-runtime';
import { getWebuiImageRetentionStore, resetWebuiImageRetentionStoresForTests } from '@/lib/webui-image-retention-store';
import { NextRequest } from 'next/server';
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';

const PAGE_PASSWORD_FIXTURE = ['customer', 'access', 'code'].join('-');
const validFilename = '1781567999000-aaaaaaaaaaaaaaaa-0.png';
const missingFilename = '1781567999001-bbbbbbbbbbbbbbbb-1.webp';
const symlinkFilename = '1781567999002-cccccccccccccccc-2.png';
let originalEnv: NodeJS.ProcessEnv;
let originalCwd = '';
let tempDir = '';

beforeEach(async () => {
    originalEnv = { ...process.env };
    originalCwd = process.cwd();
    tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-retention-route-'));
    process.chdir(tempDir);
    delete process.env.APP_PASSWORD;
    resetWebuiImageRetentionStoresForTests();
});

afterEach(async () => {
    resetWebuiImageRetentionStoresForTests();
    process.chdir(originalCwd);
    await rm(tempDir, { recursive: true, force: true });
    restoreProcessEnv(originalEnv);
});

describe('GET and POST /api/image-retention', { concurrency: false }, () => {
    it('preserves valid top-level files in one batch and reports invalid files', async () => {
        await writeOutputFile(validFilename);

        const response = await POST(
            jsonRequest({
                action: 'preserve',
                filenames: [validFilename, '../outside.png', missingFilename]
            })
        );
        const body = (await response.json()) as {
            results: Array<{ filename: string; success: boolean; error?: string }>;
        };

        assert.equal(response.status, 207);
        assert.deepEqual(body.results, [
            { filename: validFilename, success: true },
            { filename: '../outside.png', success: false, error: '文件名格式无效。' },
            { filename: missingFilename, success: false, error: '文件不存在。' }
        ]);

        const store = await getWebuiImageRetentionStore();
        assert.deepEqual(await store.listPermanentFilenames(), [validFilename]);

        const listResponse = await GET(new NextRequest('http://localhost/api/image-retention'));
        assert.equal(listResponse.status, 200);
        assert.deepEqual(await listResponse.json(), { filenames: [validFilename] });
    });

    it('rejects preserve requests for symbolic links', async () => {
        const outputDir = await outputDirectory();
        const targetPath = path.join(tempDir, 'outside.png');
        await writeFile(targetPath, 'outside');
        await symlink(targetPath, path.join(outputDir, symlinkFilename));

        const response = await POST(
            jsonRequest({
                action: 'preserve',
                filenames: [symlinkFilename]
            })
        );
        const body = (await response.json()) as {
            results: Array<{ filename: string; success: boolean; error?: string }>;
        };

        assert.equal(response.status, 207);
        assert.deepEqual(body.results, [{ filename: symlinkFilename, success: false, error: '文件必须是常规文件。' }]);
    });

    it('releases stale markers without requiring the source file to exist', async () => {
        const store = await getWebuiImageRetentionStore();
        await store.preserve([missingFilename]);

        const response = await POST(
            jsonRequest({
                action: 'release',
                filenames: [missingFilename]
            })
        );
        const body = (await response.json()) as {
            results: Array<{ filename: string; success: boolean; error?: string }>;
        };

        assert.equal(response.status, 200);
        assert.deepEqual(body.results, [{ filename: missingFilename, success: true }]);
        assert.deepEqual(await store.listPermanentFilenames(), []);
    });

    it('rejects malformed or oversized retention batches before writing state', async () => {
        const malformed = await POST(jsonRequest({ action: 'preserve', filenames: [1] }));
        assert.equal(malformed.status, 400);

        const invalidAction = await POST(jsonRequest({ action: 'delete', filenames: [validFilename] }));
        assert.equal(invalidAction.status, 400);

        const oversized = await POST(
            jsonRequest({
                action: 'preserve',
                filenames: Array.from(
                    { length: 101 },
                    (_, index) => `1781567999${String(index).padStart(3, '0')}-aaaaaaaaaaaaaaaa-0.png`
                )
            })
        );
        assert.equal(oversized.status, 400);

        const store = await getWebuiImageRetentionStore();
        assert.deepEqual(await store.listPermanentFilenames(), []);
    });

    it('deduplicates repeated filenames before preserving them', async () => {
        await writeOutputFile(validFilename);

        const response = await POST(
            jsonRequest({
                action: 'preserve',
                filenames: [validFilename, validFilename]
            })
        );

        assert.equal(response.status, 200);
        assert.deepEqual(await response.json(), {
            results: [{ filename: validFilename, success: true }]
        });
        assert.deepEqual(await (await getWebuiImageRetentionStore()).listPermanentFilenames(), [validFilename]);
    });

    it('serializes a release behind an in-flight preserve for the same filename', { timeout: 5_000 }, async () => {
        await writeOutputFile(validFilename);
        const store = await getWebuiImageRetentionStore();
        const originalPreserve = store.preserve.bind(store);
        let releasePreserve: (() => void) | undefined;
        const preserveGate = new Promise<void>((resolve) => {
            releasePreserve = resolve;
        });
        let markPreserveEntered: (() => void) | undefined;
        const preserveEntered = new Promise<void>((resolve) => {
            markPreserveEntered = resolve;
        });

        store.preserve = async (filenames, now) => {
            markPreserveEntered?.();
            await preserveGate;
            await originalPreserve(filenames, now);
        };

        try {
            const preserve = POST(jsonRequest({ action: 'preserve', filenames: [validFilename] }));
            await preserveEntered;
            const release = POST(jsonRequest({ action: 'release', filenames: [validFilename] }));
            releasePreserve?.();

            assert.equal((await preserve).status, 200);
            assert.equal((await release).status, 200);
            assert.deepEqual(await store.listPermanentFilenames(), []);
        } finally {
            releasePreserve?.();
            store.preserve = originalPreserve;
        }
    });

    it('requires a valid password hash for POST and an access cookie for GET', async () => {
        await writeOutputFile(validFilename);
        process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;

        const missingHash = await POST(
            jsonRequest({
                action: 'preserve',
                filenames: [validFilename]
            })
        );
        assert.equal(missingHash.status, 401);

        const invalidHash = await POST(
            jsonRequest({
                action: 'preserve',
                filenames: [validFilename],
                passwordHash: '0'.repeat(64)
            })
        );
        assert.equal(invalidHash.status, 401);

        const authorizedPost = await POST(
            jsonRequest({
                action: 'preserve',
                filenames: [validFilename],
                passwordHash: sha256(PAGE_PASSWORD_FIXTURE)
            })
        );
        assert.equal(authorizedPost.status, 200);

        const missingCookie = await GET(new NextRequest('http://localhost/api/image-retention'));
        assert.equal(missingCookie.status, 401);

        const accessCookie = createAccessToken(PAGE_PASSWORD_FIXTURE);
        const authorizedGet = await GET(
            new NextRequest('http://localhost/api/image-retention', {
                headers: { Cookie: `gptImageAccess=${accessCookie}` }
            })
        );
        assert.equal(authorizedGet.status, 200);
    });
});

function jsonRequest(body: unknown): NextRequest {
    return new NextRequest('http://localhost/api/image-retention', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body)
    });
}

async function outputDirectory(): Promise<string> {
    const directory = path.join(tempDir, 'generated-images');
    await mkdir(directory, { recursive: true });
    return directory;
}

async function writeOutputFile(filename: string): Promise<void> {
    await writeFile(path.join(await outputDirectory(), filename), 'image');
}

function restoreProcessEnv(snapshot: NodeJS.ProcessEnv): void {
    for (const key of Object.keys(process.env)) {
        if (!(key in snapshot)) delete process.env[key];
    }
    for (const [key, value] of Object.entries(snapshot)) {
        process.env[key] = value;
    }
}

function sha256(value: string): string {
    return crypto.createHash('sha256').update(value).digest('hex');
}