Field Rich Editor
PandaPanel\Forms\Components\RichEditor adalah field untuk teks berformat yang disimpan sebagai HTML. Gunakan field ini ketika penulis membutuhkan heading, penekanan teks, daftar, dan tautan, lalu hasilnya akan dirender sebagai markup. Jika hasil harus tetap berupa teks biasa, gunakan Textarea. Jika penulis nyaman menulis markup sendiri, gunakan MarkdownEditor, yang menyimpan teks Markdown, bukan HTML.
Form minimal
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Posts\Forms;
use PandaPanel\Forms\Components\RichEditor;
use PandaPanel\Forms\FormSchema;
final class PostForm
{
public static function configure(FormSchema $schema): FormSchema
{
return $schema->schema([
RichEditor::make('body')
->maxLength(20000)
->columnSpanFull(),
]);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Konfigurasi tersebut merender area yang dapat diedit dengan toolbar default, memvalidasi body sebagai nullable|string|max:20000, dan melakukan sanitasi HTML sebelum nilainya masuk ke record.
Nilai disanitasi sebelum disimpan
HTML dari form adalah salah satu jenis nilai field yang berbahaya secara default: nilainya ditulis oleh user lalu dirender kembali sebagai markup. Itulah definisi dari stored XSS. Field ini menyelesaikan masalah tersebut di satu tempat — mutate(), tepat sebelum nilai ditulis ke record — karena tiga alasan:
- Bukan ketika nilai dibaca kembali, karena satu render yang tidak di-escape saja sudah cukup untuk membatalkan perlindungan.
- Bukan dengan mempercayai editor, karena browser dapat mengirim request tanpa menggunakan control tersebut sama sekali.
- Bukan saat display di setiap lokasi yang menampilkan nilai, karena daftar lokasi itu sulit dipastikan selalu lengkap.
Dengan begitu, nilai yang tersimpan sudah berada dalam bentuk aman, dan pembacaan berikutnya menggunakan nilai yang sudah disanitasi tersebut.
use PandaPanel\Forms\Components\RichEditor;
RichEditor::make('body')->mutate(
'<p>ok</p><iframe src="https://evil.test"></iframe>',
null,
);
// => '<p>ok</p>'2
3
4
5
6
7
Method
allowedTags(array $tags): self
Menerima list<string>. Daftar ini menentukan tag HTML yang tetap dipertahankan setelah sanitasi. Default-nya sengaja dibuat terbatas:
['p', 'br', 'strong', 'b', 'em', 'i', 'u', 's',
'ul', 'ol', 'li', 'blockquote', 'code', 'pre',
'h2', 'h3', 'h4', 'a', 'hr']2
3
Tidak ada script, style, iframe, object, embed, atau form, karena masing-masing dapat mengubah teks tersimpan menjadi behavior yang dapat dieksekusi atau memengaruhi halaman secara berbahaya.
RichEditor::make('body')->allowedTags([
'p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li',
]);2
3
Pemanggilan method ini mengganti daftar sebelumnya, bukan menambahkan item ke daftar default. Memperluas allowlist merupakan keputusan eksplisit di level schema, dan setiap tag tambahan harus dipahami konsekuensi keamanannya.
Daftar allowedTags tidak pernah dikirim ke browser. Server tetap menjadi otoritas, sedangkan toolbar hanya membantu user menghasilkan markup.
toolbar(array $buttons): self
Menerima list<string>. Menentukan tombol apa saja yang ditampilkan editor. Default-nya:
['bold', 'italic', 'strike', 'link',
'h2', 'h3', 'bulletList', 'orderedList', 'blockquote', 'undo', 'redo']2
Berikut nama yang dipahami renderer dan command editing yang dihasilkannya:
| Nama | Tombol | Menghasilkan |
|---|---|---|
bold | B | <b> / <strong> |
italic | I | <i> / <em> |
underline | U | <u> |
strike | S | <s> |
h2 | H2 | <h2> |
h3 | H3 | <h3> |
blockquote | ❝ | <blockquote> |
bulletList | • List | <ul><li> |
orderedList | 1. List | <ol><li> |
link | Link | <a href>, dari prompt |
undo | ↶ | — |
redo | ↷ | — |
Nama yang tidak terdapat dalam tabel tersebut tidak merender tombol apa pun, sama seperti icon yang tidak terdaftar tidak menghasilkan icon. Mengirim [] akan merender editor tanpa toolbar.
RichEditor::make('excerpt')->toolbar(['bold', 'italic', 'link']);maxLength(int $length): self
Default null. Menambahkan rule max:N dan dikirim ke browser sebagai maxLength. Nilai di bawah 1 akan di-clamp menjadi 1.
RichEditor::make('body')->maxLength(20000);sanitize(string $html): string
Method public yang menjadi implementasi utama dari jaminan sanitasi di atas. mutate() memanggilnya secara otomatis. Anda juga dapat memanggilnya langsung jika membutuhkan sanitasi yang sama di lokasi lain atau di dalam test.
$field = RichEditor::make('body');
$field->sanitize('<p>Hello <script>alert(1)</script><b onclick="steal()">there</b></p>');
// => '<p>Hello alert(1)<b>there</b></p>'
$field->sanitize('<a href="javascript:alert(1)">click</a>');
// => '<a>click</a>'2
3
4
5
6
7
Perhatikan apa yang terjadi pada script: tag-nya dihapus, tetapi teks di dalamnya tetap ada. strip_tags() menghapus element namun mempertahankan isi teksnya, sehingga alert(1) tetap ada sebagai teks biasa. Ini aman karena bukan markup lagi, tetapi berbeda dengan menghapus node beserta seluruh isinya. Schema yang mengharapkan isi element terlarang ikut hilang perlu memahami perbedaan ini.
Sanitasi dilakukan dalam dua tahap, dan tahap kedua sangat penting:
strip_tags()menggunakan allowlist. Tahap ini membuang element yang tidak diizinkan, tetapi tidak menghapus seluruh attribute pada tag yang tetap dipertahankan, sehingga tahap ini belum cukup.- Pembersihan attribute yang menghapus handler
on*, melakukan decode entity pada URL, dan mempertahankanhrefatausrchanya jika URL bersifat relatif atau menggunakan schemehttp,https,mailto, atautel. Scheme sepertijavascript:,data:, termasuk bentuk yang di-encode atau diberi whitespace, akan dibuang bersama attribute-nya.
mutate(mixed $value, ?Model $record): mixed
Method ini mengoverride Field. Jika value berupa string, HTML akan disanitasi terlebih dahulu, lalu hasilnya diteruskan ke callback yang dideklarasikan melalui dehydrateStateUsing() atau mutateUsing(). Value non-string diteruskan tanpa perubahan.
use Illuminate\Support\Str;
RichEditor::make('body')
// Receives HTML that has already been sanitized.
->dehydrateStateUsing(static fn (mixed $value): string => Str::of((string) $value)
->trim()
->toString());2
3
4
5
6
7
type(): FieldType
Mengembalikan FieldType::RichEditor, yang diserialisasi sebagai 'rich_editor'.
Bentuk serialized payload
RichEditor::make('body')->maxLength(20000)->toArray(null, 'create') menambahkan dua key pada payload dasar field:
| Key | Type | Default |
|---|---|---|
toolbar | string[] | sebelas nama default di atas |
maxLength | number | null | null |
allowedTags sengaja tidak ikut dikirim. Browser tidak memerlukan daftar yang tidak menjadi otoritas baginya.
Editor di frontend
RichEditorField.vue menggunakan area contenteditable dan command editing bawaan browser. Implementasinya sengaja dependency-free. Menambahkan library editor adalah keputusan dependency di level application, bukan sekadar detail rendering, sehingga field ini menggunakan API yang sudah tersedia di browser.
document.execCommand memang berstatus deprecated, tetapi hingga saat ini tetap menjadi API editing yang tersedia secara luas. Ketika pengganti yang universal tersedia, component inilah satu-satunya lokasi yang perlu berubah.
Dua konsekuensi penting:
- Markup persis yang dihasilkan dapat berbeda antar-browser.
boldmisalnya dapat menghasilkan<b>atau<strong>; karena itu keduanya berada di allowlist default. - Pada beberapa browser, area kosong dapat menghasilkan
<br>. Component mengubah kasus tersebut menjadi'', sehingga editor kosong tidak tanpa sengaja lolos dari fieldrequired.
Merender nilai yang tersimpan
Nilai disimpan sebagai HTML. Untuk merendernya di Vue digunakan v-html — dan inilah alasan sanitasi dilakukan sebelum penyimpanan, bukan setelahnya.
<template>
<article class="prose dark:prose-invert" v-html="post.body" />
</template>2
3
Lakukan ini hanya terhadap value yang ditulis oleh field ini. HTML yang berasal dari sumber lain belum tentu pernah melewati sanitize().
Hal yang perlu diperhatikan
maxLengthmenghitung HTML, bukan jumlah kata.<p><strong>Hi</strong></p>berjumlah 26 karakter, bukan 2. Tentukan batas berdasarkan ukuran column dan markup, bukan hanya teks yang terlihat.- Toolbar dan allowlist adalah dua daftar berbeda dan keduanya dapat tidak sinkron. Menambahkan
underlineke toolbar bekerja karenausudah diizinkan. Sebaliknya, jika tombol menghasilkan tag yang Anda hapus dariallowedTags(), user dapat menerapkan format tersebut tetapi server akan membuangnya saat save. Ubah keduanya secara konsisten. - Sebagian besar attribute pada tag yang diizinkan tetap dapat tersimpan.
strip_tags()menyaring element, bukan attribute. Tahap kedua menghapus handleron*serta URLhref/srcyang tidak aman. Tetapi attribute sepertistyle,class,id, ataudata-*yang dipaste dapat tetap tersimpan. Itu bukan executable code, tetapi tetap dapat memengaruhi tampilan halaman saat dirender. - Tombol
linkmeminta URL melaluiwindow.prompt. Jawaban kosong atau cancel membiarkan selection apa adanya. Tidak ada link editor khusus, dan tombol hanya menambahkanhref; tidak adatargetataurel. - Tidak ada tombol image dan
imgtidak termasuk allowlist default. Simpan gambar menggunakanFileUpload, yang menggunakan endpoint Panel dengan disk dan directory yang sudah dideklarasikan. MenambahkanimgkeallowedTags()berarti HTML tersimpan dapat mereferensikan URL mana pun, jadi lakukan dengan sengaja. required()memeriksa string, bukan makna kontennya. Markup seperti<p></p>tetap merupakan string non-empty dan dapat memenuhirequired, walaupun secara visual tidak berarti. Kasus<br>sudah ditangani, tetapi kasus kosong semantik lainnya tidak.- Hook Anda berjalan setelah sanitasi.
mutate()melakukan sanitasi sebelum memanggildehydrateStateUsing()ataumutateUsing(). Artinya markup baru yang dibuat oleh hook akan disimpan apa adanya dan tidak melewati allowlist lagi.