diff --git a/spk_kontrakan/app/Console/Commands/MigratePaymentProofsToPrivate.php b/spk_kontrakan/app/Console/Commands/MigratePaymentProofsToPrivate.php
new file mode 100644
index 0000000..31532b3
--- /dev/null
+++ b/spk_kontrakan/app/Console/Commands/MigratePaymentProofsToPrivate.php
@@ -0,0 +1,111 @@
+option('dry-run');
+ $limit = $this->option('limit');
+ $limit = is_numeric($limit) ? (int) $limit : null;
+
+ $query = Booking::query()->whereNotNull('payment_proof')->orderBy('id');
+ if ($limit && $limit > 0) {
+ $query->limit($limit);
+ }
+
+ $total = (clone $query)->count();
+ if ($total === 0) {
+ $this->info('No bookings with payment_proof found.');
+ return self::SUCCESS;
+ }
+
+ $migrated = 0;
+ $skipped = 0;
+ $missing = 0;
+ $failed = 0;
+
+ $this->info(($dryRun ? '[DRY RUN] ' : '') . "Processing {$total} booking(s)...");
+
+ $bar = $this->output->createProgressBar($total);
+ $bar->start();
+
+ $query->chunkById(200, function ($bookings) use ($public, $private, $dryRun, &$migrated, &$skipped, &$missing, &$failed, $bar) {
+ foreach ($bookings as $booking) {
+ $path = $booking->payment_proof;
+ if (!$path) {
+ $skipped++;
+ $bar->advance();
+ continue;
+ }
+
+ try {
+ if ($private->exists($path)) {
+ // Already private
+ $skipped++;
+ $bar->advance();
+ continue;
+ }
+
+ if (!$public->exists($path)) {
+ $missing++;
+ $bar->advance();
+ continue;
+ }
+
+ if ($dryRun) {
+ $migrated++;
+ $bar->advance();
+ continue;
+ }
+
+ $readStream = $public->readStream($path);
+ if ($readStream === false) {
+ $failed++;
+ $bar->advance();
+ continue;
+ }
+
+ $private->writeStream($path, $readStream);
+ if (is_resource($readStream)) {
+ fclose($readStream);
+ }
+
+ $public->delete($path);
+ $migrated++;
+ } catch (\Throwable $e) {
+ $failed++;
+ }
+
+ $bar->advance();
+ }
+ });
+
+ $bar->finish();
+ $this->newLine(2);
+
+ $this->line('Done.');
+ $this->line('Migrated : ' . $migrated);
+ $this->line('Skipped : ' . $skipped);
+ $this->line('Missing : ' . $missing);
+ $this->line('Failed : ' . $failed);
+
+ if ($dryRun) {
+ $this->comment('Dry-run mode: no files were changed.');
+ }
+
+ return $failed > 0 ? self::FAILURE : self::SUCCESS;
+ }
+}
diff --git a/spk_kontrakan/app/Console/Kernel.php b/spk_kontrakan/app/Console/Kernel.php
index a57fade..69a32b2 100644
--- a/spk_kontrakan/app/Console/Kernel.php
+++ b/spk_kontrakan/app/Console/Kernel.php
@@ -27,6 +27,7 @@ protected function commands(): void
\App\Console\Commands\SyncAdminsFromUsers::class,
\App\Console\Commands\DedupeAdmins::class,
\App\Console\Commands\AdminSetPassword::class,
+ \App\Console\Commands\MigratePaymentProofsToPrivate::class,
]);
require base_path('routes/console.php');
diff --git a/spk_kontrakan/app/Http/Controllers/ActivityLogController.php b/spk_kontrakan/app/Http/Controllers/ActivityLogController.php
index 2d4caf0..315c451 100644
--- a/spk_kontrakan/app/Http/Controllers/ActivityLogController.php
+++ b/spk_kontrakan/app/Http/Controllers/ActivityLogController.php
@@ -11,7 +11,6 @@ public function index(Request $request)
{
$query = ActivityLog::with('user');
- // Use enhanced scopes for better performance
if ($request->filled('action')) {
$query->byAction($request->action);
}
@@ -28,7 +27,6 @@ public function index(Request $request)
$query->recent($request->days);
}
- // Filter by date range
if ($request->filled('date_from')) {
$query->whereDate('created_at', '>=', $request->date_from);
}
@@ -37,9 +35,9 @@ public function index(Request $request)
$query->whereDate('created_at', '<=', $request->date_to);
}
- // Security check for suspicious activity
- if (auth()->user()->hasRole('super_admin') && $request->filled('security_check')) {
+ if (auth()->user()->role === 'super_admin' && $request->filled('security_check')) {
$suspiciousUsers = ActivityLog::getSecurityStats()['suspicious_users'];
+
if (!empty($suspiciousUsers)) {
session()->flash('warning', 'Detected suspicious activity from ' . count($suspiciousUsers) . ' users');
}
@@ -47,22 +45,21 @@ public function index(Request $request)
$logs = $query->orderBy('created_at', 'desc')->paginate(50);
- // Get enhanced statistics
$stats = ActivityLog::getSummaryStats();
$modelStats = ActivityLog::getModelStats();
- $securityStats = auth()->user()->hasRole('super_admin') ? ActivityLog::getSecurityStats() : [];
+ $securityStats = auth()->user()->role === 'super_admin'
+ ? ActivityLog::getSecurityStats()
+ : [];
return view('admin.activity-logs.index', compact('logs', 'stats', 'modelStats', 'securityStats'));
}
public function show(ActivityLog $activityLog)
{
- // Load polymorphic relationship
$activityLog->load(['user', 'loggable']);
-
- // Access the polymorphic related model
+
$relatedModel = $activityLog->loggable;
-
+
return view('admin.activity-logs.show', compact('activityLog', 'relatedModel'));
}
@@ -88,12 +85,12 @@ public function export(Request $request)
$logs = $query->orderBy('created_at', 'desc')->get();
- // Generate CSV
$csv = "Tanggal,User,Action,Deskripsi,Model Type,Model ID,IP Address\n";
+
foreach ($logs as $log) {
$csv .= sprintf(
'"%s","%s","%s","%s","%s","%s","%s"' . "\n",
- $log->created_at->format('d/m/Y H:i:s'),
+ $log->created_at ? $log->created_at->format('d/m/Y H:i:s') : '-',
$log->user->name ?? 'Unknown',
$log->action,
$log->description,
@@ -110,59 +107,62 @@ public function export(Request $request)
public function clear(Request $request)
{
- if (!auth()->user()->hasRole('super_admin')) {
+ if (auth()->user()->role !== 'super_admin') {
abort(403);
}
$days = $request->input('days', 30);
+
$deleted = ActivityLog::where('created_at', '<', now()->subDays($days))->delete();
return redirect()->back()->with('success', "Dihapus $deleted log aktivitas lebih dari $days hari.");
}
- /**
- * Enhanced dashboard with analytics
- */
public function dashboard()
{
+ if (auth()->user()->role !== 'super_admin') {
+ abort(403);
+ }
+
$stats = ActivityLog::getSummaryStats();
$modelStats = ActivityLog::getModelStats();
$securityStats = ActivityLog::getSecurityStats();
$topUsers = ActivityLog::getTopUsers();
- // Get activity by time range for charts
$dailyActivity = ActivityLog::getActivityByTimeRange(
- now()->subDays(30),
- now(),
+ now()->subDays(30),
+ now(),
'day'
);
$hourlyActivity = ActivityLog::getActivityByTimeRange(
- now()->subDay(),
- now(),
+ now()->subDay(),
+ now(),
'hour'
);
return view('admin.activity-logs.dashboard', compact(
- 'stats', 'modelStats', 'securityStats', 'topUsers',
- 'dailyActivity', 'hourlyActivity'
+ 'stats',
+ 'modelStats',
+ 'securityStats',
+ 'topUsers',
+ 'dailyActivity',
+ 'hourlyActivity'
));
}
- /**
- * Archive old logs to files
- */
public function archive(Request $request)
{
- if (!auth()->user()->hasRole('super_admin')) {
+ if (auth()->user()->role !== 'super_admin') {
abort(403);
}
$keepDays = $request->input('keep_days', 365);
+
$result = ActivityLog::archiveOldLogs($keepDays);
if ($result['archived_count'] > 0) {
- return redirect()->back()->with('success',
+ return redirect()->back()->with('success',
"Successfully archived {$result['archived_count']} logs to {$result['archived_file']} and deleted {$result['deleted_count']} records from database."
);
}
@@ -170,17 +170,14 @@ public function archive(Request $request)
return redirect()->back()->with('info', 'No old logs found to archive.');
}
- /**
- * Security monitoring
- */
public function security()
{
- if (!auth()->user()->hasRole('super_admin')) {
+ if (auth()->user()->role !== 'super_admin') {
abort(403);
}
$securityStats = ActivityLog::getSecurityStats();
-
+
$suspiciousActivity = ActivityLog::select('user_id', 'ip_address')
->selectRaw('COUNT(*) as activity_count')
->where('created_at', '>=', now()->subHour())
@@ -196,13 +193,12 @@ public function security()
->get();
return view('admin.activity-logs.security', compact(
- 'securityStats', 'suspiciousActivity', 'failedActions'
+ 'securityStats',
+ 'suspiciousActivity',
+ 'failedActions'
));
}
- /**
- * API endpoint for real-time stats
- */
public function apiStats()
{
return response()->json([
@@ -215,4 +211,4 @@ public function apiStats()
'last_updated' => now()->toISOString()
]);
}
-}
+}
\ No newline at end of file
diff --git a/spk_kontrakan/app/Http/Controllers/AdminProfileController.php b/spk_kontrakan/app/Http/Controllers/AdminProfileController.php
index 0aa01ad..9ab60e2 100644
--- a/spk_kontrakan/app/Http/Controllers/AdminProfileController.php
+++ b/spk_kontrakan/app/Http/Controllers/AdminProfileController.php
@@ -20,7 +20,7 @@ public function update(Request $request)
$validated = $request->validate([
'name' => 'required|string|min:3|max:255',
- 'email' => 'required|email|unique:users,email,' . $user->id,
+ 'email' => 'required|email|unique:admins,email,' . $user->id,
'phone' => 'nullable|string|min:8|max:20',
'password' => 'nullable|string|min:8|confirmed',
], [
diff --git a/spk_kontrakan/app/Http/Controllers/Api/AuthController.php b/spk_kontrakan/app/Http/Controllers/Api/AuthController.php
index 8b9ffe0..85002c0 100644
--- a/spk_kontrakan/app/Http/Controllers/Api/AuthController.php
+++ b/spk_kontrakan/app/Http/Controllers/Api/AuthController.php
@@ -53,7 +53,8 @@ public function register(RegisterRequest $request)
]);
return response()->json([
'success' => false,
- 'message' => 'Database error: ' . $e->getMessage(),
+ // ✅ Do not leak DB details to clients
+ 'message' => 'Terjadi kesalahan saat registrasi. Silakan coba lagi.',
'error_code' => 'DB_ERROR',
], 500);
} catch (\Exception $e) {
@@ -65,7 +66,8 @@ public function register(RegisterRequest $request)
]);
return response()->json([
'success' => false,
- 'message' => 'Terjadi kesalahan saat registrasi: ' . $e->getMessage(),
+ // ✅ Do not leak internal exception details to clients
+ 'message' => 'Terjadi kesalahan saat registrasi. Silakan coba lagi.',
'error_code' => 'REGISTRATION_FAILED',
], 500);
}
diff --git a/spk_kontrakan/app/Http/Controllers/Api/BookingController.php b/spk_kontrakan/app/Http/Controllers/Api/BookingController.php
index 100bd11..db6b68e 100644
--- a/spk_kontrakan/app/Http/Controllers/Api/BookingController.php
+++ b/spk_kontrakan/app/Http/Controllers/Api/BookingController.php
@@ -7,11 +7,16 @@
use App\Models\Kontrakan;
use Carbon\Carbon;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
class BookingController extends Controller
{
+ private const PAYMENT_PROOF_DIR = 'payment_proofs';
+ private const PAYMENT_PROOF_PRIVATE_DISK = 'private';
+ private const PAYMENT_PROOF_PUBLIC_DISK = 'public';
+
/**
* List bookings user (history)
*/
@@ -110,7 +115,8 @@ public function store(Request $request)
// Handle payment proof upload
if ($request->hasFile('payment_proof')) {
- $path = $request->file('payment_proof')->store('payment_proofs', 'public');
+ // ✅ Store sensitive payment proof in PRIVATE storage
+ $path = $request->file('payment_proof')->store(self::PAYMENT_PROOF_DIR, self::PAYMENT_PROOF_PRIVATE_DISK);
$bookingData['payment_proof'] = $path;
$bookingData['payment_status'] = 'paid';
$bookingData['payment_method'] = 'transfer';
@@ -283,11 +289,14 @@ public function uploadPaymentProof(Request $request, $id)
// Hapus bukti lama jika ada
if ($booking->payment_proof) {
- Storage::disk('public')->delete($booking->payment_proof);
+ // ✅ Delete from both disks to support migration from old public storage
+ Storage::disk(self::PAYMENT_PROOF_PUBLIC_DISK)->delete($booking->payment_proof);
+ Storage::disk(self::PAYMENT_PROOF_PRIVATE_DISK)->delete($booking->payment_proof);
}
// Simpan gambar baru
- $path = $request->file('payment_proof')->store('payment_proofs', 'public');
+ // ✅ Store sensitive payment proof in PRIVATE storage
+ $path = $request->file('payment_proof')->store(self::PAYMENT_PROOF_DIR, self::PAYMENT_PROOF_PRIVATE_DISK);
// Update booking
$booking->update([
@@ -303,4 +312,72 @@ public function uploadPaymentProof(Request $request, $id)
'data' => $booking->fresh()->load('kontrakan'),
], 200);
}
+
+ /**
+ * Securely stream payment proof file (AUTH required).
+ * - Only booking owner can access.
+ * - Migrates legacy public-stored files to private storage on first access.
+ */
+ public function getPaymentProof(Request $request, $id)
+ {
+ $booking = Booking::query()
+ ->where('id', $id)
+ ->where('user_id', $request->user()->id)
+ ->first();
+
+ if (!$booking) {
+ return response()->json([
+ 'success' => false,
+ 'message' => 'Booking tidak ditemukan',
+ 'error_code' => 'NOT_FOUND',
+ ], 404);
+ }
+
+ if (!$booking->payment_proof) {
+ return response()->json([
+ 'success' => false,
+ 'message' => 'Bukti pembayaran tidak tersedia',
+ 'error_code' => 'NO_PAYMENT_PROOF',
+ ], 404);
+ }
+
+ $path = $booking->payment_proof;
+
+ // If file is still on public disk (legacy), migrate to private.
+ if (!Storage::disk(self::PAYMENT_PROOF_PRIVATE_DISK)->exists($path) && Storage::disk(self::PAYMENT_PROOF_PUBLIC_DISK)->exists($path)) {
+ try {
+ $readStream = Storage::disk(self::PAYMENT_PROOF_PUBLIC_DISK)->readStream($path);
+ if ($readStream !== false) {
+ Storage::disk(self::PAYMENT_PROOF_PRIVATE_DISK)->writeStream($path, $readStream);
+ if (is_resource($readStream)) {
+ fclose($readStream);
+ }
+ Storage::disk(self::PAYMENT_PROOF_PUBLIC_DISK)->delete($path);
+ }
+ } catch (\Throwable $e) {
+ Log::warning('Failed to migrate payment proof to private storage', [
+ 'booking_id' => $booking->id,
+ 'path' => $path,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ if (!Storage::disk(self::PAYMENT_PROOF_PRIVATE_DISK)->exists($path)) {
+ return response()->json([
+ 'success' => false,
+ 'message' => 'File bukti pembayaran tidak ditemukan',
+ 'error_code' => 'FILE_NOT_FOUND',
+ ], 404);
+ }
+
+ $absolutePath = Storage::disk(self::PAYMENT_PROOF_PRIVATE_DISK)->path($path);
+
+ // Stream file. Browser/app will infer mime.
+ return response()->file($absolutePath, [
+ 'Content-Disposition' => 'inline; filename="payment-proof-' . $booking->id . '"',
+ 'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
+ 'Pragma' => 'no-cache',
+ ]);
+ }
}
diff --git a/spk_kontrakan/app/Http/Controllers/Api/SAWController.php b/spk_kontrakan/app/Http/Controllers/Api/SAWController.php
index 2e7d7ca..b66551d 100644
--- a/spk_kontrakan/app/Http/Controllers/Api/SAWController.php
+++ b/spk_kontrakan/app/Http/Controllers/Api/SAWController.php
@@ -260,7 +260,9 @@ public function calculateKontrakan(Request $request)
public function calculateLaundry(Request $request)
{
$validator = Validator::make($request->all(), [
- 'jenis_layanan' => 'nullable|string|in:harian,jam,reguler,express,kiloan,satuan,kilat',
+ // NOTE: Mobile app can send dynamic values sourced from DB.
+ // We accept any short string and normalize it in mapJenisLayananQueryValues().
+ 'jenis_layanan' => 'nullable|string|max:50',
'harga_min' => 'nullable|numeric',
'harga_max' => 'nullable|numeric',
'jarak_max' => 'nullable|numeric',
diff --git a/spk_kontrakan/app/Http/Controllers/BookingController.php b/spk_kontrakan/app/Http/Controllers/BookingController.php
index f6e267a..81777df 100644
--- a/spk_kontrakan/app/Http/Controllers/BookingController.php
+++ b/spk_kontrakan/app/Http/Controllers/BookingController.php
@@ -8,10 +8,14 @@
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
use Exception;
class BookingController extends Controller
{
+ private const PAYMENT_PROOF_PRIVATE_DISK = 'private';
+ private const PAYMENT_PROOF_PUBLIC_DISK = 'public';
+
/**
* Tampilkan daftar semua booking (admin)
*/
@@ -245,6 +249,59 @@ public function show(Booking $booking)
return view('admin.bookings.show', compact('booking'));
}
+ /**
+ * Stream payment proof securely for admin (NOT public storage link).
+ * Also migrates legacy public-stored files to private storage on first access.
+ */
+ public function paymentProof(Booking $booking)
+ {
+ // ========== AUTHORIZATION: Cek apakah admin punya akses ke booking ini ==========
+ $admin = auth()->guard('admin')->user();
+ if ($admin && $admin->role !== 'super_admin') {
+ if ($booking->kontrakan->admin_id !== $admin->id) {
+ abort(403, 'Anda tidak memiliki akses ke booking ini.');
+ }
+ }
+
+ if (!$booking->payment_proof) {
+ abort(404, 'Bukti pembayaran tidak tersedia.');
+ }
+
+ $path = $booking->payment_proof;
+
+ // Migrate legacy public file to private storage
+ if (!Storage::disk(self::PAYMENT_PROOF_PRIVATE_DISK)->exists($path) && Storage::disk(self::PAYMENT_PROOF_PUBLIC_DISK)->exists($path)) {
+ try {
+ $readStream = Storage::disk(self::PAYMENT_PROOF_PUBLIC_DISK)->readStream($path);
+ if ($readStream !== false) {
+ Storage::disk(self::PAYMENT_PROOF_PRIVATE_DISK)->writeStream($path, $readStream);
+ if (is_resource($readStream)) {
+ fclose($readStream);
+ }
+ Storage::disk(self::PAYMENT_PROOF_PUBLIC_DISK)->delete($path);
+ }
+ } catch (\Throwable $e) {
+ Log::warning('Failed to migrate admin payment proof to private storage', [
+ 'booking_id' => $booking->id,
+ 'path' => $path,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ if (!Storage::disk(self::PAYMENT_PROOF_PRIVATE_DISK)->exists($path)) {
+ abort(404, 'File bukti pembayaran tidak ditemukan.');
+ }
+
+ $absolutePath = Storage::disk(self::PAYMENT_PROOF_PRIVATE_DISK)->path($path);
+
+ return response()->file($absolutePath, [
+ 'Content-Disposition' => 'inline; filename="payment-proof-' . $booking->id . '"',
+ 'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
+ 'Pragma' => 'no-cache',
+ ]);
+ }
+
/**
* Tampilkan form edit booking
*/
diff --git a/spk_kontrakan/app/Http/Controllers/SAWController.php b/spk_kontrakan/app/Http/Controllers/SAWController.php
index ad5fcd1..9cbfec4 100644
--- a/spk_kontrakan/app/Http/Controllers/SAWController.php
+++ b/spk_kontrakan/app/Http/Controllers/SAWController.php
@@ -165,7 +165,7 @@ public function proses(Request $request)
]);
return redirect()->route('saw.index')
- ->with('error', 'Terjadi kesalahan saat memproses data: ' . $e->getMessage());
+ ->with('error', 'Terjadi kesalahan saat memproses data. Silakan coba lagi.');
}
}
diff --git a/spk_kontrakan/app/Http/Requests/RegisterRequest.php b/spk_kontrakan/app/Http/Requests/RegisterRequest.php
index 0312fb7..345f0df 100644
--- a/spk_kontrakan/app/Http/Requests/RegisterRequest.php
+++ b/spk_kontrakan/app/Http/Requests/RegisterRequest.php
@@ -16,7 +16,8 @@ public function rules(): array
return [
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
- 'password' => 'required|string|min:6|confirmed',
+ // ✅ Updated: minimum 8 characters + must have letters and numbers
+ 'password' => 'required|string|min:8|confirmed|regex:/^(?=.*[a-zA-Z])(?=.*[0-9])/i',
'phone' => 'nullable|string|max:20',
];
}
@@ -30,7 +31,8 @@ public function messages(): array
'email.email' => 'Format email tidak valid',
'email.unique' => 'Email sudah terdaftar',
'password.required' => 'Password wajib diisi',
- 'password.min' => 'Password minimal 6 karakter',
+ 'password.min' => 'Password minimal 8 karakter', // ✅ Updated
+ 'password.regex' => 'Password harus kombinasi huruf dan angka', // ✅ New
'password.confirmed' => 'Konfirmasi password tidak cocok',
'phone.max' => 'Nomor telepon maksimal 20 karakter',
];
diff --git a/spk_kontrakan/app/Models/ActivityLog.php b/spk_kontrakan/app/Models/ActivityLog.php
index c3e96b5..fcc1aa9 100644
--- a/spk_kontrakan/app/Models/ActivityLog.php
+++ b/spk_kontrakan/app/Models/ActivityLog.php
@@ -62,8 +62,14 @@ public function scopeByModel($query, $modelType)
public static function log($action, $description, $modelType = null, $modelId = null, $oldValues = null, $newValues = null)
{
+ $userId = auth()->id();
+
+ if ($userId && ! User::query()->whereKey($userId)->exists()) {
+ $userId = null;
+ }
+
return self::create([
- 'user_id' => auth()->id(),
+ 'user_id' => $userId,
'action' => $action,
'description' => $description,
'model_type' => $modelType,
diff --git a/spk_kontrakan/app/Models/Admin.php b/spk_kontrakan/app/Models/Admin.php
index 9e19df5..5661fcb 100644
--- a/spk_kontrakan/app/Models/Admin.php
+++ b/spk_kontrakan/app/Models/Admin.php
@@ -72,4 +72,12 @@ public function isAdmin()
{
return $this->role === 'admin';
}
+
+ /**
+ * Helper: Label human-readable untuk role admin.
+ */
+ public function getRoleLabel()
+ {
+ return $this->role === 'super_admin' ? 'Super Admin' : 'Admin';
+ }
}
diff --git a/spk_kontrakan/config/filesystems.php b/spk_kontrakan/config/filesystems.php
index 3d671bd..93f64e7 100644
--- a/spk_kontrakan/config/filesystems.php
+++ b/spk_kontrakan/config/filesystems.php
@@ -38,6 +38,15 @@
'report' => false,
],
+ // ✅ Private disk for sensitive documents (NOT publicly accessible)
+ 'private' => [
+ 'driver' => 'local',
+ 'root' => storage_path('app/private'),
+ 'serve' => false,
+ 'throw' => false,
+ 'report' => false,
+ ],
+
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
diff --git a/spk_kontrakan/database/migrations/2026_06_05_000001_make_activity_logs_user_id_nullable.php b/spk_kontrakan/database/migrations/2026_06_05_000001_make_activity_logs_user_id_nullable.php
new file mode 100644
index 0000000..2f700d7
--- /dev/null
+++ b/spk_kontrakan/database/migrations/2026_06_05_000001_make_activity_logs_user_id_nullable.php
@@ -0,0 +1,50 @@
+dropForeign(['user_id']);
+ });
+
+ DB::statement('ALTER TABLE activity_logs MODIFY user_id BIGINT UNSIGNED NULL');
+
+ Schema::table('activity_logs', function (Blueprint $table) {
+ $table->foreign('user_id')->references('id')->on('users')->nullOnDelete();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ if (! Schema::hasTable('activity_logs')) {
+ return;
+ }
+
+ Schema::table('activity_logs', function (Blueprint $table) {
+ $table->dropForeign(['user_id']);
+ });
+
+ DB::table('activity_logs')->whereNull('user_id')->delete();
+ DB::statement('ALTER TABLE activity_logs MODIFY user_id BIGINT UNSIGNED NOT NULL');
+
+ Schema::table('activity_logs', function (Blueprint $table) {
+ $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
+ });
+ }
+};
\ No newline at end of file
diff --git a/spk_kontrakan/resources/views/admin/bookings/index.blade.php b/spk_kontrakan/resources/views/admin/bookings/index.blade.php
index b1604e5..e04357d 100644
--- a/spk_kontrakan/resources/views/admin/bookings/index.blade.php
+++ b/spk_kontrakan/resources/views/admin/bookings/index.blade.php
@@ -190,7 +190,7 @@
{{ $booking->payment_status_label }}
@if($booking->payment_proof)
-
+
@endif
diff --git a/spk_kontrakan/resources/views/admin/bookings/show.blade.php b/spk_kontrakan/resources/views/admin/bookings/show.blade.php
index 968b3ae..e00016d 100644
--- a/spk_kontrakan/resources/views/admin/bookings/show.blade.php
+++ b/spk_kontrakan/resources/views/admin/bookings/show.blade.php
@@ -216,14 +216,14 @@ class="rounded me-3" style="width: 120px; height: 90px; object-fit: cover;">
@if($booking->payment_proof)
Bukti Pembayaran:
-
-
+
-
Lihat Full
diff --git a/spk_kontrakan/resources/views/auth/admin-login.blade.php b/spk_kontrakan/resources/views/auth/admin-login.blade.php
index 7447f58..996f1c4 100644
--- a/spk_kontrakan/resources/views/auth/admin-login.blade.php
+++ b/spk_kontrakan/resources/views/auth/admin-login.blade.php
@@ -197,7 +197,7 @@
diff --git a/spk_kontrakan/routes/api.php b/spk_kontrakan/routes/api.php
index e801b09..b0a781d 100644
--- a/spk_kontrakan/routes/api.php
+++ b/spk_kontrakan/routes/api.php
@@ -70,6 +70,7 @@
'bookings' => [
'GET /api/bookings' => 'History booking [AUTH]',
'GET /api/bookings/{id}' => 'Detail booking [AUTH]',
+ 'GET /api/bookings/{id}/payment-proof' => 'Lihat bukti bayar (secure) [AUTH]',
'POST /api/bookings' => 'Create booking [AUTH]',
'POST /api/bookings/{id}/cancel' => 'Cancel booking [AUTH]',
'POST /api/bookings/{id}/extend' => 'Extend booking [AUTH]',
@@ -111,10 +112,11 @@
});
// -------------------------------------------------
-// AUTH ROUTES (PUBLIC - DEVELOPMENT MODE - NO RATE LIMITING)
+// AUTH ROUTES (PUBLIC - WITH RATE LIMITING)
// -------------------------------------------------
-Route::post('/register', [AuthController::class, 'register']);
-Route::post('/login', [AuthController::class, 'login']);
+// ✅ FIXED: Added rate limiting to prevent brute force attacks
+Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:3,1'); // 3 attempts per 1 minute
+Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:5,1'); // 5 attempts per 1 minute
// -------------------------------------------------
// KONTRAKAN ROUTES (PUBLIC - TANPA AUTH)
@@ -170,6 +172,8 @@
Route::prefix('bookings')->group(function () {
Route::get('/', [BookingController::class, 'index']);
Route::get('/{id}', [BookingController::class, 'show']);
+ // ✅ Secure access to payment proof (no public storage URL)
+ Route::get('/{id}/payment-proof', [BookingController::class, 'getPaymentProof']);
Route::post('/', [BookingController::class, 'store']);
Route::post('/{id}/cancel', [BookingController::class, 'cancel']);
Route::post('/{id}/extend', [BookingController::class, 'extend']);
diff --git a/spk_kontrakan/routes/web.php b/spk_kontrakan/routes/web.php
index 96d1681..3ae543f 100644
--- a/spk_kontrakan/routes/web.php
+++ b/spk_kontrakan/routes/web.php
@@ -35,8 +35,15 @@
// -------------------------------------------------
Route::get('/admin/login', [AdminAuthController::class, 'loginPage'])->name('admin.login');
Route::post('/admin/login', [AdminAuthController::class, 'login'])->middleware('throttle:10,1')->name('admin.login.post');
-Route::get('/admin/register', [AdminAuthController::class, 'registerPage'])->name('admin.register');
-Route::post('/admin/register', [AdminAuthController::class, 'register'])->middleware('throttle:10,1')->name('admin.register.post');
+
+// Admin register:
+// - Default: disabled in production (security)
+// - Enabled for local dev or when ENABLE_ADMIN_SELF_REGISTER=true
+if (app()->environment('local') || env('ENABLE_ADMIN_SELF_REGISTER', false)) {
+ Route::get('/admin/register', [AdminAuthController::class, 'registerPage'])->name('admin.register');
+ Route::post('/admin/register', [AdminAuthController::class, 'register'])->middleware('throttle:3,1')->name('admin.register.post');
+}
+
// Local-only helper: reset demo admin passwords
Route::get('/admin/reset-demo-passwords', function () {
@@ -124,6 +131,8 @@
Route::post('/', [BookingController::class, 'store'])->name('store');
Route::post('/bulk-destroy', [BookingController::class, 'bulkDestroy'])->name('bulk-destroy');
Route::get('/{booking}', [BookingController::class, 'show'])->name('show');
+ // ✅ Secure payment proof view (not public storage link)
+ Route::get('/{booking}/payment-proof', [BookingController::class, 'paymentProof'])->name('payment-proof');
Route::get('/{booking}/edit', [BookingController::class, 'edit'])->name('edit');
Route::put('/{booking}', [BookingController::class, 'update'])->name('update');
Route::delete('/{booking}', [BookingController::class, 'destroy'])->name('destroy');
diff --git a/spk_kontrakan/tests/Feature/AdminSecurityRoutesTest.php b/spk_kontrakan/tests/Feature/AdminSecurityRoutesTest.php
index 97ca451..ff54cf3 100644
--- a/spk_kontrakan/tests/Feature/AdminSecurityRoutesTest.php
+++ b/spk_kontrakan/tests/Feature/AdminSecurityRoutesTest.php
@@ -19,7 +19,9 @@ public function test_guest_is_redirected_from_admin_register_page(): void
public function test_ensure_role_redirects_guest_to_login(): void
{
- Auth::shouldReceive('check')->once()->andReturn(false);
+ $guard = \Mockery::mock(\Illuminate\Contracts\Auth\StatefulGuard::class);
+ Auth::shouldReceive('guard')->once()->with('admin')->andReturn($guard);
+ $guard->shouldReceive('check')->once()->andReturn(false);
$middleware = new EnsureRole();
$response = $middleware->handle(Request::create('/admin/register', 'GET'), fn () => response('ok'), 'super_admin');
@@ -30,8 +32,10 @@ public function test_ensure_role_redirects_guest_to_login(): void
public function test_ensure_role_forbids_non_matching_role(): void
{
- Auth::shouldReceive('check')->once()->andReturn(true);
- Auth::shouldReceive('user')->once()->andReturn((object) ['role' => 'admin']);
+ $guard = \Mockery::mock(\Illuminate\Contracts\Auth\StatefulGuard::class);
+ Auth::shouldReceive('guard')->twice()->with('admin')->andReturn($guard);
+ $guard->shouldReceive('check')->once()->andReturn(true);
+ $guard->shouldReceive('user')->once()->andReturn((object) ['role' => 'admin']);
$this->expectException(HttpException::class);
$this->expectExceptionMessage('Akses ditolak. Anda tidak memiliki izin untuk halaman ini.');
@@ -42,8 +46,10 @@ public function test_ensure_role_forbids_non_matching_role(): void
public function test_ensure_role_allows_matching_role(): void
{
- Auth::shouldReceive('check')->once()->andReturn(true);
- Auth::shouldReceive('user')->once()->andReturn((object) ['role' => 'super_admin']);
+ $guard = \Mockery::mock(\Illuminate\Contracts\Auth\StatefulGuard::class);
+ Auth::shouldReceive('guard')->twice()->with('admin')->andReturn($guard);
+ $guard->shouldReceive('check')->once()->andReturn(true);
+ $guard->shouldReceive('user')->once()->andReturn((object) ['role' => 'super_admin']);
$middleware = new EnsureRole();
$response = $middleware->handle(Request::create('/admin/register', 'GET'), fn () => response('ok'), 'super_admin');
diff --git a/spk_mobile/lib/config/app_config.dart b/spk_mobile/lib/config/app_config.dart
index 0577c8e..5c453a2 100644
--- a/spk_mobile/lib/config/app_config.dart
+++ b/spk_mobile/lib/config/app_config.dart
@@ -1,45 +1,74 @@
+import 'package:flutter/foundation.dart';
+
class AppConfig {
// ============================================================================
- // 🔧 BASE URL CONFIGURATION - FIXED IP (NOT AUTO-DETECT)
- // ============================================================================
- // PENTING: Update IP ini sesuai dengan server backend Anda!
- // Format: http://[IP_ADDRESS]:[PORT]
- //
- // Contoh:
- // - Local: http://localhost:8000
- // - Network: http://192.168.1.100:8000
- // - Remote: http://your-server.com
- //
- // ⚠️ JIKA CONNECTION TIMEOUT:
- // - Pastikan backend server running
- // - Pastikan device/emulator connect ke WiFi yang sama
- // - Pastikan port sudah benar
+ // BASE URL CONFIGURATION
// ============================================================================
- /// Backend server URL - DIUBAH MENJADI FIXED (tidak auto-detect)
- /// Alasan: ServerDiscoveryService scanning port yang salah
- ///
- /// UPDATE INI SESUAI DENGAN BACKEND ANDA!
- /// Jika backend di: php artisan serve
- /// Maka gunakan: http://127.0.0.1:8000 (localhost) atau http://[IP]:8000 (network)
- static const String _defaultServer = 'http://192.168.18.16:8000';
+ /// Host server untuk production (override via --dart-define=PROD_SERVER_URL=...).
+ static const String _productionServer = String.fromEnvironment(
+ 'PROD_SERVER_URL',
+ // defaultValue: 'http://10.133.61.99:8000',
+ defaultValue: 'https://spk-kontrakan.taskbuddy.web.id',
+ );
- // Runtime values — bisa di-override via setServerUrl() jika perlu
- static String _serverUrl = _defaultServer;
+ /// Host server untuk development (override via --dart-define=DEV_SERVER_URL=...).
+ static const String _developmentServer = String.fromEnvironment(
+ 'DEV_SERVER_URL',
+ // defaultValue: 'http://10.133.61.99:8000',
+ defaultValue: 'https://spk-kontrakan.taskbuddy.web.id',
+ );
+
+ static String _serverUrl = _normalizeServerUrl(
+ kReleaseMode ? _productionServer : _developmentServer,
+ );
static String get serverUrl => _serverUrl;
- static String get baseUrl => '$_serverUrl/api';
- static String get storageUrl => '$_serverUrl/storage';
- /// Override server URL jika perlu (untuk testing atau switching server)
+ /// API Laravel
+ static String get baseUrl => '$_serverUrl/api';
+
+ /// URL gambar/file public
+ static String get storageUrl => _serverUrl;
+
+ /// Override server URL jika perlu
static void setServerUrl(String url) {
- _serverUrl = url;
+ _serverUrl = _normalizeServerUrl(url);
}
- // Timeouts - Increased untuk network yang lambat dan operations yang heavy (password hashing, db insert)
- static const Duration connectionTimeout = Duration(seconds: 30);
+ /// Normalisasi URL agar selalu konsisten untuk request API.
+ static String _normalizeServerUrl(String url) {
+ final raw = url.trim();
+ if (raw.isEmpty) return _developmentServer;
- // ✅ UPDATED: Backend base URL sesuai IP hotspot HP (10.119.236.99:8000)
+ var normalized = raw;
+
+ // Default ke HTTP jika skema tidak dicantumkan.
+ if (!normalized.startsWith('http://') &&
+ !normalized.startsWith('https://')) {
+ normalized = 'http://$normalized';
+ }
+
+ // Hilangkan trailing slash agar baseUrl tidak menjadi //api.
+ while (normalized.endsWith('/')) {
+ normalized = normalized.substring(0, normalized.length - 1);
+ }
+
+ final uri = Uri.tryParse(normalized);
+ if (uri == null) return _developmentServer;
+
+ // Untuk host lokal/IP tanpa port, default ke 8000.
+ if (!uri.hasPort &&
+ (uri.host == 'localhost' ||
+ RegExp(r'^\d+\.\d+\.\d+\.\d+$').hasMatch(uri.host))) {
+ normalized = '${uri.scheme}://${uri.host}:8000';
+ }
+
+ return normalized;
+ }
+
+ // Timeouts
+ static const Duration connectionTimeout = Duration(seconds: 30);
static const Duration receiveTimeout = Duration(seconds: 30);
// Local Storage Keys
diff --git a/spk_mobile/lib/config/environment.dart b/spk_mobile/lib/config/environment.dart
index 1bf3d2a..241bc4f 100644
--- a/spk_mobile/lib/config/environment.dart
+++ b/spk_mobile/lib/config/environment.dart
@@ -8,12 +8,12 @@ class Environment {
// Pilih sesuai platform dan environment saat development
static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
- defaultValue: 'http://10.119.236.99:8000', // Default IP lokal - Updated
+ defaultValue: 'http://10.133.61.99:8000',
);
static const String storageBaseUrl = String.fromEnvironment(
'STORAGE_BASE_URL',
- defaultValue: 'http://10.119.236.99:8000/storage',
+ defaultValue: 'http://10.133.61.99:8000/storage',
);
// Mode debugging
diff --git a/spk_mobile/lib/main.dart b/spk_mobile/lib/main.dart
index b07387c..852c209 100644
--- a/spk_mobile/lib/main.dart
+++ b/spk_mobile/lib/main.dart
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'login.dart';
import 'screens/improved_home_screen.dart';
import 'services/auth_service.dart';
+import 'services/server_discovery_service.dart';
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -12,6 +13,11 @@ Future main() async {
statusBarIconBrightness: Brightness.light,
),
);
+
+ // ✅ Load persisted auth session (token/user) from secure storage
+ final authService = AuthService();
+ await authService.loadToken();
+
runApp(const MyApp());
}
@@ -284,24 +290,19 @@ class _SplashScreenState extends State
}
Future _checkAuth() async {
- // ⚠️ DISABLED: Auto-discovery scanning wrong ports
- // Sekarang menggunakan fixed IP dari AppConfig
- // Jika perlu auto-discovery, uncomment kode di bawah dan configure timeout
-
- /*
- // 🔍 Auto-detect server IP (disabled - causing port scan issues)
+ // 🔍 Auto-detect server IP dengan fallback ke konfigurasi default.
+ // Ini membantu APK tetap bisa konek saat IP LAN berubah.
await ServerDiscoveryService.discover(
onStatus: (status) {
if (mounted) setState(() => _statusText = status);
},
).timeout(
- const Duration(seconds: 5),
+ const Duration(seconds: 8),
onTimeout: () {
- debugPrint('Server discovery timeout, using configured IP');
+ debugPrint('Server discovery timeout, using configured server URL');
return false;
},
);
- */
if (mounted) setState(() => _statusText = 'Memeriksa sesi...');
await _authService.loadToken();
diff --git a/spk_mobile/lib/models/kontrakan.dart b/spk_mobile/lib/models/kontrakan.dart
index c3bb75b..5a77e27 100644
--- a/spk_mobile/lib/models/kontrakan.dart
+++ b/spk_mobile/lib/models/kontrakan.dart
@@ -11,7 +11,7 @@ class Kontrakan {
final String? deskripsi;
final String status;
final String? fotoUtama;
- final String? foto; // Field foto dari API
+ final String? foto;
final String? noWhatsapp;
final List galeri;
final double? avgRating;
@@ -40,26 +40,22 @@ class Kontrakan {
});
factory Kontrakan.fromJson(Map json) {
- // Try to get jarak from different possible field names
double jarak = 0;
+
if (json['jarak_kampus'] != null) {
jarak = double.tryParse(json['jarak_kampus'].toString()) ?? 0;
} else if (json['jarak'] != null) {
- // Backend uses 'jarak' column, convert meters to km
jarak = (double.tryParse(json['jarak'].toString()) ?? 0) / 1000;
}
- // Get foto from API and build gallery list
final List galleryList = [];
- // Parse galeri array if exists
if (json['galeri'] != null && (json['galeri'] as List).isNotEmpty) {
galleryList.addAll(
(json['galeri'] as List).map((g) => Galeri.fromJson(g)).toList(),
);
}
- // If gallery is empty but foto field exists, create a galeri item from it
if (galleryList.isEmpty &&
json['foto'] != null &&
json['foto'].toString().isNotEmpty) {
@@ -84,7 +80,7 @@ class Kontrakan {
deskripsi: json['deskripsi'],
status: json['status'] ?? 'tersedia',
fotoUtama: json['foto_utama'],
- foto: json['foto'], // Store raw foto field
+ foto: json['foto'],
noWhatsapp: json['no_whatsapp'],
galeri: galleryList,
avgRating: json['avg_rating'] != null
@@ -100,7 +96,6 @@ class Kontrakan {
);
}
- // Check if kontrakan is available
bool get isAvailable => status == 'available' || status == 'tersedia';
bool get hasPhoto {
@@ -113,44 +108,49 @@ class Kontrakan {
if (isValidUrl(foto)) return true;
if (galeri.any((g) => isValidUrl(g.foto))) return true;
+
return false;
}
- // Get primary photo URL
- // Prioritize 'foto' field (Admin-set primary photo) over galeri entries,
- // because galeri items may reference files that have been deleted/replaced.
String get primaryPhoto {
- // 1. Prefer the explicit 'foto' field (most up-to-date primary photo)
if (foto != null && foto!.isNotEmpty) {
if (foto!.startsWith('http')) {
return foto!;
}
- return '${AppConfig.serverUrl}/uploads/Kontrakan/$foto';
+
+ if (foto!.startsWith('uploads/')) {
+ return '${AppConfig.serverUrl}/$foto';
+ }
+
+ return '${AppConfig.serverUrl}/uploads/kontrakan/$foto';
}
- // 2. Fall back to galeri items if foto is not set
+
if (galeri.isNotEmpty) {
final primary = galeri.firstWhere(
(g) => g.isPrimary,
orElse: () => galeri.first,
);
+
if (primary.foto.isNotEmpty && primary.foto.startsWith('http')) {
return primary.foto;
}
+
if (primary.foto.isNotEmpty) {
- return '${AppConfig.serverUrl}/uploads/Kontrakan/${primary.foto}';
+ if (primary.foto.startsWith('uploads/')) {
+ return '${AppConfig.serverUrl}/${primary.foto}';
+ }
+
+ return '${AppConfig.serverUrl}/uploads/kontrakan/${primary.foto}';
}
}
- // Return empty string when no photo available to avoid unnecessary
- // network requests to placeholder services (prevents TLS/handshake errors)
+
return '';
}
- // Format harga dengan Rupiah
String get formattedHarga {
return 'Rp ${harga.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}';
}
- // Get fasilitas as list
List get fasilitasList {
return fasilitas.split(',').map((f) => f.trim()).toList();
}
@@ -178,13 +178,15 @@ class Galeri {
);
}
- // Get full photo URL
String get photoUrl {
- // Check if it's already a full URL
if (foto.startsWith('http')) {
return foto;
}
- // Build full URL from uploads
- return '${AppConfig.serverUrl}/uploads/Kontrakan/$foto';
+
+ if (foto.startsWith('uploads/')) {
+ return '${AppConfig.serverUrl}/$foto';
+ }
+
+ return '${AppConfig.serverUrl}/uploads/kontrakan/$foto';
}
}
diff --git a/spk_mobile/lib/register.dart b/spk_mobile/lib/register.dart
index 4c92f72..a5e4e2e 100644
--- a/spk_mobile/lib/register.dart
+++ b/spk_mobile/lib/register.dart
@@ -21,6 +21,14 @@ class _RegisterScreenState extends State {
bool _isLoading = false;
final _authService = AuthService();
+ // ✅ NEW: Helper function to check password strength
+ bool _isPasswordStrong(String password) {
+ // Requirements: 8+ chars + mix of letters and numbers
+ bool hasLetters = password.contains(RegExp(r'[a-zA-Z]'));
+ bool hasNumbers = password.contains(RegExp(r'[0-9]'));
+ return hasLetters && hasNumbers;
+ }
+
@override
void dispose() {
_nameController.dispose();
@@ -159,7 +167,10 @@ class _RegisterScreenState extends State {
validator: (v) {
if (v == null || v.isEmpty)
return 'Password tidak boleh kosong';
- if (v.length < 6) return 'Password minimal 6 karakter';
+ if (v.length < 8) return 'Password minimal 8 karakter'; // ✅ Updated to 8
+ if (!_isPasswordStrong(v)) {
+ return 'Password harus kombinasi huruf dan angka';
+ }
return null;
},
),
diff --git a/spk_mobile/lib/screens/booking_history_screen.dart b/spk_mobile/lib/screens/booking_history_screen.dart
index 9e341c3..0658811 100644
--- a/spk_mobile/lib/screens/booking_history_screen.dart
+++ b/spk_mobile/lib/screens/booking_history_screen.dart
@@ -1,7 +1,7 @@
import 'dart:io';
+import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
-import '../config/app_config.dart';
import '../services/booking_service.dart';
import '../models/booking.dart';
@@ -726,8 +726,8 @@ class _BookingHistoryScreenState extends State
const SizedBox(height: 12),
GestureDetector(
onTap: () {
- final url =
- '${AppConfig.storageUrl}/${booking.paymentProof}';
+ final Future future =
+ _bookingService.getPaymentProofBytes(booking.id);
showDialog(
context: context,
builder: (ctx) => Dialog(
@@ -792,7 +792,38 @@ class _BookingHistoryScreenState extends State
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(16),
),
- child: Image.network(url, fit: BoxFit.contain),
+ child: FutureBuilder(
+ future: future,
+ builder: (context, snapshot) {
+ if (snapshot.connectionState ==
+ ConnectionState.waiting) {
+ return const Padding(
+ padding: EdgeInsets.all(24),
+ child: Center(
+ child: CircularProgressIndicator(),
+ ),
+ );
+ }
+
+ final bytes = snapshot.data;
+ if (bytes == null || bytes.isEmpty) {
+ return const Padding(
+ padding: EdgeInsets.all(24),
+ child: Center(
+ child: Text(
+ 'Gagal memuat bukti pembayaran',
+ textAlign: TextAlign.center,
+ ),
+ ),
+ );
+ }
+
+ return Image.memory(
+ bytes,
+ fit: BoxFit.contain,
+ );
+ },
+ ),
),
],
),
diff --git a/spk_mobile/lib/screens/home_screen.dart b/spk_mobile/lib/screens/home_screen.dart
index d5f5cd3..b4511af 100644
--- a/spk_mobile/lib/screens/home_screen.dart
+++ b/spk_mobile/lib/screens/home_screen.dart
@@ -6,7 +6,6 @@ import '../models/laundry.dart';
import '../services/kontrakan_service.dart';
import '../services/laundry_service.dart';
import '../services/auth_service.dart';
-import '../config/app_config.dart';
import 'search_screen.dart';
import 'kontrakan_detail_screen.dart';
import 'laundry_list_screen.dart';
diff --git a/spk_mobile/lib/services/auth_service.dart b/spk_mobile/lib/services/auth_service.dart
index dbc3a23..494b029 100644
--- a/spk_mobile/lib/services/auth_service.dart
+++ b/spk_mobile/lib/services/auth_service.dart
@@ -3,9 +3,11 @@ import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
+import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../config/app_config.dart';
+import '../services/server_discovery_service.dart';
import '../models/user.dart';
class AuthService {
@@ -19,6 +21,14 @@ class AuthService {
// HttpClient - lazy initialization
HttpClient? _httpClient;
+ // ✅ Secure storage for sensitive data (token & user)
+ static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
+ aOptions: AndroidOptions(encryptedSharedPreferences: true),
+ iOptions: IOSOptions(
+ accessibility: KeychainAccessibility.first_unlock_this_device,
+ ),
+ );
+
AuthService._internal() {
_token = null;
_currentUser = null;
@@ -28,55 +38,133 @@ class AuthService {
HttpClient _getHttpClient() {
if (_httpClient == null) {
_httpClient = HttpClient();
- _httpClient!.badCertificateCallback = (cert, host, port) =>
- true; // Accept all certs (development only)
+
+ // ✅ Security: NEVER bypass certificate validation in release builds.
+ // In debug builds, allow self-signed certs only for local dev hosts.
+ if (kDebugMode) {
+ _httpClient!.badCertificateCallback = (cert, host, port) {
+ return host == 'localhost' ||
+ host == '127.0.0.1' ||
+ host.startsWith('192.168.') ||
+ host.startsWith('10.') ||
+ host.startsWith('172.16.') ||
+ host.startsWith('172.17.') ||
+ host.startsWith('172.18.') ||
+ host.startsWith('172.19.') ||
+ host.startsWith('172.2') ||
+ host.startsWith('172.30.') ||
+ host.startsWith('172.31.');
+ };
+ }
+
_httpClient!.connectionTimeout = const Duration(seconds: 30);
}
return _httpClient!;
}
+ String _redactSensitive(String input) {
+ // Redact common sensitive fields from JSON-ish strings.
+ var out = input;
+ out = out.replaceAll(
+ RegExp(r'("token"\s*:\s*")([^"]+)(")', caseSensitive: false),
+ r'$1***$3',
+ );
+ out = out.replaceAll(
+ RegExp(r'("password"\s*:\s*")([^"]+)(")', caseSensitive: false),
+ r'$1***$3',
+ );
+ out = out.replaceAll(
+ RegExp(r'("authorization"\s*:\s*")([^"]+)(")', caseSensitive: false),
+ r'$1***$3',
+ );
+ return out;
+ }
+
String? get token => _token;
User? get currentUser => _currentUser;
bool get isAuthenticated => _token != null;
// Load token from local storage
Future loadToken() async {
- final prefs = await SharedPreferences.getInstance();
- _token = prefs.getString(AppConfig.tokenKey);
+ try {
+ _token = await _secureStorage.read(key: AppConfig.tokenKey);
+ final userJson = await _secureStorage.read(key: AppConfig.userKey);
+ if (userJson != null) {
+ _currentUser = User.fromJson(jsonDecode(userJson));
+ }
- final userJson = prefs.getString(AppConfig.userKey);
- if (userJson != null) {
- _currentUser = User.fromJson(jsonDecode(userJson));
+ // ✅ Migration: if secure storage empty, try legacy SharedPreferences once.
+ if (_token == null) {
+ final prefs = await SharedPreferences.getInstance();
+ final legacyToken = prefs.getString(AppConfig.tokenKey);
+ final legacyUserJson = prefs.getString(AppConfig.userKey);
+
+ if (legacyToken != null) {
+ await _secureStorage.write(
+ key: AppConfig.tokenKey,
+ value: legacyToken,
+ );
+ await prefs.remove(AppConfig.tokenKey);
+ _token = legacyToken;
+ }
+
+ if (legacyUserJson != null) {
+ await _secureStorage.write(
+ key: AppConfig.userKey,
+ value: legacyUserJson,
+ );
+ await prefs.remove(AppConfig.userKey);
+ try {
+ _currentUser = User.fromJson(jsonDecode(legacyUserJson));
+ } catch (_) {
+ // ignore parse errors
+ }
+ }
+ }
+ } catch (e) {
+ debugPrint('Failed to load auth session: $e');
+ _token = null;
+ _currentUser = null;
}
}
// Save token to local storage
Future _saveToken(String token, User user) async {
- final prefs = await SharedPreferences.getInstance();
- await prefs.setString(AppConfig.tokenKey, token);
- await prefs.setString(AppConfig.userKey, jsonEncode(user.toJson()));
- _token = token;
- _currentUser = user;
+ try {
+ await _secureStorage.write(key: AppConfig.tokenKey, value: token);
+ await _secureStorage.write(
+ key: AppConfig.userKey,
+ value: jsonEncode(user.toJson()),
+ );
+ _token = token;
+ _currentUser = user;
+ } catch (e) {
+ debugPrint('Failed to save auth session: $e');
+ _token = token;
+ _currentUser = user;
+ }
}
// Clear token (logout)
Future clearToken() async {
- final prefs = await SharedPreferences.getInstance();
- await prefs.remove(AppConfig.tokenKey);
- await prefs.remove(AppConfig.userKey);
- await prefs.remove(AppConfig.deviceTokenKey);
- _token = null;
- _currentUser = null;
+ try {
+ await _secureStorage.delete(key: AppConfig.tokenKey);
+ await _secureStorage.delete(key: AppConfig.userKey);
+ await _secureStorage.delete(key: AppConfig.deviceTokenKey);
+ } catch (e) {
+ debugPrint('Failed to clear auth session: $e');
+ } finally {
+ _token = null;
+ _currentUser = null;
+ }
}
Future saveDeviceToken(String token) async {
- final prefs = await SharedPreferences.getInstance();
- await prefs.setString(AppConfig.deviceTokenKey, token);
+ await _secureStorage.write(key: AppConfig.deviceTokenKey, value: token);
}
Future loadDeviceToken() async {
- final prefs = await SharedPreferences.getInstance();
- return prefs.getString(AppConfig.deviceTokenKey);
+ return _secureStorage.read(key: AppConfig.deviceTokenKey);
}
/// Tangani response 401 secara terpusat.
@@ -96,8 +184,11 @@ class AuthService {
required String body,
}) async {
debugPrint('[HTTP] POST $uri');
- debugPrint('[HTTP] Headers: $headers');
- debugPrint('[HTTP] Body: $body');
+ if (kDebugMode) {
+ // Avoid printing secrets in logs.
+ debugPrint('[HTTP] Headers: $headers');
+ debugPrint('[HTTP] Body: (redacted)');
+ }
try {
final client = _getHttpClient();
@@ -113,7 +204,13 @@ class AuthService {
final responseBody = await response.transform(utf8.decoder).join();
debugPrint('[HTTP] Status: ${response.statusCode}');
- debugPrint('[HTTP] Response: $responseBody');
+ if (kDebugMode) {
+ final preview = responseBody.substring(
+ 0,
+ math.min(responseBody.length, 500),
+ );
+ debugPrint('[HTTP] Response: ${_redactSensitive(preview)}');
+ }
return http.Response(responseBody, response.statusCode);
} on SocketException catch (e) {
@@ -135,6 +232,7 @@ class AuthService {
required String password,
required String passwordConfirmation,
String? phone,
+ bool allowRetry = true,
}) async {
try {
final url = '${AppConfig.baseUrl}/register';
@@ -156,9 +254,13 @@ class AuthService {
);
debugPrint('Register response status: ${response.statusCode}');
- debugPrint(
- 'Register response body: ${response.body.substring(0, math.min(response.body.length, 300))}',
- );
+ if (kDebugMode) {
+ final preview = response.body.substring(
+ 0,
+ math.min(response.body.length, 300),
+ );
+ debugPrint('Register response preview: ${_redactSensitive(preview)}');
+ }
Map? data;
String? rawBody;
@@ -214,12 +316,41 @@ class AuthService {
};
} on SocketException catch (e) {
debugPrint('SocketException in register: $e');
+
+ // Coba temukan server lagi lalu retry sekali.
+ if (allowRetry && await _rediscoverServer()) {
+ try {
+ return await register(
+ name: name,
+ email: email,
+ password: password,
+ passwordConfirmation: passwordConfirmation,
+ phone: phone,
+ allowRetry: false,
+ );
+ } catch (_) {}
+ }
+
return {
'success': false,
'message': 'Gagal terhubung ke server. Periksa koneksi internet.',
};
} on TimeoutException catch (e) {
debugPrint('TimeoutException in register: $e');
+
+ if (allowRetry && await _rediscoverServer()) {
+ try {
+ return await register(
+ name: name,
+ email: email,
+ password: password,
+ passwordConfirmation: passwordConfirmation,
+ phone: phone,
+ allowRetry: false,
+ );
+ } catch (_) {}
+ }
+
return {
'success': false,
'message': 'Registrasi timeout. Server tidak merespons.',
@@ -236,6 +367,7 @@ class AuthService {
Future