@amxts/perms 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/PAWN.md +53 -0
- package/PAWN.ru.md +53 -0
- package/README.md +271 -2
- package/README.ru.md +272 -0
- package/include/perms.inc +35 -0
- package/package.json +71 -5
- package/prebuilt/linux/perms.aot +0 -0
- package/prebuilt/manifest.json +205 -0
- package/prebuilt/windows/perms.aot +0 -0
- package/src/commands.ts +221 -0
- package/src/db-store.ts +174 -0
- package/src/editor.ts +277 -0
- package/src/file-store.ts +134 -0
- package/src/import.ts +63 -0
- package/src/index.ts +127 -0
- package/src/model.ts +387 -0
- package/src/natives.ts +46 -0
- package/src/players.ts +186 -0
- package/src/server.ts +62 -0
- package/src/state.ts +102 -0
- package/src/time.ts +48 -0
- package/src/types.ts +36 -0
- package/testing/index.ts +72 -0
package/src/db-store.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The permissions in MySQL, for servers that share one set: the tables are
|
|
3
|
+
* made on the first start, every change is written as it happens and bumps
|
|
4
|
+
* `perms_revision`, and each server reads everything again when the
|
|
5
|
+
* revision it polls has moved.
|
|
6
|
+
*/
|
|
7
|
+
import { Database } from "@amxts/core";
|
|
8
|
+
import type { Change, Grant, Group, Membership, User } from "./model";
|
|
9
|
+
import { allGroups, allUsers, groupNamed, replaceAll, userWith } from "./model";
|
|
10
|
+
|
|
11
|
+
interface GroupRow {
|
|
12
|
+
name: string;
|
|
13
|
+
weight: number;
|
|
14
|
+
inherits: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface GrantRow {
|
|
18
|
+
owner: string;
|
|
19
|
+
node: string;
|
|
20
|
+
value: number;
|
|
21
|
+
until: number;
|
|
22
|
+
server: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface UserRow {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
password: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface MemberRow {
|
|
32
|
+
user: string;
|
|
33
|
+
grp: string;
|
|
34
|
+
until: number;
|
|
35
|
+
server: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface RevisionRow {
|
|
39
|
+
revision: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const TABLES = [
|
|
43
|
+
"CREATE TABLE IF NOT EXISTS perms_groups (name VARCHAR(64) PRIMARY KEY, weight INT NOT NULL DEFAULT 0, inherits TEXT NOT NULL)",
|
|
44
|
+
"CREATE TABLE IF NOT EXISTS perms_grants (owner VARCHAR(96) NOT NULL, node VARCHAR(128) NOT NULL, value BOOLEAN NOT NULL, until BIGINT NOT NULL DEFAULT 0, server VARCHAR(64) NOT NULL DEFAULT '', PRIMARY KEY (owner, node, server))",
|
|
45
|
+
"CREATE TABLE IF NOT EXISTS perms_users (id VARCHAR(96) PRIMARY KEY, name VARCHAR(64) NOT NULL DEFAULT '', password VARCHAR(80) NOT NULL DEFAULT '')",
|
|
46
|
+
"CREATE TABLE IF NOT EXISTS perms_members (user VARCHAR(96) NOT NULL, grp VARCHAR(64) NOT NULL, until BIGINT NOT NULL DEFAULT 0, server VARCHAR(64) NOT NULL DEFAULT '', PRIMARY KEY (user, grp, server))",
|
|
47
|
+
"CREATE TABLE IF NOT EXISTS perms_revision (id INT PRIMARY KEY, revision INT NOT NULL)",
|
|
48
|
+
"INSERT IGNORE INTO perms_revision (id, revision) VALUES (1, 0)",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
let db: Database | null = null;
|
|
52
|
+
let revision = -1;
|
|
53
|
+
|
|
54
|
+
export function useDatabase(url: string) {
|
|
55
|
+
db = new Database(url);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function usesDatabase() {
|
|
59
|
+
return db != null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function createTables() {
|
|
63
|
+
for (const sql of TABLES) await db!.execute(sql, []);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function grantFrom(row: GrantRow): Grant {
|
|
67
|
+
return { node: row.node, value: row.value != 0, until: row.until, server: row.server };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Reads every table into the model. */
|
|
71
|
+
export async function readDatabase() {
|
|
72
|
+
const found = await db!.query<RevisionRow>("SELECT revision FROM perms_revision WHERE id = 1", []);
|
|
73
|
+
const groupRows = await db!.query<GroupRow>("SELECT name, weight, inherits FROM perms_groups", []);
|
|
74
|
+
const grantRows = await db!.query<GrantRow>("SELECT owner, node, value, until, server FROM perms_grants", []);
|
|
75
|
+
const userRows = await db!.query<UserRow>("SELECT id, name, password FROM perms_users", []);
|
|
76
|
+
const memberRows = await db!.query<MemberRow>("SELECT user, grp, until, server FROM perms_members", []);
|
|
77
|
+
|
|
78
|
+
const groups = groupRows.map<Group>(row => ({
|
|
79
|
+
name: row.name,
|
|
80
|
+
weight: row.weight,
|
|
81
|
+
inherits: row.inherits.split(",").filter(name => name != ""),
|
|
82
|
+
grants: grantRows.filter(grant => grant.owner == `group:${row.name}`).map<Grant>(grantFrom),
|
|
83
|
+
}));
|
|
84
|
+
const users = userRows.map<User>(row => ({
|
|
85
|
+
id: row.id,
|
|
86
|
+
name: row.name,
|
|
87
|
+
password: row.password,
|
|
88
|
+
groups: memberRows.filter(member => member.user == row.id).map<Membership>(member => ({ group: member.grp, until: member.until, server: member.server })),
|
|
89
|
+
grants: grantRows.filter(grant => grant.owner == `user:${row.id}`).map<Grant>(grantFrom),
|
|
90
|
+
}));
|
|
91
|
+
replaceAll(groups, users);
|
|
92
|
+
revision = found.length > 0 ? found[0].revision : 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Whether another server changed the set since it was read. */
|
|
96
|
+
export async function changedElsewhere() {
|
|
97
|
+
const found = await db!.query<RevisionRow>("SELECT revision FROM perms_revision WHERE id = 1", []);
|
|
98
|
+
return found.length > 0 && found[0].revision != revision;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function saveGroup(name: string) {
|
|
102
|
+
const group = groupNamed(name);
|
|
103
|
+
if (group == null) return;
|
|
104
|
+
await db!.execute("INSERT INTO perms_groups (name, weight, inherits) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE weight = VALUES(weight), inherits = VALUES(inherits)", [group.name, group.weight, group.inherits.join(",")]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function saveUser(id: string) {
|
|
108
|
+
const user = userWith(id);
|
|
109
|
+
if (user == null) return;
|
|
110
|
+
await db!.execute("INSERT INTO perms_users (id, name, password) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name), password = VALUES(password)", [user.id, user.name, user.password]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function saveGrant(owner: string, grant: Grant) {
|
|
114
|
+
await db!.execute("INSERT INTO perms_grants (owner, node, value, until, server) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE value = VALUES(value), until = VALUES(until)", [owner, grant.node, grant.value, grant.until, grant.server]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Writes one change that the model applied. */
|
|
118
|
+
async function writeChange(change: Change) {
|
|
119
|
+
const owner = change.user != "" ? `user:${userWith(change.user)!.id}` : `group:${change.group}`;
|
|
120
|
+
const op = change.op;
|
|
121
|
+
|
|
122
|
+
if (op == "grant") {
|
|
123
|
+
if (change.user != "") await saveUser(change.user);
|
|
124
|
+
await saveGrant(owner, { node: change.node, value: change.value, until: change.until, server: change.server });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (op == "revoke") {
|
|
128
|
+
await db!.execute("DELETE FROM perms_grants WHERE owner = ? AND node = ? AND server = ?", [owner, change.node, change.server]);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (op == "memberAdd") {
|
|
132
|
+
await saveUser(change.user);
|
|
133
|
+
await db!.execute("INSERT INTO perms_members (user, grp, until, server) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE until = VALUES(until)", [userWith(change.user)!.id, change.group, change.until, change.server]);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (op == "memberRemove") {
|
|
137
|
+
await db!.execute("DELETE FROM perms_members WHERE user = ? AND grp = ? AND server = ?", [userWith(change.user)!.id, change.group, change.server]);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (op == "userCreate") {
|
|
141
|
+
await saveUser(change.user);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (op == "groupDelete") {
|
|
145
|
+
await db!.execute("DELETE FROM perms_groups WHERE name = ?", [change.group]);
|
|
146
|
+
await db!.execute("DELETE FROM perms_grants WHERE owner = ?", [owner]);
|
|
147
|
+
await db!.execute("DELETE FROM perms_members WHERE grp = ?", [change.group]);
|
|
148
|
+
for (const group of allGroups()) await saveGroup(group.name);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
await saveGroup(change.group);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Writes the changes the model applied and moves the revision, so the other servers read them. */
|
|
155
|
+
export async function writeChanges(changes: Change[]) {
|
|
156
|
+
for (const change of changes) await writeChange(change);
|
|
157
|
+
await db!.execute("UPDATE perms_revision SET revision = revision + 1 WHERE id = 1", []);
|
|
158
|
+
const found = await db!.query<RevisionRow>("SELECT revision FROM perms_revision WHERE id = 1", []);
|
|
159
|
+
if (found.length > 0) revision = found[0].revision;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Writes the whole model, for `perms import` into an empty database. */
|
|
163
|
+
export async function writeAll() {
|
|
164
|
+
for (const group of allGroups()) {
|
|
165
|
+
await saveGroup(group.name);
|
|
166
|
+
for (const grant of group.grants) await saveGrant(`group:${group.name}`, grant);
|
|
167
|
+
}
|
|
168
|
+
for (const user of allUsers()) {
|
|
169
|
+
await saveUser(user.id);
|
|
170
|
+
for (const grant of user.grants) await saveGrant(`user:${user.id}`, grant);
|
|
171
|
+
for (const membership of user.groups) await db!.execute("INSERT INTO perms_members (user, grp, until, server) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE until = VALUES(until)", [user.id, membership.group, membership.until, membership.server]);
|
|
172
|
+
}
|
|
173
|
+
await db!.execute("UPDATE perms_revision SET revision = revision + 1 WHERE id = 1", []);
|
|
174
|
+
}
|
package/src/editor.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web editor: `perms editor` uploads the permissions to the store as a
|
|
3
|
+
* session and prints the page's link; while the session lives, the module
|
|
4
|
+
* polls it for the changes the page saved, commits them, and tells the page
|
|
5
|
+
* what it did.
|
|
6
|
+
*/
|
|
7
|
+
import { Cvar, clearInterval, fetch, server, setInterval } from "@amxts/core";
|
|
8
|
+
import type { Change, ChangeOp, Grant, Membership } from "./model";
|
|
9
|
+
import { allGroups, allUsers, changeOf, declaredNodes, serverName, userWith } from "./model";
|
|
10
|
+
import { FLAGS, idOf } from "./players";
|
|
11
|
+
import { commit, storageName } from "./state";
|
|
12
|
+
import { momentOf, textOf } from "./time";
|
|
13
|
+
|
|
14
|
+
const PAGE = "https://amxts.github.io/modules/perms/editor";
|
|
15
|
+
export const DEFAULT_STORE = "https://perms-store.brokukaschannel.workers.dev";
|
|
16
|
+
const POLL_MS = 3000;
|
|
17
|
+
const LIFETIME_MS = 60 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
interface WireGrant {
|
|
20
|
+
node: string;
|
|
21
|
+
value: boolean;
|
|
22
|
+
until: string | null;
|
|
23
|
+
server: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface WireMembership {
|
|
27
|
+
group: string;
|
|
28
|
+
until: string | null;
|
|
29
|
+
server: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface WireGroup {
|
|
33
|
+
name: string;
|
|
34
|
+
weight: number;
|
|
35
|
+
inherits: string[];
|
|
36
|
+
grants: WireGrant[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface WireUser {
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
online: boolean;
|
|
43
|
+
groups: WireMembership[];
|
|
44
|
+
grants: WireGrant[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface WireNode {
|
|
48
|
+
node: string;
|
|
49
|
+
description: string;
|
|
50
|
+
everyone: boolean;
|
|
51
|
+
plugin: string;
|
|
52
|
+
pawn: boolean;
|
|
53
|
+
letter: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface WireServer {
|
|
57
|
+
name: string;
|
|
58
|
+
address: string;
|
|
59
|
+
storage: string;
|
|
60
|
+
context: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Session {
|
|
64
|
+
version: number;
|
|
65
|
+
server: WireServer;
|
|
66
|
+
createdAt: string;
|
|
67
|
+
expiresAt: string;
|
|
68
|
+
nodes: WireNode[];
|
|
69
|
+
groups: WireGroup[];
|
|
70
|
+
users: WireUser[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface Created {
|
|
74
|
+
id: string;
|
|
75
|
+
key: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface WireOwner {
|
|
79
|
+
group?: string;
|
|
80
|
+
user?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface WireChange {
|
|
84
|
+
op: string;
|
|
85
|
+
owner?: WireOwner;
|
|
86
|
+
node?: string;
|
|
87
|
+
value?: boolean;
|
|
88
|
+
until?: string | null;
|
|
89
|
+
server?: string | null;
|
|
90
|
+
group?: string;
|
|
91
|
+
weight?: number;
|
|
92
|
+
parent?: string;
|
|
93
|
+
user?: string;
|
|
94
|
+
name?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface WireRevision {
|
|
98
|
+
revision: number;
|
|
99
|
+
changes: WireChange[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface WireSkipped {
|
|
103
|
+
change: WireChange;
|
|
104
|
+
reason: string;
|
|
105
|
+
code: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface Applied {
|
|
109
|
+
revision: number;
|
|
110
|
+
done: WireChange[];
|
|
111
|
+
skipped: WireSkipped[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let store = DEFAULT_STORE;
|
|
115
|
+
let sessionId = "";
|
|
116
|
+
let sessionKey = "";
|
|
117
|
+
let expiresAt = 0;
|
|
118
|
+
let seen = 0;
|
|
119
|
+
let polling = 0;
|
|
120
|
+
let busy = false;
|
|
121
|
+
|
|
122
|
+
export function setStore(url: string) {
|
|
123
|
+
store = url.endsWith("/") ? url.substring(0, url.length - 1) : url;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function nullable(text: string) {
|
|
127
|
+
const value: string | null = text == "" ? null : text;
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function wireGrant(grant: Grant): WireGrant {
|
|
132
|
+
return { node: grant.node, value: grant.value, until: nullable(textOf(grant.until)), server: nullable(grant.server) };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function wireMembership(membership: Membership): WireMembership {
|
|
136
|
+
return { group: membership.group, until: nullable(textOf(membership.until)), server: nullable(membership.server) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function sessionOf(now: number) {
|
|
140
|
+
const nodes = declaredNodes().map<WireNode>(each => ({ node: each.node, description: each.description, everyone: each.byDefault, plugin: each.node.split(".")[0], pawn: each.pawn, letter: "" }));
|
|
141
|
+
for (const flag of FLAGS) nodes.push({ node: `amxx.${flag.name}`, description: `AMX Mod X: ${flag.name} (${flag.letter})`, everyone: false, plugin: "amxx", pawn: true, letter: flag.letter });
|
|
142
|
+
|
|
143
|
+
const users = allUsers().map<WireUser>(user => ({ id: user.id, name: user.name, online: false, groups: user.groups.map<WireMembership>(wireMembership), grants: user.grants.map<WireGrant>(wireGrant) }));
|
|
144
|
+
for (const player of server.players.filter(each => !each.isBot)) {
|
|
145
|
+
const id = idOf(player);
|
|
146
|
+
const known = users.find(user => user.id == id);
|
|
147
|
+
if (known != null) {
|
|
148
|
+
known.online = true;
|
|
149
|
+
known.name = player.name;
|
|
150
|
+
} else {
|
|
151
|
+
users.push({ id, name: player.name, online: true, groups: [], grants: [] });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const session: Session = {
|
|
156
|
+
version: 1,
|
|
157
|
+
server: { name: new Cvar("hostname").value, address: new Cvar("net_address").value, storage: storageName(), context: serverName() },
|
|
158
|
+
createdAt: textOf(now),
|
|
159
|
+
expiresAt: textOf(now + LIFETIME_MS),
|
|
160
|
+
nodes,
|
|
161
|
+
groups: allGroups().map<WireGroup>(group => ({ name: group.name, weight: group.weight, inherits: group.inherits, grants: group.grants.map<WireGrant>(wireGrant) })),
|
|
162
|
+
users,
|
|
163
|
+
};
|
|
164
|
+
return session;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The page's link for the open session. */
|
|
168
|
+
function link() {
|
|
169
|
+
const page = store == DEFAULT_STORE ? PAGE : `${store}/editor`;
|
|
170
|
+
return `${page}#${sessionId}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function isOpen() {
|
|
174
|
+
return sessionId != "" && Date.now() < expiresAt;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Opens a session, or gives the one that is open; the lines to print, or an error's. */
|
|
178
|
+
export async function openEditor() {
|
|
179
|
+
if (isOpen()) return `[perms] The editor is open: ${link()}`;
|
|
180
|
+
|
|
181
|
+
const now = Date.now();
|
|
182
|
+
const response = await fetch(`${store}/session`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(sessionOf(now)) });
|
|
183
|
+
if (!response.ok) return `[perms] The editor's store answered ${response.status}: ${await response.text()}`;
|
|
184
|
+
|
|
185
|
+
const created = await response.json<Created>();
|
|
186
|
+
sessionId = created.id;
|
|
187
|
+
sessionKey = created.key;
|
|
188
|
+
expiresAt = now + LIFETIME_MS;
|
|
189
|
+
seen = 0;
|
|
190
|
+
if (polling == 0) polling = setInterval(() => poll(), POLL_MS);
|
|
191
|
+
return `[perms] Open the editor (an hour): ${link()}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Closes the session: the store forgets it and the module stops polling. */
|
|
195
|
+
export async function closeEditor() {
|
|
196
|
+
if (sessionId == "") return;
|
|
197
|
+
|
|
198
|
+
const id = sessionId;
|
|
199
|
+
stopPolling();
|
|
200
|
+
await fetch(`${store}/session/${id}`, { method: "DELETE", headers: { Authorization: `Bearer ${sessionKey}` } });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function stopPolling() {
|
|
204
|
+
sessionId = "";
|
|
205
|
+
if (polling != 0) clearInterval(polling);
|
|
206
|
+
polling = 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function poll() {
|
|
210
|
+
if (busy || sessionId == "") return;
|
|
211
|
+
if (Date.now() >= expiresAt) {
|
|
212
|
+
stopPolling();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
busy = true;
|
|
217
|
+
try {
|
|
218
|
+
const response = await fetch(`${store}/session/${sessionId}/changes?after=${seen}`, { headers: { Authorization: `Bearer ${sessionKey}` } });
|
|
219
|
+
if (response.status == 404) stopPolling();
|
|
220
|
+
if (response.ok) await applyRevisions(await response.json<WireRevision[]>());
|
|
221
|
+
} catch (error) {
|
|
222
|
+
console.error(`[perms] editor: ${error.message}`);
|
|
223
|
+
}
|
|
224
|
+
busy = false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Applies what the page saved, revision by revision, applies it and says so to the store. */
|
|
228
|
+
export async function applyRevisions(revisions: WireRevision[]) {
|
|
229
|
+
for (const entry of revisions) {
|
|
230
|
+
const committed = await commit(entry.changes.map<Change>(changeFrom));
|
|
231
|
+
seen = entry.revision;
|
|
232
|
+
const applied: Applied = {
|
|
233
|
+
revision: entry.revision,
|
|
234
|
+
done: committed.done.map<WireChange>(wireChange),
|
|
235
|
+
skipped: committed.skipped.map<WireSkipped>(skip => ({ change: wireChange(skip.change), reason: skip.reason, code: skip.code })),
|
|
236
|
+
};
|
|
237
|
+
console.log(`[perms] the editor's changes: ${applied.done.length} applied${applied.skipped.length > 0 ? `, ${applied.skipped.length} skipped` : ""}`);
|
|
238
|
+
applied.skipped.forEach(skip => console.log(`[perms] skipped: ${skip.reason}`));
|
|
239
|
+
await fetch(`${store}/session/${sessionId}/applied`, { method: "POST", headers: { "Authorization": `Bearer ${sessionKey}`, "Content-Type": "application/json" }, body: JSON.stringify(applied) });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const OPS = ["grant", "revoke", "groupCreate", "groupDelete", "groupWeight", "parentAdd", "parentRemove", "memberAdd", "memberRemove", "userCreate"];
|
|
244
|
+
|
|
245
|
+
/** A change as the page wrote it; an op the module does not know becomes a grant of no node, which is skipped as invalid. */
|
|
246
|
+
function changeFrom(wire: WireChange) {
|
|
247
|
+
const known = OPS.includes(wire.op);
|
|
248
|
+
const change = changeOf(known ? (wire.op as ChangeOp) : "grant");
|
|
249
|
+
const owner = wire.owner;
|
|
250
|
+
change.group = owner != null ? (owner.group ?? "") : (wire.group ?? "");
|
|
251
|
+
change.user = owner != null ? (owner.user ?? "") : (wire.user ?? "");
|
|
252
|
+
change.node = known ? (wire.node ?? "") : "";
|
|
253
|
+
change.value = wire.value ?? true;
|
|
254
|
+
change.until = momentOf(wire.until ?? "");
|
|
255
|
+
change.server = wire.server ?? "";
|
|
256
|
+
change.weight = wire.weight ?? 0;
|
|
257
|
+
change.parent = wire.parent ?? "";
|
|
258
|
+
change.name = wire.name ?? "";
|
|
259
|
+
if (userWith(change.user) != null) change.user = userWith(change.user)!.id;
|
|
260
|
+
return change;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function wireChange(change: Change) {
|
|
264
|
+
const usesOwner = change.op == "grant" || change.op == "revoke";
|
|
265
|
+
const wire: WireChange = { op: change.op };
|
|
266
|
+
if (usesOwner) wire.owner = change.user != "" ? { user: change.user } : { group: change.group };
|
|
267
|
+
if (!usesOwner && change.group != "") wire.group = change.group;
|
|
268
|
+
if (!usesOwner && change.user != "") wire.user = change.user;
|
|
269
|
+
if (change.node != "") wire.node = change.node;
|
|
270
|
+
if (change.op == "grant") wire.value = change.value;
|
|
271
|
+
if (change.until != 0) wire.until = textOf(change.until);
|
|
272
|
+
if (change.server != "") wire.server = change.server;
|
|
273
|
+
if (change.op == "groupCreate" || change.op == "groupWeight") wire.weight = change.weight;
|
|
274
|
+
if (change.parent != "") wire.parent = change.parent;
|
|
275
|
+
if (change.name != "") wire.name = change.name;
|
|
276
|
+
return wire;
|
|
277
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The permissions in a config file, `perms.yaml` by default (or `.json`),
|
|
3
|
+
* read and written whole through config-core:
|
|
4
|
+
*
|
|
5
|
+
* groups:
|
|
6
|
+
* admin:
|
|
7
|
+
* weight: 50
|
|
8
|
+
* inherits: [vip]
|
|
9
|
+
* permissions: [amxx.kick, "kz.admin.*", "-kz.admin.wipe", { node: kz.vip, until: 2026-12-01T00:00:00Z }]
|
|
10
|
+
* users:
|
|
11
|
+
* - id: "STEAM_0:1:12345"
|
|
12
|
+
* name: Bob
|
|
13
|
+
* groups: [admin, { group: vip, until: 2026-12-01T00:00:00Z, server: kz1 }]
|
|
14
|
+
* permissions: [kz.admin.wipe]
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from "@amxts/core/fs";
|
|
17
|
+
import * as configs from "@amxts/config-core";
|
|
18
|
+
import type { ConfigNode } from "@amxts/config-core";
|
|
19
|
+
import type { Grant, Group, Membership, User } from "./model";
|
|
20
|
+
import { allGroups, allUsers, normalId, replaceAll } from "./model";
|
|
21
|
+
import { momentOf, textOf } from "./time";
|
|
22
|
+
|
|
23
|
+
let fileName = "perms";
|
|
24
|
+
|
|
25
|
+
export function setFile(name: string) {
|
|
26
|
+
fileName = name;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The file's path, for the console. */
|
|
30
|
+
export function filePath() {
|
|
31
|
+
return configs.resolve(fileName);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function grantOf(item: ConfigNode): Grant {
|
|
35
|
+
if (item.kind == "object") {
|
|
36
|
+
const node = item.getString("node");
|
|
37
|
+
const negated = node.startsWith("-");
|
|
38
|
+
return { node: negated ? node.substring(1) : node, value: !negated && item.getBoolean("value", true), until: momentOf(item.getString("until")), server: item.getString("server") };
|
|
39
|
+
}
|
|
40
|
+
const text = item.getString();
|
|
41
|
+
const negated = text.startsWith("-");
|
|
42
|
+
return { node: negated ? text.substring(1) : text, value: !negated, until: 0, server: "" };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function membershipOf(item: ConfigNode): Membership {
|
|
46
|
+
if (item.kind == "object") return { group: item.getString("group"), until: momentOf(item.getString("until")), server: item.getString("server") };
|
|
47
|
+
return { group: item.getString(), until: 0, server: "" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function grantsAt(node: ConfigNode, path: string) {
|
|
51
|
+
return node.values(path).map<Grant>(grantOf).filter(grant => grant.node != "");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reads the file into the model; `false`, with the model untouched, when it could not be read. */
|
|
55
|
+
export function readFile() {
|
|
56
|
+
const root = configs.read(fileName);
|
|
57
|
+
if (root.kind != "object") return false;
|
|
58
|
+
if (root.keys().length == 0 && hasContent(root.file)) return false;
|
|
59
|
+
|
|
60
|
+
const groups: Group[] = [];
|
|
61
|
+
for (const name of root.keys("groups")) {
|
|
62
|
+
const group = root.get(`groups.${name}`)!;
|
|
63
|
+
groups.push({ name, weight: group.getNumber("weight"), inherits: group.getStrings("inherits"), grants: grantsAt(group, "permissions") });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const users: User[] = [];
|
|
67
|
+
for (const entry of root.values("users")) {
|
|
68
|
+
const id = entry.getString("id");
|
|
69
|
+
if (id == "") continue;
|
|
70
|
+
|
|
71
|
+
users.push({
|
|
72
|
+
id: normalId(id),
|
|
73
|
+
name: entry.getString("name"),
|
|
74
|
+
password: entry.getString("password"),
|
|
75
|
+
groups: entry.values("groups").map<Membership>(membershipOf).filter(membership => membership.group != ""),
|
|
76
|
+
grants: grantsAt(entry, "permissions"),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
replaceAll(groups, users);
|
|
81
|
+
console.log(`[perms] ${root.file}: ${groups.length} groups, ${users.length} players`);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether a file has more than comments: one that does and reads as empty failed to read, and config-core said where. */
|
|
86
|
+
function hasContent(path: string) {
|
|
87
|
+
const text = fs.readFileSync(path) ?? "";
|
|
88
|
+
return text.split("\n").some(line => line.trim() != "" && !line.trim().startsWith("#") && !line.trim().startsWith("//"));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A plain grant as text (`node`, `-node`), one with an expiry or a server as an object. */
|
|
92
|
+
function writeGrant(root: ConfigNode, path: string, grant: Grant) {
|
|
93
|
+
const text = grant.value ? grant.node : `-${grant.node}`;
|
|
94
|
+
if (grant.until == 0 && grant.server == "") return root.set(path, text);
|
|
95
|
+
|
|
96
|
+
root.set(`${path}.node`, text);
|
|
97
|
+
if (grant.until != 0) root.set(`${path}.until`, textOf(grant.until));
|
|
98
|
+
if (grant.server != "") root.set(`${path}.server`, grant.server);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function writeMembership(root: ConfigNode, path: string, membership: Membership) {
|
|
103
|
+
if (membership.until == 0 && membership.server == "") return root.set(path, membership.group);
|
|
104
|
+
|
|
105
|
+
root.set(`${path}.group`, membership.group);
|
|
106
|
+
if (membership.until != 0) root.set(`${path}.until`, textOf(membership.until));
|
|
107
|
+
if (membership.server != "") root.set(`${path}.server`, membership.server);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Writes the model into the file, keeping its comments on lines of their own. */
|
|
112
|
+
export function writeFile() {
|
|
113
|
+
const root = configs.read(fileName);
|
|
114
|
+
root.remove("groups");
|
|
115
|
+
root.remove("users");
|
|
116
|
+
|
|
117
|
+
for (const group of allGroups()) {
|
|
118
|
+
const at = `groups.${group.name}`;
|
|
119
|
+
root.setNumber(`${at}.weight`, group.weight);
|
|
120
|
+
if (group.inherits.length > 0) root.setStrings(`${at}.inherits`, group.inherits);
|
|
121
|
+
group.grants.forEach((grant, i) => writeGrant(root, `${at}.permissions[${i}]`, grant));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
allUsers().forEach((user, i) => {
|
|
125
|
+
const at = `users[${i}]`;
|
|
126
|
+
root.set(`${at}.id`, user.id);
|
|
127
|
+
if (user.name != "") root.set(`${at}.name`, user.name);
|
|
128
|
+
if (user.password != "") root.set(`${at}.password`, user.password);
|
|
129
|
+
user.groups.forEach((membership, j) => writeMembership(root, `${at}.groups[${j}]`, membership));
|
|
130
|
+
user.grants.forEach((grant, j) => writeGrant(root, `${at}.permissions[${j}]`, grant));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return root.save();
|
|
134
|
+
}
|
package/src/import.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `perms import users.ini`: AMX Mod X's admins, each line a player with his
|
|
3
|
+
* flags as `amxx.*` grants of his own.
|
|
4
|
+
*
|
|
5
|
+
* "STEAM_0:0:123456" "" "abcdefghijklmnopqrstu" "ce"
|
|
6
|
+
* "Bob" "secret" "abc" "a"
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "@amxts/core/fs";
|
|
9
|
+
import { server } from "@amxts/core";
|
|
10
|
+
import type { Grant, User } from "./model";
|
|
11
|
+
import { allUsers, normalId } from "./model";
|
|
12
|
+
import { FLAGS, sha256 } from "./players";
|
|
13
|
+
|
|
14
|
+
/** The quoted words of a line, or the plain ones between spaces. */
|
|
15
|
+
function wordsOf(line: string) {
|
|
16
|
+
const words: string[] = [];
|
|
17
|
+
let rest = line.trim();
|
|
18
|
+
while (rest != "") {
|
|
19
|
+
const quoted = rest.startsWith("\"");
|
|
20
|
+
const end = quoted ? rest.indexOf("\"", 1) : rest.indexOf(" ");
|
|
21
|
+
const stop = end < 0 ? rest.length : end;
|
|
22
|
+
words.push(rest.substring(quoted ? 1 : 0, stop));
|
|
23
|
+
rest = rest.substring(Math.min(rest.length, stop + 1)).trim();
|
|
24
|
+
}
|
|
25
|
+
return words;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function userOfLine(words: string[]) {
|
|
29
|
+
const auth = words[0];
|
|
30
|
+
const password = words.length > 1 ? words[1] : "";
|
|
31
|
+
const access = words.length > 2 ? words[2] : "";
|
|
32
|
+
const account = words.length > 3 ? words[3] : "";
|
|
33
|
+
|
|
34
|
+
const id = account.includes("c") ? auth : account.includes("d") ? `ip:${auth}` : `name:${auth}`;
|
|
35
|
+
const grants = FLAGS.filter(flag => access.includes(flag.letter)).map<Grant>(flag => ({ node: `amxx.${flag.name}`, value: true, until: 0, server: "" }));
|
|
36
|
+
const user: User = {
|
|
37
|
+
id: normalId(id),
|
|
38
|
+
name: id.startsWith("name:") ? auth : "",
|
|
39
|
+
password: id.startsWith("name:") && !account.includes("e") && password != "" ? sha256(password) : "",
|
|
40
|
+
groups: [],
|
|
41
|
+
grants,
|
|
42
|
+
};
|
|
43
|
+
return user;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Reads `users.ini` (or another file of the configs folder) into the model; how many players it held, -1 when there is no file. */
|
|
47
|
+
export function importUsers(file: string) {
|
|
48
|
+
const text = fs.readFileSync(`${server.configsDir}/${file}`);
|
|
49
|
+
if (text == null) return -1;
|
|
50
|
+
|
|
51
|
+
const lines = text.split("\n").map<string>(line => line.trim()).filter(line => line != "" && !line.startsWith(";") && !line.startsWith("//"));
|
|
52
|
+
const users = allUsers();
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
const words = wordsOf(line);
|
|
55
|
+
if (words.length < 3 || words[0] == "") continue;
|
|
56
|
+
|
|
57
|
+
const user = userOfLine(words);
|
|
58
|
+
const at = users.findIndex(each => each.id == user.id);
|
|
59
|
+
if (at >= 0) users[at] = user;
|
|
60
|
+
else users.push(user);
|
|
61
|
+
}
|
|
62
|
+
return lines.length;
|
|
63
|
+
}
|