Skip to content

Commit 491885b

Browse files
committed
feat: 避免删除配置后 Gist 变空,新增占位文件兜底并回传同步状态
- 删除 artifact 时新增空仓库兜底文件 `.sub-store-placeholder`,避免 GitHub/GitLab Gist/代码片段因无文件而报错 - `syncToGist` 支持可选 `emptyFileFallback`,默认写入上述占位文件,并在上传真实文件时自动移除 - `Gist.upload` 增加空文件兜底元信息上报(created/removed/retained),兼容 GitHub 与 GitLab 的文件增删改造 - 恢复配置时会跳过占位文件,artifact 名称校验也排除该占位文件名 - 删除接口返回值改为包含远端同步状态(成功/失败/占位文件保留) - 补充 `gist.spec.js` 单测,覆盖占位文件创建、移除、默认兜底参数是否透传
1 parent 3ac78bd commit 491885b

4 files changed

Lines changed: 323 additions & 40 deletions

File tree

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sub-store",
3-
"version": "2.22.21",
3+
"version": "2.22.22",
44
"description": "Advanced Subscription Manager for QX, Loon, Surge, Stash and Shadowrocket.",
55
"main": "src/main.js",
66
"scripts": {

backend/src/restful/artifacts.js

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ import {
2121
import Gist from '@/utils/gist';
2222
import { archiveArtifact } from '@/utils/archive';
2323

24+
const ARTIFACT_GIST_PLACEHOLDER_FILENAME = '.sub-store-placeholder';
25+
const ARTIFACT_GIST_PLACEHOLDER_CONTENT = [
26+
'Sub-Store placeholder',
27+
'This file keeps the Gist alive when all sync configuration files are deleted.',
28+
].join('\n');
29+
2430
export default function register($app) {
2531
// Initialization
2632
if (!$.read(ARTIFACTS_KEY)) $.write({}, ARTIFACTS_KEY);
@@ -62,6 +68,10 @@ async function restoreArtifacts(_, res) {
6268
Object.keys(gist.files).map((key) => {
6369
const filename = gist.files[key]?.filename;
6470
if (filename) {
71+
if (isArtifactGistPlaceholder(filename)) {
72+
$.info(`忽略 Gist 占位文件: ${filename}`);
73+
return;
74+
}
6575
if (encodeURIComponent(filename) !== filename) {
6676
$.error(`文件名 ${filename} 未编码 不保存`);
6777
failed.push(filename);
@@ -188,8 +198,8 @@ async function deleteArtifact(req, res) {
188198
if (shouldArchiveDeletion(req.query.mode)) {
189199
archiveArtifact(name);
190200
}
191-
await deleteArtifactItem(name);
192-
success(res);
201+
const result = await deleteArtifactItem(name);
202+
success(res, result);
193203
} catch (err) {
194204
$.error(`无法删除远程配置:${req.params.name},原因:${err}`);
195205
failed(
@@ -208,7 +218,10 @@ async function deleteArtifact(req, res) {
208218
}
209219

210220
function validateArtifactName(name) {
211-
return /^[a-zA-Z0-9._-]*$/.test(name);
221+
return (
222+
/^[a-zA-Z0-9._-]*$/.test(name) &&
223+
!isArtifactGistPlaceholder(name)
224+
);
212225
}
213226

214227
function createArtifactItem(artifact) {
@@ -241,6 +254,10 @@ async function deleteArtifactItem(name) {
241254
`Artifact ${name} does not exist!`,
242255
);
243256
}
257+
const remote = {
258+
attempted: false,
259+
status: 'not_attempted',
260+
};
244261
if (artifact.updated) {
245262
const files = {};
246263
files[encodeURIComponent(artifact.name)] = {
@@ -251,15 +268,30 @@ async function deleteArtifactItem(name) {
251268
content: '',
252269
};
253270
}
271+
remote.attempted = true;
254272
try {
255-
await syncToGist(files);
273+
const resp = await syncToGist(files);
274+
const fallback = resp.subStoreUploadMeta?.emptyFileFallback;
275+
remote.status =
276+
fallback?.status === 'created' ||
277+
fallback?.status === 'retained'
278+
? 'placeholder_retained'
279+
: 'deleted';
280+
if (fallback?.filename) {
281+
remote.placeholderFilename = fallback.filename;
282+
}
256283
} catch (error) {
284+
remote.status = 'failed';
285+
remote.message = `${error.message ?? error}`;
257286
$.error(`Function syncToGist: ${name} : ${error}`);
258287
}
259288
}
260289
deleteByName(allArtifacts, name);
261290
$.write(allArtifacts, ARTIFACTS_KEY);
262-
return artifact;
291+
return {
292+
artifact,
293+
remote,
294+
};
263295
}
264296

265297
function shouldArchiveDeletion(mode) {
@@ -275,7 +307,18 @@ function shouldArchiveDeletion(mode) {
275307
);
276308
}
277309

278-
async function syncToGist(files) {
310+
function isArtifactGistPlaceholder(name) {
311+
return name === ARTIFACT_GIST_PLACEHOLDER_FILENAME;
312+
}
313+
314+
function getArtifactGistEmptyFileFallback() {
315+
return {
316+
filename: ARTIFACT_GIST_PLACEHOLDER_FILENAME,
317+
content: ARTIFACT_GIST_PLACEHOLDER_CONTENT,
318+
};
319+
}
320+
321+
async function syncToGist(files, options = {}) {
279322
const { gistToken, syncPlatform } = $.read(SETTINGS_KEY);
280323
if (!gistToken) {
281324
return Promise.reject('未设置 GitHub Token!');
@@ -285,7 +328,11 @@ async function syncToGist(files) {
285328
key: ARTIFACT_REPOSITORY_KEY,
286329
syncPlatform,
287330
});
288-
const res = await manager.upload(files);
331+
const res = await manager.upload(files, {
332+
...options,
333+
emptyFileFallback:
334+
options.emptyFileFallback ?? getArtifactGistEmptyFileFallback(),
335+
});
289336
let body = {};
290337
try {
291338
body = JSON.parse(res.body);

backend/src/test/utils/gist.spec.js

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { expect } from 'chai';
22
import { describe, it } from 'mocha';
33

4-
import { getGithubGistBaseURL } from '@/utils/gist';
4+
import $ from '@/core/app';
5+
import { SETTINGS_KEY } from '@/constants';
6+
import Gist, { getGithubGistBaseURL } from '@/utils/gist';
7+
import { syncToGist } from '@/restful/artifacts';
58

69
describe('Gist GitHub API URL', function () {
710
it('uses the default GitHub API URL when unset', function () {
@@ -32,4 +35,163 @@ describe('Gist GitHub API URL', function () {
3235
}),
3336
).to.equal('https://litegist.example.com/api');
3437
});
38+
39+
it('keeps an existing Gist alive with a fallback file when a delete would empty it', async function () {
40+
const manager = Object.create(Gist.prototype);
41+
let patchBody;
42+
43+
manager.syncPlatform = '';
44+
manager.locate = async () => ({
45+
id: 'gist-id',
46+
files: {
47+
artifact: {
48+
filename: 'artifact',
49+
},
50+
},
51+
});
52+
manager.http = {
53+
patch: async ({ body }) => {
54+
patchBody = JSON.parse(body);
55+
return {
56+
body: JSON.stringify({
57+
files: {
58+
'.sub-store-placeholder': {
59+
filename: '.sub-store-placeholder',
60+
},
61+
},
62+
}),
63+
};
64+
},
65+
};
66+
67+
const response = await manager.upload(
68+
{
69+
artifact: {
70+
content: '',
71+
},
72+
},
73+
{
74+
emptyFileFallback: {
75+
filename: '.sub-store-placeholder',
76+
content: 'placeholder',
77+
},
78+
},
79+
);
80+
81+
expect(patchBody.files.artifact).to.equal(null);
82+
expect(patchBody.files['.sub-store-placeholder']).to.deep.equal({
83+
content: 'placeholder',
84+
});
85+
expect(response.subStoreUploadMeta.emptyFileFallback).to.deep.equal({
86+
status: 'created',
87+
filename: '.sub-store-placeholder',
88+
});
89+
});
90+
91+
it('removes the fallback file when a real file is uploaded later', async function () {
92+
const manager = Object.create(Gist.prototype);
93+
let patchBody;
94+
95+
manager.syncPlatform = '';
96+
manager.locate = async () => ({
97+
id: 'gist-id',
98+
files: {
99+
'.sub-store-placeholder': {
100+
filename: '.sub-store-placeholder',
101+
},
102+
},
103+
});
104+
manager.http = {
105+
patch: async ({ body }) => {
106+
patchBody = JSON.parse(body);
107+
return {
108+
body: JSON.stringify({
109+
files: {
110+
artifact: {
111+
filename: 'artifact',
112+
},
113+
},
114+
}),
115+
};
116+
},
117+
};
118+
119+
const response = await manager.upload(
120+
{
121+
artifact: {
122+
content: 'real content',
123+
},
124+
},
125+
{
126+
emptyFileFallback: {
127+
filename: '.sub-store-placeholder',
128+
content: 'placeholder',
129+
},
130+
},
131+
);
132+
133+
expect(patchBody.files.artifact).to.deep.equal({
134+
content: 'real content',
135+
});
136+
expect(patchBody.files['.sub-store-placeholder']).to.equal(null);
137+
expect(response.subStoreUploadMeta.emptyFileFallback).to.deep.equal({
138+
status: 'removed',
139+
filename: '.sub-store-placeholder',
140+
});
141+
});
142+
143+
it('passes the artifact placeholder fallback to syncToGist by default', async function () {
144+
const originalRead = $.read.bind($);
145+
const originalWrite = $.write.bind($);
146+
const originalUpload = Gist.prototype.upload;
147+
let capturedOptions;
148+
let writtenSettings;
149+
150+
$.read = (key) => {
151+
if (key === SETTINGS_KEY) {
152+
return {
153+
gistToken: 'token',
154+
};
155+
}
156+
return originalRead(key);
157+
};
158+
$.write = (data, key) => {
159+
if (key === SETTINGS_KEY) {
160+
writtenSettings = data;
161+
return true;
162+
}
163+
return originalWrite(data, key);
164+
};
165+
Gist.prototype.upload = async function (_, options) {
166+
capturedOptions = options;
167+
return {
168+
body: JSON.stringify({
169+
html_url: 'https://gist.example.com/sub-store',
170+
files: {},
171+
}),
172+
};
173+
};
174+
175+
try {
176+
await syncToGist({
177+
artifact: {
178+
content: 'real content',
179+
},
180+
});
181+
} finally {
182+
$.read = originalRead;
183+
$.write = originalWrite;
184+
Gist.prototype.upload = originalUpload;
185+
}
186+
187+
expect(capturedOptions.emptyFileFallback).to.deep.equal({
188+
filename: '.sub-store-placeholder',
189+
content:
190+
'Sub-Store placeholder\nThis file keeps the Gist alive when all sync configuration files are deleted.',
191+
});
192+
expect(writtenSettings.artifactStore).to.equal(
193+
'https://gist.example.com/sub-store',
194+
);
195+
expect(writtenSettings.artifactStoreStatus).to.equal('VALID');
196+
});
35197
});

0 commit comments

Comments
 (0)