Skip to content
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,7 @@ yarn-error.log
.env.e2e

# Ignore AI files
QWEN.md
QWEN.md
.kilocode
.qwen
/template_ai/
80 changes: 80 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# AGENTS.md

This file provides guidance to agents when working with code in this repository.

## Commands

**Testing:**
- Run PHPUnit tests: `php artisan test` or `./vendor/bin/phpunit`
- Run single test: `php artisan test --filter TestName`
- Run E2E tests: `npm run test:e2e` (auto-starts server and runs migrations)
- Run E2E with UI: `npm run test:e2e:ui`
- Run E2E in debug mode: `npm run test:e2e:debug`

**Code Quality:**
- Fix PHP code style: `vendor/bin/php-cs-fixer fix`
- Check PHP code style: `vendor/bin/php-cs-fixer fix --dry-run`

**Development:**
- Start server: `php artisan serve`
- Compile assets: `npm run dev` (development) or `npm run prod` (production)

## Critical Patterns

**Security - GitHub API (MANDATORY):**
- All GitHub API calls MUST use `lastrelease()` helper which validates URLs via `is_trusted_github_api_url()`
- Only these endpoints are allowed: `/repos/OpenSID/rilis-premium/releases/latest`, `/repos/OpenSID/rilis-pbb/releases/latest`, `/repos/OpenSID/opendk/releases/latest`, `/repos/OpenSID/rilis-opensid-api/releases/latest`
- Never make direct HTTP requests to GitHub without this validation (SSRF protection)

**Custom Helpers (Auto-loaded):**
- `app/Helpers/helper.php` - Core helpers (version checking, domain validation, backup functions)
- `helpers/general_helper.php` - General helpers (date formatting, image handling, number formatting)
- Both are auto-loaded via composer.json, no manual imports needed

**Database & Models:**
- Desa model uses complex scopes: `jumlahDesa()`, `desaValid()`, `filterWilayah()`, `hostingOnline()`, `hostingOffline()`, `aktif()`
- Region filtering via `FilterWilayahTrait` and `HasRegionAccess` trait
- Version checking uses cached GitHub API calls with fallback defaults
- TEMA_PRO themes: ['Silir', 'Batuah', 'Pusako', 'DeNava', 'Lestari']

**E2E Testing:**
- Configuration loaded from `.env.e2e` file (not `.env`)
- Test credentials: `eddie.ridwan@gmail.com` / `Admin100%` (from UserSeeder)
- Authentication state saved in `test-results/storage-state/auth.json`
- Playwright auto-starts Laravel server via webServer config
- Global setup runs `php artisan migrate:fresh --seed` automatically

**Code Style:**
- PHP CS Fixer configured with Laravel preset (`.php-cs-fixer.php`)
- Single quotes for strings, short array syntax
- Ordered imports alphabetically
- No trailing whitespace, specific brace positioning
- StyleCI config (`.styleci.yml`) overrides: disables `no_unused_imports` rule

**Gotchas:**
- `sudahInstal()` helper checks for `storage_path('installed')` file existence
- Domain validation uses `fixDomainName()` to normalize URLs
- Local IP detection via `is_local()` helper (localhost, 192.168, 127.0, 10.x)
- Backup functions check for `/usr/bin/rclone` existence for cloud storage sync
- Log permissions can be changed via `changeLogPermissions()` helper

## Security & Bug Review Focus

**Celah Keamanan yang Perlu Diperiksa:**
- SQL injection pada query builder raw SQL
- XSS pada output user input
- CSRF token validation
- Command injection pada exec() calls
- Path traversal pada file operations
- Authentication bypass pada login
- Authorization bypass pada Admin Wilayah
- Hardcoded secrets di config files
- SSRF pada GitHub API calls (cek `is_trusted_github_api_url()`)

**Bug Logika yang Sering Terjadi:**
- Race condition pada backup operations
- Null pointer pada helper functions
- Memory leak pada large dataset queries
- Infinite loop pada recursive functions
- Error handling yang hilang pada HTTP requests
- Edge case pada date/time calculations
48 changes: 48 additions & 0 deletions app/Enums/Layanan.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace App\Enums;

