lapeeh 1.0.0

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.
Files changed (76) hide show
  1. package/.env.example +14 -0
  2. package/LICENSE +21 -0
  3. package/bin/index.js +934 -0
  4. package/doc/en/ARCHITECTURE_GUIDE.md +79 -0
  5. package/doc/en/CHANGELOG.md +203 -0
  6. package/doc/en/CHEATSHEET.md +90 -0
  7. package/doc/en/CLI.md +111 -0
  8. package/doc/en/CONTRIBUTING.md +119 -0
  9. package/doc/en/DEPLOYMENT.md +171 -0
  10. package/doc/en/FAQ.md +69 -0
  11. package/doc/en/FEATURES.md +99 -0
  12. package/doc/en/GETTING_STARTED.md +84 -0
  13. package/doc/en/INTRODUCTION.md +62 -0
  14. package/doc/en/PACKAGES.md +63 -0
  15. package/doc/en/PERFORMANCE.md +98 -0
  16. package/doc/en/ROADMAP.md +104 -0
  17. package/doc/en/SECURITY.md +95 -0
  18. package/doc/en/STRUCTURE.md +79 -0
  19. package/doc/en/TUTORIAL.md +145 -0
  20. package/doc/id/ARCHITECTURE_GUIDE.md +76 -0
  21. package/doc/id/CHANGELOG.md +203 -0
  22. package/doc/id/CHEATSHEET.md +90 -0
  23. package/doc/id/CLI.md +139 -0
  24. package/doc/id/CONTRIBUTING.md +119 -0
  25. package/doc/id/DEPLOYMENT.md +171 -0
  26. package/doc/id/FAQ.md +69 -0
  27. package/doc/id/FEATURES.md +169 -0
  28. package/doc/id/GETTING_STARTED.md +91 -0
  29. package/doc/id/INTRODUCTION.md +62 -0
  30. package/doc/id/PACKAGES.md +63 -0
  31. package/doc/id/PERFORMANCE.md +100 -0
  32. package/doc/id/ROADMAP.md +107 -0
  33. package/doc/id/SECURITY.md +94 -0
  34. package/doc/id/STRUCTURE.md +79 -0
  35. package/doc/id/TUTORIAL.md +145 -0
  36. package/docker-compose.yml +24 -0
  37. package/ecosystem.config.js +17 -0
  38. package/eslint.config.mjs +26 -0
  39. package/gitignore.template +30 -0
  40. package/lib/bootstrap.ts +210 -0
  41. package/lib/core/realtime.ts +34 -0
  42. package/lib/core/redis.ts +139 -0
  43. package/lib/core/serializer.ts +63 -0
  44. package/lib/core/server.ts +70 -0
  45. package/lib/core/store.ts +116 -0
  46. package/lib/middleware/auth.ts +63 -0
  47. package/lib/middleware/error.ts +50 -0
  48. package/lib/middleware/multipart.ts +13 -0
  49. package/lib/middleware/rateLimit.ts +14 -0
  50. package/lib/middleware/requestLogger.ts +27 -0
  51. package/lib/middleware/visitor.ts +178 -0
  52. package/lib/utils/logger.ts +100 -0
  53. package/lib/utils/pagination.ts +56 -0
  54. package/lib/utils/response.ts +88 -0
  55. package/lib/utils/validator.ts +394 -0
  56. package/nodemon.json +6 -0
  57. package/package.json +126 -0
  58. package/readme.md +357 -0
  59. package/scripts/check-update.js +92 -0
  60. package/scripts/config-clear.js +45 -0
  61. package/scripts/generate-jwt-secret.js +38 -0
  62. package/scripts/init-project.js +84 -0
  63. package/scripts/make-module.js +89 -0
  64. package/scripts/release.js +494 -0
  65. package/scripts/seed-json.js +158 -0
  66. package/scripts/verify-rbac-functional.js +187 -0
  67. package/src/config/app.ts +9 -0
  68. package/src/config/cors.ts +5 -0
  69. package/src/modules/Auth/auth.controller.ts +519 -0
  70. package/src/modules/Rbac/rbac.controller.ts +533 -0
  71. package/src/routes/auth.ts +74 -0
  72. package/src/routes/index.ts +7 -0
  73. package/src/routes/rbac.ts +42 -0
  74. package/storage/logs/.gitkeep +0 -0
  75. package/tsconfig.build.json +12 -0
  76. package/tsconfig.json +30 -0
