jskelet 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +132 -132
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -21
- package/bin/jskelet.mjs +103 -103
- package/docs/01-baslangic.md +285 -285
- package/docs/02-mimari.md +287 -287
- package/docs/03-routing.md +480 -480
- package/docs/04-render-ve-sablonlar.md +490 -490
- package/docs/05-islands.md +482 -482
- package/docs/06-cache.md +1209 -1202
- package/docs/08-build.md +366 -366
- package/docs/09-dev-araclari.md +335 -335
- package/docs/10-dagitim.md +329 -329
- package/docs/12-panel-ve-oturum.md +384 -384
- package/docs/README.md +105 -105
- package/docs/en/01-getting-started.md +292 -292
- package/docs/en/02-architecture.md +305 -305
- package/docs/en/03-routing.md +497 -497
- package/docs/en/04-rendering.md +504 -504
- package/docs/en/05-islands.md +492 -492
- package/docs/en/06-caching.md +1239 -1232
- package/docs/en/07-configuration.md +986 -986
- package/docs/en/08-build.md +383 -383
- package/docs/en/09-dev-tools.md +342 -342
- package/docs/en/10-deployment.md +332 -332
- package/docs/en/11-migration.md +359 -359
- package/docs/en/12-dashboards-and-sessions.md +392 -392
- package/docs/en/README.md +112 -112
- package/package.json +102 -102
- package/src/build/ensure-build.mjs +15 -15
- package/src/build/paths.mjs +143 -143
- package/src/build/resolve-peer.mjs +36 -36
- package/src/build/tasks/client.mjs +268 -268
- package/src/build/tasks/css.mjs +124 -124
- package/src/build/tasks/fonts.mjs +146 -146
- package/src/build/tasks/icons.mjs +224 -224
- package/src/build/tasks/images.mjs +244 -244
- package/src/build/tasks/precompress.mjs +78 -78
- package/src/client/cache-panel/i18n.js +670 -0
- package/src/client/cache-panel/login.html +74 -71
- package/src/client/cache-panel/panel.css +756 -740
- package/src/client/cache-panel/panel.html +308 -307
- package/src/client/cache-panel/panel.js +915 -808
- package/src/client/devtools/report.html +185 -185
- package/src/client/devtools/report.js +725 -725
- package/src/client/dom.js +95 -95
- package/src/client/form.js +192 -192
- package/src/client/index.js +35 -35
- package/src/client/registry.js +297 -297
- package/src/client/safe-image.js +91 -91
- package/src/client/store.js +36 -36
- package/src/client/swap.js +188 -188
- package/src/config/pattern.js +107 -107
- package/src/http/control-flow.js +71 -71
- package/src/http/cookies.js +257 -257
- package/src/http/request-cache.js +46 -46
- package/src/http/request-context.js +162 -162
- package/src/index.js +83 -83
- package/src/init.mjs +221 -221
- package/src/runtime/alias-hooks.mjs +119 -119
- package/src/runtime/register.mjs +4 -4
- package/src/server/assets.js +147 -147
- package/src/server/cache-deps.js +42 -42
- package/src/server/cache-panel.js +759 -738
- package/src/server/cloudflare.js +607 -595
- package/src/server/create-app.js +291 -291
- package/src/server/data-cache.js +462 -462
- package/src/server/dev/report.js +369 -369
- package/src/server/dev/socket.js +170 -170
- package/src/server/dev/version-check.mjs +139 -139
- package/src/server/html-cache.js +817 -817
- package/src/server/metadata.js +102 -102
- package/src/server/middleware/compression.js +205 -205
- package/src/server/middleware/csrf.js +134 -134
- package/src/server/middleware/dev-gate.js +62 -62
- package/src/server/middleware/headers.js +37 -37
- package/src/server/middleware/redirects.js +32 -32
- package/src/server/middleware/static-precompressed.js +100 -100
- package/src/server/middleware/upstream-proxy.js +141 -141
- package/src/server/prewarm.js +601 -601
- package/src/server/redis.js +569 -569
- package/src/server/router.js +128 -128
- package/src/server/status-page.js +164 -164
- package/src/server/upstream-limiter.js +376 -376
- package/src/server/upstream-tracking.js +166 -166
- package/src/start.mjs +7 -7
- package/src/templates/layout.ejs +44 -44
- package/src/version.mjs +31 -31
- package/src/views/components/loader.js +85 -85
- package/src/views/helpers/html.js +102 -102
- package/src/views/helpers/tags.js +245 -245
|
@@ -1,384 +1,384 @@
|
|
|
1
|
-
# 12 — Panel, oturum ve kişiye özel sayfalar
|
|
2
|
-
|
|
3
|
-
JSkelet'in ağırlık merkezi public, önbelleklenebilir sayfalar. Bir panel ise
|
|
4
|
-
tam ters eksende durur: her ziyaretçiye farklı HTML, önbellek yok, yoğun
|
|
5
|
-
etkileşim. Bu belge o ekseni anlatır — `private: true`, oturum cookie'leri,
|
|
6
|
-
CSRF, fragment uçları ve parça takası.
|
|
7
|
-
|
|
8
|
-
Kapsam dışında bırakılan bir şey var ve bilinçli: **canlı veri taşıması**.
|
|
9
|
-
SSE, WebSocket ya da aralıklı sorgu arasındaki seçim uygulamanın; framework
|
|
10
|
-
yalnızca "şu parçayı sunucudan tazele" adımını veriyor. Sıkıştırma katmanı
|
|
11
|
-
`text/event-stream` yanıtlarını ve başlıklarını kendisi yazan akışları
|
|
12
|
-
atladığı için SSE'yi elle kurmak da bir şeyi bozmuyor.
|
|
13
|
-
|
|
14
|
-
Çalışan karşılığı `examples/dashboard/`; bu belgedeki kod parçalarının kaynağı
|
|
15
|
-
orası.
|
|
16
|
-
|
|
17
|
-
## Neden ayrı bir yol gerekiyor
|
|
18
|
-
|
|
19
|
-
HTML önbelleğinin anahtarı yalnızca yol ve query:
|
|
20
|
-
|
|
21
|
-
```
|
|
22
|
-
`${req.path}?${new URLSearchParams(query).toString()}`
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
Kimlik anahtarın parçası değil. Yani oturuma bağlı bir sayfa normal `route()`
|
|
26
|
-
ile kaydedilirse, ilk isteyenin HTML'i o TTL boyunca **herkese** servis edilir.
|
|
27
|
-
Bu hata hiçbir yerde patlamaz: sayfa çalışır, testler geçer, sorun yalnızca
|
|
28
|
-
ikinci kullanıcı geldiğinde ve genelde üretimde görünür.
|
|
29
|
-
|
|
30
|
-
## `private: true`
|
|
31
|
-
|
|
32
|
-
```js
|
|
33
|
-
export default function register(app, { route, redirect }) {
|
|
34
|
-
app.get(
|
|
35
|
-
"/panel",
|
|
36
|
-
route(
|
|
37
|
-
async ({ req }) => {
|
|
38
|
-
const user = currentUser(req);
|
|
39
|
-
if (!user) redirect("/giris?next=%2Fpanel");
|
|
40
|
-
|
|
41
|
-
return { view: "pages/overview", data: { user } };
|
|
42
|
-
},
|
|
43
|
-
{ private: true },
|
|
44
|
-
),
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
Bayrağın yaptığı işler:
|
|
50
|
-
|
|
51
|
-
| Davranış | Public `route()` | `private: true` |
|
|
52
|
-
| --- | --- | --- |
|
|
53
|
-
| HTML önbelleği | TTL varsa açık | Kapalı, açılamaz |
|
|
54
|
-
| `cache.html` deseni | TTL'i ezer | Yok sayılır |
|
|
55
|
-
| `Cache-Control` | `public, s-maxage=…` | `private, no-store` |
|
|
56
|
-
| `Vary` | `Accept-Encoding` | `Cookie, Accept-Encoding` |
|
|
57
|
-
| ETag | Var | Yok |
|
|
58
|
-
| `X-JSkelet-Cache` | `HIT`/`STALE`/`MISS` | Yazılmaz |
|
|
59
|
-
|
|
60
|
-
ETag'in düşmesi ayrıntı gibi görünüyor ama değil: kullanıcıya özel bir gövdenin
|
|
61
|
-
güçlü ETag'i, o kullanıcıya özgü bir parmak izidir ve `no-store`'a uymayan bir
|
|
62
|
-
katmanda kimlik ayrımı için kullanılabilir.
|
|
63
|
-
|
|
64
|
-
Kişiye özel bir sayfadan atılan yönlendirme de önbelleklenmez. "Giriş yapmalısın"
|
|
65
|
-
kararı oturuma bağlı; saklanması, giriş yapmış kullanıcının da login sayfasına
|
|
66
|
-
atılması demek.
|
|
67
|
-
|
|
68
|
-
## Bayrağı unutursanız
|
|
69
|
-
|
|
70
|
-
Framework kimliğe dokunan erişimleri izliyor. Controller'a giden `req`, şu
|
|
71
|
-
okumaları işaretleyen ince bir Proxy ile sarılı:
|
|
72
|
-
|
|
73
|
-
- `req.headers.cookie`, `req.headers.authorization`,
|
|
74
|
-
`req.headers["proxy-authorization"]`
|
|
75
|
-
- `req.get("Cookie")` / `req.header("Authorization")`
|
|
76
|
-
- `req.cookies`, `req.signedCookies`, `req.session`, `req.user`
|
|
77
|
-
- `parseCookies(req)` ve `getSignedCookie(req, …)` (doğrudan bildiriyorlar)
|
|
78
|
-
|
|
79
|
-
İşaretlenen bir render önbelleğe **yazılmaz**. Üretimde yanıt `no-store` ile
|
|
80
|
-
gider ve şu satır loglanır:
|
|
81
|
-
|
|
82
|
-
```
|
|
83
|
-
[render] /panel kimliğe bağlı veri okudu (req.headers.cookie), önbelleğe
|
|
84
|
-
alınmadı. Route 'private: true' ile kaydedilmeli.
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
Development'ta aynı durum isteği bir hatayla düşürür. Sessiz kalmamasının
|
|
88
|
-
sebebi basit: bu hata çalışan bir sayfa üretiyor, yani kendi başına asla fark
|
|
89
|
-
edilmiyor.
|
|
90
|
-
|
|
91
|
-
`csrfField()` de aynı işaretlemeyi yapıyor. Token basan bir sayfa önbellekten
|
|
92
|
-
dönemez — dönerse tüm ziyaretçiler aynı token'ı paylaşır ve çift gönderim
|
|
93
|
-
kontrolü hiçbir şey doğrulamaz.
|
|
94
|
-
|
|
95
|
-
## Oturum: imzalı cookie
|
|
96
|
-
|
|
97
|
-
Framework kimlik sağlamıyor. Verdiği tek şey "bu değeri ben yazdım,
|
|
98
|
-
kurcalanmamış" garantisi:
|
|
99
|
-
|
|
100
|
-
```js
|
|
101
|
-
import { clearCookie, getSignedCookie, setSignedCookie } from "jskelet/cookies";
|
|
102
|
-
|
|
103
|
-
export function startSession(res, username) {
|
|
104
|
-
setSignedCookie(res, "dash_session", username, { maxAge: 60 * 60 * 8 });
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function currentUser(req) {
|
|
108
|
-
const username = getSignedCookie(req, "dash_session");
|
|
109
|
-
return username ? findUser(username) : null;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export function endSession(res) {
|
|
113
|
-
clearCookie(res, "dash_session");
|
|
114
|
-
}
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
İmza HMAC-SHA256, karşılaştırma sabit zamanlı. İmza uymuyorsa `getSignedCookie`
|
|
118
|
-
`null` döner — kurcalanmış bir değer "belki geçerlidir" diye kullanılmaz.
|
|
119
|
-
|
|
120
|
-
Sır `security.cookieSecret` ya da `JSKELET_SECRET` ortam değişkeninden gelir.
|
|
121
|
-
Sır yoksa imzalı API **hata verir**; "yapılandırma hatası siteyi düşürmez"
|
|
122
|
-
kuralı burada geçerli değil, çünkü sessiz alternatif imzasız bir cookie'ye
|
|
123
|
-
güvenmek olurdu.
|
|
124
|
-
|
|
125
|
-
Varsayılanlar kısıtlayıcı tarafta: `HttpOnly`, `SameSite=Lax`, development
|
|
126
|
-
dışında `Secure`, `Path=/`. `SameSite=Lax` tek başına CSRF'in büyük kısmını
|
|
127
|
-
kapatıyor — cookie çapraz site POST'larında hiç gönderilmiyor.
|
|
128
|
-
|
|
129
|
-
Cookie **şifrelenmiyor**, imzalanıyor. Değer okunabilir; gizli kalması gereken
|
|
130
|
-
veriyi değil, onun kimliğini koyun.
|
|
131
|
-
|
|
132
|
-
## CSRF
|
|
133
|
-
|
|
134
|
-
Gövdeyi framework ayrıştırıyor (`express.urlencoded` + `express.json`), yani
|
|
135
|
-
state değiştiren istekleri kabul eden katman o. Koruma iki katmanlı.
|
|
136
|
-
|
|
137
|
-
### Katman 1 — origin kontrolü (varsayılan açık)
|
|
138
|
-
|
|
139
|
-
`Origin` kendi host'umuzla uyuşmuyorsa ya da `Sec-Fetch-Site: cross-site`
|
|
140
|
-
geldiyse güvenli olmayan metotlar 403 alır. **Başlıkların hiçbiri yoksa istek
|
|
141
|
-
geçer**: tarayıcılar çapraz origin bir POST'ta `Origin`'i her zaman gönderir,
|
|
142
|
-
webhook'lar ve sunucudan sunucuya çağrılar hiç göndermez. Bu ayrım korumayı
|
|
143
|
-
açık bırakırken entegrasyonları bozmuyor.
|
|
144
|
-
|
|
145
|
-
```js
|
|
146
|
-
security: {
|
|
147
|
-
csrf: {
|
|
148
|
-
// Ayrı bir alan adından gelen panel gibi meşru istisnalar.
|
|
149
|
-
allowedOrigins: ["https://admin.example.com"],
|
|
150
|
-
// Tarayıcıdan gelmeyen uçlar.
|
|
151
|
-
exclude: ["/webhook/:path*"],
|
|
152
|
-
},
|
|
153
|
-
}
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
### Katman 2 — çift gönderim token'ı (opsiyonel)
|
|
157
|
-
|
|
158
|
-
`security.csrf.token: true` ile açılır. Formlar token'ı `csrfField()` ile basar:
|
|
159
|
-
|
|
160
|
-
```ejs
|
|
161
|
-
<form method="post" action="/panel/notlar">
|
|
162
|
-
<%- csrfField() %>
|
|
163
|
-
…
|
|
164
|
-
</form>
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
Token'ı **middleware üretmiyor**, `csrfField()` üretiyor — yani gerçekten bir
|
|
168
|
-
forma basıldığı anda. Sebebi somut: token her yanıtta yazılsaydı public ve
|
|
169
|
-
önbelleklenebilir bir sayfa da `Set-Cookie` taşırdı, bir CDN o yanıtı saklardı
|
|
170
|
-
ve tüm ziyaretçiler aynı token'ı paylaşırdı.
|
|
171
|
-
|
|
172
|
-
Sunucu tarafında token, imzalı cookie ile gönderilen alanın eşleşmesini
|
|
173
|
-
istiyor; alan yerine `X-CSRF-Token` başlığı da kabul ediliyor.
|
|
174
|
-
|
|
175
|
-
`csrfField()` `security.csrf.token` kapalıyken boş string döner, yani şablon
|
|
176
|
-
her koşulda render edilebilir.
|
|
177
|
-
|
|
178
|
-
## Fragment uçları
|
|
179
|
-
|
|
180
|
-
`fragment()` politikası sabit bir parça yanıtı üretir: layout basılmaz, yanıt
|
|
181
|
-
`private, no-store` ve ETag'siz gider, HTML önbelleğine hiç uğramaz.
|
|
182
|
-
|
|
183
|
-
```js
|
|
184
|
-
export default function register(app, { fragment }) {
|
|
185
|
-
app.get(
|
|
186
|
-
"/_fragment/siparisler",
|
|
187
|
-
fragment(async ({ req, query }) => {
|
|
188
|
-
const user = currentUser(req);
|
|
189
|
-
if (!user) return { view: "partials/session-expired", status: 401 };
|
|
190
|
-
|
|
191
|
-
return {
|
|
192
|
-
view: "partials/order-table",
|
|
193
|
-
data: { siparisler: getOrders(user.username, Number(query.sayfa ?? 1)) },
|
|
194
|
-
};
|
|
195
|
-
}),
|
|
196
|
-
);
|
|
197
|
-
}
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
Controller ya `{ view, data?, status? }` ya doğrudan bir HTML string döner.
|
|
201
|
-
Hata durumunda tüm sayfa yerine küçük bir parça döner
|
|
202
|
-
(`<div role="alert" data-fragment-error>`): takas edilen bölge bir hata
|
|
203
|
-
sayfasının tamamını içine almamalı.
|
|
204
|
-
|
|
205
|
-
Aynı şablonun sayfanın içinde de fragment ucunda da kullanılması esas fikir —
|
|
206
|
-
işaretlemenin tek kaynağı sunucuda kalır, istemci ikinci bir şablon taşımaz.
|
|
207
|
-
|
|
208
|
-
## İstemci: parça takası
|
|
209
|
-
|
|
210
|
-
```js
|
|
211
|
-
import { registerAll, start, startForms, startSwapLinks } from "jskelet/client";
|
|
212
|
-
|
|
213
|
-
registerAll({ "live-clock": () => import("../islands/live-clock.js") });
|
|
214
|
-
|
|
215
|
-
start();
|
|
216
|
-
startSwapLinks();
|
|
217
|
-
startForms();
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
`startSwapLinks()` `data-swap` taşıyan bağlantıları bağlar:
|
|
221
|
-
|
|
222
|
-
```html
|
|
223
|
-
<a href="/_fragment/siparisler?sayfa=2" data-swap="#siparisler">Sonraki</a>
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
`href` gerçek bir URL olduğu için JS yoksa bağlantı normal gezinmeye düşer.
|
|
227
|
-
Programatik kullanım için `swap()`:
|
|
228
|
-
|
|
229
|
-
```js
|
|
230
|
-
import { swap } from "jskelet/client";
|
|
231
|
-
|
|
232
|
-
await swap("#siparisler", "/_fragment/siparisler?sayfa=2", { history: true });
|
|
233
|
-
```
|
|
234
|
-
|
|
235
|
-
`swap()` sırayla: eski alt ağacın island'larını söker, içeriği değiştirir, yeni
|
|
236
|
-
alt ağacı hidre eder ve odağı kaybolmuşsa geri getirir. İstek süresince bölgeye
|
|
237
|
-
`aria-busy="true"` yazılır — bekleme göstergesini ayrı bir sınıfa bağlamak
|
|
238
|
-
yerine erişilebilirlik durumuna bağlamak ikisinin birbirinden ayrı düşmesini de
|
|
239
|
-
engelliyor.
|
|
240
|
-
|
|
241
|
-
Yönlendirmeyle karşılaşırsa (oturum düştü, login'e gidiliyor) parçayı takmak
|
|
242
|
-
yerine sayfayı o adrese götürür.
|
|
243
|
-
|
|
244
|
-
### Island sökme
|
|
245
|
-
|
|
246
|
-
Bu, takasın en kolay atlanan yarısı. `mount()` bir temizlik fonksiyonu
|
|
247
|
-
döndürebiliyor:
|
|
248
|
-
|
|
249
|
-
```js
|
|
250
|
-
export function mount(element) {
|
|
251
|
-
const timer = setInterval(() => tick(element), 1000);
|
|
252
|
-
return () => clearInterval(timer);
|
|
253
|
-
}
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
`innerHTML` ile değiştirilen bir bölgenin island'ları DOM'dan çıkar ama
|
|
257
|
-
`document`/`window` üzerine kurdukları dinleyiciler ve `setInterval`'ları
|
|
258
|
-
yaşamaya devam eder; birkaç takastan sonra aynı iş onlarca kez çalışır.
|
|
259
|
-
`swap()` ve form yardımcıları `unmount()` çağırıyor, elle DOM değiştiriyorsanız
|
|
260
|
-
sizin çağırmanız gerekiyor:
|
|
261
|
-
|
|
262
|
-
```js
|
|
263
|
-
import { hydrate, unmount } from "jskelet/client";
|
|
264
|
-
|
|
265
|
-
unmount(bolge);
|
|
266
|
-
bolge.innerHTML = html;
|
|
267
|
-
hydrate(bolge);
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
## Formlar
|
|
271
|
-
|
|
272
|
-
`startForms()` `data-enhance` taşıyan formları bağlar. Sözleşme progressive
|
|
273
|
-
enhancement: form normal bir `<form method="post" action="…">`, JS yalnızca
|
|
274
|
-
aradaki tam sayfa turunu kaldırıyor.
|
|
275
|
-
|
|
276
|
-
```html
|
|
277
|
-
<form method="post" action="/panel/notlar" data-enhance data-target="#notlar">
|
|
278
|
-
<%- csrfField() %>
|
|
279
|
-
<textarea name="metin" required minlength="3"></textarea>
|
|
280
|
-
<button type="submit">Kaydet</button>
|
|
281
|
-
</form>
|
|
282
|
-
```
|
|
283
|
-
|
|
284
|
-
Sunucu üç cevaptan birini verir:
|
|
285
|
-
|
|
286
|
-
- **yönlendirme** → `location.assign` ile izlenir (başarılı mutasyon, JS'siz yol)
|
|
287
|
-
- **4xx + parça** → formun yerine takılır (doğrulama hataları)
|
|
288
|
-
- **2xx + parça** → `data-target` bölgesine takılır ve form sıfırlanır
|
|
289
|
-
|
|
290
|
-
Sunucu tarafı ikisini de karşılıyor:
|
|
291
|
-
|
|
292
|
-
```js
|
|
293
|
-
app.post(
|
|
294
|
-
"/panel/notlar",
|
|
295
|
-
fragment(async ({ req }) => {
|
|
296
|
-
const user = currentUser(req);
|
|
297
|
-
if (!user) seeOther("/giris?next=%2Fpanel");
|
|
298
|
-
|
|
299
|
-
const result = addNote(user.username, req.body?.metin);
|
|
300
|
-
|
|
301
|
-
// JS kapalıysa istemci `X-Requested-With` göndermez: tam sayfa turu.
|
|
302
|
-
if (req.get("X-Requested-With") !== "fragment") {
|
|
303
|
-
seeOther(result.ok ? "/panel" : "/panel?not=hata");
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
return result.ok
|
|
307
|
-
? { view: "partials/note-list", data: { notlar: getNotes(user.username) } }
|
|
308
|
-
: { view: "partials/note-form", data: { hata: result.error }, status: 422 };
|
|
309
|
-
}),
|
|
310
|
-
);
|
|
311
|
-
```
|
|
312
|
-
|
|
313
|
-
`seeOther()` yerine `redirect()` kullanmak burada bir hata olurdu: `redirect()`
|
|
314
|
-
307 yazıyor ve 307 metodu koruyor, yani tarayıcı hedefe yeniden POST ediyor.
|
|
315
|
-
Form sonrası akış 303 gerektiriyor.
|
|
316
|
-
|
|
317
|
-
POST handler'ının `fragment()` içinden geçmesinin sebebi de var: layout'suz
|
|
318
|
-
render ve `no-store`'un yanında **istek bağlamı** kazandırıyor. Bağlam olmadan
|
|
319
|
-
hata durumunda yeniden basılan formun `csrfField()`i boş çıkar ve kullanıcının
|
|
320
|
-
ikinci denemesi 403 alır.
|
|
321
|
-
|
|
322
|
-
Doğrulama hatasında odak ilk hatalı alana taşınıyor; işaret olarak
|
|
323
|
-
`aria-invalid="true"` ya da `data-field-error` aranıyor.
|
|
324
|
-
|
|
325
|
-
## Yapılandırma
|
|
326
|
-
|
|
327
|
-
```js
|
|
328
|
-
export default {
|
|
329
|
-
security: {
|
|
330
|
-
/**
|
|
331
|
-
* Ters proxy arkasında değilsen kapat: açıkken istemci kendi
|
|
332
|
-
* `X-Forwarded-For` başlığını uydurabilir.
|
|
333
|
-
*/
|
|
334
|
-
trustProxy: true,
|
|
335
|
-
|
|
336
|
-
cookieSecret: process.env.JSKELET_SECRET,
|
|
337
|
-
|
|
338
|
-
csrf: {
|
|
339
|
-
enabled: true,
|
|
340
|
-
token: false,
|
|
341
|
-
allowedOrigins: [],
|
|
342
|
-
exclude: [],
|
|
343
|
-
cookieName: "csrf_token",
|
|
344
|
-
fieldName: "_csrf",
|
|
345
|
-
headerName: "x-csrf-token",
|
|
346
|
-
},
|
|
347
|
-
},
|
|
348
|
-
};
|
|
349
|
-
```
|
|
350
|
-
|
|
351
|
-
Panel yolları için iki ek ayar işe yarıyor:
|
|
352
|
-
|
|
353
|
-
```js
|
|
354
|
-
// Yan etkili bir bağlantının önden getirilmesi kullanıcıyı oturumdan atabilir.
|
|
355
|
-
navigation: { exclude: ["/panel/:path*", "/cikis"] },
|
|
356
|
-
|
|
357
|
-
// Isıtıcının oturumu yok; korumalı sayfalar ısıtılamaz.
|
|
358
|
-
prewarmSkip: ["/api/", "/_fragment/", "/panel", "/cikis"],
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
Ve `headers()` ile indeksleme kapatılır — `no-store` önbelleği engelliyor, ama
|
|
362
|
-
indekslemeyi ayrıca söylemek gerekiyor:
|
|
363
|
-
|
|
364
|
-
```js
|
|
365
|
-
{
|
|
366
|
-
source: "/panel/:path*",
|
|
367
|
-
headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }],
|
|
368
|
-
}
|
|
369
|
-
```
|
|
370
|
-
|
|
371
|
-
## Kontrol listesi
|
|
372
|
-
|
|
373
|
-
Kişiye özel bir bölüm eklerken:
|
|
374
|
-
|
|
375
|
-
- [ ] Sayfalar `route(fn, { private: true })` ile kayıtlı.
|
|
376
|
-
- [ ] Fragment uçları `fragment()` ile kayıtlı.
|
|
377
|
-
- [ ] `security.cookieSecret` ortam değişkeninden geliyor, kodda sabit değil.
|
|
378
|
-
- [ ] Mutasyon formlarında `csrfField()` var; `security.csrf.token` açık.
|
|
379
|
-
- [ ] Çıkış bir POST; GET değil.
|
|
380
|
-
- [ ] Girişten sonraki `next` parametresi yalnızca site içi yolları kabul ediyor.
|
|
381
|
-
- [ ] `prewarmSkip` ve `navigation.exclude` korumalı öneki dışlıyor.
|
|
382
|
-
- [ ] `headers()` altında `X-Robots-Tag: noindex`.
|
|
383
|
-
- [ ] Duman testi `no-store` başlığını ve token'sız POST'un reddini kontrol
|
|
384
|
-
ediyor — bunlar bozulduğunda ekranda hiçbir şey değişmiyor.
|
|
1
|
+
# 12 — Panel, oturum ve kişiye özel sayfalar
|
|
2
|
+
|
|
3
|
+
JSkelet'in ağırlık merkezi public, önbelleklenebilir sayfalar. Bir panel ise
|
|
4
|
+
tam ters eksende durur: her ziyaretçiye farklı HTML, önbellek yok, yoğun
|
|
5
|
+
etkileşim. Bu belge o ekseni anlatır — `private: true`, oturum cookie'leri,
|
|
6
|
+
CSRF, fragment uçları ve parça takası.
|
|
7
|
+
|
|
8
|
+
Kapsam dışında bırakılan bir şey var ve bilinçli: **canlı veri taşıması**.
|
|
9
|
+
SSE, WebSocket ya da aralıklı sorgu arasındaki seçim uygulamanın; framework
|
|
10
|
+
yalnızca "şu parçayı sunucudan tazele" adımını veriyor. Sıkıştırma katmanı
|
|
11
|
+
`text/event-stream` yanıtlarını ve başlıklarını kendisi yazan akışları
|
|
12
|
+
atladığı için SSE'yi elle kurmak da bir şeyi bozmuyor.
|
|
13
|
+
|
|
14
|
+
Çalışan karşılığı `examples/dashboard/`; bu belgedeki kod parçalarının kaynağı
|
|
15
|
+
orası.
|
|
16
|
+
|
|
17
|
+
## Neden ayrı bir yol gerekiyor
|
|
18
|
+
|
|
19
|
+
HTML önbelleğinin anahtarı yalnızca yol ve query:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
`${req.path}?${new URLSearchParams(query).toString()}`
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Kimlik anahtarın parçası değil. Yani oturuma bağlı bir sayfa normal `route()`
|
|
26
|
+
ile kaydedilirse, ilk isteyenin HTML'i o TTL boyunca **herkese** servis edilir.
|
|
27
|
+
Bu hata hiçbir yerde patlamaz: sayfa çalışır, testler geçer, sorun yalnızca
|
|
28
|
+
ikinci kullanıcı geldiğinde ve genelde üretimde görünür.
|
|
29
|
+
|
|
30
|
+
## `private: true`
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
export default function register(app, { route, redirect }) {
|
|
34
|
+
app.get(
|
|
35
|
+
"/panel",
|
|
36
|
+
route(
|
|
37
|
+
async ({ req }) => {
|
|
38
|
+
const user = currentUser(req);
|
|
39
|
+
if (!user) redirect("/giris?next=%2Fpanel");
|
|
40
|
+
|
|
41
|
+
return { view: "pages/overview", data: { user } };
|
|
42
|
+
},
|
|
43
|
+
{ private: true },
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Bayrağın yaptığı işler:
|
|
50
|
+
|
|
51
|
+
| Davranış | Public `route()` | `private: true` |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| HTML önbelleği | TTL varsa açık | Kapalı, açılamaz |
|
|
54
|
+
| `cache.html` deseni | TTL'i ezer | Yok sayılır |
|
|
55
|
+
| `Cache-Control` | `public, s-maxage=…` | `private, no-store` |
|
|
56
|
+
| `Vary` | `Accept-Encoding` | `Cookie, Accept-Encoding` |
|
|
57
|
+
| ETag | Var | Yok |
|
|
58
|
+
| `X-JSkelet-Cache` | `HIT`/`STALE`/`MISS` | Yazılmaz |
|
|
59
|
+
|
|
60
|
+
ETag'in düşmesi ayrıntı gibi görünüyor ama değil: kullanıcıya özel bir gövdenin
|
|
61
|
+
güçlü ETag'i, o kullanıcıya özgü bir parmak izidir ve `no-store`'a uymayan bir
|
|
62
|
+
katmanda kimlik ayrımı için kullanılabilir.
|
|
63
|
+
|
|
64
|
+
Kişiye özel bir sayfadan atılan yönlendirme de önbelleklenmez. "Giriş yapmalısın"
|
|
65
|
+
kararı oturuma bağlı; saklanması, giriş yapmış kullanıcının da login sayfasına
|
|
66
|
+
atılması demek.
|
|
67
|
+
|
|
68
|
+
## Bayrağı unutursanız
|
|
69
|
+
|
|
70
|
+
Framework kimliğe dokunan erişimleri izliyor. Controller'a giden `req`, şu
|
|
71
|
+
okumaları işaretleyen ince bir Proxy ile sarılı:
|
|
72
|
+
|
|
73
|
+
- `req.headers.cookie`, `req.headers.authorization`,
|
|
74
|
+
`req.headers["proxy-authorization"]`
|
|
75
|
+
- `req.get("Cookie")` / `req.header("Authorization")`
|
|
76
|
+
- `req.cookies`, `req.signedCookies`, `req.session`, `req.user`
|
|
77
|
+
- `parseCookies(req)` ve `getSignedCookie(req, …)` (doğrudan bildiriyorlar)
|
|
78
|
+
|
|
79
|
+
İşaretlenen bir render önbelleğe **yazılmaz**. Üretimde yanıt `no-store` ile
|
|
80
|
+
gider ve şu satır loglanır:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
[render] /panel kimliğe bağlı veri okudu (req.headers.cookie), önbelleğe
|
|
84
|
+
alınmadı. Route 'private: true' ile kaydedilmeli.
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Development'ta aynı durum isteği bir hatayla düşürür. Sessiz kalmamasının
|
|
88
|
+
sebebi basit: bu hata çalışan bir sayfa üretiyor, yani kendi başına asla fark
|
|
89
|
+
edilmiyor.
|
|
90
|
+
|
|
91
|
+
`csrfField()` de aynı işaretlemeyi yapıyor. Token basan bir sayfa önbellekten
|
|
92
|
+
dönemez — dönerse tüm ziyaretçiler aynı token'ı paylaşır ve çift gönderim
|
|
93
|
+
kontrolü hiçbir şey doğrulamaz.
|
|
94
|
+
|
|
95
|
+
## Oturum: imzalı cookie
|
|
96
|
+
|
|
97
|
+
Framework kimlik sağlamıyor. Verdiği tek şey "bu değeri ben yazdım,
|
|
98
|
+
kurcalanmamış" garantisi:
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
import { clearCookie, getSignedCookie, setSignedCookie } from "jskelet/cookies";
|
|
102
|
+
|
|
103
|
+
export function startSession(res, username) {
|
|
104
|
+
setSignedCookie(res, "dash_session", username, { maxAge: 60 * 60 * 8 });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function currentUser(req) {
|
|
108
|
+
const username = getSignedCookie(req, "dash_session");
|
|
109
|
+
return username ? findUser(username) : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function endSession(res) {
|
|
113
|
+
clearCookie(res, "dash_session");
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
İmza HMAC-SHA256, karşılaştırma sabit zamanlı. İmza uymuyorsa `getSignedCookie`
|
|
118
|
+
`null` döner — kurcalanmış bir değer "belki geçerlidir" diye kullanılmaz.
|
|
119
|
+
|
|
120
|
+
Sır `security.cookieSecret` ya da `JSKELET_SECRET` ortam değişkeninden gelir.
|
|
121
|
+
Sır yoksa imzalı API **hata verir**; "yapılandırma hatası siteyi düşürmez"
|
|
122
|
+
kuralı burada geçerli değil, çünkü sessiz alternatif imzasız bir cookie'ye
|
|
123
|
+
güvenmek olurdu.
|
|
124
|
+
|
|
125
|
+
Varsayılanlar kısıtlayıcı tarafta: `HttpOnly`, `SameSite=Lax`, development
|
|
126
|
+
dışında `Secure`, `Path=/`. `SameSite=Lax` tek başına CSRF'in büyük kısmını
|
|
127
|
+
kapatıyor — cookie çapraz site POST'larında hiç gönderilmiyor.
|
|
128
|
+
|
|
129
|
+
Cookie **şifrelenmiyor**, imzalanıyor. Değer okunabilir; gizli kalması gereken
|
|
130
|
+
veriyi değil, onun kimliğini koyun.
|
|
131
|
+
|
|
132
|
+
## CSRF
|
|
133
|
+
|
|
134
|
+
Gövdeyi framework ayrıştırıyor (`express.urlencoded` + `express.json`), yani
|
|
135
|
+
state değiştiren istekleri kabul eden katman o. Koruma iki katmanlı.
|
|
136
|
+
|
|
137
|
+
### Katman 1 — origin kontrolü (varsayılan açık)
|
|
138
|
+
|
|
139
|
+
`Origin` kendi host'umuzla uyuşmuyorsa ya da `Sec-Fetch-Site: cross-site`
|
|
140
|
+
geldiyse güvenli olmayan metotlar 403 alır. **Başlıkların hiçbiri yoksa istek
|
|
141
|
+
geçer**: tarayıcılar çapraz origin bir POST'ta `Origin`'i her zaman gönderir,
|
|
142
|
+
webhook'lar ve sunucudan sunucuya çağrılar hiç göndermez. Bu ayrım korumayı
|
|
143
|
+
açık bırakırken entegrasyonları bozmuyor.
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
security: {
|
|
147
|
+
csrf: {
|
|
148
|
+
// Ayrı bir alan adından gelen panel gibi meşru istisnalar.
|
|
149
|
+
allowedOrigins: ["https://admin.example.com"],
|
|
150
|
+
// Tarayıcıdan gelmeyen uçlar.
|
|
151
|
+
exclude: ["/webhook/:path*"],
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Katman 2 — çift gönderim token'ı (opsiyonel)
|
|
157
|
+
|
|
158
|
+
`security.csrf.token: true` ile açılır. Formlar token'ı `csrfField()` ile basar:
|
|
159
|
+
|
|
160
|
+
```ejs
|
|
161
|
+
<form method="post" action="/panel/notlar">
|
|
162
|
+
<%- csrfField() %>
|
|
163
|
+
…
|
|
164
|
+
</form>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Token'ı **middleware üretmiyor**, `csrfField()` üretiyor — yani gerçekten bir
|
|
168
|
+
forma basıldığı anda. Sebebi somut: token her yanıtta yazılsaydı public ve
|
|
169
|
+
önbelleklenebilir bir sayfa da `Set-Cookie` taşırdı, bir CDN o yanıtı saklardı
|
|
170
|
+
ve tüm ziyaretçiler aynı token'ı paylaşırdı.
|
|
171
|
+
|
|
172
|
+
Sunucu tarafında token, imzalı cookie ile gönderilen alanın eşleşmesini
|
|
173
|
+
istiyor; alan yerine `X-CSRF-Token` başlığı da kabul ediliyor.
|
|
174
|
+
|
|
175
|
+
`csrfField()` `security.csrf.token` kapalıyken boş string döner, yani şablon
|
|
176
|
+
her koşulda render edilebilir.
|
|
177
|
+
|
|
178
|
+
## Fragment uçları
|
|
179
|
+
|
|
180
|
+
`fragment()` politikası sabit bir parça yanıtı üretir: layout basılmaz, yanıt
|
|
181
|
+
`private, no-store` ve ETag'siz gider, HTML önbelleğine hiç uğramaz.
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
export default function register(app, { fragment }) {
|
|
185
|
+
app.get(
|
|
186
|
+
"/_fragment/siparisler",
|
|
187
|
+
fragment(async ({ req, query }) => {
|
|
188
|
+
const user = currentUser(req);
|
|
189
|
+
if (!user) return { view: "partials/session-expired", status: 401 };
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
view: "partials/order-table",
|
|
193
|
+
data: { siparisler: getOrders(user.username, Number(query.sayfa ?? 1)) },
|
|
194
|
+
};
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Controller ya `{ view, data?, status? }` ya doğrudan bir HTML string döner.
|
|
201
|
+
Hata durumunda tüm sayfa yerine küçük bir parça döner
|
|
202
|
+
(`<div role="alert" data-fragment-error>`): takas edilen bölge bir hata
|
|
203
|
+
sayfasının tamamını içine almamalı.
|
|
204
|
+
|
|
205
|
+
Aynı şablonun sayfanın içinde de fragment ucunda da kullanılması esas fikir —
|
|
206
|
+
işaretlemenin tek kaynağı sunucuda kalır, istemci ikinci bir şablon taşımaz.
|
|
207
|
+
|
|
208
|
+
## İstemci: parça takası
|
|
209
|
+
|
|
210
|
+
```js
|
|
211
|
+
import { registerAll, start, startForms, startSwapLinks } from "jskelet/client";
|
|
212
|
+
|
|
213
|
+
registerAll({ "live-clock": () => import("../islands/live-clock.js") });
|
|
214
|
+
|
|
215
|
+
start();
|
|
216
|
+
startSwapLinks();
|
|
217
|
+
startForms();
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
`startSwapLinks()` `data-swap` taşıyan bağlantıları bağlar:
|
|
221
|
+
|
|
222
|
+
```html
|
|
223
|
+
<a href="/_fragment/siparisler?sayfa=2" data-swap="#siparisler">Sonraki</a>
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`href` gerçek bir URL olduğu için JS yoksa bağlantı normal gezinmeye düşer.
|
|
227
|
+
Programatik kullanım için `swap()`:
|
|
228
|
+
|
|
229
|
+
```js
|
|
230
|
+
import { swap } from "jskelet/client";
|
|
231
|
+
|
|
232
|
+
await swap("#siparisler", "/_fragment/siparisler?sayfa=2", { history: true });
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`swap()` sırayla: eski alt ağacın island'larını söker, içeriği değiştirir, yeni
|
|
236
|
+
alt ağacı hidre eder ve odağı kaybolmuşsa geri getirir. İstek süresince bölgeye
|
|
237
|
+
`aria-busy="true"` yazılır — bekleme göstergesini ayrı bir sınıfa bağlamak
|
|
238
|
+
yerine erişilebilirlik durumuna bağlamak ikisinin birbirinden ayrı düşmesini de
|
|
239
|
+
engelliyor.
|
|
240
|
+
|
|
241
|
+
Yönlendirmeyle karşılaşırsa (oturum düştü, login'e gidiliyor) parçayı takmak
|
|
242
|
+
yerine sayfayı o adrese götürür.
|
|
243
|
+
|
|
244
|
+
### Island sökme
|
|
245
|
+
|
|
246
|
+
Bu, takasın en kolay atlanan yarısı. `mount()` bir temizlik fonksiyonu
|
|
247
|
+
döndürebiliyor:
|
|
248
|
+
|
|
249
|
+
```js
|
|
250
|
+
export function mount(element) {
|
|
251
|
+
const timer = setInterval(() => tick(element), 1000);
|
|
252
|
+
return () => clearInterval(timer);
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
`innerHTML` ile değiştirilen bir bölgenin island'ları DOM'dan çıkar ama
|
|
257
|
+
`document`/`window` üzerine kurdukları dinleyiciler ve `setInterval`'ları
|
|
258
|
+
yaşamaya devam eder; birkaç takastan sonra aynı iş onlarca kez çalışır.
|
|
259
|
+
`swap()` ve form yardımcıları `unmount()` çağırıyor, elle DOM değiştiriyorsanız
|
|
260
|
+
sizin çağırmanız gerekiyor:
|
|
261
|
+
|
|
262
|
+
```js
|
|
263
|
+
import { hydrate, unmount } from "jskelet/client";
|
|
264
|
+
|
|
265
|
+
unmount(bolge);
|
|
266
|
+
bolge.innerHTML = html;
|
|
267
|
+
hydrate(bolge);
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Formlar
|
|
271
|
+
|
|
272
|
+
`startForms()` `data-enhance` taşıyan formları bağlar. Sözleşme progressive
|
|
273
|
+
enhancement: form normal bir `<form method="post" action="…">`, JS yalnızca
|
|
274
|
+
aradaki tam sayfa turunu kaldırıyor.
|
|
275
|
+
|
|
276
|
+
```html
|
|
277
|
+
<form method="post" action="/panel/notlar" data-enhance data-target="#notlar">
|
|
278
|
+
<%- csrfField() %>
|
|
279
|
+
<textarea name="metin" required minlength="3"></textarea>
|
|
280
|
+
<button type="submit">Kaydet</button>
|
|
281
|
+
</form>
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Sunucu üç cevaptan birini verir:
|
|
285
|
+
|
|
286
|
+
- **yönlendirme** → `location.assign` ile izlenir (başarılı mutasyon, JS'siz yol)
|
|
287
|
+
- **4xx + parça** → formun yerine takılır (doğrulama hataları)
|
|
288
|
+
- **2xx + parça** → `data-target` bölgesine takılır ve form sıfırlanır
|
|
289
|
+
|
|
290
|
+
Sunucu tarafı ikisini de karşılıyor:
|
|
291
|
+
|
|
292
|
+
```js
|
|
293
|
+
app.post(
|
|
294
|
+
"/panel/notlar",
|
|
295
|
+
fragment(async ({ req }) => {
|
|
296
|
+
const user = currentUser(req);
|
|
297
|
+
if (!user) seeOther("/giris?next=%2Fpanel");
|
|
298
|
+
|
|
299
|
+
const result = addNote(user.username, req.body?.metin);
|
|
300
|
+
|
|
301
|
+
// JS kapalıysa istemci `X-Requested-With` göndermez: tam sayfa turu.
|
|
302
|
+
if (req.get("X-Requested-With") !== "fragment") {
|
|
303
|
+
seeOther(result.ok ? "/panel" : "/panel?not=hata");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return result.ok
|
|
307
|
+
? { view: "partials/note-list", data: { notlar: getNotes(user.username) } }
|
|
308
|
+
: { view: "partials/note-form", data: { hata: result.error }, status: 422 };
|
|
309
|
+
}),
|
|
310
|
+
);
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
`seeOther()` yerine `redirect()` kullanmak burada bir hata olurdu: `redirect()`
|
|
314
|
+
307 yazıyor ve 307 metodu koruyor, yani tarayıcı hedefe yeniden POST ediyor.
|
|
315
|
+
Form sonrası akış 303 gerektiriyor.
|
|
316
|
+
|
|
317
|
+
POST handler'ının `fragment()` içinden geçmesinin sebebi de var: layout'suz
|
|
318
|
+
render ve `no-store`'un yanında **istek bağlamı** kazandırıyor. Bağlam olmadan
|
|
319
|
+
hata durumunda yeniden basılan formun `csrfField()`i boş çıkar ve kullanıcının
|
|
320
|
+
ikinci denemesi 403 alır.
|
|
321
|
+
|
|
322
|
+
Doğrulama hatasında odak ilk hatalı alana taşınıyor; işaret olarak
|
|
323
|
+
`aria-invalid="true"` ya da `data-field-error` aranıyor.
|
|
324
|
+
|
|
325
|
+
## Yapılandırma
|
|
326
|
+
|
|
327
|
+
```js
|
|
328
|
+
export default {
|
|
329
|
+
security: {
|
|
330
|
+
/**
|
|
331
|
+
* Ters proxy arkasında değilsen kapat: açıkken istemci kendi
|
|
332
|
+
* `X-Forwarded-For` başlığını uydurabilir.
|
|
333
|
+
*/
|
|
334
|
+
trustProxy: true,
|
|
335
|
+
|
|
336
|
+
cookieSecret: process.env.JSKELET_SECRET,
|
|
337
|
+
|
|
338
|
+
csrf: {
|
|
339
|
+
enabled: true,
|
|
340
|
+
token: false,
|
|
341
|
+
allowedOrigins: [],
|
|
342
|
+
exclude: [],
|
|
343
|
+
cookieName: "csrf_token",
|
|
344
|
+
fieldName: "_csrf",
|
|
345
|
+
headerName: "x-csrf-token",
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Panel yolları için iki ek ayar işe yarıyor:
|
|
352
|
+
|
|
353
|
+
```js
|
|
354
|
+
// Yan etkili bir bağlantının önden getirilmesi kullanıcıyı oturumdan atabilir.
|
|
355
|
+
navigation: { exclude: ["/panel/:path*", "/cikis"] },
|
|
356
|
+
|
|
357
|
+
// Isıtıcının oturumu yok; korumalı sayfalar ısıtılamaz.
|
|
358
|
+
prewarmSkip: ["/api/", "/_fragment/", "/panel", "/cikis"],
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Ve `headers()` ile indeksleme kapatılır — `no-store` önbelleği engelliyor, ama
|
|
362
|
+
indekslemeyi ayrıca söylemek gerekiyor:
|
|
363
|
+
|
|
364
|
+
```js
|
|
365
|
+
{
|
|
366
|
+
source: "/panel/:path*",
|
|
367
|
+
headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }],
|
|
368
|
+
}
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
## Kontrol listesi
|
|
372
|
+
|
|
373
|
+
Kişiye özel bir bölüm eklerken:
|
|
374
|
+
|
|
375
|
+
- [ ] Sayfalar `route(fn, { private: true })` ile kayıtlı.
|
|
376
|
+
- [ ] Fragment uçları `fragment()` ile kayıtlı.
|
|
377
|
+
- [ ] `security.cookieSecret` ortam değişkeninden geliyor, kodda sabit değil.
|
|
378
|
+
- [ ] Mutasyon formlarında `csrfField()` var; `security.csrf.token` açık.
|
|
379
|
+
- [ ] Çıkış bir POST; GET değil.
|
|
380
|
+
- [ ] Girişten sonraki `next` parametresi yalnızca site içi yolları kabul ediyor.
|
|
381
|
+
- [ ] `prewarmSkip` ve `navigation.exclude` korumalı öneki dışlıyor.
|
|
382
|
+
- [ ] `headers()` altında `X-Robots-Tag: noindex`.
|
|
383
|
+
- [ ] Duman testi `no-store` başlığını ve token'sız POST'un reddini kontrol
|
|
384
|
+
ediyor — bunlar bozulduğunda ekranda hiçbir şey değişmiyor.
|