enum Layanan: string
{
case SIAPPAKAI = 'siappakai';
case PREMIUM = 'premium';
case UMUM = 'umum';

/**
* Get all available layanan options as an array.
*
* @return array<string, string>
*/
public static function toArray(): array
{
return [
self::SIAPPAKAI->value => 'Siappakai',
self::PREMIUM->value => 'Premium',
self::UMUM->value => 'Umum',
];
}

/**
* Get the label for the layanan.
*
* @return string
*/
public function label(): string
{
return match ($this) {
self::SIAPPAKAI => 'Siappakai',
self::PREMIUM => 'Premium',
self::UMUM => 'Umum',
};
}

/**
* Get all layanan values.
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
8 changes: 7 additions & 1 deletion app/Exports/DesaExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Exports;

use App\Enums\Layanan;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithHeadings;
Expand All @@ -18,7 +19,8 @@ public function __construct($data, $hiddenColumns = [])

public function collection()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL] 🚨 Performance: Missing Pagination - Memory Exhaustion Risk

Masalah: Export menggunakan ->get() tanpa limit, load semua desa ke memory sekaligus
Kode:

return Desa::with('akses')
    ->laporan()
    ->fillter($this->request)
    ->get()  // ← Load ALL rows ke memory
    ->map(function ($desa) { ... });

Dampak: Dengan sistem tracking 100 juta+ request/hari:

  • Asumsi 10.000 desa aktif
  • Setiap row ~2KB data (dengan relasi akses)
  • Total memory: 10K × 2KB = 20MB minimum
  • Dengan 50K desa = 100MB
  • PHP memory_limit default 128MB → Fatal Error
  • Export timeout pada dataset besar

Fix:

public function collection()
{
    // Gunakan chunk() untuk batch processing
    $results = collect();
    
    Desa::with('akses')
        ->laporan()
        ->fillter($this->request)
        ->chunk(1000, function ($desas) use ($results) {
            foreach ($desas as $desa) {
                $results->push([
                    'kode_desa' => $desa->kode_desa,
                    'nama_desa' => $desa->nama_desa,
                    'nama_kepala_desa' => $desa->nama_kepala_desa,
                    'nama_kecamatan' => $desa->nama_kecamatan,
                    'nama_kabupaten' => $desa->nama_kabupaten,
                    'nama_provinsi' => $desa->nama_provinsi,
                    'alamat_kantor' => $desa->alamat_kantor,
                    'email_desa' => $desa->email_desa,
                    'telepon' => $desa->telepon,
                    'website' => $desa->website,
                    'layanan' => $desa->layanan ? $desa->layanan->label() : '-',
                    'sebutan_desa' => $desa->sebutan_desa ?? '-',
                    'versi_lokal' => $desa->akses->versi_lokal ?? '-',
                    'tema' => $desa->akses->tema ?? '-',
                    'hosting' => $desa->akses->hosting ? 'Hosting' : 'Lokal',
                    'premium' => $desa->akses->premium ? 'Premium' : 'Umum',
                    'demo' => $desa->akses->demo ? 'Demo' : 'Non Demo',
                    'akses' => $desa->akses->akses ? 'Aktif' : 'Non Aktif',
                ]);
            }
        });
    
    return $results;
}

Atau gunakan cursor() untuk memory efficiency:

public function collection()
{
    return Desa::with('akses')
        ->laporan()
        ->fillter($this->request)
        ->cursor()  // ← Lazy load, minimal memory
        ->map(function ($desa) {
            return [
                // ... mapping sama
            ];
        });
}

{
return $this->data->map(function ($item, $index) {
$mapLayanan = Layanan::toArray();
return $this->data->map(function ($item, $index) use($mapLayanan) {
return [
'no' => $index + 1, // Menambahkan nomor urut berdasarkan index
'nama_desa' => $item->nama_desa,
Expand All @@ -42,6 +44,8 @@ public function collection()
'jml_persil' => $item->jml_persil,
'jml_dokumen' => $item->jml_dokumen,
'jml_keluarga' => $item->jml_keluarga,
'layanan' => $mapLayanan[$item->layanan] ?? $item->layanan,
'sebutan_desa' => $item->sebutan_desa,
'tgl_akses' => $item->tgl_akses,
];
});
Expand Down Expand Up @@ -72,6 +76,8 @@ public function headings(): array
'Persil',
'Dokumen',
'Keluarga',
'Layanan',
'Sebutan Desa',
'Akses Terakhir',
];
}
Expand Down
2 changes: 1 addition & 1 deletion app/Helpers/helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/
function pantau_versi()
{
return 'v2603.0.0';
return 'v2604.0.0';
}
}

Expand Down
34 changes: 23 additions & 11 deletions app/Http/Controllers/LaporanController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

namespace App\Http\Controllers;

use App\Enums\Layanan;
use App\Exports\DesaExport;
use App\Models\Desa;
use App\Models\Scopes\RegionAccessScope;
use App\Services\SebutanDesaService;
use Illuminate\Http\Request;
use Maatwebsite\Excel\Facades\Excel;
use Yajra\DataTables\Facades\DataTables;
Expand All @@ -14,9 +16,13 @@ class LaporanController extends Controller
/** @var Desa */
protected $desa;

public function __construct(Desa $desa)
/** @var SebutanDesaService */
protected $sebutanDesaService;

public function __construct(Desa $desa, SebutanDesaService $sebutanDesaService)
{
$this->desa = $desa;
$this->sebutanDesaService = $sebutanDesaService;
}

public function desa(Request $request)
Expand All @@ -36,43 +42,49 @@ public function desa(Request $request)
'versi_hosting' => $request->versi_hosting,
'tte' => $request->tte,
'tipe_pengguna' => $request->tipe_pengguna,
'layanan' => $request->layanan,
'sebutan_desa' => $request->sebutan_desa,
];
$hiddenColumns = [];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL] 🚨 Performance: N+1 Query - Missing Eager Loading

