javascript-solid-server 0.0.175 → 0.0.177

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/src/auth/token.js CHANGED
@@ -4,11 +4,14 @@
4
4
  * Supports multiple modes:
5
5
  * 1. Simple tokens (for local/dev use): base64(JSON({webId, iat, exp})) + HMAC signature
6
6
  * 2. Solid-OIDC DPoP tokens (for federation): verified via external IdP JWKS
7
- * 3. Nostr NIP-98 tokens: Schnorr signatures, returns did:nostr identity
7
+ * 3. LWS10-CID JWTs (FPWD 2026-04-23): kid points at a verificationMethod
8
+ * in the subject's WebID profile; signed with a JWS alg (ES256K, ES256, …)
9
+ * 4. Nostr NIP-98 tokens: Schnorr signatures, returns did:nostr identity
8
10
  */
9
11
 
10
12
  import crypto from 'crypto';
11
13
  import { verifySolidOidc, hasSolidOidcAuth } from './solid-oidc.js';
14
+ import { verifyLwsCidAuth, hasLwsCidAuth } from './lws-cid.js';
12
15
  import { verifyNostrAuth, hasNostrAuth } from './nostr.js';
13
16
  import { webIdTlsAuth, hasClientCertificate } from './webid-tls.js';
14
17
  import { resolveTokenSecret } from './token-secret.js';
@@ -205,6 +208,14 @@ export async function getWebIdFromRequestAsync(request) {
205
208
  return verifySolidOidc(request);
206
209
  }
207
210
 
211
+ // Try LWS10-CID (Bearer JWT whose kid is a fragment URL into a CID
212
+ // document). Detected by header shape, so it doesn't conflict with
213
+ // the IDP-issued JWTs handled in the Bearer fallback below — those
214
+ // use opaque fingerprint kids, not URLs.
215
+ if (hasLwsCidAuth(request)) {
216
+ return verifyLwsCidAuth(request);
217
+ }
218
+
208
219
  // Try Nostr NIP-98 (Schnorr signatures)
