cortico-world-starpost 0.4.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - 支持批量生成独立的一次性注册码,提供私有 TXT、CSV 和仅含哈希的导入文件。
6
+ - 注册码核销与账号创建使用同一事务,支持同一安装重试、批次状态查询和撤销;重复导入不会恢复已用码。
7
+ - 保留原管理员码及客户端注册界面兼容。
8
+
3
9
  ## 0.4.0
4
10
 
5
11
  - 服务端改用内置 SQLite、WAL 与事务,按记录保存账号、消息和社交数据,避免重写整个 JSON 文件。
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 部署中转服务
6
6
 
7
- 服务端需要 Node.js 22.16+,使用内置 SQLite,无需另装数据库进程或 npm 运行依赖。注册功能需要设置至少 24 字符的 `STARPOST_REGISTRATION_SECRET`。服务端为新账号顺序分配聊天 ID,身份凭据由扩展在本机生成并保存。旧版手工账号仍可在 `BOT_KEYS` 中保留:
7
+ 服务端需要 Node.js 22.16+,使用内置 SQLite,无需另装数据库进程或 npm 运行依赖。注册可使用管理员码或独立的一次性码;管理员码 `STARPOST_REGISTRATION_SECRET` 至少 24 字符。服务端为新账号顺序分配聊天 ID,身份凭据由扩展在本机生成并保存。旧版手工账号仍可在 `BOT_KEYS` 中保留:
8
8
 