@@ -0,0 +1,50 @@
1
+ import { Request, Response, NextFunction } from "express";
2
+ import { ZodError } from "zod";
3
+ import { sendError } from "../utils/response";
4
+ import { Log } from "../utils/logger";
5
+
6
+ export function errorHandler(
7
+ err: any,
8
+ req: Request,
9
+ res: Response,
10
+ _next: NextFunction
11
+ ) {
12
+ // 1. Zod Validation Error
13
+ if (err instanceof ZodError) {
14
+ const formattedErrors = err.errors.map((e) => ({
15
+ field: e.path.join("."),
16
+ message: e.message,
17
+ }));
18
+ return sendError(res, 400, "Validation Error", formattedErrors);
19
+ }
20
+
21
+ // 2. JWT Errors
22
+ if (err.name === "JsonWebTokenError") {
23
+ return sendError(res, 401, "Invalid token");
24
+ }
25
+ if (err.name === "TokenExpiredError") {
26
+ return sendError(res, 401, "Token expired");
27
+ }
28
+
29
+ // 4. Syntax Error (JSON body parsing)
30
+ if (err instanceof SyntaxError && "body" in err) {
31
+ return sendError(res, 400, "Invalid JSON format");
32
+ }
33
+
34
+ // 5. Default / Custom Error
35
+ const code = err.statusCode || 500;
36
+ const msg = err.message || "Internal Server Error";
37
+
38
+ // Log error (file log for production, console for dev)
39
+ if (code === 500) {
40
+ Log.error(msg, {
41
+ error: err,
42
+ path: req.path,
43
+ method: req.method,
44
+ ip: req.ip,
45
+ stack: err.stack,
46
+ });
47
+ }
48
+
49
+ return sendError(res, code, msg);
50
+ }
@@ -0,0 +1,13 @@
1
+ import multer from "multer";
2
+
3
+ // Middleware for parsing multipart/form-data (text fields only)
4
+ export const parseMultipart = multer().none();
5
+
6
+ // Middleware for parsing multipart/form-data with files
7
+ // You can configure storage/limits here as needed
8
+ export const upload = multer({
9
+ dest: "storage/uploads/",
10
+ limits: {
11
+ fileSize: 5 * 1024 * 1024, // 5MB
12
+ },
13
+ });
@@ -0,0 +1,14 @@
1
+ import rateLimit from "express-rate-limit";
2
+ // import { redis } from "../core/redis"; // Optional: Use Redis for distributed rate limiting
3
+
4
+ // Rate limiting untuk mencegah brute force dan DDoS ringan
5
+ export const apiLimiter = rateLimit({
6
+ windowMs: 15 * 60 * 1000, // 15 menit
7
+ max: 100, // Batas 100 request per window per IP
8
+ standardHeaders: true, // Return rate limit info di `RateLimit-*` headers
9
+ legacyHeaders: false, // Disable `X-RateLimit-*` headers
10
+ message: {
11
+ success: false,
12
+ message: "Terlalu banyak permintaan, silakan coba lagi nanti.",
13
+ },
14
+ });
@@ -0,0 +1,27 @@
1
+ import { Request, Response, NextFunction } from "express";
2
+ import { Log } from "../utils/logger";
3
+
4
+ export const requestLogger = (
5
+ req: Request,
6
+ res: Response,
7
+ next: NextFunction
8
+ ) => {
9
+ const start = Date.now();
10
+ const { method, url, ip } = req;
11
+
12
+ // Log saat response selesai
13
+ res.on("finish", () => {
14
+ const duration = Date.now() - start;
15
+ const { statusCode } = res;
16
+
17
+ const message = `${method} ${url} ${statusCode} - ${duration}ms - ${ip}`;
18
+
19
+ if (statusCode >= 400) {
20
+ Log.warn(message);
21
+ } else {
22
+ Log.info(message);
23
+ }
24
+ });
25
+
26
+ next();
27
+ };
@@ -0,0 +1,178 @@
1
+ import { Request, Response, NextFunction } from "express";
2
+ import { v4 as uuidv4 } from "uuid";
3
+ import { redis } from "../core/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 (redis && redis.status === "ready") {
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
+ } else {
132
+ let stats = memoryStats.get(dateKey);
133
+ if (!stats) {
134
+ stats = {
135
+ requests: 0,
136
+ newVisitors: 0,
137
+ visitors: new Set<string>(),
138
+ newVisitorsMobile: 0,
139
+ visitorsMobile: new Set<string>(),
140
+ ipAddresses: 0,
141
+ ipSet: new Set<string>(),
142
+ sessions: 0,
143
+ sessionSet: new Set<string>(),
144
+ };
145
+ memoryStats.set(dateKey, stats);
146
+ }
147
+
148
+ stats.requests += 1;
149
+
150
+ if (!globalVisitors.has(visitorId)) {
151
+ globalVisitors.add(visitorId);
152
+ stats.newVisitors += 1;
153
+ if (mobile) {
154
+ stats.newVisitorsMobile += 1;
155
+ }
156
+ }
157
+
158
+ if (!stats.visitors.has(visitorId)) {
159
+ stats.visitors.add(visitorId);
160
+ }
161
+
162
+ if (mobile && !stats.visitorsMobile.has(visitorId)) {
163
+ stats.visitorsMobile.add(visitorId);
164
+ }
165
+
166
+ if (ip && !stats.ipSet.has(ip)) {
167
+ stats.ipSet.add(ip);
168
+ stats.ipAddresses += 1;
169
+ }
170
+
171
+ if (!stats.sessionSet.has(sessionId)) {
172
+ stats.sessionSet.add(sessionId);
173
+ stats.sessions += 1;
174
+ }
175
+ }
176
+
177
+ next();
178
+ }
@@ -0,0 +1,100 @@
1
+ import winston from "winston";
2
+ import "winston-daily-rotate-file";
3
+ import path from "path";
4
+ import fs from "fs";
5
+
6
+ const logDirectory = path.join(process.cwd(), "storage", "logs");
7
+
8
+ // Ensure log directory exists
9
+ if (!fs.existsSync(logDirectory)) {
10
+ fs.mkdirSync(logDirectory, { recursive: true });
11
+ }
12
+
13
+ const dailyRotateFileTransport = new winston.transports.DailyRotateFile({
14
+ filename: "lapeeh-%DATE%.log",
15
+ dirname: logDirectory,
16
+ datePattern: "YYYY-MM-DD",
17
+ zippedArchive: true,
18
+ maxSize: "20m",
19
+ maxFiles: "3d",
20
+ format: winston.format.combine(
21
+ winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
22
+ winston.format.printf((info: any) => {
23
+ let log = `[${info.timestamp}] ${info.level.toUpperCase()}: ${
24
+ info.message
25
+ }`;
26
+
27
+ // Handle metadata (errors, etc)
28
+ const { timestamp, level, message, service, ...meta } = info;
29
+ if (Object.keys(meta).length > 0) {
30
+ // If meta has 'errors', nicely format it
31
+ if (meta.errors) {
32
+ log += `\nErrors: ${JSON.stringify(meta.errors, null, 2)}`;
33
+ delete meta.errors;
34
+ }
35
+ // If there are other meta properties remaining, log them
36
+ if (Object.keys(meta).length > 0 && !meta.stack && !meta.error) {
37
+ log += `\nMeta: ${JSON.stringify(meta)}`;
38
+ }
39
+ }
40
+
41
+ if (info.stack) {
42
+ log += `\n${info.stack}`;
43
+ } else if (info.error && info.error.stack) {
44
+ log += `\n${info.error.stack}`;
45
+ }
46
+ return log;
47
+ })
48
+ ),
49
+ });
50
+
51
+ const logger = winston.createLogger({
52
+ level: process.env.LOG_LEVEL || "info",
53
+ format: winston.format.combine(
54
+ winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
55
+ winston.format.errors({ stack: true }),
56
+ winston.format.splat(),
57
+ winston.format.json()
58
+ ),
59
+ defaultMeta: { service: "lapeeh-service" },
60
+ transports: [
61
+ // Write all logs with importance level of `error` or less to `error.log`
62
+ // new winston.transports.File({ filename: 'error.log', level: 'error' }),
63
+ // Write all logs to `combined.log`
64
+ // new winston.transports.File({ filename: 'combined.log' }),
65
+ dailyRotateFileTransport,
66
+ ],
67
+ });
68
+
69
+ // If we're not in production then log to the `console` with the format:
70
+ // `${info.level}: ${info.message} JSON.stringify({ ...rest }) `
71
+ if (process.env.NODE_ENV !== "production") {
72
+ logger.add(
73
+ new winston.transports.Console({
74
+ format: winston.format.combine(
75
+ winston.format.colorize(),
76
+ winston.format.simple()
77
+ ),
78
+ })
79
+ );
80
+ }
81
+
82
+ export class Log {
83
+ static info(message: string, meta?: any) {
84
+ logger.info(message, meta);
85
+ }
86
+
87
+ static error(message: string, meta?: any) {
88
+ logger.error(message, meta);
89
+ }
90
+
91
+ static warn(message: string, meta?: any) {
92
+ logger.warn(message, meta);
93
+ }
94
+
95
+ static debug(message: string, meta?: any) {
96
+ logger.debug(message, meta);
97
+ }
98
+ }
99
+
100
+ export default logger;
@@ -0,0 +1,56 @@
1
+ export type PaginationQuery = {
2
+ page?: string | string[] | number;
3
+ per_page?: string | string[] | number;
4
+ };
5
+
6
+ export type PaginationParams = {
7
+ page: number;
8
+ perPage: number;
9
+ skip: number;
10
+ take: number;
11
+ };
12
+
13
+ export type PaginationMeta = {
14
+ page: number;
15
+ perPage: number;
16
+ total: number;
17
+ lastPage: number;
18
+ };
19
+
20
+ function toNumber(value: string | string[] | number | undefined) {
21
+ if (Array.isArray(value)) {
22
+ if (value.length === 0) return undefined;
23
+ return toNumber(value[0]);
24
+ }
25
+ if (typeof value === "number") {
26
+ return value;
27
+ }
28
+ if (typeof value === "string") {
29
+ const n = parseInt(value, 10);
30
+ if (!Number.isNaN(n)) {
31
+ return n;
32
+ }
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ export function getPagination(query: PaginationQuery): PaginationParams {
38
+ const pageRaw = toNumber(query.page);
39
+ const perPageRaw = toNumber(query.per_page);
40
+ const page = pageRaw && pageRaw > 0 ? pageRaw : 1;
41
+ const perPage =
42
+ perPageRaw && perPageRaw > 0 && perPageRaw <= 100 ? perPageRaw : 10;
43
+ const skip = (page - 1) * perPage;
44
+ const take = perPage;
45
+ return { page, perPage, skip, take };
46
+ }
47
+
48
+ export function buildPaginationMeta(
49
+ page: number,
50
+ perPage: number,
51
+ total: number
52
+ ): PaginationMeta {
53
+ const lastPage = total === 0 ? 1 : Math.ceil(total / perPage);
54
+ return { page, perPage, total, lastPage };
55
+ }
56
+
@@ -0,0 +1,88 @@
1
+ import { Response } from "express";
2
+ import { Log } from "./logger";
3
+
4
+ type SuccessStatus = "success";
5
+ type ErrorStatus = "error";
6
+
7
+ type SuccessBody<T> = {
8
+ status: SuccessStatus;
9
+ message: string;
10
+ data?: T;
11
+ };
12
+
13
+ type ErrorBody<T = unknown> = {
14
+ status: ErrorStatus;
15
+ message: string;
16
+ errors?: T;
17
+ };
18
+
19
+ function toJsonSafe(value: unknown): unknown {
20
+ if (value instanceof Date) {
21
+ return value.toISOString();
22
+ }
23
+ if (typeof value === "bigint") {
24
+ return value.toString();
25
+ }
26
+ if (Array.isArray(value)) {
27
+ return value.map((item) => toJsonSafe(item));
28
+ }
29
+ if (value && typeof value === "object") {
30
+ const result: Record<string, unknown> = {};
31
+ for (const [key, val] of Object.entries(value)) {
32
+ result[key] = toJsonSafe(val);
33
+ }
34
+ return result;
35
+ }
36
+ return value;
37
+ }
38
+
39
+ export function sendSuccess<T = any>(
40
+ res: Response,
41
+ statusCode: number,
42
+ message: string,
43
+ data?: T
44
+ ) {
45
+ const body: SuccessBody<T | undefined> = { status: "success", message, data };
46
+ return res.status(statusCode).json(toJsonSafe(body));
47
+ }
48
+
49
+ /**
50
+ * Mengirim response sukses dengan performa tinggi menggunakan Schema Serialization (Fastify-style).
51
+ * Melewati proses JSON.stringify standar yang lambat.
52
+ *
53
+ * @param serializer Fungsi serializer yang sudah dicompile dari src/core/serializer
54
+ */
55
+ export function sendFastSuccess(
56
+ res: Response,
57
+ statusCode: number,
58
+ serializer: (doc: any) => string,
59
+ data: any
60
+ ) {
61
+ // Set header manual karena kita mengirim raw string
62
+ res.setHeader("Content-Type", "application/json");
63
+ res.status(statusCode);
64
+
65
+ // Serializer mengembalikan string JSON
66
+ const jsonString = serializer(data);
67
+ return res.send(jsonString);
68
+ }
69
+
70
+ export function sendError<T = unknown>(
71
+ res: Response,
72
+ statusCode: number,
73
+ message: string,
74
+ errors?: T
75
+ ) {
76
+ // Log the error
77
+ if (statusCode >= 500) {
78
+ Log.error(message, { statusCode, errors });
79
+ } else if (statusCode >= 400) {
80
+ Log.warn(message, { statusCode, errors });
81
+ }
82
+
83
+ const body: ErrorBody<T> = { status: "error", message };
84
+ if (errors !== undefined) {
85
+ body.errors = errors;
86
+ }
87
+ return res.status(statusCode).json(toJsonSafe(body));
88
+ }