arblant
panel-gateway / src / accounting.js
accounting.js125 lines · 4.9 KB
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));
  }
}