lapeh 1.0.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/.env.example +16 -0
- package/bin/index.js +105 -0
- package/docker-compose.yml +17 -0
- package/package.json +70 -0
- package/prisma/base.prisma +8 -0
- package/prisma/migrations/20251225163737_init/migration.sql +236 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +174 -0
- package/prisma/seed.ts +350 -0
- package/prisma.config.ts +15 -0
- package/readme.md +120 -0
- package/scripts/compile-schema.js +29 -0
- package/scripts/generate-jwt-secret.js +38 -0
- package/scripts/make-model.js +42 -0
- package/scripts/make-module.js +158 -0
- package/src/controllers/authController.ts +347 -0
- package/src/controllers/rbacController.ts +353 -0
- package/src/index.ts +29 -0
- package/src/middleware/auth.ts +56 -0
- package/src/middleware/error.ts +8 -0
- package/src/middleware/visitor.ts +180 -0
- package/src/models/schema.prisma +159 -0
- package/src/prisma.ts +32 -0
- package/src/realtime.ts +34 -0
- package/src/redis.ts +69 -0
- package/src/routes/auth.ts +74 -0
- package/src/routes/rbac.ts +55 -0
- package/src/schema/auth-schema.ts +62 -0
- package/src/schema/user-schema.ts +57 -0
- package/src/server.ts +32 -0
- package/src/utils/pagination.ts +56 -0
- package/src/utils/response.ts +59 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { Request, Response } from "express";
|
|
2
|
+
import { prisma } from "../prisma";
|
|
3
|
+
import { sendSuccess, sendError } from "../utils/response";
|
|
4
|
+
|
|
5
|
+
export async function createRole(req: Request, res: Response) {
|
|
6
|
+
const { name, slug, description } = req.body || {};
|
|
7
|
+
if (!name || !slug) {
|
|
8
|
+
sendError(res, 422, "Validation error", {
|
|
9
|
+
name: !name ? ["Nama role wajib diisi"] : undefined,
|
|
10
|
+
slug: !slug ? ["Slug role wajib diisi"] : undefined,
|
|
11
|
+
});
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const exists = await prisma.roles.findUnique({ where: { slug } });
|
|
15
|
+
if (exists) {
|
|
16
|
+
sendError(res, 409, "Role already exists", {
|
|
17
|
+
slug: ["Slug role sudah digunakan"],
|
|
18
|
+
});
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const role = await prisma.roles.create({
|
|
22
|
+
data: {
|
|
23
|
+
name,
|
|
24
|
+
slug,
|
|
25
|
+
description: description || null,
|
|
26
|
+
created_at: new Date(),
|
|
27
|
+
updated_at: new Date(),
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
sendSuccess(res, 201, "Role created", role);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function listRoles(_req: Request, res: Response) {
|
|
34
|
+
const roles = await prisma.roles.findMany({
|
|
35
|
+
orderBy: { id: "asc" },
|
|
36
|
+
});
|
|
37
|
+
sendSuccess(res, 200, "Roles list", roles);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function updateRole(req: Request, res: Response) {
|
|
41
|
+
const { id } = req.params;
|
|
42
|
+
const roleId = BigInt(id);
|
|
43
|
+
const { name, slug, description } = req.body || {};
|
|
44
|
+
const role = await prisma.roles.findUnique({ where: { id: roleId } });
|
|
45
|
+
if (!role) {
|
|
46
|
+
sendError(res, 404, "Role not found");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (slug) {
|
|
50
|
+
const exists = await prisma.roles.findFirst({
|
|
51
|
+
where: {
|
|
52
|
+
slug,
|
|
53
|
+
NOT: { id: roleId },
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
if (exists) {
|
|
57
|
+
sendError(res, 409, "Role already exists", {
|
|
58
|
+
slug: ["Slug role sudah digunakan"],
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const updated = await prisma.roles.update({
|
|
64
|
+
where: { id: roleId },
|
|
65
|
+
data: {
|
|
66
|
+
name: name ?? role.name,
|
|
67
|
+
slug: slug ?? role.slug,
|
|
68
|
+
description: description ?? role.description,
|
|
69
|
+
updated_at: new Date(),
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
sendSuccess(res, 200, "Role updated", updated);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function deleteRole(req: Request, res: Response) {
|
|
76
|
+
const { id } = req.params;
|
|
77
|
+
const roleId = BigInt(id);
|
|
78
|
+
const role = await prisma.roles.findUnique({ where: { id: roleId } });
|
|
79
|
+
if (!role) {
|
|
80
|
+
sendError(res, 404, "Role not found");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
await prisma.role_permissions.deleteMany({ where: { role_id: roleId } });
|
|
84
|
+
await prisma.user_roles.deleteMany({ where: { role_id: roleId } });
|
|
85
|
+
await prisma.roles.delete({ where: { id: roleId } });
|
|
86
|
+
sendSuccess(res, 200, "Role deleted", null);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function createPermission(req: Request, res: Response) {
|
|
90
|
+
const { name, slug, description } = req.body || {};
|
|
91
|
+
if (!name || !slug) {
|
|
92
|
+
sendError(res, 422, "Validation error", {
|
|
93
|
+
name: !name ? ["Nama permission wajib diisi"] : undefined,
|
|
94
|
+
slug: !slug ? ["Slug permission wajib diisi"] : undefined,
|
|
95
|
+
});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const exists = await prisma.permissions.findUnique({ where: { slug } });
|
|
99
|
+
if (exists) {
|
|
100
|
+
sendError(res, 409, "Permission already exists", {
|
|
101
|
+
slug: ["Slug permission sudah digunakan"],
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const permission = await prisma.permissions.create({
|
|
106
|
+
data: {
|
|
107
|
+
name,
|
|
108
|
+
slug,
|
|
109
|
+
description: description || null,
|
|
110
|
+
created_at: new Date(),
|
|
111
|
+
updated_at: new Date(),
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
sendSuccess(res, 201, "Permission created", permission);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function listPermissions(_req: Request, res: Response) {
|
|
118
|
+
const permissions = await prisma.permissions.findMany({
|
|
119
|
+
orderBy: { id: "asc" },
|
|
120
|
+
});
|
|
121
|
+
sendSuccess(res, 200, "Permissions list", permissions);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function updatePermission(req: Request, res: Response) {
|
|
125
|
+
const { id } = req.params;
|
|
126
|
+
const permissionId = BigInt(id);
|
|
127
|
+
const { name, slug, description } = req.body || {};
|
|
128
|
+
const permission = await prisma.permissions.findUnique({
|
|
129
|
+
where: { id: permissionId },
|
|
130
|
+
});
|
|
131
|
+
if (!permission) {
|
|
132
|
+
sendError(res, 404, "Permission not found");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (slug) {
|
|
136
|
+
const exists = await prisma.permissions.findFirst({
|
|
137
|
+
where: {
|
|
138
|
+
slug,
|
|
139
|
+
NOT: { id: permissionId },
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
if (exists) {
|
|
143
|
+
sendError(res, 409, "Permission already exists", {
|
|
144
|
+
slug: ["Slug permission sudah digunakan"],
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const updated = await prisma.permissions.update({
|
|
150
|
+
where: { id: permissionId },
|
|
151
|
+
data: {
|
|
152
|
+
name: name ?? permission.name,
|
|
153
|
+
slug: slug ?? permission.slug,
|
|
154
|
+
description: description ?? permission.description,
|
|
155
|
+
updated_at: new Date(),
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
sendSuccess(res, 200, "Permission updated", updated);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function deletePermission(req: Request, res: Response) {
|
|
162
|
+
const { id } = req.params;
|
|
163
|
+
const permissionId = BigInt(id);
|
|
164
|
+
const permission = await prisma.permissions.findUnique({
|
|
165
|
+
where: { id: permissionId },
|
|
166
|
+
});
|
|
167
|
+
if (!permission) {
|
|
168
|
+
sendError(res, 404, "Permission not found");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
await prisma.role_permissions.deleteMany({
|
|
172
|
+
where: { permission_id: permissionId },
|
|
173
|
+
});
|
|
174
|
+
await prisma.user_permissions.deleteMany({
|
|
175
|
+
where: { permission_id: permissionId },
|
|
176
|
+
});
|
|
177
|
+
await prisma.permissions.delete({ where: { id: permissionId } });
|
|
178
|
+
sendSuccess(res, 200, "Permission deleted", null);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function assignRoleToUser(req: Request, res: Response) {
|
|
182
|
+
const { userId, roleId } = req.body || {};
|
|
183
|
+
if (!userId || !roleId) {
|
|
184
|
+
sendError(res, 422, "Validation error", {
|
|
185
|
+
userId: !userId ? ["userId wajib diisi"] : undefined,
|
|
186
|
+
roleId: !roleId ? ["roleId wajib diisi"] : undefined,
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const user = await prisma.users.findUnique({
|
|
191
|
+
where: { id: BigInt(userId) },
|
|
192
|
+
});
|
|
193
|
+
if (!user) {
|
|
194
|
+
sendError(res, 404, "User not found");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const role = await prisma.roles.findUnique({
|
|
198
|
+
where: { id: BigInt(roleId) },
|
|
199
|
+
});
|
|
200
|
+
if (!role) {
|
|
201
|
+
sendError(res, 404, "Role not found");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
await prisma.user_roles.upsert({
|
|
205
|
+
where: {
|
|
206
|
+
user_id_role_id: {
|
|
207
|
+
user_id: BigInt(userId),
|
|
208
|
+
role_id: BigInt(roleId),
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
create: {
|
|
212
|
+
user_id: BigInt(userId),
|
|
213
|
+
role_id: BigInt(roleId),
|
|
214
|
+
created_at: new Date(),
|
|
215
|
+
},
|
|
216
|
+
update: {},
|
|
217
|
+
});
|
|
218
|
+
sendSuccess(res, 200, "Role assigned to user", null);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function removeRoleFromUser(req: Request, res: Response) {
|
|
222
|
+
const { userId, roleId } = req.body || {};
|
|
223
|
+
if (!userId || !roleId) {
|
|
224
|
+
sendError(res, 422, "Validation error", {
|
|
225
|
+
userId: !userId ? ["userId wajib diisi"] : undefined,
|
|
226
|
+
roleId: !roleId ? ["roleId wajib diisi"] : undefined,
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
await prisma.user_roles.deleteMany({
|
|
231
|
+
where: {
|
|
232
|
+
user_id: BigInt(userId),
|
|
233
|
+
role_id: BigInt(roleId),
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
sendSuccess(res, 200, "Role removed from user", null);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function assignPermissionToRole(req: Request, res: Response) {
|
|
240
|
+
const { roleId, permissionId } = req.body || {};
|
|
241
|
+
if (!roleId || !permissionId) {
|
|
242
|
+
sendError(res, 422, "Validation error", {
|
|
243
|
+
roleId: !roleId ? ["roleId wajib diisi"] : undefined,
|
|
244
|
+
permissionId: !permissionId ? ["permissionId wajib diisi"] : undefined,
|
|
245
|
+
});
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const role = await prisma.roles.findUnique({
|
|
249
|
+
where: { id: BigInt(roleId) },
|
|
250
|
+
});
|
|
251
|
+
if (!role) {
|
|
252
|
+
sendError(res, 404, "Role not found");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const permission = await prisma.permissions.findUnique({
|
|
256
|
+
where: { id: BigInt(permissionId) },
|
|
257
|
+
});
|
|
258
|
+
if (!permission) {
|
|
259
|
+
sendError(res, 404, "Permission not found");
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
await prisma.role_permissions.upsert({
|
|
263
|
+
where: {
|
|
264
|
+
role_id_permission_id: {
|
|
265
|
+
role_id: BigInt(roleId),
|
|
266
|
+
permission_id: BigInt(permissionId),
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
create: {
|
|
270
|
+
role_id: BigInt(roleId),
|
|
271
|
+
permission_id: BigInt(permissionId),
|
|
272
|
+
created_at: new Date(),
|
|
273
|
+
},
|
|
274
|
+
update: {},
|
|
275
|
+
});
|
|
276
|
+
sendSuccess(res, 200, "Permission assigned to role", null);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function removePermissionFromRole(req: Request, res: Response) {
|
|
280
|
+
const { roleId, permissionId } = req.body || {};
|
|
281
|
+
if (!roleId || !permissionId) {
|
|
282
|
+
sendError(res, 422, "Validation error", {
|
|
283
|
+
roleId: !roleId ? ["roleId wajib diisi"] : undefined,
|
|
284
|
+
permissionId: !permissionId ? ["permissionId wajib diisi"] : undefined,
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
await prisma.role_permissions.deleteMany({
|
|
289
|
+
where: {
|
|
290
|
+
role_id: BigInt(roleId),
|
|
291
|
+
permission_id: BigInt(permissionId),
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
sendSuccess(res, 200, "Permission removed from role", null);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function assignPermissionToUser(req: Request, res: Response) {
|
|
298
|
+
const { userId, permissionId } = req.body || {};
|
|
299
|
+
if (!userId || !permissionId) {
|
|
300
|
+
sendError(res, 422, "Validation error", {
|
|
301
|
+
userId: !userId ? ["userId wajib diisi"] : undefined,
|
|
302
|
+
permissionId: !permissionId ? ["permissionId wajib diisi"] : undefined,
|
|
303
|
+
});
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const user = await prisma.users.findUnique({
|
|
307
|
+
where: { id: BigInt(userId) },
|
|
308
|
+
});
|
|
309
|
+
if (!user) {
|
|
310
|
+
sendError(res, 404, "User not found");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const permission = await prisma.permissions.findUnique({
|
|
314
|
+
where: { id: BigInt(permissionId) },
|
|
315
|
+
});
|
|
316
|
+
if (!permission) {
|
|
317
|
+
sendError(res, 404, "Permission not found");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
await prisma.user_permissions.upsert({
|
|
321
|
+
where: {
|
|
322
|
+
user_id_permission_id: {
|
|
323
|
+
user_id: BigInt(userId),
|
|
324
|
+
permission_id: BigInt(permissionId),
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
create: {
|
|
328
|
+
user_id: BigInt(userId),
|
|
329
|
+
permission_id: BigInt(permissionId),
|
|
330
|
+
created_at: new Date(),
|
|
331
|
+
},
|
|
332
|
+
update: {},
|
|
333
|
+
});
|
|
334
|
+
sendSuccess(res, 200, "Permission assigned to user", null);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function removePermissionFromUser(req: Request, res: Response) {
|
|
338
|
+
const { userId, permissionId } = req.body || {};
|
|
339
|
+
if (!userId || !permissionId) {
|
|
340
|
+
sendError(res, 422, "Validation error", {
|
|
341
|
+
userId: !userId ? ["userId wajib diisi"] : undefined,
|
|
342
|
+
permissionId: !permissionId ? ["permissionId wajib diisi"] : undefined,
|
|
343
|
+
});
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
await prisma.user_permissions.deleteMany({
|
|
347
|
+
where: {
|
|
348
|
+
user_id: BigInt(userId),
|
|
349
|
+
permission_id: BigInt(permissionId),
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
sendSuccess(res, 200, "Permission removed from user", null);
|
|
353
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import dotenv from "dotenv";
|
|
2
|
+
dotenv.config();
|
|
3
|
+
import { app } from "./server";
|
|
4
|
+
import http from "http";
|
|
5
|
+
import { initRealtime } from "./realtime";
|
|
6
|
+
import { useRedis, pingRedis } from "./redis";
|
|
7
|
+
|
|
8
|
+
const port = process.env.PORT ? Number(process.env.PORT) : 4000;
|
|
9
|
+
const server = http.createServer(app);
|
|
10
|
+
|
|
11
|
+
initRealtime(server);
|
|
12
|
+
|
|
13
|
+
server.listen(port, () => {
|
|
14
|
+
(async () => {
|
|
15
|
+
if (!useRedis) {
|
|
16
|
+
console.log("Redis not configured, using in-memory cache");
|
|
17
|
+
} else {
|
|
18
|
+
const ok = await pingRedis();
|
|
19
|
+
if (ok) {
|
|
20
|
+
console.log(`Redis connected at ${process.env.REDIS_URL}`);
|
|
21
|
+
} else {
|
|
22
|
+
console.log(
|
|
23
|
+
`Redis configured at ${process.env.REDIS_URL}, but not reachable. Using in-memory cache`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
console.log(`API running at http://localhost:${port}`);
|
|
28
|
+
})();
|
|
29
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from "express";
|
|
2
|
+
import jwt from "jsonwebtoken";
|
|
3
|
+
import { sendError } from "../utils/response";
|
|
4
|
+
import { ACCESS_TOKEN_EXPIRES_IN_SECONDS } from "../controllers/authController";
|
|
5
|
+
|
|
6
|
+
export function requireAuth(req: Request, res: Response, next: NextFunction) {
|
|
7
|
+
const header = req.headers.authorization;
|
|
8
|
+
if (!header || !header.startsWith("Bearer ")) {
|
|
9
|
+
sendError(res, 401, "Unauthorized");
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const token = header.slice(7);
|
|
13
|
+
const secret = process.env.JWT_SECRET;
|
|
14
|
+
if (!secret) {
|
|
15
|
+
sendError(res, 500, "Server misconfigured");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const payload = jwt.verify(token, secret) as {
|
|
20
|
+
userId: string;
|
|
21
|
+
role: string;
|
|
22
|
+
};
|
|
23
|
+
(req as any).user = { userId: payload.userId, role: payload.role };
|
|
24
|
+
|
|
25
|
+
const accessExpiresInSeconds = ACCESS_TOKEN_EXPIRES_IN_SECONDS;
|
|
26
|
+
const accessExpiresAt = new Date(
|
|
27
|
+
Date.now() + accessExpiresInSeconds * 1000
|
|
28
|
+
).toISOString();
|
|
29
|
+
const newToken = jwt.sign(
|
|
30
|
+
{ userId: payload.userId, role: payload.role },
|
|
31
|
+
secret,
|
|
32
|
+
{ expiresIn: accessExpiresInSeconds }
|
|
33
|
+
);
|
|
34
|
+
res.setHeader("x-access-token", newToken);
|
|
35
|
+
res.setHeader("x-access-expires-at", accessExpiresAt);
|
|
36
|
+
|
|
37
|
+
next();
|
|
38
|
+
} catch {
|
|
39
|
+
sendError(res, 401, "Invalid token");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
|
44
|
+
const user = (req as any).user as
|
|
45
|
+
| { userId: string; role: string }
|
|
46
|
+
| undefined;
|
|
47
|
+
if (!user) {
|
|
48
|
+
sendError(res, 401, "Unauthorized");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (user.role !== "admin" && user.role !== "super_admin") {
|
|
52
|
+
sendError(res, 403, "Forbidden");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
next();
|
|
56
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from "express"
|
|
2
|
+
import { sendError } from "../utils/response"
|
|
3
|
+
export function errorHandler(err: any, _req: Request, res: Response, _next: NextFunction) {
|
|
4
|
+
const code = err.statusCode || 500
|
|
5
|
+
const msg = err.message || "Internal Server Error"
|
|
6
|
+
sendError(res, code, msg)
|
|
7
|
+
}
|
|
8
|
+
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from "express";
|
|
2
|
+
import { v4 as uuidv4 } from "uuid";
|
|
3
|
+
import { redis, useRedis } from "../redis";
|
|
4
|
+
|
|
5
|
+
type DayMemoryStats = {
|
|
6
|
+
requests: number;
|
|
7
|
+
newVisitors: number;
|
|
8
|
+
visitors: Set<string>;
|
|
9
|
+
newVisitorsMobile: number;
|
|
10
|
+
visitorsMobile: Set<string>;
|
|
11
|
+
ipAddresses: number;
|
|
12
|
+
ipSet: Set<string>;
|
|
13
|
+
sessions: number;
|
|
14
|
+
sessionSet: Set<string>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const memoryStats = new Map<string, DayMemoryStats>();
|
|
18
|
+
const globalVisitors = new Set<string>();
|
|
19
|
+
|
|
20
|
+
function formatDateKey(d: Date) {
|
|
21
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
22
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
23
|
+
const yyyy = d.getFullYear();
|
|
24
|
+
return `${dd}-${mm}-${yyyy}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseCookies(header: string | undefined) {
|
|
28
|
+
const cookies: Record<string, string> = {};
|
|
29
|
+
if (!header) return cookies;
|
|
30
|
+
const parts = header.split(";");
|
|
31
|
+
for (const part of parts) {
|
|
32
|
+
const [k, v] = part.split("=").map((s) => s.trim());
|
|
33
|
+
if (k && v) cookies[k] = decodeURIComponent(v);
|
|
34
|
+
}
|
|
35
|
+
return cookies;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isMobileUserAgent(ua: string | undefined) {
|
|
39
|
+
if (!ua) return false;
|
|
40
|
+
return /Mobile|Android|iPhone|iPad|iPod/i.test(ua);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function visitorCounter(
|
|
44
|
+
req: Request,
|
|
45
|
+
res: Response,
|
|
46
|
+
next: NextFunction
|
|
47
|
+
) {
|
|
48
|
+
const now = new Date();
|
|
49
|
+
const dateKey = formatDateKey(now);
|
|
50
|
+
const ip =
|
|
51
|
+
req.ip ||
|
|
52
|
+
(req.headers["x-forwarded-for"] as string | undefined) ||
|
|
53
|
+
req.socket.remoteAddress ||
|
|
54
|
+
"";
|
|
55
|
+
const userAgent = req.headers["user-agent"] as string | undefined;
|
|
56
|
+
const mobile = isMobileUserAgent(userAgent);
|
|
57
|
+
|
|
58
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
59
|
+
let visitorId = cookies["visitor_id"];
|
|
60
|
+
if (!visitorId) {
|
|
61
|
+
visitorId = uuidv4();
|
|
62
|
+
res.cookie("visitor_id", visitorId, {
|
|
63
|
+
httpOnly: true,
|
|
64
|
+
sameSite: "lax",
|
|
65
|
+
maxAge: 365 * 24 * 60 * 60 * 1000,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let sessionId = cookies["visitor_session_id"];
|
|
70
|
+
if (!sessionId) {
|
|
71
|
+
sessionId = uuidv4();
|
|
72
|
+
res.cookie("visitor_session_id", sessionId, {
|
|
73
|
+
httpOnly: true,
|
|
74
|
+
sameSite: "lax",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (useRedis && redis) {
|
|
79
|
+
const base = dateKey;
|
|
80
|
+
const kRequests = `requests-${base}`;
|
|
81
|
+
const kNewVisitors = `new-visitors-${base}`;
|
|
82
|
+
const kVisitors = `visitors-${base}`;
|
|
83
|
+
const kNewVisitorsMobile = `new-visitors-from-mobile-${base}`;
|
|
84
|
+
const kVisitorsMobile = `visitors-from-mobile-${base}`;
|
|
85
|
+
const kIpAddresses = `ip-addresses-${base}`;
|
|
86
|
+
const kSessions = `sessions-${base}`;
|
|
87
|
+
const kVisitorsSet = `visitors-set-${base}`;
|
|
88
|
+
const kVisitorsMobileSet = `visitors-from-mobile-set-${base}`;
|
|
89
|
+
const kIpSet = `ip-addresses-set-${base}`;
|
|
90
|
+
const kSessionsSet = `sessions-set-${base}`;
|
|
91
|
+
const kVisitorsAll = `visitors-all`;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
await redis.incr(kRequests);
|
|
95
|
+
|
|
96
|
+
const isNewEver = await redis.sadd(kVisitorsAll, visitorId);
|
|
97
|
+
if (isNewEver === 1) {
|
|
98
|
+
await redis.incr(kNewVisitors);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const addedVisitor = await redis.sadd(kVisitorsSet, visitorId);
|
|
102
|
+
if (addedVisitor === 1) {
|
|
103
|
+
await redis.incr(kVisitors);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (mobile) {
|
|
107
|
+
const addedMobileVisitor = await redis.sadd(
|
|
108
|
+
kVisitorsMobileSet,
|
|
109
|
+
visitorId
|
|
110
|
+
);
|
|
111
|
+
if (addedMobileVisitor === 1) {
|
|
112
|
+
await redis.incr(kVisitorsMobile);
|
|
113
|
+
}
|
|
114
|
+
if (isNewEver === 1) {
|
|
115
|
+
await redis.incr(kNewVisitorsMobile);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (ip) {
|
|
120
|
+
const addedIp = await redis.sadd(kIpSet, ip);
|
|
121
|
+
if (addedIp === 1) {
|
|
122
|
+
await redis.incr(kIpAddresses);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const addedSession = await redis.sadd(kSessionsSet, sessionId);
|
|
127
|
+
if (addedSession === 1) {
|
|
128
|
+
await redis.incr(kSessions);
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
let stats = memoryStats.get(dateKey);
|
|
134
|
+
if (!stats) {
|
|
135
|
+
stats = {
|
|
136
|
+
requests: 0,
|
|
137
|
+
newVisitors: 0,
|
|
138
|
+
visitors: new Set<string>(),
|
|
139
|
+
newVisitorsMobile: 0,
|
|
140
|
+
visitorsMobile: new Set<string>(),
|
|
141
|
+
ipAddresses: 0,
|
|
142
|
+
ipSet: new Set<string>(),
|
|
143
|
+
sessions: 0,
|
|
144
|
+
sessionSet: new Set<string>(),
|
|
145
|
+
};
|
|
146
|
+
memoryStats.set(dateKey, stats);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
stats.requests += 1;
|
|
150
|
+
|
|
151
|
+
if (!globalVisitors.has(visitorId)) {
|
|
152
|
+
globalVisitors.add(visitorId);
|
|
153
|
+
stats.newVisitors += 1;
|
|
154
|
+
if (mobile) {
|
|
155
|
+
stats.newVisitorsMobile += 1;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!stats.visitors.has(visitorId)) {
|
|
160
|
+
stats.visitors.add(visitorId);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (mobile && !stats.visitorsMobile.has(visitorId)) {
|
|
164
|
+
stats.visitorsMobile.add(visitorId);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (ip && !stats.ipSet.has(ip)) {
|
|
168
|
+
stats.ipSet.add(ip);
|
|
169
|
+
stats.ipAddresses += 1;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!stats.sessionSet.has(sessionId)) {
|
|
173
|
+
stats.sessionSet.add(sessionId);
|
|
174
|
+
stats.sessions += 1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
next();
|
|
179
|
+
}
|
|
180
|
+
|