OPEN-ISP v0.5 (pickle-lime) — rilis open-source perdana
Aplikasi billing & manajemen ISP (fiber optic) — PWA pelanggan, portal teknisi & kolektor, admin panel lengkap, multi-vendor OLT, RADIUS, WhatsApp/Telegram, pembayaran QRIS. Kredit & lisensi (ISC): - Base kode & repo: alijayanet/billing-rtrw oleh Alijaya Net — terima kasih banyak atas base kode dan repo yang bermanfaat. - Add-on: GoWA, LibreSpeed/OpenSpeedTest, Uptime Kuma, notification-forwarder. - Dikembangkan menjadi OPEN-ISP oleh oxpoe — https://github.com/oxpoe/open-isp
This commit is contained in:
commit
eaf05f2811
280 files changed
+123975
No files matched your search
@@ -0,0 +1,55 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
|
||||
const uploadDir = path.join(__dirname, '../public/uploads/attendance');
|
||||
|
||||
function ensureUploadDir() {
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination(req, file, cb) {
|
||||
ensureUploadDir();
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename(req, file, cb) {
|
||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
||||
cb(null, `attendance-${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||
}
|
||||
});
|
||||
|
||||
const uploadAttendance = multer({
|
||||
storage,
|
||||
limits: { fileSize: 3 * 1024 * 1024 },
|
||||
fileFilter(req, file, cb) {
|
||||
const allowedTypes = /jpeg|jpg|png|webp/;
|
||||
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
||||
const mimetype = allowedTypes.test(file.mimetype);
|
||||
if (mimetype && extname) {
|
||||
return cb(null, true);
|
||||
}
|
||||
cb(new Error('Hanya file gambar yang diperbolehkan (JPEG, PNG, WebP)'));
|
||||
}
|
||||
});
|
||||
|
||||
function removeAttendanceFile(file) {
|
||||
if (!file) return;
|
||||
const targetPath = typeof file === 'string' ? file : file.path;
|
||||
if (!targetPath) return;
|
||||
|
||||
try {
|
||||
if (fs.existsSync(targetPath)) {
|
||||
fs.unlinkSync(targetPath);
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore cleanup failures so the main request can finish.
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
uploadAttendance,
|
||||
removeAttendanceFile
|
||||
};
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Error Handling Middleware
|
||||
* Menangani error secara terpusat dan memberikan response yang konsisten
|
||||
*/
|
||||
const { logger } = require('../config/logger');
|
||||
|
||||
/**
|
||||
* Custom Error Classes
|
||||
*/
|
||||
class AppError extends Error {
|
||||
constructor(message, statusCode, isOperational = true) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.isOperational = isOperational;
|
||||
this.timestamp = new Date().toISOString();
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
class ValidationError extends AppError {
|
||||
constructor(message) {
|
||||
super(message, 400);
|
||||
this.name = 'ValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
class UnauthorizedError extends AppError {
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(message, 401);
|
||||
this.name = 'UnauthorizedError';
|
||||
}
|
||||
}
|
||||
|
||||
class ForbiddenError extends AppError {
|
||||
constructor(message = 'Forbidden') {
|
||||
super(message, 403);
|
||||
this.name = 'ForbiddenError';
|
||||
}
|
||||
}
|
||||
|
||||
class NotFoundError extends AppError {
|
||||
constructor(message = 'Resource not found') {
|
||||
super(message, 404);
|
||||
this.name = 'NotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
class ConflictError extends AppError {
|
||||
constructor(message = 'Resource conflict') {
|
||||
super(message, 409);
|
||||
this.name = 'ConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
class RateLimitError extends AppError {
|
||||
constructor(message = 'Rate limit exceeded') {
|
||||
super(message, 429);
|
||||
this.name = 'RateLimitError';
|
||||
}
|
||||
}
|
||||
|
||||
class InternalServerError extends AppError {
|
||||
constructor(message = 'Internal server error') {
|
||||
super(message, 500);
|
||||
this.name = 'InternalServerError';
|
||||
this.isOperational = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error Handler Middleware
|
||||
*/
|
||||
function errorHandler(err, req, res, next) {
|
||||
if (res.headersSent) return next(err);
|
||||
// Log error
|
||||
if (err.isOperational) {
|
||||
logger.warn(`[Error Handler] Operational error: ${err.message}`, {
|
||||
statusCode: err.statusCode,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip
|
||||
});
|
||||
} else {
|
||||
logger.error(`[Error Handler] Unexpected error: ${err.message}`, {
|
||||
statusCode: err.statusCode,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
stack: err.stack
|
||||
});
|
||||
}
|
||||
|
||||
// Determine status code
|
||||
const statusCode = err.statusCode || 500;
|
||||
|
||||
// Determine response format based on request type
|
||||
const isApi = req.path.startsWith('/api/');
|
||||
const _accH = String(req.headers.accept || '');
|
||||
const _xrw = String(req.headers['x-requested-with'] || '').toLowerCase();
|
||||
const isJson = isApi || _accH.indexOf('application/json') !== -1 || req.xhr === true || _xrw === 'fetch';
|
||||
|
||||
if (isJson) {
|
||||
// JSON response for API requests
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: err.message || 'An error occurred',
|
||||
type: err.name || 'Error',
|
||||
statusCode: statusCode,
|
||||
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// HTML response for web requests
|
||||
const errorMessage = err.message || 'Terjadi kesalahan. Silakan coba lagi atau hubungi admin.';
|
||||
const errorTitle = err.name || 'Error';
|
||||
|
||||
// Set flash message for web pages
|
||||
if (req.session) {
|
||||
req.session._msg = {
|
||||
type: 'error',
|
||||
text: errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
// Render error page (kondisi JSON sudah ditangani di atas)
|
||||
res.status(statusCode).render('error', {
|
||||
title: `${statusCode} - ${errorTitle}`,
|
||||
error: errorMessage,
|
||||
statusCode: statusCode,
|
||||
path: req.path
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 404 Not Found Handler
|
||||
*/
|
||||
function notFoundHandler(req, res, next) {
|
||||
const isApi = req.path.startsWith('/api/');
|
||||
const _accH = String(req.headers.accept || '');
|
||||
const _xrw = String(req.headers['x-requested-with'] || '').toLowerCase();
|
||||
const isJson = isApi || _accH.indexOf('application/json') !== -1 || req.xhr === true || _xrw === 'fetch';
|
||||
|
||||
if (isJson) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Resource not found',
|
||||
type: 'NotFoundError',
|
||||
statusCode: 404,
|
||||
path: req.path
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (req.session) {
|
||||
req.session._msg = {
|
||||
type: 'error',
|
||||
text: 'Halaman tidak ditemukan'
|
||||
};
|
||||
}
|
||||
res.status(404).render('error', {
|
||||
title: '404 - Not Found',
|
||||
error: 'Halaman yang Anda cari tidak ditemukan',
|
||||
statusCode: 404,
|
||||
path: req.path
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Async Error Wrapper
|
||||
* Menangani error di async functions secara otomatis
|
||||
*/
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation Helper
|
||||
* Memvalidasi input dan throw ValidationError jika tidak valid
|
||||
*/
|
||||
function validateInput(data, schema) {
|
||||
const errors = [];
|
||||
|
||||
for (const [field, rules] of Object.entries(schema)) {
|
||||
const value = data[field];
|
||||
|
||||
// Required check
|
||||
if (rules.required && (value === undefined || value === null || value === '')) {
|
||||
errors.push(`${field} is required`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip other validations if field is not required and not provided
|
||||
if (!rules.required && (value === undefined || value === null || value === '')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type check
|
||||
if (rules.type) {
|
||||
if (rules.type === 'string' && typeof value !== 'string') {
|
||||
errors.push(`${field} must be a string`);
|
||||
} else if (rules.type === 'number' && typeof value !== 'number') {
|
||||
errors.push(`${field} must be a number`);
|
||||
} else if (rules.type === 'email' && typeof value === 'string') {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
errors.push(`${field} must be a valid email`);
|
||||
}
|
||||
} else if (rules.type === 'phone' && typeof value === 'string') {
|
||||
const phoneRegex = /^[\d\s\-\+]+$/;
|
||||
if (!phoneRegex.test(value)) {
|
||||
errors.push(`${field} must be a valid phone number`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Min length check
|
||||
if (rules.minLength && typeof value === 'string' && value.length < rules.minLength) {
|
||||
errors.push(`${field} must be at least ${rules.minLength} characters`);
|
||||
}
|
||||
|
||||
// Max length check
|
||||
if (rules.maxLength && typeof value === 'string' && value.length > rules.maxLength) {
|
||||
errors.push(`${field} must be at most ${rules.maxLength} characters`);
|
||||
}
|
||||
|
||||
// Min value check
|
||||
if (rules.min !== undefined && typeof value === 'number' && value < rules.min) {
|
||||
errors.push(`${field} must be at least ${rules.min}`);
|
||||
}
|
||||
|
||||
// Max value check
|
||||
if (rules.max !== undefined && typeof value === 'number' && value > rules.max) {
|
||||
errors.push(`${field} must be at most ${rules.max}`);
|
||||
}
|
||||
|
||||
// Pattern check
|
||||
if (rules.pattern && typeof value === 'string') {
|
||||
if (!rules.pattern.test(value)) {
|
||||
errors.push(`${field} format is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom validation
|
||||
if (rules.validate && typeof rules.validate === 'function') {
|
||||
const result = rules.validate(value);
|
||||
if (result !== true) {
|
||||
errors.push(result || `${field} is invalid`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new ValidationError(errors.join(', '));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
errorHandler,
|
||||
notFoundHandler,
|
||||
asyncHandler,
|
||||
validateInput,
|
||||
AppError,
|
||||
ValidationError,
|
||||
UnauthorizedError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
RateLimitError,
|
||||
InternalServerError
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
let loginRateLimiter = (req, res, next) => next();
|
||||
|
||||
try {
|
||||
const rateLimit = require('express-rate-limit');
|
||||
loginRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 30, // Limit each IP to 30 login requests per windowMs (CGNAT-friendly)
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
validate: { xForwardedForHeader: false },
|
||||
handler: (req, res) => {
|
||||
if (req.xhr || (req.headers.accept && req.headers.accept.includes('application/json'))) {
|
||||
return res.status(429).json({
|
||||
ok: false,
|
||||
error: 'Terlalu banyak percobaan login. Silakan coba lagi dalam 15 menit.'
|
||||
});
|
||||
}
|
||||
return res.status(429).send(`
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Akses Dibatasi | Keamanan Portal</title>
|
||||
<style>
|
||||
body { display:flex; align-items:center; justify-content:center; min-height:100vh; background:#0f172a; color:#f8fafc; font-family:system-ui, -apple-system, sans-serif; margin:0; }
|
||||
.card-limit { background:#1e293b; padding:32px; border-radius:12px; border:1px solid #334155; text-align:center; max-width:420px; box-shadow: 0 10px 25px -5px rgba(0,0,0,0.5); }
|
||||
.card-limit h3 { color:#ef4444; margin-top:0; margin-bottom:12px; font-size:20px; }
|
||||
.card-limit p { color:#94a3b8; font-size:14px; line-height:1.5; margin-bottom:24px; }
|
||||
.btn-back { display:inline-block; padding:10px 20px; background:#3b82f6; color:#fff; text-decoration:none; border-radius:8px; font-size:14px; font-weight:600; }
|
||||
.btn-back:hover { background:#2563eb; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card-limit">
|
||||
<h3>🚫 Terlalu Banyak Percobaan Login</h3>
|
||||
<p>Sistem keamanan mendeteksi terlalu banyak percobaan login dari IP/perangkat Anda. Untuk mencegah akses tanpa izin, login dibatasi sementara selama 15 menit.</p>
|
||||
<a href="javascript:history.back()" class="btn-back">Kembali ke Halaman Login</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// express-rate-limit missing fallback
|
||||
loginRateLimiter = (req, res, next) => next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loginRateLimiter
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Settings Middleware
|
||||
* Untuk access control & validation pada settings endpoints
|
||||
*/
|
||||
const { logger } = require('../config/logger');
|
||||
const { validateSettings } = require('../config/settingsValidator');
|
||||
const { getMaskedSettings } = require('../config/settingsEncryption');
|
||||
|
||||
/**
|
||||
* Require Super Admin untuk akses settings
|
||||
*/
|
||||
function requireSuperAdmin(req, res, next) {
|
||||
if (req.session?.isAdmin && req.session?.isSuperAdmin) {
|
||||
return next();
|
||||
}
|
||||
|
||||
logger.warn(`[settings] Unauthorized settings access attempt from ${req.ip}`);
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden - Super Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate settings sebelum save
|
||||
*/
|
||||
function validateSettingsMiddleware(req, res, next) {
|
||||
try {
|
||||
let settings = req.body;
|
||||
|
||||
// Normalize boolean fields dari form submission
|
||||
const booleanFields = [
|
||||
'whatsapp_enabled',
|
||||
'telegram_enabled',
|
||||
'tripay_enabled',
|
||||
'midtrans_enabled',
|
||||
'xendit_enabled',
|
||||
'duitku_enabled',
|
||||
'auto_backup_enabled',
|
||||
'login_otp_enabled'
|
||||
];
|
||||
|
||||
booleanFields.forEach(field => {
|
||||
if (field in settings) {
|
||||
const value = settings[field];
|
||||
if (typeof value === 'string') {
|
||||
settings[field] = value === 'true' || value === '1' || value === 'on';
|
||||
} else if (value === undefined || value === null) {
|
||||
settings[field] = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Trim string fields yang sensitive terhadap whitespace
|
||||
const stringTrimFields = ['office_lat', 'office_lng', 'company_phone', 'company_email'];
|
||||
stringTrimFields.forEach(field => {
|
||||
if (field in settings && typeof settings[field] === 'string') {
|
||||
settings[field] = settings[field].trim();
|
||||
}
|
||||
});
|
||||
|
||||
// Validate
|
||||
const validation = validateSettings(settings);
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
errors: validation.errors
|
||||
});
|
||||
}
|
||||
|
||||
// Attach validated settings ke request
|
||||
req.validatedSettings = settings;
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error(`[settings] Validation error: ${error.message}`);
|
||||
return res.status(500).json({
|
||||
error: 'Internal server error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask sensitive values dalam response
|
||||
*/
|
||||
function maskSensitiveValues(req, res, next) {
|
||||
// Override res.json untuk mask sensitive values
|
||||
const originalJson = res.json.bind(res);
|
||||
|
||||
res.json = function(data) {
|
||||
if (data && data.settings) {
|
||||
data.settings = getMaskedSettings(data.settings);
|
||||
}
|
||||
return originalJson(data);
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Log settings access
|
||||
*/
|
||||
function logSettingsAccess(req, res, next) {
|
||||
const originalJson = res.json.bind(res);
|
||||
|
||||
res.json = function(data) {
|
||||
if (req.method === 'POST' || req.method === 'PUT') {
|
||||
logger.info(`[settings] Settings ${req.method} by ${req.session?.username || 'unknown'}`, {
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent')
|
||||
});
|
||||
}
|
||||
return originalJson(data);
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiting untuk settings endpoints
|
||||
*/
|
||||
function rateLimitSettings(req, res, next) {
|
||||
// Simple rate limiting: max 10 requests per minute per IP
|
||||
const key = `settings:${req.ip}`;
|
||||
const store = req.app.locals.rateLimitStore || new Map();
|
||||
|
||||
if (!store.has(key)) {
|
||||
store.set(key, { count: 1, resetTime: Date.now() + 60000 });
|
||||
req.app.locals.rateLimitStore = store;
|
||||
return next();
|
||||
}
|
||||
|
||||
const limit = store.get(key);
|
||||
if (Date.now() > limit.resetTime) {
|
||||
limit.count = 1;
|
||||
limit.resetTime = Date.now() + 60000;
|
||||
return next();
|
||||
}
|
||||
|
||||
if (limit.count >= 10) {
|
||||
logger.warn(`[settings] Rate limit exceeded for ${req.ip}`);
|
||||
return res.status(429).json({
|
||||
error: 'Too many requests - please try again later'
|
||||
});
|
||||
}
|
||||
|
||||
limit.count++;
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
requireSuperAdmin,
|
||||
validateSettingsMiddleware,
|
||||
maskSensitiveValues,
|
||||
logSettingsAccess,
|
||||
rateLimitSettings
|
||||
};
|
||||
Reference in new issue
Block a user