9
9
  ```sh
10
10
  export BOT_KEYS='{"10001":"replace-with-long-random-secret-a"}'
@@ -22,6 +22,33 @@ node server/server.mjs
22
22
 
23
23
  用 HTTPS 反向代理把公网域名或公网 IP 转发至 `127.0.0.1:8787`,开放 `/rpc` 和 `/register`。`deploy/starpost.service`、`deploy/nginx-ip.conf` 和 `deploy/renew-hook.sh` 提供了 systemd、Nginx 与 Certbot 的部署模板;将 Nginx 模板中的 `__STARPOST_IP__` 换成实际 IP。公网 IP 证书有效期较短,必须启用 Certbot 自动续期,并在续期后重载 Nginx。
24
24
 
25
+ ### 批量一次性注册码
26
+
27
+ 管理员可生成任意 1 至 10000 个独立码,例如生成 100 个用于测试:
28
+
29
+ ```sh
30
+ node server/invitations.mjs generate --count 100 --output /srv/starpost-private/test-codes
31
+ ```
32
+
33
+ 生成 `.txt`、`.csv` 和 `.hashes.json` 三个文件。明文只在 TXT、CSV 中;每个码含 192 位随机数,默认无到期时间,每码最多注册一个新账号。不得把明文或哈希导入文件提交到公开仓库。将 `.hashes.json` 放到服务器服务账户可读的位置;先停止服务,再以服务账户导入,完成后启动服务:
34
+
35
+ ```sh
36
+ sudo systemctl stop starpost
37
+ sudo -u starpost node server/invitations.mjs import --db /var/lib/starpost/data.sqlite --manifest /etc/starpost/test-codes.hashes.json
38
+ sudo systemctl start starpost
39
+ ```
40
+
41
+ 工具只输出批次 ID、文件路径和数量,不在终端打印明文。生成命令拒绝覆盖已有文件;重复导入不会重置已使用或已撤销的码。数据库保存哈希和使用状态;核销与创建账号在同一个事务内完成,同一安装凭原身份重试不会重复占号。已注册账号用另一个未使用的码重试,也不会消耗新码。
42
+
43
+ 可用以下命令查看批次状态;撤销批次时同样先停止服务,以服务账户执行,随后启动服务:
44
+
45
+ ```sh
46
+ sudo -u starpost node server/invitations.mjs status --db /var/lib/starpost/data.sqlite --batch <批次ID>
47
+ sudo -u starpost node server/invitations.mjs revoke --db /var/lib/starpost/data.sqlite --batch <批次ID>
48
+ ```
49
+
50
+ 撤销只阻止继续使用该批次注册码,不会删除已注册账号。原管理员码仍可重复注册新账号;如需仅允许独立码,可在导入批次后移除管理员码配置。
51
+
25
52
  ### 存储与旧版迁移
26
53
 
27
54
  - 账号、好友、群成员、消息、动态、评论和漂流瓶按记录保存在 SQLite 中,使用 WAL 和事务;每次操作只更新相关记录。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-starpost",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "星邮:让 Cortico bot 通过独立中转服务私聊、群聊、交换漂流瓶并分享动态的社交 World 扩展",
6
6
  "license": "MIT",
@@ -0,0 +1,90 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { StarpostStorage } from './storage.mjs';
6
+
7
+ const validBatch = value => typeof value === 'string' && /^[0-9a-f-]{36}$/.test(value);
8
+
9
+ export function generateInvitations({ count = 100, outputPrefix, label = '测试注册码' }) {
10
+ if (!Number.isInteger(count) || count < 1 || count > 10000) throw new Error('数量须为 1 至 10000');
11
+ if (!outputPrefix || typeof label !== 'string' || label.length > 80) throw new Error('请提供输出路径及不超过 80 字的备注');
12
+ const prefix = resolve(outputPrefix), batchId = randomUUID(), createdAt = new Date().toISOString();
13
+ const files = { text: `${prefix}.txt`, csv: `${prefix}.csv`, manifest: `${prefix}.hashes.json` };
14
+ if (Object.values(files).some(path => existsSync(path))) throw new Error('输出文件已存在,拒绝覆盖注册码');
15
+ mkdirSync(dirname(prefix), { recursive: true, mode: 0o700 });
16
+ const codes = new Set();
17
+ while (codes.size < count) codes.add(`SPT-${randomBytes(24).toString('hex')}`);
18
+ const values = [...codes];
19
+ // Only local delivery files contain plaintext; the import manifest contains hashes.
20
+ const manifest = { version: 1, batchId, label, createdAt, hashes: values.map(code => createHash('sha256').update(code).digest('hex')) };
21
+ writeFileSync(files.text, `${values.join('\n')}\n`, { flag: 'wx', mode: 0o600 });
22
+ const csv = ['序号,注册码,最多注册账号数,批次', ...values.map((code, index) => `${index + 1},${code},1,${batchId}`)].join('\r\n');
23
+ writeFileSync(files.csv, `\uFEFF${csv}\r\n`, { flag: 'wx', mode: 0o600 });
24
+ writeFileSync(files.manifest, JSON.stringify(manifest, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
25
+ return { batchId, count, files };
26
+ }
27
+
28
+ export function importInvitations(store, manifest) {
29
+ if (manifest?.version !== 1 || !validBatch(manifest.batchId) || typeof manifest.label !== 'string' || manifest.label.length > 80 ||
30
+ typeof manifest.createdAt !== 'string' || !Number.isFinite(Date.parse(manifest.createdAt)) || !Array.isArray(manifest.hashes) ||
31
+ manifest.hashes.length < 1 || manifest.hashes.length > 10000 || manifest.hashes.some(value => typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value)) ||
32
+ new Set(manifest.hashes).size !== manifest.hashes.length) throw new Error('注册码导入文件不合法');
33
+ let imported = 0;
34
+ store.transaction(() => {
35
+ for (const hash of manifest.hashes) {
36
+ const existing = store.get('registrationCodes', hash);
37
+ if (existing) {
38
+ if (existing.batchId !== manifest.batchId) throw new Error('注册码与其他批次冲突');
39
+ continue; // Reimporting must never reset redeemed or revoked codes.
40
+ }
41
+ store.put('registrationCodes', hash, { batchId: manifest.batchId, label: manifest.label, createdAt: manifest.createdAt, usedBy: null, usedAt: null, revoked: false });
42
+ imported++;
43
+ }
44
+ });
45
+ return { batchId: manifest.batchId, imported, ...invitationStatus(store, manifest.batchId) };
46
+ }
47
+
48
+ export function invitationStatus(store, batchId) {
49
+ if (batchId !== undefined && !validBatch(batchId)) throw new Error('批次 ID 不合法');
50
+ const values = store.list('registrationCodes', batchId === undefined ? '1' : 'owner=?', batchId === undefined ? [] : [batchId], { limit: -1 });
51
+ return { total: values.length, unused: values.filter(value => !value.usedBy && !value.revoked).length, used: values.filter(value => Boolean(value.usedBy)).length, revoked: values.filter(value => value.revoked).length };
52
+ }
53
+
54
+ export function revokeInvitations(store, batchId) {
55
+ if (!validBatch(batchId)) throw new Error('批次 ID 不合法');
56
+ store.transaction(() => {
57
+ const rows = store.statement("SELECT key,data FROM documents WHERE collection='registrationCodes' AND owner=?").all(batchId);
58
+ for (const row of rows) store.put('registrationCodes', row.key, { ...JSON.parse(row.data), revoked: true });
59
+ });
60
+ return { batchId, ...invitationStatus(store, batchId) };
61
+ }
62
+
63
+ function options(args, allowed) {
64
+ const result = {};
65
+ for (let i = 0; i < args.length; i += 2) {
66
+ if (!args[i].startsWith('--') || !allowed.includes(args[i].slice(2)) || args[i + 1] === undefined || args[i + 1].startsWith('--')) throw new Error('命令参数不合法');
67
+ const key = args[i].slice(2);
68
+ if (result[key] !== undefined) throw new Error('命令参数重复');
69
+ result[key] = args[i + 1];
70
+ }
71
+ return result;
72
+ }
73
+
74
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
75
+ const command = process.argv[2];
76
+ if (command === 'generate') {
77
+ const args = options(process.argv.slice(3), ['count', 'output', 'label']);
78
+ console.log(JSON.stringify(generateInvitations({ count: args.count === undefined ? 100 : Number(args.count), outputPrefix: args.output, label: args.label })));
79
+ } else if (['import', 'status', 'revoke'].includes(command)) {
80
+ const args = options(process.argv.slice(3), command === 'import' ? ['db', 'manifest', 'media-dir'] : ['db', 'batch', 'media-dir']);
81
+ if (!args.db || !existsSync(args.db)) throw new Error('请提供已存在的数据库路径 --db');
82
+ if (command === 'import' && !args.manifest) throw new Error('请提供哈希文件路径 --manifest');
83
+ const store = new StarpostStorage({ dbFile: args.db, mediaDir: args['media-dir'] });
84
+ try {
85
+ const result = command === 'import' ? importInvitations(store, JSON.parse(readFileSync(args.manifest, 'utf8'))) :
86
+ command === 'revoke' ? revokeInvitations(store, args.batch) : { batchId: args.batch, ...invitationStatus(store, args.batch) };
87
+ console.log(JSON.stringify(result));
88
+ } finally { store.close(); }
89
+ } else throw new Error('用法:invitations.mjs generate|import|status|revoke;注册码明文只写入 generate 的 --output 文件');
90
+ }
package/server/server.mjs CHANGED
@@ -39,13 +39,13 @@ export function createStarpostServer({ file = './starpost-data.json', dbFile, me
39
39
  for (const key of Object.values(keys)) if (typeof key !== 'string' || key.length < 24) fail('每个 bot 密钥至少 24 字符');
40
40
  if (new Set(Object.values(keys)).size !== known.size) fail('不同 bot 必须使用不同密钥');
41
41
  if (registrationSecret && registrationSecret.length < 24) fail('注册码至少 24 字符');
42
- if (!known.size && !registrationSecret) fail('必须设置 BOT_KEYS 或注册码');
43
42
  const legacyFile = file.endsWith('.json') ? file : undefined;
44
43
  const store = new StarpostStorage({ dbFile: dbFile ?? (legacyFile ? file.replace(/\.json$/, '.sqlite') : file), legacyFile, mediaDir });
45
44
  const accountIds = () => store.statement("SELECT key FROM documents WHERE collection='accounts'").all().map(row => row.key);
46
45
  const knownIds = () => [...new Set([...known, ...accountIds()])];
47
46
  const isKnown = (who) => known.has(who) || store.get('accounts', who) !== undefined;
48
47
  try {
48
+ if (!known.size && !registrationSecret && !store.count('accounts') && !store.count('registrationCodes')) fail('必须设置 BOT_KEYS 或导入注册码');
49
49
  for (const botId of accountIds()) { id(botId); if (known.has(botId)) fail('注册账号与 BOT_KEYS 的 ID 冲突'); }
50
50
  const next = store.get('meta', 'nextBotId');
51
51
  store.transaction(() => store.put('meta', 'nextBotId', Math.max(10001, Number.isSafeInteger(next) ? next : 10001, ...knownIds().map(x => Number(x) + 1))));
@@ -61,12 +61,16 @@ export function createStarpostServer({ file = './starpost-data.json', dbFile, me
61
61
  }
62
62
  const profile = (who) => ({ botId: who, nickname: '', bio: '', discoverable: true, ...store.get('profiles', who) });
63
63
  function register(input) {
64
- if (!registrationSecret) fail('此中转服务未开放注册', 503);
65
- if (!same(input?.code, registrationSecret)) fail('注册码错误', 403);
64
+ if (!registrationSecret && !store.count('registrationCodes')) fail('此中转服务未开放注册', 503);
65
+ const code = str(input?.code, 128);
66
+ const legacyCode = Boolean(registrationSecret && same(code, registrationSecret));
67
+ const codeHash = hash(code), invitation = legacyCode ? undefined : store.get('registrationCodes', codeHash);
68
+ if (!legacyCode && (!invitation || invitation.revoked)) fail('注册码无效或已停用', 403);
66
69
  const installationId = str(input?.installationId, 80);
67
70
  const token = str(input?.token, 128);
68
71
  if (!/^[0-9a-f]{8}-[0-9a-f-]{27,36}$/i.test(installationId) || !/^[a-f0-9]{64}$/i.test(token)) fail('注册凭据格式不合法');
69
72
  const existing = store.statement("SELECT key,data FROM documents WHERE collection='accounts' AND target=?").get(installationId);
73
+ if (invitation?.usedBy && invitation.usedBy !== existing?.key) fail('注册码已经使用', 403);
70
74
  if (existing) {
71
75
  if (!same(hash(token), JSON.parse(existing.data).tokenHash)) fail('该安装已注册', 409);
72
76
  return { botId: existing.key, profile: profile(existing.key) };
@@ -74,6 +78,7 @@ export function createStarpostServer({ file = './starpost-data.json', dbFile, me
74
78
  const next = store.get('meta', 'nextBotId'), botId = id(String(next));
75
79
  store.put('meta', 'nextBotId', next + 1);
76
80
  store.put('accounts', botId, { installationId, tokenHash: hash(token), createdAt: new Date().toISOString() });
81
+ if (invitation) store.put('registrationCodes', codeHash, { ...invitation, usedBy: botId, usedAt: new Date().toISOString() });
77
82
  return { botId, profile: profile(botId) };
78
83
  }
79
84
  const friends = (who) => store.friends(who);
@@ -94,6 +94,7 @@ export class StarpostStorage {
94
94
  if (collection === 'bottles') target = v.pickedBy ?? '';
95
95
  if (collection === 'posts') kind = v.deleted ? 'deleted' : 'active';
96
96
  if (collection === 'comments') target = v.postId;
97
+ if (collection === 'registrationCodes') owner = v.batchId;
97
98
  return [owner, target, kind, scope, v.at ?? v.createdAt ?? ''];
98
99
  }
99
100
  remove(collection, key) { this.statement('DELETE FROM documents WHERE collection=? AND key=?').run(collection, String(key)); }