209
220
  if (hasNostrAuth(request)) {
210
221
  return verifyNostrAuth(request);
@@ -7,9 +7,10 @@ import * as jose from 'jose';
7
7
  import crypto from 'crypto';
8
8
  import fs from 'fs-extra';
9
9
  import path from 'path';
10
- import { authenticate, findByWebId, updatePassword, verifyPassword, deleteAccount } from './accounts.js';
10
+ import { authenticate, findByUsername, findByWebId, updatePassword, verifyPassword, deleteAccount } from './accounts.js';
11
11
  import { getJwks } from './keys.js';
12
12
  import { getWebIdFromRequestAsync } from '../auth/token.js';
13
+ import { accountDeletePage } from './views.js';
13
14
 
14
15
  /**
15
16
  * Handle POST /idp/credentials
@@ -358,39 +359,65 @@ export async function handleDeleteAccount(request, reply, options = {}) {
358
359
  });
359
360
  }
360
361
 
361
- // 5. Delete the account record + indexes
362
+ // 5. Delete via the shared helper (also handles optional pod-data purge).
363
+ // See deleteAccountAndOptionallyPurge below for the purge semantics
364
+ // and rationale (#391 pass 2 / pass 3).
365
+ const { purged } = await deleteAccountAndOptionallyPurge(request, account, purgeData);
366
+
367
+ reply.header('Cache-Control', 'no-store');
368
+ reply.header('Pragma', 'no-cache');
369
+ return {
370
+ ok: true,
371
+ webid: account.webId,
372
+ purged,
373
+ };
374
+ }
375
+
376
+ /**
377
+ * Apply anti-clickjacking + no-store cache headers to a response.
378
+ * Used on every account-deletion HTML response (form, success, error
379
+ * re-render, disabled-message page) and on the corresponding routes.
380
+ *
381
+ * - Cache-Control: no-store — destructive form/success page should
382
+ * never sit in a shared cache; if a token gets shared via the URL
383
+ * the browser shouldn't replay the action from cache either.
384
+ * - X-Frame-Options + frame-ancestors — block clickjacking.
385
+ * Embedding this form in a hostile iframe and tricking a user into
386
+ * submitting a captured action is exactly the threat shape these
387
+ * headers exist to mitigate.
388
+ */
389
+ export function setNoCacheClickjackHeaders(reply) {
390
+ reply.header('Cache-Control', 'no-store');
391
+ reply.header('Pragma', 'no-cache');
392
+ reply.header('X-Frame-Options', 'DENY');
393
+ reply.header('Content-Security-Policy', "frame-ancestors 'none'");
394
+ }
395
+
396
+ /**
397
+ * Internal: delete an account record + optional pod-data purge.
398
+ * Shared between the JSON endpoint (handleDeleteAccount) and the
399
+ * form-driven endpoint (handleAccountDeleteForm in #392).
400
+ *
401
+ * Best-effort purge — fs.remove can throw, but the account is already
402
+ * gone and we want a clean signal rather than a 500. Path is derived
403
+ * from account.podName (NOT username, which createAccount lowercases —
404
+ * pod dir on disk is original case, see #391 pass 2). Belt-and-
405
+ * suspenders path-relative check rejects ../traversal and works at FS
406
+ * roots (see #391 pass 3).
407
+ *
408
+ * @param {object} request - Fastify request, used only for log access
409
+ * @param {object} account - Account record (with username, podName, webId)
410
+ * @param {boolean} purgeData - If true, also remove the pod's filesystem tree
411
+ * @returns {Promise<{purged: boolean}>}
412
+ */
413
+ async function deleteAccountAndOptionallyPurge(request, account, purgeData) {
362
414
  await deleteAccount(account.id);
363
415
 
364
- // 6. Optionally purge the pod's filesystem data. Mirrors the CLI
365
- // `--purge` semantics. The path is `<dataRoot>/<podName>/`.
366
- //
367
- // Use account.podName, NOT account.username: createAccount normalizes
368
- // username to lowercase (`username.toLowerCase().trim()`) but the pod
369
- // directory on disk is created with the original case (per the input
370
- // to handleCreatePod). On case-sensitive filesystems, deriving the
371
- // purge path from username would either no-op (path doesn't exist)
372
- // or hit a different directory if one exists at the lowercased name.
373
- // Pod-name validation regex is /^[a-zA-Z0-9_-]+$/ (alphanum + dash +
374
- // underscore; no dots, no traversal sequences) so podName is safe to
375
- // join — defensive normalize stays as belt-and-suspenders.
376
- //
377
- // Best-effort: if fs.remove throws (permissions, transient FS error,
378
- // race with another consumer), the account is already deleted and we
379
- // shouldn't 500 over the leftover files. Log server-side and return
380
- // purged: false so the caller knows pod data may still exist; an
381
- // operator can finish the cleanup with a follow-up `rm -rf` or
382
- // CLI `--purge` against the now-orphaned directory.
383
416
  let purged = false;
384
417
  if (purgeData) {
385
418
  const dataRoot = process.env.DATA_ROOT || './data';
386
419
  const candidate = path.resolve(dataRoot, account.podName || account.username);
387
420
  const root = path.resolve(dataRoot);
388
- // Belt-and-suspenders: refuse to remove anything that isn't a
389
- // proper child of the data root. Won't trigger on registered pod
390
- // names; protects against config drift / future bugs. Use
391
- // path.relative so the check works when dataRoot is a filesystem
392
- // root like `/` (where startsWith(root + path.sep) would compare
393
- // against `//`, false-negative all valid children).
394
421
  const rel = path.relative(root, candidate);
395
422
  const isProperChild = rel && rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
396
423
  if (isProperChild) {
@@ -400,20 +427,132 @@ export async function handleDeleteAccount(request, reply, options = {}) {
400
427
  } catch (err) {
401
428
  request.log.error({ err, path: candidate, username: account.username },
402
429
  'Pod data purge failed after account deletion');
403
- // Don't surface the raw error to the user (file paths,
404
- // permission detail leak); response.purged signals the
405
- // outcome.
406
430
  }
431
+ } else {
432
+ // Belt-and-suspenders rejection — shouldn't trigger on registered
433
+ // pod names but logging it surfaces config drift (e.g. someone
434
+ // changed dataRoot, podName field is malformed, etc.) and makes
435
+ // the "purge did not complete" UX message diagnosable from the
436
+ // operator's logs without leaking the path to the client.
437
+ request.log.warn({ path: candidate, dataRoot: root, podName: account.podName, username: account.username },
438
+ 'Pod data purge skipped: candidate path is not a proper child of dataRoot');
407
439
  }
408
440
  }
441
+ return { purged };
442
+ }
409
443
 
410
- reply.header('Cache-Control', 'no-store');
411
- reply.header('Pragma', 'no-cache');
412
- return {
413
- ok: true,
414
- webid: account.webId,
415
- purged,
416
- };
444
+ /**
445
+ * Handle POST /idp/account/delete (#392) — form-driven account deletion.
446
+ *
447
+ * Public unauthenticated endpoint that takes a form-encoded body with
448
+ * username + currentPassword + confirmUsername (+ optional keepData
449
+ * opt-out checkbox; default behavior is purge-on for the leaving-user
450
+ * UX, opposite the JSON endpoint's purge-off default). Authenticates
451
+ * the user via password directly (no Bearer token round-trip), validates
452
+ * the destructive-action UX guard, then calls into the same delete
453
+ * logic as the JSON endpoint via deleteAccountAndOptionallyPurge.
454
+ * Returns HTML directly:
455
+ * - success → success page
456
+ * - any failure → the form re-rendered with an error message and the
457
+ * username field pre-filled (no redirect — single response, status
458
+ * 200 with the rendered form)
459
+ *
460
+ * GET /idp/account/delete is a separate route that just renders the
461
+ * form via accountDeletePage(); see src/idp/index.js. This handler is
462
+ * POST-only.
463
+ *
464
+ * Single-user mode: this handler short-circuits to the disabled-message
465
+ * page (matches the GET route's behavior). Same policy as the JSON
466
+ * endpoint, which 403s with the equivalent message.
467
+ *
468
+ * @param {object} request - Fastify request
469
+ * @param {object} reply - Fastify reply
470
+ * @param {object} options
471
+ * @param {boolean} [options.singleUser] - Single-user mode flag
472
+ */
473
+ export async function handleAccountDeleteForm(request, reply, options = {}) {
474
+ // Every response from this handler is a destructive-action surface
475
+ // that re-takes the user's password — never cache, never embed.
476
+ setNoCacheClickjackHeaders(reply);
477
+
478
+ if (options.singleUser) {
479
+ // 403 matches the GET route, /idp/register's disabled-route policy,
480
+ // and the JSON DELETE endpoint's 403 — consistent status across
481
+ // every disabled-in-single-user surface.
482
+ return reply.code(403).type('text/html').send(accountDeletePage({ singleUser: true }));
483
+ }
484
+
485
+ // Parse form-encoded body. JSS registers a wildcard parseAs:'buffer'
486
+ // content parser (server.js:190), so request.body arrives here as a
487
+ // Buffer. We coerce to a string and parse the application/x-www-form-
488
+ // urlencoded shape via URLSearchParams. (No @fastify/formbody is
489
+ // installed; doing it inline keeps this self-contained and matches
490
+ // what handleChangePassword does for JSON.)
491
+ let body = request.body;
492
+ if (Buffer.isBuffer(body)) body = body.toString('utf-8');
493
+ if (typeof body === 'string') {
494
+ try {
495
+ const params = new URLSearchParams(body);
496
+ body = Object.fromEntries(params);
497
+ } catch { body = {}; }
498
+ }
499
+
500
+ const username = (body?.username || '').trim();
501
+ const currentPassword = body?.currentPassword || '';
502
+ const confirmUsername = (body?.confirmUsername || '').trim();
503
+ // Form field is `keepData` (inverse of the JSON endpoint's `purgeData`)
504
+ // so the form's default is purge-on: a user who is leaving the server
505
+ // probably wants their files gone too. Checking "Keep my pod data" is
506
+ // the opt-out. JSON endpoint (DELETE /idp/account) keeps the
507
+ // explicit `purgeData` shape that matches the CLI's default-off
508
+ // semantics for operator scripts; the form intentionally diverges.
509
+ const keepData = body?.keepData === 'on' || body?.keepData === true;
510
+ const purgeData = !keepData;
511
+
512
+ if (!username || !currentPassword || !confirmUsername) {
513
+ return reply.type('text/html').send(accountDeletePage({
514
+ error: 'All fields are required.',
515
+ username,
516
+ }));
517
+ }
518
+
519
+ // Destructive-action UX guard: the user must type the same string
520
+ // twice. The string-equality check is case-sensitive on the typed
521
+ // form values; that's purely the typing-it-again confirmation
522
+ // pattern (catch typos / accidental submits). Note: findByUsername()
523
+ // lowercases internally, so "Alice" + "Alice" both resolve to the
524
+ // same account record as "alice" — the case-sensitive comparison
525
+ // here doesn't gate which record gets deleted, only whether the
526
+ // user typed the same thing twice.
527
+ if (username !== confirmUsername) {
528
+ return reply.type('text/html').send(accountDeletePage({
529
+ error: 'Confirmation does not match the username you entered.',
530
+ username,
531
+ }));
532
+ }
533
+
534
+ // Look up + verify password without side effects. authenticate() is
535
+ // tempting (looks up + verifies in one call) but writes lastLogin on
536
+ // success — wrong shape for a destructive proof-of-possession check
537
+ // (and would fail the deletion if the account file weren't writable).
538
+ // Mirrors handleChangePassword / handleDeleteAccount which both use
539
+ // verifyPassword for the same reason.
540
+ const account = await findByUsername(username);
541
+ if (!account || !(await verifyPassword(account, currentPassword))) {
542
+ return reply.type('text/html').send(accountDeletePage({
543
+ error: 'Username or password is incorrect.',
544
+ username,
545
+ }));
546
+ }
547
+
548
+ const { purged } = await deleteAccountAndOptionallyPurge(request, account, purgeData);
549
+
550
+ // If the user asked for a purge but it didn't run (fs.remove threw,
551
+ // path-relative check rejected, etc.), surface that on the success
552
+ // page. Account deletion succeeded — don't roll that back — but the
553
+ // operator may need to finish cleanup. Don't leak server paths.
554
+ const purgeFailed = purgeData && !purged;
555
+ return reply.type('text/html').send(accountDeletePage({ success: true, purgeFailed }));
417
556
  }
418
557
 
419
558
  /**
package/src/idp/index.js CHANGED
@@ -24,10 +24,12 @@ import {
24
24
  handleCredentialsInfo,
25
25
  handleChangePassword,
26
26
  handleDeleteAccount,
27
+ handleAccountDeleteForm,
28
+ setNoCacheClickjackHeaders,
27
29
  } from './credentials.js';
28
30
  import * as passkey from './passkey.js';
29
31
  import { addTrustedIssuer } from '../auth/solid-oidc.js';
30
- import { landingPage } from './views.js';
32
+ import { landingPage, accountDeletePage } from './views.js';
31
33
 
32
34
  /**
33
35
  * IdP Fastify Plugin
@@ -295,6 +297,36 @@ export async function idpPlugin(fastify, options) {
295
297
  return handleDeleteAccount(request, reply, { singleUser });
296
298
  });
297
299
 
300
+ // GET account-delete form (#392) - human-friendly UI for #352. Public
301
+ // unauthenticated page; auth happens at form submission via password.
302
+ // Single-user mode returns 403 to stay consistent with /idp/register's
303
+ // disabled-route policy and the JSON DELETE /idp/account endpoint
304
+ // (which also 403s in single-user mode). Body is still HTML so a
305
+ // browser visitor sees the explanation. Every response sets
306
+ // anti-clickjacking + no-store headers — destructive-action page.
307
+ fastify.get('/idp/account/delete', async (request, reply) => {
308
+ setNoCacheClickjackHeaders(reply);
309
+ if (singleUser) {
310
+ return reply.code(403).type('text/html').send(accountDeletePage({ singleUser: true }));
311
+ }
312
+ return reply.type('text/html').send(accountDeletePage({ singleUser: false }));
313
+ });
314
+
315
+ // POST account-delete form (#392) - processes the form submission.
316
+ // Same rate-limit as the JSON endpoint to keep the destructive-action
317
+ // surface consistent across both paths.
318
+ fastify.post('/idp/account/delete', {
319
+ config: {
320
+ rateLimit: {
321
+ max: 5,
322
+ timeWindow: '1 minute',
323
+ keyGenerator: (request) => request.ip
324
+ }
325
+ }
326
+ }, async (request, reply) => {
327
+ return handleAccountDeleteForm(request, reply, { singleUser });
328
+ });
329
+
298
330
  // Interaction routes (our custom login/consent UI)
299
331
  // These bypass oidc-provider and use our handlers
300
332
 
package/src/idp/views.js CHANGED
@@ -533,6 +533,194 @@ export function consentPage(uid, client, params, account) {
533
533
  `;
534
534
  }
535
535
 
536
+ /**
537
+ * Account-deletion form HTML (#392).
538
+ *
539
+ * Public unauthenticated page (matches the existing /idp landing and
540
+ * /idp/register pattern). Auth happens at submission time: the user
541
+ * supplies username + password, which the server validates and uses as
542
+ * proof-of-possession for the delete. The "type your username again to
543
+ * confirm" field is the destructive-action UX guard.
544
+ *
545
+ * On any failure (wrong password, mismatched confirmation, etc.) the
546
+ * handler re-renders this same form in place at status 200 with an
547
+ * error message and the identifier field pre-filled — no redirect.
548
+ *
549
+ * @param {object} opts
550
+ * @param {string|null} opts.error - Error message (e.g. wrong password) to display
551
+ * @param {string} opts.username - Pre-fill the identifier field on re-render after error
552
+ * @param {boolean} opts.singleUser - When true, render a disabled message
553
+ * instead of the form. Deletion via HTTP is blocked in single-user mode
554
+ * (would brick the IdP until re-seed); operator path stays the CLI.
555
+ * @param {boolean} opts.success - When true, render the post-delete confirmation
556
+ * @param {boolean} opts.purgeFailed - When true (only on success), include a
557
+ * notice that the user requested a pod-data purge but it didn't complete.
558
+ * Account deletion still succeeded.
559
+ */
560
+ export function accountDeletePage({ error = null, username = '', singleUser = false, success = false, purgeFailed = false } = {}) {
561
+ if (singleUser) {
562
+ return `
563
+ <!DOCTYPE html>
564
+ <html lang="en">
565
+ <head>
566
+ <meta charset="UTF-8">
567
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
568
+ <title>Account deletion disabled - Solid IdP</title>
569
+ <style>${styles}</style>
570
+ </head>
571
+ <body>
572
+ <div class="container">
573
+ <div class="logo">${solidLogo}</div>
574
+ <h1>Account deletion disabled</h1>
575
+ <p>This server runs in <strong>single-user mode</strong>. Deleting the single account
576
+ via HTTP would leave the server with no IdP account until re-seed,
577
+ so this endpoint is disabled.</p>
578
+ <p>The operator can still delete the account at the shell with:</p>
579
+ <pre style="background: #f1f5f9; padding: 12px; border-radius: 6px; font-size: 13px;">jss account delete &lt;username&gt;</pre>
580
+ <a href="/idp" class="btn btn-secondary" style="text-decoration: none;">Back</a>
581
+ </div>
582
+ </body>
583
+ </html>
584
+ `;
585
+ }
586
+
587
+ if (success) {
588
+ return `
589
+ <!DOCTYPE html>
590
+ <html lang="en">
591
+ <head>
592
+ <meta charset="UTF-8">
593
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
594
+ <title>Account deleted - Solid IdP</title>
595
+ <style>${styles}</style>
596
+ </head>
597
+ <body>
598
+ <div class="container">
599
+ <div class="logo">${solidLogo}</div>
600
+ <h1>Account deleted</h1>
601
+ <p>Your account record has been removed from this server. Future sign-ins
602
+ with this username will fail.</p>
603
+ <p style="font-size: 13px; color: #64748b; margin-top: 8px;">
604
+ Note: any access tokens already issued may remain usable until they
605
+ expire — the server does not currently revoke them on account deletion.
606
+ </p>
607
+ ${purgeFailed ? `
608
+ <div class="error" style="margin-top: 16px;">
609
+ Your account was deleted, but the pod-data purge did not complete on
610
+ this server. Some files may still exist. Contact the operator to
611
+ finish the cleanup if needed.
612
+ </div>
613
+ ` : ''}
614
+ <a href="/idp" class="btn btn-primary" style="text-decoration: none; margin-top: 16px;">Return to sign-in</a>
615
+ </div>
616
+ </body>
617
+ </html>
618
+ `;
619
+ }
620
+
621
+ return `
622
+ <!DOCTYPE html>
623
+ <html lang="en">
624
+ <head>
625
+ <meta charset="UTF-8">
626
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
627
+ <title>Delete account - Solid IdP</title>
628
+ <style>${styles}
629
+ .danger {
630
+ background: #fef2f2;
631
+ border: 1px solid #fecaca;
632
+ color: #991b1b;
633
+ padding: 14px 16px;
634
+ border-radius: 8px;
635
+ margin: 16px 0 24px;
636
+ font-size: 13px;
637
+ line-height: 1.55;
638
+ }
639
+ .danger strong { color: #7f1d1d; }
640
+ .btn-danger {
641
+ background: #dc2626;
642
+ color: #fff;
643
+ }
644
+ .btn-danger:hover { background: #b91c1c; }
645
+ .checkbox-row {
646
+ display: flex;
647
+ align-items: flex-start;
648
+ gap: 10px;
649
+ margin: 16px 0 8px;
650
+ padding: 10px 12px;
651
+ background: #f8fafc;
652
+ border: 1px solid #e2e8f0;
653
+ border-radius: 6px;
654
+ }
655
+ .checkbox-row input[type="checkbox"] { margin-top: 3px; flex-shrink: 0; }
656
+ .checkbox-row label {
657
+ margin: 0;
658
+ font-size: 13px;
659
+ line-height: 1.5;
660
+ color: #334155;
661
+ cursor: pointer;
662
+ }
663
+ .checkbox-row label strong { color: #0f172a; }
664
+ </style>
665
+ </head>
666
+ <body>
667
+ <div class="container">
668
+ <div class="logo">${solidLogo}</div>
669
+ <h1>Delete your account</h1>
670
+
671
+ <div class="danger">
672
+ <strong>This is permanent.</strong> Your account record and credentials will be
673
+ removed; future sign-ins with this username will fail. By default, your pod
674
+ data (every file you've stored, including your WebID profile document) is
675
+ also wiped — check the box below if you want to keep it. Federated references
676
+ (ActivityPub follows, Nostr relays, type indexes) cannot be retracted from
677
+ this server.
678
+ <br><br>
679
+ <span style="font-size: 12px; color: #7f1d1d;">
680
+ Note: access tokens already issued may remain usable until they
681
+ expire — the server does not currently revoke them on deletion.
682
+ </span>
683
+ </div>
684
+
685
+ ${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
686
+
687
+ <form method="POST" action="/idp/account/delete">
688
+ <label for="username">Username</label>
689
+ <input type="text" id="username" name="username" required autofocus
690
+ value="${escapeHtml(username || '')}"
691
+ placeholder="alice">
692
+
693
+ <label for="currentPassword">Current password</label>
694
+ <input type="password" id="currentPassword" name="currentPassword" required
695
+ placeholder="Re-enter your password">
696
+
697
+ <label for="confirmUsername">Type your username again to confirm</label>
698
+ <input type="text" id="confirmUsername" name="confirmUsername" required
699
+ placeholder="Must match the username above">
700
+
701
+ <div class="checkbox-row">
702
+ <input type="checkbox" id="keepData" name="keepData" value="on">
703
+ <label for="keepData">
704
+ <strong>Keep my pod data on this server.</strong> Check only if you want
705
+ to delete just your account record and leave your files in place. Default
706
+ (unchecked) wipes the pod folder along with the account.
707
+ </label>
708
+ </div>
709
+
710
+ <button type="submit" class="btn btn-danger" style="width: 100%; margin-top: 16px;">
711
+ Delete my account permanently
712
+ </button>
713
+ </form>
714
+
715
+ <p style="text-align: center; margin-top: 16px; font-size: 13px;">
716
+ <a href="/idp">Cancel and go back</a>
717
+ </p>
718
+ </div>
719
+ </body>
720
+ </html>
721
+ `;
722
+ }
723
+
536
724
  /**
537
725
  * Error page HTML
538
726
  */