Masalah: Query DataTables tidak menggunakan eager loading untuk relasi yang diakses di editColumn. Setiap row akan trigger query terpisah untuk region, opendk, dan pbb.

Kode:

->make(true);

Dampak: Dengan 100 desa di tabel, akan terjadi:

  • 1 query utama untuk desa
  • 100 query untuk region (jika diakses)
  • 100 query untuk opendk (line 73-74)
  • 100 query untuk pbb (line 75-76)
  • Total: 301 queries untuk 1 request

Fix:

$query = Desa::query()
    ->with(['region', 'opendk', 'pbb']) // Eager load relasi
    ->fillter($request);

return DataTables::eloquent($query)
    // ... rest of code
    ->make(true);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kode baris berapa, saya tidak menemukan code yang dimaksud

$adminWilayah = auth()->check() && auth()->user()->isAdminWilayah();
if ($adminWilayah) {
$hiddenColumns[] = 'aksi';
$hiddenColumns[] = 'kontak';
$hiddenColumns[] = 'kontak';
}

if ($request->ajax() || $request->excel) {
$query = DataTables::of($this->desa->fillter($fillters)->laporan());
if ($request->excel) {
$query->filtering();
if(in_array('aksi', $hiddenColumns)){
if (in_array('aksi', $hiddenColumns)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] ⚠️ PHP Quality: Array Search Without Strict Mode

Kategori: PHP Quality / Best Practices
Masalah: in_array() tanpa parameter strict mode bisa menyebabkan false positive karena type coercion (misal: 0 == 'aksi' akan true). Best practice adalah selalu gunakan strict mode.
Kode: if (in_array('aksi', $hiddenColumns))

Fix:

// Tambahkan parameter ketiga true untuk strict comparison
if (in_array('aksi', $hiddenColumns, true)) {
    $dt->removeColumn('aksi');
}

unset($hiddenColumns['aksi']);
}
}
return Excel::download(new DesaExport($query->results(), $hiddenColumns), 'Desa-yang-memasang-OpenSID.xlsx');
}

return $query->addIndexColumn()
->editColumn('kontak', function ($q) {
$identitas = $q->kontak;
if ($identitas) {
return '<div><div>'.$identitas['nama'].'</div><div>'.$identitas['hp'].'</div></div>';
// Escape output untuk mencegah XSS
$nama = e($identitas['nama'] ?? '-');
$hp = e($identitas['hp'] ?? '-');
return '<div><div>' . $nama . '</div><div>' . $hp . '</div></div>';
}

return '';
})
->addColumn('action', function ($data) {
$delete = '<button data-href="'.url('laporan/desa/'.$data->id).'" class="btn btn-sm btn-danger" data-toggle="modal" data-target="#confirm-delete"><i class="fas fa-trash"></i></button>';
$delete = '<button data-href="' . url('laporan/desa/' . $data->id) . '" class="btn btn-sm btn-danger" data-toggle="modal" data-target="#confirm-delete"><i class="fas fa-trash"></i></button>';

return '<div class="btn btn-group">'.$delete.'</div>';
})
->rawColumns(['action', 'kontak'])
return '<div class="btn btn-group">' . $delete . '</div>';
})->editColumn('layanan', function ($data) {
return (Layanan::tryFrom($data->layanan))?->label() ?? '-';
})->rawColumns(['action', 'kontak'])
->make(true);
}

