@swimmingliu/autovpn 1.8.0 → 2.0.2
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/dist/artifacts/list.js +48 -6
- package/dist/cli/accounts-commands.js +304 -0
- package/dist/cli/commands/index.js +6 -0
- package/dist/cli/native-commands.js +10 -0
- package/dist/cli/output.js +1 -0
- package/dist/config/profile.js +119 -5
- package/dist/events/schema.js +2 -0
- package/dist/pipeline/apk-discovery.js +504 -0
- package/dist/pipeline/brand-cache.js +126 -0
- package/dist/pipeline/brand-protocol.js +210 -0
- package/dist/pipeline/credential-source.js +1406 -0
- package/dist/pipeline/extract.js +55 -2
- package/dist/pipeline/orchestrator.js +88 -9
- package/dist/pool/account-store.js +484 -0
- package/dist/pool/brands.js +110 -0
- package/dist/pool/csv.js +388 -0
- package/dist/server/http.js +25 -0
- package/dist/server/runtime.js +76 -2
- package/dist/server/web-adapter.js +18 -2
- package/dist/web/renderer/THIRD_PARTY_NOTICES.txt +345 -0
- package/dist/web/renderer/analytics.bundle.css +2 -0
- package/dist/web/renderer/analytics.bundle.js +66 -0
- package/dist/web/renderer/app.js +385 -80
- package/dist/web/renderer/appearance.js +76 -0
- package/dist/web/renderer/assets/vpn-auto-logo-v3.png +0 -0
- package/dist/web/renderer/i18n.js +1 -1
- package/dist/web/renderer/icons.js +38 -0
- package/dist/web/renderer/index.html +24 -2
- package/dist/web/renderer/state.js +0 -1
- package/dist/web/renderer/styles.css +584 -48
- package/dist/web/renderer/tables.js +208 -0
- package/dist/web/renderer/views.js +483 -255
- package/dist/web/renderer/workspace.css +381 -0
- package/package.json +4 -3
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account pool: the credentials every official-site login draws from.
|
|
3
|
+
*
|
|
4
|
+
* SQLite rather than a profile section, for three reasons the profile cannot
|
|
5
|
+
* serve: the pool is written by the pipeline while a run is in flight (usage
|
|
6
|
+
* counters, failure marks), it outlives any one profile, and it has to answer
|
|
7
|
+
* "how many usable accounts does brand X have" without parsing TOML.
|
|
8
|
+
*
|
|
9
|
+
* Retired accounts are soft-deleted. The row stays so the history and the
|
|
10
|
+
* `(brand, account)` uniqueness survive a re-import, but `deleted_at` takes it
|
|
11
|
+
* out of rotation permanently until an operator revives it.
|
|
12
|
+
*/
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { resolveUserRuntimeRoot } from '../runtime/paths.js';
|
|
17
|
+
import { brandSite, normalizeBrand } from './brands.js';
|
|
18
|
+
import { RETIRED_STATUSES, normalizeExpiry } from './csv.js';
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
21
|
+
export function resolveAccountsDbPath(env = process.env) {
|
|
22
|
+
const override = String(env.VPN_AUTOMATION_ACCOUNTS_DB ?? '').trim();
|
|
23
|
+
if (override)
|
|
24
|
+
return path.resolve(override);
|
|
25
|
+
return path.join(resolveUserRuntimeRoot(env), 'accounts.db');
|
|
26
|
+
}
|
|
27
|
+
/** Keep the first and last character; a 6-char account still reads as itself. */
|
|
28
|
+
export function maskAccount(value) {
|
|
29
|
+
const text = String(value ?? '');
|
|
30
|
+
if (text.length <= 2)
|
|
31
|
+
return '*'.repeat(text.length);
|
|
32
|
+
if (text.length <= 4)
|
|
33
|
+
return `${text[0]}${'*'.repeat(text.length - 2)}${text[text.length - 1]}`;
|
|
34
|
+
return `${text.slice(0, 2)}${'*'.repeat(Math.min(6, text.length - 4))}${text.slice(-2)}`;
|
|
35
|
+
}
|
|
36
|
+
function isoNow(now) {
|
|
37
|
+
return now.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Today as the operator's local calendar day.
|
|
41
|
+
*
|
|
42
|
+
* A membership `expires_at` is a calendar date the vendor states, not an
|
|
43
|
+
* instant, so it must be compared against the local day: with the UTC day, an
|
|
44
|
+
* account that lapsed yesterday evening Beijing time is still leaseable until
|
|
45
|
+
* 08:00 the next morning.
|
|
46
|
+
*/
|
|
47
|
+
function isoDay(now) {
|
|
48
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
49
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
50
|
+
}
|
|
51
|
+
const SCHEMA = `
|
|
52
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
53
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
54
|
+
brand TEXT NOT NULL,
|
|
55
|
+
account TEXT NOT NULL,
|
|
56
|
+
password TEXT NOT NULL,
|
|
57
|
+
official_site TEXT NOT NULL DEFAULT '',
|
|
58
|
+
expires_at TEXT NOT NULL DEFAULT '',
|
|
59
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
60
|
+
status_reason TEXT NOT NULL DEFAULT '',
|
|
61
|
+
note TEXT NOT NULL DEFAULT '',
|
|
62
|
+
created_at TEXT NOT NULL,
|
|
63
|
+
updated_at TEXT NOT NULL,
|
|
64
|
+
last_used_at TEXT NOT NULL DEFAULT '',
|
|
65
|
+
use_count INTEGER NOT NULL DEFAULT 0,
|
|
66
|
+
success_count INTEGER NOT NULL DEFAULT 0,
|
|
67
|
+
fail_count INTEGER NOT NULL DEFAULT 0,
|
|
68
|
+
deleted_at TEXT,
|
|
69
|
+
UNIQUE (brand, account)
|
|
70
|
+
);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS accounts_rotation ON accounts (brand, status, deleted_at, last_used_at);
|
|
72
|
+
CREATE TABLE IF NOT EXISTS pool_events (
|
|
73
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
74
|
+
account_id INTEGER NOT NULL,
|
|
75
|
+
at TEXT NOT NULL,
|
|
76
|
+
kind TEXT NOT NULL,
|
|
77
|
+
detail TEXT NOT NULL DEFAULT ''
|
|
78
|
+
);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS pool_events_account ON pool_events (account_id, id);
|
|
80
|
+
`;
|
|
81
|
+
export class AccountStore {
|
|
82
|
+
db;
|
|
83
|
+
now;
|
|
84
|
+
readOnly;
|
|
85
|
+
dbPath;
|
|
86
|
+
constructor(options = {}) {
|
|
87
|
+
this.dbPath = options.dbPath ?? resolveAccountsDbPath(options.env ?? process.env);
|
|
88
|
+
this.now = options.now ?? (() => new Date());
|
|
89
|
+
this.readOnly = options.readOnly === true;
|
|
90
|
+
const inMemory = this.dbPath === ':memory:';
|
|
91
|
+
if (!inMemory) {
|
|
92
|
+
// The pool holds plaintext credentials in the main file *and* in the WAL
|
|
93
|
+
// while it is open. Create the directory owner-only, and create the
|
|
94
|
+
// database plus its sidecars under a restrictive umask so a world-readable
|
|
95
|
+
// `-wal` never appears in the first place. The umask is restored before
|
|
96
|
+
// anything else in the process runs.
|
|
97
|
+
fs.mkdirSync(path.dirname(this.dbPath), { recursive: true, mode: 0o700 });
|
|
98
|
+
}
|
|
99
|
+
const previousUmask = inMemory ? null : process.umask(0o077);
|
|
100
|
+
try {
|
|
101
|
+
this.db = new DatabaseSync(this.dbPath);
|
|
102
|
+
this.db.exec('PRAGMA journal_mode = WAL;');
|
|
103
|
+
this.db.exec('PRAGMA busy_timeout = 5000;');
|
|
104
|
+
this.db.exec(SCHEMA);
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
if (previousUmask !== null)
|
|
108
|
+
process.umask(previousUmask);
|
|
109
|
+
}
|
|
110
|
+
if (!inMemory) {
|
|
111
|
+
// A file created by an older build, or by an operator's editor, may still
|
|
112
|
+
// be world-readable. The sidecars persist for the life of the connection,
|
|
113
|
+
// so tightening them here holds until the next open.
|
|
114
|
+
for (const file of [this.dbPath, `${this.dbPath}-wal`, `${this.dbPath}-shm`]) {
|
|
115
|
+
try {
|
|
116
|
+
fs.chmodSync(file, 0o600);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// A read-only mount or a non-POSIX host still gets a working pool.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
close() {
|
|
125
|
+
this.db.close();
|
|
126
|
+
}
|
|
127
|
+
event(accountId, kind, detail = '') {
|
|
128
|
+
this.db
|
|
129
|
+
.prepare('INSERT INTO pool_events (account_id, at, kind, detail) VALUES (?, ?, ?, ?)')
|
|
130
|
+
.run(accountId, isoNow(this.now()), kind, detail);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Retire accounts whose membership date has passed.
|
|
134
|
+
*
|
|
135
|
+
* Runs before every read that decides rotation, so an account expires
|
|
136
|
+
* without anyone having to notice: the date in the file is the source of
|
|
137
|
+
* truth and the pool enforces it.
|
|
138
|
+
*/
|
|
139
|
+
expireDue() {
|
|
140
|
+
if (this.readOnly)
|
|
141
|
+
return 0;
|
|
142
|
+
const today = isoDay(this.now());
|
|
143
|
+
const due = this.db
|
|
144
|
+
.prepare(`SELECT id FROM accounts
|
|
145
|
+
WHERE deleted_at IS NULL AND status = 'active' AND expires_at != '' AND expires_at < ?`)
|
|
146
|
+
.all(today);
|
|
147
|
+
if (due.length === 0)
|
|
148
|
+
return 0;
|
|
149
|
+
const stamp = isoNow(this.now());
|
|
150
|
+
const mark = this.db.prepare(`UPDATE accounts SET status = 'expired', status_reason = 'membership expired', deleted_at = ?, updated_at = ? WHERE id = ?`);
|
|
151
|
+
for (const row of due) {
|
|
152
|
+
mark.run(stamp, stamp, row.id);
|
|
153
|
+
this.event(row.id, 'expired', 'membership expired');
|
|
154
|
+
}
|
|
155
|
+
return due.length;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Insert or refresh rows from an import file.
|
|
159
|
+
*
|
|
160
|
+
* An import is the operator asserting current truth, so a changed password
|
|
161
|
+
* updates the row and revives it. That is deliberately different from the
|
|
162
|
+
* runtime path (`markInvalid`), where a rejected login retires the row: one
|
|
163
|
+
* is "here is the new password", the other is "the stored one no longer works".
|
|
164
|
+
*/
|
|
165
|
+
importRecords(records) {
|
|
166
|
+
const outcome = { inserted: 0, updated: 0, rotated: 0, revived: 0, unchanged: 0, skipped: 0 };
|
|
167
|
+
const stamp = isoNow(this.now());
|
|
168
|
+
const find = this.db.prepare('SELECT * FROM accounts WHERE brand = ? AND account = ?');
|
|
169
|
+
const insert = this.db.prepare(`INSERT INTO accounts (brand, account, password, official_site, expires_at, status, note, created_at, updated_at, deleted_at)
|
|
170
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
171
|
+
const update = this.db.prepare(`UPDATE accounts SET password = ?, official_site = ?, expires_at = ?, status = ?, note = ?,
|
|
172
|
+
status_reason = '', updated_at = ?, deleted_at = ? WHERE id = ?`);
|
|
173
|
+
// IMMEDIATE takes the write lock up front. A deferred BEGIN reads first and
|
|
174
|
+
// then fails instantly with 'database is locked' when another process (a
|
|
175
|
+
// running pipeline, a second UI import) committed in between; that path
|
|
176
|
+
// never consults busy_timeout.
|
|
177
|
+
if (!this.readOnly)
|
|
178
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
179
|
+
try {
|
|
180
|
+
for (const record of records) {
|
|
181
|
+
const brand = normalizeBrand(record.brand);
|
|
182
|
+
if (!brand || !record.account || !record.password) {
|
|
183
|
+
outcome.skipped += 1;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const existing = find.get(brand, record.account);
|
|
187
|
+
const retired = RETIRED_STATUSES.includes(record.status);
|
|
188
|
+
if (!existing) {
|
|
189
|
+
const deletedAt = retired ? stamp : null;
|
|
190
|
+
outcome.inserted += 1;
|
|
191
|
+
if (this.readOnly)
|
|
192
|
+
continue;
|
|
193
|
+
const result = insert.run(brand, record.account, record.password, record.official_site || brandSite(brand), record.expires_at, record.status, record.note, stamp, stamp, deletedAt);
|
|
194
|
+
this.event(Number(result.lastInsertRowid), 'imported', record.status);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const rotated = existing.password !== record.password;
|
|
198
|
+
// A column the file omits is "not specified", not "clear it". The
|
|
199
|
+
// template advertises a 3-column CSV, and treating its empty cells as
|
|
200
|
+
// erasures would wipe a known expiry (so the row could never expire
|
|
201
|
+
// again) and the note on every password rotation.
|
|
202
|
+
const expiresAt = record.expires_at || existing.expires_at;
|
|
203
|
+
const note = record.note || existing.note;
|
|
204
|
+
const officialSite = record.official_site || existing.official_site || brandSite(brand);
|
|
205
|
+
const revived = existing.deleted_at !== null && !retired;
|
|
206
|
+
// A stale expiry in the file (or the one already stored) still retires
|
|
207
|
+
// the row: un-retiring an account whose membership lapsed would put a
|
|
208
|
+
// dead credential back into rotation on every re-import.
|
|
209
|
+
const expiredByDate = !retired && Boolean(expiresAt) && expiresAt < isoDay(this.now());
|
|
210
|
+
const status = expiredByDate ? 'expired' : record.status;
|
|
211
|
+
const deletedAt = RETIRED_STATUSES.includes(status) ? stamp : null;
|
|
212
|
+
const changed = rotated ||
|
|
213
|
+
revived ||
|
|
214
|
+
expiredByDate ||
|
|
215
|
+
existing.status !== status ||
|
|
216
|
+
existing.expires_at !== expiresAt ||
|
|
217
|
+
existing.official_site !== officialSite ||
|
|
218
|
+
existing.note !== note;
|
|
219
|
+
if (!changed) {
|
|
220
|
+
outcome.unchanged += 1;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
outcome.updated += 1;
|
|
224
|
+
if (rotated)
|
|
225
|
+
outcome.rotated += 1;
|
|
226
|
+
if (revived && !expiredByDate)
|
|
227
|
+
outcome.revived += 1;
|
|
228
|
+
if (this.readOnly)
|
|
229
|
+
continue;
|
|
230
|
+
update.run(record.password, officialSite, expiresAt, status, note, stamp, deletedAt, existing.id);
|
|
231
|
+
this.event(existing.id, rotated ? 'rotated' : 'updated', record.status);
|
|
232
|
+
}
|
|
233
|
+
if (!this.readOnly)
|
|
234
|
+
this.db.exec('COMMIT');
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
if (!this.readOnly)
|
|
238
|
+
this.db.exec('ROLLBACK');
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
return outcome;
|
|
242
|
+
}
|
|
243
|
+
toPublic(row) {
|
|
244
|
+
return {
|
|
245
|
+
id: row.id,
|
|
246
|
+
brand: row.brand,
|
|
247
|
+
account_masked: maskAccount(row.account),
|
|
248
|
+
official_site: row.official_site,
|
|
249
|
+
expires_at: row.expires_at,
|
|
250
|
+
status: row.status,
|
|
251
|
+
status_reason: row.status_reason,
|
|
252
|
+
note: row.note,
|
|
253
|
+
created_at: row.created_at,
|
|
254
|
+
updated_at: row.updated_at,
|
|
255
|
+
last_used_at: row.last_used_at,
|
|
256
|
+
use_count: row.use_count,
|
|
257
|
+
success_count: row.success_count,
|
|
258
|
+
fail_count: row.fail_count,
|
|
259
|
+
deleted: row.deleted_at !== null
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/** Masked rows for the UI and `accounts list`. Never carries a password. */
|
|
263
|
+
list(filter = {}) {
|
|
264
|
+
this.expireDue();
|
|
265
|
+
const clauses = [];
|
|
266
|
+
const params = [];
|
|
267
|
+
if (!filter.includeDeleted)
|
|
268
|
+
clauses.push('deleted_at IS NULL');
|
|
269
|
+
if (filter.brand) {
|
|
270
|
+
const brand = normalizeBrand(filter.brand);
|
|
271
|
+
if (!brand)
|
|
272
|
+
return [];
|
|
273
|
+
clauses.push('brand = ?');
|
|
274
|
+
params.push(brand);
|
|
275
|
+
}
|
|
276
|
+
if (filter.status && filter.status !== 'all') {
|
|
277
|
+
clauses.push('status = ?');
|
|
278
|
+
params.push(filter.status);
|
|
279
|
+
}
|
|
280
|
+
if (filter.query) {
|
|
281
|
+
clauses.push('(account LIKE ? OR note LIKE ?)');
|
|
282
|
+
params.push(`%${filter.query}%`, `%${filter.query}%`);
|
|
283
|
+
}
|
|
284
|
+
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
285
|
+
const limit = filter.limit && filter.limit > 0 ? `LIMIT ${Math.trunc(filter.limit)}` : '';
|
|
286
|
+
const rows = this.db
|
|
287
|
+
.prepare(`SELECT * FROM accounts ${where} ORDER BY brand, id ${limit}`)
|
|
288
|
+
.all(...params);
|
|
289
|
+
return rows.map((row) => this.toPublic(row));
|
|
290
|
+
}
|
|
291
|
+
stats() {
|
|
292
|
+
this.expireDue();
|
|
293
|
+
const rows = this.db
|
|
294
|
+
.prepare('SELECT brand, status, deleted_at, expires_at FROM accounts')
|
|
295
|
+
.all();
|
|
296
|
+
const stats = {
|
|
297
|
+
total: rows.length,
|
|
298
|
+
active: 0,
|
|
299
|
+
disabled: 0,
|
|
300
|
+
expired: 0,
|
|
301
|
+
invalid: 0,
|
|
302
|
+
deleted: 0,
|
|
303
|
+
expiring_soon: 0,
|
|
304
|
+
by_brand: [],
|
|
305
|
+
db_path: this.dbPath
|
|
306
|
+
};
|
|
307
|
+
const soon = isoDay(new Date(this.now().getTime() + 7 * 86_400_000));
|
|
308
|
+
const today = isoDay(this.now());
|
|
309
|
+
const brands = new Map();
|
|
310
|
+
for (const row of rows) {
|
|
311
|
+
stats[row.status] += 1;
|
|
312
|
+
if (row.deleted_at !== null)
|
|
313
|
+
stats.deleted += 1;
|
|
314
|
+
if (row.status === 'active' && row.expires_at && row.expires_at >= today && row.expires_at <= soon) {
|
|
315
|
+
stats.expiring_soon += 1;
|
|
316
|
+
}
|
|
317
|
+
const bucket = brands.get(row.brand) ?? { brand: row.brand, total: 0, active: 0, disabled: 0, expired: 0, invalid: 0 };
|
|
318
|
+
bucket.total += 1;
|
|
319
|
+
bucket[row.status] += 1;
|
|
320
|
+
brands.set(row.brand, bucket);
|
|
321
|
+
}
|
|
322
|
+
stats.by_brand = [...brands.values()].sort((left, right) => left.brand.localeCompare(right.brand));
|
|
323
|
+
return stats;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Take the next usable credential for a brand.
|
|
327
|
+
*
|
|
328
|
+
* Least-recently-used first, so a run spreads load across the pool instead of
|
|
329
|
+
* hammering account #1 until the vendor rate-limits it. Returns the only shape
|
|
330
|
+
* in this module that carries a password, and it is called by the pipeline,
|
|
331
|
+
* never by a display path.
|
|
332
|
+
*/
|
|
333
|
+
lease(brand) {
|
|
334
|
+
this.expireDue();
|
|
335
|
+
const key = normalizeBrand(brand);
|
|
336
|
+
if (!key)
|
|
337
|
+
return null;
|
|
338
|
+
const row = this.db
|
|
339
|
+
.prepare(`SELECT * FROM accounts
|
|
340
|
+
WHERE brand = ? AND status = 'active' AND deleted_at IS NULL
|
|
341
|
+
ORDER BY last_used_at ASC, use_count ASC, id ASC LIMIT 1`)
|
|
342
|
+
.get(key);
|
|
343
|
+
if (!row)
|
|
344
|
+
return null;
|
|
345
|
+
const stamp = isoNow(this.now());
|
|
346
|
+
this.db
|
|
347
|
+
.prepare('UPDATE accounts SET last_used_at = ?, use_count = use_count + 1, updated_at = ? WHERE id = ?')
|
|
348
|
+
.run(stamp, stamp, row.id);
|
|
349
|
+
this.event(row.id, 'leased');
|
|
350
|
+
return { ...row, last_used_at: stamp, use_count: row.use_count + 1 };
|
|
351
|
+
}
|
|
352
|
+
/** Every usable credential for a brand, for a run that wants to fan out. */
|
|
353
|
+
leaseAll(brand) {
|
|
354
|
+
this.expireDue();
|
|
355
|
+
const key = normalizeBrand(brand);
|
|
356
|
+
if (!key)
|
|
357
|
+
return [];
|
|
358
|
+
return this.db
|
|
359
|
+
.prepare(`SELECT * FROM accounts WHERE brand = ? AND status = 'active' AND deleted_at IS NULL
|
|
360
|
+
ORDER BY last_used_at ASC, use_count ASC, id ASC`)
|
|
361
|
+
.all(key);
|
|
362
|
+
}
|
|
363
|
+
setStatus(id, status, reason, kind) {
|
|
364
|
+
const stamp = isoNow(this.now());
|
|
365
|
+
const retired = RETIRED_STATUSES.includes(status);
|
|
366
|
+
this.db
|
|
367
|
+
.prepare('UPDATE accounts SET status = ?, status_reason = ?, deleted_at = ?, updated_at = ? WHERE id = ?')
|
|
368
|
+
.run(status, reason, retired ? stamp : null, stamp, id);
|
|
369
|
+
this.event(id, kind, reason);
|
|
370
|
+
}
|
|
371
|
+
findId(brand, account) {
|
|
372
|
+
const key = normalizeBrand(brand);
|
|
373
|
+
if (!key)
|
|
374
|
+
return null;
|
|
375
|
+
const row = this.db.prepare('SELECT id FROM accounts WHERE brand = ? AND account = ?').get(key, account);
|
|
376
|
+
return row?.id ?? null;
|
|
377
|
+
}
|
|
378
|
+
markSuccess(id) {
|
|
379
|
+
const stamp = isoNow(this.now());
|
|
380
|
+
this.db
|
|
381
|
+
.prepare('UPDATE accounts SET success_count = success_count + 1, fail_count = 0, updated_at = ? WHERE id = ?')
|
|
382
|
+
.run(stamp, id);
|
|
383
|
+
this.event(id, 'login_ok');
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* A login was rejected: the stored password no longer works.
|
|
387
|
+
*
|
|
388
|
+
* Soft-deletes rather than erasing, so the operator can see which accounts
|
|
389
|
+
* went bad and when, and a later import of the same account revives it.
|
|
390
|
+
*/
|
|
391
|
+
markInvalid(id, reason = 'login rejected') {
|
|
392
|
+
const stamp = isoNow(this.now());
|
|
393
|
+
this.db.prepare('UPDATE accounts SET fail_count = fail_count + 1, updated_at = ? WHERE id = ?').run(stamp, id);
|
|
394
|
+
this.setStatus(id, 'invalid', reason, 'invalidated');
|
|
395
|
+
}
|
|
396
|
+
markExpired(id, reason = 'membership expired') {
|
|
397
|
+
this.setStatus(id, 'expired', reason, 'expired');
|
|
398
|
+
}
|
|
399
|
+
disable(brand, account, reason = 'disabled by operator') {
|
|
400
|
+
const id = this.findId(brand, account);
|
|
401
|
+
if (id === null)
|
|
402
|
+
return false;
|
|
403
|
+
return this.disableById(id, reason);
|
|
404
|
+
}
|
|
405
|
+
enable(brand, account) {
|
|
406
|
+
const id = this.findId(brand, account);
|
|
407
|
+
if (id === null)
|
|
408
|
+
return false;
|
|
409
|
+
return this.enableById(id);
|
|
410
|
+
}
|
|
411
|
+
/** UI actions address rows by id so the raw account never leaves the DB. */
|
|
412
|
+
disableById(id, reason = 'disabled by operator') {
|
|
413
|
+
if (!this.hasId(id))
|
|
414
|
+
return false;
|
|
415
|
+
this.setStatus(id, 'disabled', reason, 'disabled');
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Bring a row back into rotation.
|
|
420
|
+
*
|
|
421
|
+
* Returns null when the membership date has already passed: flipping the
|
|
422
|
+
* status to active would be undone by the very next `expireDue` sweep, so a
|
|
423
|
+
* caller that got `true` here would show a success the pool contradicts one
|
|
424
|
+
* refresh later. The operator has to extend the date first.
|
|
425
|
+
*/
|
|
426
|
+
enableById(id) {
|
|
427
|
+
const row = this.db.prepare('SELECT status, expires_at FROM accounts WHERE id = ?').get(Math.trunc(id));
|
|
428
|
+
if (!row)
|
|
429
|
+
return false;
|
|
430
|
+
if (row.expires_at && row.expires_at < isoDay(this.now()))
|
|
431
|
+
return null;
|
|
432
|
+
this.setStatus(id, 'active', '', 'enabled');
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
hasId(id) {
|
|
436
|
+
return Boolean(this.db.prepare('SELECT id FROM accounts WHERE id = ?').get(Math.trunc(id)));
|
|
437
|
+
}
|
|
438
|
+
/** Set or clear a membership date without touching the credential. */
|
|
439
|
+
setExpiry(brand, account, expiresAt) {
|
|
440
|
+
const id = this.findId(brand, account);
|
|
441
|
+
if (id === null)
|
|
442
|
+
return false;
|
|
443
|
+
return this.setExpiryById(id, expiresAt);
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Set or clear a membership date without touching the credential.
|
|
447
|
+
*
|
|
448
|
+
* The date is normalized first: `expireDue` compares `expires_at` as text, so
|
|
449
|
+
* a stored `2026/1/5` would sort above every ISO date and never retire the
|
|
450
|
+
* row. Returns null when the value is not a date, so the caller can refuse
|
|
451
|
+
* instead of writing something the sweep will skip forever.
|
|
452
|
+
*/
|
|
453
|
+
setExpiryById(id, expiresAt) {
|
|
454
|
+
if (!this.hasId(id))
|
|
455
|
+
return false;
|
|
456
|
+
const normalized = normalizeExpiry(expiresAt);
|
|
457
|
+
if (normalized === null)
|
|
458
|
+
return null;
|
|
459
|
+
const stamp = isoNow(this.now());
|
|
460
|
+
this.db.prepare('UPDATE accounts SET expires_at = ?, updated_at = ? WHERE id = ?').run(normalized, stamp, id);
|
|
461
|
+
this.event(id, 'expiry_set', normalized || '(cleared)');
|
|
462
|
+
this.expireDue();
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
/** Full rows including secrets, for `accounts export` only. */
|
|
466
|
+
exportRecords(includeDeleted = false) {
|
|
467
|
+
const where = includeDeleted ? '' : 'WHERE deleted_at IS NULL';
|
|
468
|
+
const rows = this.db.prepare(`SELECT * FROM accounts ${where} ORDER BY brand, id`).all();
|
|
469
|
+
return rows.map((row) => ({
|
|
470
|
+
brand: row.brand,
|
|
471
|
+
account: row.account,
|
|
472
|
+
password: row.password,
|
|
473
|
+
official_site: row.official_site,
|
|
474
|
+
expires_at: row.expires_at,
|
|
475
|
+
status: row.status,
|
|
476
|
+
note: row.note
|
|
477
|
+
}));
|
|
478
|
+
}
|
|
479
|
+
history(id, limit = 20) {
|
|
480
|
+
return this.db
|
|
481
|
+
.prepare('SELECT at, kind, detail FROM pool_events WHERE account_id = ? ORDER BY id DESC LIMIT ?')
|
|
482
|
+
.all(id, Math.max(1, Math.trunc(limit)));
|
|
483
|
+
}
|
|
484
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical brand registry for the account pool.
|
|
3
|
+
*
|
|
4
|
+
* An account belongs to a brand, not to a profile source: `xuanfeng-area` and
|
|
5
|
+
* `xuanfeng-all-area` were two views of one vendor, and a pool keyed by source
|
|
6
|
+
* name would split one vendor's accounts across two buckets. Every spelling a
|
|
7
|
+
* profile, an import file or a human is likely to use resolves to one key here.
|
|
8
|
+
*/
|
|
9
|
+
export const BRANDS = [
|
|
10
|
+
{
|
|
11
|
+
key: 'leiting',
|
|
12
|
+
label: '雷霆加速器',
|
|
13
|
+
prefix: 'lt',
|
|
14
|
+
official_site: 'https://www.68123836.com',
|
|
15
|
+
aliases: ['雷霆', '雷霆加速器', 'leiting', 'lt', 'www.68123836.com']
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
key: 'xuanfeng',
|
|
19
|
+
label: '旋风加速器',
|
|
20
|
+
prefix: 'xf',
|
|
21
|
+
official_site: 'https://www.16863988.com',
|
|
22
|
+
aliases: ['旋风', '旋风加速器', 'xuanfeng', 'xf', 'xuanfeng-area', 'xuanfeng-all-area', 'www.16863988.com']
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
key: 'heidong',
|
|
26
|
+
label: '黑洞加速器',
|
|
27
|
+
prefix: 'hd',
|
|
28
|
+
official_site: 'https://www.67986457.com',
|
|
29
|
+
aliases: ['黑洞', '黑洞加速器', 'heidong', 'blackhole', 'hd', 'www.67986457.com']
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
key: 'mifeng',
|
|
33
|
+
label: '蜜蜂加速器',
|
|
34
|
+
prefix: 'mf',
|
|
35
|
+
official_site: 'https://www.86933221.com',
|
|
36
|
+
aliases: ['蜜蜂', '蜜蜂加速器', 'mifeng', 'mf', 'www.86933221.com']
|
|
37
|
+
}
|
|
38
|
+
];
|
|
39
|
+
const ALIAS_INDEX = new Map();
|
|
40
|
+
for (const brand of BRANDS) {
|
|
41
|
+
ALIAS_INDEX.set(brand.key, brand.key);
|
|
42
|
+
for (const alias of brand.aliases) {
|
|
43
|
+
const key = alias.toLowerCase();
|
|
44
|
+
ALIAS_INDEX.set(key, brand.key);
|
|
45
|
+
// A host is the same site with or without its `www.` label; register both
|
|
46
|
+
// spellings so an operator who drops it still resolves to this brand.
|
|
47
|
+
if (/^www\./.test(key))
|
|
48
|
+
ALIAS_INDEX.set(key.slice(4), brand.key);
|
|
49
|
+
else if (key.includes('.'))
|
|
50
|
+
ALIAS_INDEX.set(`www.${key}`, brand.key);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Resolve any brand spelling to its canonical key.
|
|
55
|
+
*
|
|
56
|
+
* Section headings in the legacy file carry an index suffix (`雷霆加速器 #12`),
|
|
57
|
+
* and profile sources carry a platform or variant suffix (`leiting_ios`), so a
|
|
58
|
+
* trailing `#N` or `_suffix` is stripped before the lookup. Returns null for an
|
|
59
|
+
* unknown brand rather than guessing: importing under the wrong brand would
|
|
60
|
+
* send a working credential to a vendor that has never heard of it.
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeBrand(value) {
|
|
63
|
+
const raw = String(value ?? '').trim();
|
|
64
|
+
if (!raw)
|
|
65
|
+
return null;
|
|
66
|
+
const direct = ALIAS_INDEX.get(raw.toLowerCase());
|
|
67
|
+
if (direct)
|
|
68
|
+
return direct;
|
|
69
|
+
const stripped = raw.replace(/\s*#\s*\d+\s*$/, '').replace(/[_-](area|all-area|ios|android|\d+)$/i, '').trim();
|
|
70
|
+
return ALIAS_INDEX.get(stripped.toLowerCase()) ?? null;
|
|
71
|
+
}
|
|
72
|
+
export function brandDefinition(key) {
|
|
73
|
+
const found = BRANDS.find((brand) => brand.key === key);
|
|
74
|
+
if (!found)
|
|
75
|
+
throw new Error(`unknown brand: ${key}`);
|
|
76
|
+
return found;
|
|
77
|
+
}
|
|
78
|
+
export function brandLabel(key) {
|
|
79
|
+
return brandDefinition(key).label;
|
|
80
|
+
}
|
|
81
|
+
/** Official site for a brand, used when an imported row omits it. */
|
|
82
|
+
export function brandSite(key) {
|
|
83
|
+
return brandDefinition(key).official_site;
|
|
84
|
+
}
|
|
85
|
+
/** Brand of an official site URL or bare host, if it is one we know. */
|
|
86
|
+
export function brandFromSite(site) {
|
|
87
|
+
const raw = String(site ?? '').trim();
|
|
88
|
+
if (!raw)
|
|
89
|
+
return null;
|
|
90
|
+
let host = raw.toLowerCase();
|
|
91
|
+
try {
|
|
92
|
+
const parsed = new URL(raw);
|
|
93
|
+
// `new URL('www.68123836.com:443')` succeeds with an empty host and the
|
|
94
|
+
// bogus scheme `www.68123836.com:`; only a real http(s) parse is usable.
|
|
95
|
+
host = parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
|
96
|
+
? parsed.host.toLowerCase()
|
|
97
|
+
: raw.replace(/^https?:\/\//i, '').split('/')[0].toLowerCase();
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
host = raw.replace(/^https?:\/\//i, '').split('/')[0].toLowerCase();
|
|
101
|
+
}
|
|
102
|
+
// Strip the port, a trailing dot and a `www.` label, in any order: an operator
|
|
103
|
+
// who writes `68123836.com` must resolve to the same brand as the canonical
|
|
104
|
+
// `www.68123836.com`, or the cross-brand guard silently lets the row through.
|
|
105
|
+
const bare = host.replace(/:\d+$/, '').replace(/\.$/, '');
|
|
106
|
+
return ALIAS_INDEX.get(host)
|
|
107
|
+
?? ALIAS_INDEX.get(bare)
|
|
108
|
+
?? ALIAS_INDEX.get(bare.replace(/^www\./, ''))
|
|
109
|
+
?? null;
|
|
110
|
+
}
|