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
/* Accounting Durable Object — single source of truth for users + byte counters.
SQLite-backed (state.storage.sql). Talked to via JSON RPC over fetch:
POST /<method> body: params object -> JSON result
Methods: validate, addUsage, createUser, revokeUser, listUsers, resetUsage
*/
import { newUuid, isValidUuid } from './vless.js';
const SCHEMA = `
CREATE TABLE IF NOT EXISTS users(
uuid TEXT PRIMARY KEY,
name TEXT NOT NULL,
quota_bytes INTEGER NOT NULL DEFAULT 0,
used_up INTEGER NOT NULL DEFAULT 0,
used_down INTEGER NOT NULL DEFAULT 0,
expires_at INTEGER NOT NULL DEFAULT 0,
revoked INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);`;
function rowToUser(r) {
return {
uuid: r.uuid, name: r.name,
quotaBytes: r.quota_bytes, quotaGB: r.quota_bytes ? +(r.quota_bytes / 1073741824).toFixed(2) : 0,
usedUp: r.used_up, usedDown: r.used_down, usedTotal: r.used_up + r.used_down,
usedGB: +((r.used_up + r.used_down) / 1073741824).toFixed(3),
expiresAt: r.expires_at, revoked: !!r.revoked, createdAt: r.created_at,
};
}
export class Accounting {
constructor(state, env) {
this.state = state;
this.env = env;
this.sql = state.storage.sql;
this.sql.exec(SCHEMA);
}
async fetch(request) {
const url = new URL(request.url);
const method = url.pathname.replace(/^\//, '');
let params = {};
try { params = await request.json(); } catch { /* empty */ }
try {
if (typeof this[method] !== 'function' || method.startsWith('_') || method === 'fetch' || method === 'constructor') {
return Response.json({ ok: false, error: 'unknown method' }, { status: 404 });
}
const result = await this[method](params);
return Response.json({ ok: true, result });
} catch (e) {
return Response.json({ ok: false, error: String(e && e.message || e) }, { status: 500 });
}
}
_get(uuid) {
return this.sql.exec('SELECT * FROM users WHERE uuid = ?', uuid).one();
}
_check(row, now) {
if (!row) return { ok: false, reason: 'unknown-user' };
if (row.revoked) return { ok: false, reason: 'revoked' };
if (row.expires_at && now > row.expires_at) return { ok: false, reason: 'expired' };
if (row.quota_bytes > 0 && (row.used_up + row.used_down) >= row.quota_bytes)
return { ok: false, reason: 'quota-exceeded' };
return { ok: true };
}
validate({ uuid }) {
const row = this._get(String(uuid).toLowerCase());
return this._check(row, Date.now());
}
addUsage({ uuid, up = 0, down = 0 }) {
uuid = String(uuid).toLowerCase();
this.sql.exec('UPDATE users SET used_up = used_up + ?, used_down = used_down + ? WHERE uuid = ?',
Math.max(0, up | 0), Math.max(0, down | 0), uuid);
const row = this._get(uuid);
if (!row) return { ok: false, reason: 'unknown-user', overQuota: true };
const chk = this._check(row, Date.now());
return { ok: chk.ok, reason: chk.reason, overQuota: !chk.ok,
usedTotal: row.used_up + row.used_down, quotaBytes: row.quota_bytes };
}
createUser({ name, quotaGB = 0, expiryDays = 0 }) {
if (!name || String(name).length > 64) throw new Error('bad name');
const uuid = newUuid();
const now = Date.now();
this.sql.exec(
'INSERT INTO users(uuid,name,quota_bytes,used_up,used_down,expires_at,revoked,created_at) VALUES(?,?,?,?,?,?,?,?)',
uuid, String(name), Math.floor(Number(quotaGB) * 1073741824), 0, 0,
expiryDays > 0 ? now + Math.floor(Number(expiryDays)) * 86400000 : 0, 0, now);
return { uuid, ...(rowToUser(this._get(uuid))) };
}
revokeUser({ uuid }) {
const cur = this.sql.exec('UPDATE users SET revoked = 1 WHERE uuid = ?', String(uuid).toLowerCase());
if (cur.rowsWritten === 0) throw new Error('unknown user');
return { revoked: true };
}
listUsers() {
return this.sql.exec('SELECT * FROM users ORDER BY created_at DESC').toArray().map(rowToUser);
}
resetUsage({ uuid }) {
this.sql.exec('UPDATE users SET used_up = 0, used_down = 0 WHERE uuid = ?', String(uuid).toLowerCase());
return { reset: true };
}
upsertUser({ uuid, name, quotaGB = 0, expiryDays = 0, usedUp = 0, usedDown = 0, revoked = false }) {
uuid = String(uuid).toLowerCase();
if (!isValidUuid(uuid)) throw new Error('bad uuid');
const now = Date.now();
this.sql.exec(
`INSERT INTO users(uuid,name,quota_bytes,used_up,used_down,expires_at,revoked,created_at)
VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(uuid) DO UPDATE SET name=excluded.name, quota_bytes=excluded.quota_bytes,
used_up=excluded.used_up, used_down=excluded.used_down,
expires_at=excluded.expires_at, revoked=excluded.revoked`,
uuid, String(name || 'restored').slice(0, 64), Math.floor(Number(quotaGB) * 1073741824),
Math.max(0, usedUp | 0), Math.max(0, usedDown | 0),
expiryDays > 0 ? now + Math.floor(Number(expiryDays)) * 86400000 : 0,
revoked ? 1 : 0, now);
return rowToUser(this._get(uuid));
}
}