return view('laporan.desa', compact('fillters', 'hiddenColumns'));
$sebutanDesaList = (new SebutanDesaService())->getSebutanDesaList();
return view('laporan.desa', compact('fillters', 'hiddenColumns', 'sebutanDesaList'));
}

public function deleteDesa(Desa $desa)
Expand Down
10 changes: 10 additions & 0 deletions app/Http/Requests/TrackRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Http\Requests;

use App\Enums\Layanan;
use App\Models\Region;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] ⚠️ PHP Quality: Missing Validation Attributes

Kategori: PHP Quality / Validation
Masalah: Validasi rules ditambahkan untuk 'sebutan_desa' dan 'layanan', tetapi tidak ada di method attributes(). Ini membuat error message tidak user-friendly dan menampilkan field name mentah.
Kode: Method attributes() tidak memiliki entry untuk field baru

Fix:

public function attributes(): array
{
    return [
        'region_id' => 'Wilayah',
        'nama' => 'Nama',
        'kode' => 'Kode',
        'hp' => 'No. HP',
        'email' => 'Email',
        'sebutan_desa' => 'Sebutan Desa',  // Tambahkan ini
        'layanan' => 'Layanan',            // Tambahkan ini
    ];
}

Expand Down Expand Up @@ -58,6 +59,13 @@ public function rules()
'sometimes',
Rule::in(['0', '1']),
],
'sebutan_desa' => 'nullable|string|max:255',
'layanan' => [
'nullable',
'string',
'max:255',
Rule::enum(Layanan::class),
],
];
}

Expand Down Expand Up @@ -183,6 +191,8 @@ public function requestData()
'anjungan',
'kontak',
'tema',
'sebutan_desa',
'layanan',
]);
}
}
46 changes: 44 additions & 2 deletions app/Models/Desa.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ class Desa extends Model
'kontak' => 'array',
'anjungan' => 'bool',
'tema' => 'string',
'sebutan_desa' => 'string',
'layanan' => 'string',
];

/** {@inheritdoc} */
Expand Down Expand Up @@ -378,9 +380,9 @@ public function scopeLaporan($query)
{
return $query
// ->select(['*'])
->select(['nama_desa', 'kode_desa', 'nama_kecamatan', 'nama_kabupaten', 'kode_kecamatan', 'kode_kabupaten', 'nama_provinsi', 'kode_provinsi', 'versi_lokal', 'versi_hosting', 'jml_surat_tte', 'modul_tte', 'jml_penduduk', 'jml_artikel', 'jml_surat_keluar', 'jml_bantuan', 'jml_mandiri', 'jml_pengguna', 'jml_unsur_peta', 'jml_persil', 'jml_dokumen', 'jml_keluarga', 'kontak', 'tema'])
->select(['nama_desa', 'kode_desa', 'nama_kecamatan', 'nama_kabupaten', 'kode_kecamatan', 'kode_kabupaten', 'nama_provinsi', 'kode_provinsi', 'versi_lokal', 'versi_hosting', 'jml_surat_tte', 'modul_tte', 'jml_penduduk', 'jml_artikel', 'jml_surat_keluar', 'jml_bantuan', 'jml_mandiri', 'jml_pengguna', 'jml_unsur_peta', 'jml_persil', 'jml_dokumen', 'jml_keluarga', 'kontak', 'tema', 'layanan', 'sebutan_desa'])
->selectRaw('greatest(coalesce(tgl_akses_lokal, 0), coalesce(tgl_akses_hosting, 0)) as tgl_akses')
->when(auth()->check() == true, function ($query) {
->when(auth()->check() === true, function ($query) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] 🔒 Security: Missing Authorization Check on scopeFillter

Masalah:
Method scopeFillter() hanya mengecek auth()->check() untuk menentukan apakah user authenticated, tapi tidak memvalidasi apakah user memiliki permission untuk melihat data desa tertentu. Ini bisa menyebabkan IDOR (Insecure Direct Object Reference) jika user authenticated bisa mengakses data desa yang seharusnya tidak boleh mereka akses.

Kode:

->when(auth()->check() === true, function ($query) {
    if (auth()->user()->kode_desa) {
        $query->where('kode_desa', auth()->user()->kode_desa);
    }
})

Risiko:
User yang sudah login tapi tidak memiliki kode_desa bisa melihat SEMUA data desa tanpa filtering. Jika ada role seperti "operator desa" yang seharusnya hanya bisa lihat desa mereka sendiri, tapi field kode_desa kosong (null), mereka bisa bypass restriction.

PoC (Chrome Console):

// Skenario: User dengan role "operator desa" tapi kode_desa = null
// Langkah 1: Login sebagai user dengan kode_desa kosong
// Langkah 2: Akses endpoint laporan desa

const testIDOR = async () => {
    // Request ke endpoint laporan desa
    const resp = await fetch('/laporan/desa', {
        method: 'GET',
        headers: {
            'Accept': 'application/json',
            'X-Requested-With': 'XMLHttpRequest'
        }
    });
    
    const data = await resp.json();
    
    // Jika kode_desa user = null, response akan berisi SEMUA desa
    console.log('Total desa yang bisa diakses:', data.recordsTotal);
    console.log('Data desa:', data.data);
    
    // Expected: Hanya desa user sendiri
    // Actual: Semua desa jika kode_desa = null
};

testIDOR();

Fix:

->when(auth()->check() === true, function ($query) {
    $user = auth()->user();
    
    // Jika user memiliki kode_desa, filter berdasarkan itu
    if ($user->kode_desa) {
        $query->where('kode_desa', $user->kode_desa);
    } 
    // Jika user tidak punya kode_desa tapi bukan superadmin, block access
    elseif (!$user->hasRole('superadmin')) {
        // Return empty result untuk user tanpa kode_desa dan bukan superadmin
        $query->whereRaw('1 = 0');
    }
    // Superadmin bisa lihat semua (no additional filter)
})

Rekomendasi Tambahan:
Gunakan Laravel Policy untuk authorization check yang lebih robust:

// app/Policies/DesaPolicy.php
public function viewAny(User $user)
{
    return $user->hasRole('superadmin') || $user->kode_desa !== null;
}

public function view(User $user, Desa $desa)
{
    return $user->hasRole('superadmin') || $user->kode_desa === $desa->kode_desa;
}

$query->selectRaw('url_lokal, url_hosting');
})
->when(session('provinsi'), function ($query, $provinsi) {
Expand All @@ -403,6 +405,8 @@ public function scopeFillter($query, array $fillters)
'versi_hosting' => null,
'tte' => null,
'tipe_pengguna' => null,
'layanan' => null,
'sebutan_desa' => null,
], $fillters);

return $query->select(['*'])
Expand Down Expand Up @@ -466,6 +470,12 @@ public function scopeFillter($query, array $fillters)
$sub->where($this->getTable().'.versi_lokal', 'NOT LIKE', '%-premium%')
->orWhereNull($this->getTable().'.versi_lokal');
});
})
->when($fillters['layanan'], function ($query, $layanan) {
$query->layanan($layanan);
})
->when($fillters['sebutan_desa'], function ($query, $sebutanDesa) {
$query->sebutanDesa($sebutanDesa);
});
}

Expand Down Expand Up @@ -624,6 +634,38 @@ public function scopeHostingOffline($query)
return $query->whereNotNull($this->getTable() . '.versi_lokal')->whereNull($this->getTable() . '.versi_hosting');
}

/**
* Scope a query by layanan.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string|null $layanan
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeLayanan($query, $layanan = null)
{
if ($layanan === null) {
return $query;
}

return $query->where($this->getTable() . '.layanan', $layanan);
}

/**
* Scope a query by sebutan desa.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string|null $sebutanDesa
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeSebutanDesa($query, $sebutanDesa = null)
{
if ($sebutanDesa === null) {
return $query;
}

return $query->where($this->getTable() . '.sebutan_desa', $sebutanDesa);
}

/**
* Scope a query kecamatan OpenSID.
*
Expand Down
Loading