1
   2
   3
   4
   5
   6
   7
   8
   9
  10
  11
  12
  13
  14
  15
  16
  17
  18
  19
  20
  21
  22
  23
  24
  25
  26
  27
  28
  29
  30
  31
  32
  33
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
package main

import (
	"bytes"
	"database/sql"
	"encoding/xml"
	"errors"
	"flag"
	"fmt"
	"html"
	"io"
	"net/http"
	"net/url"
	"path"
	"sort"
	"strings"
	"time"

	chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
	"github.com/alecthomas/chroma/v2/styles"
	zsqlite "zombiezen.com/go/sqlite"
)

// server holds the shared state for the HTTP handlers.
type server struct {
	db      *sql.DB // read pool; every request reads through a snapshot on this
	writeDB *sql.DB // single-connection writer for the lazy render cache
	dbPath  string  // path to the SQLite file, for opening short-lived blob conns
	baseURL string
	css     []byte
}

// dbReader is the read subset shared by *sql.DB and *sql.Tx, so every
// request-time query helper can take either the pool or (as they all do now) a
// per-request read snapshot.
//
// Handlers open one read-only transaction per request and thread it through, so
// a whole page renders from a single consistent view of the database. This
// matters because a page captures the live generation once (projectHead) and
// then filters every later query on it: without a snapshot, an ingest
// committing mid-render — which flips head_generation and deletes the previous
// generation's rows in one transaction (see gcOldGenerations) — would make the
// remaining queries return nothing, yielding a half-empty page. In WAL mode the
// snapshot is fixed at the transaction's first read and costs nothing to hold.
//
// Writes deliberately do not go through this: the lazy render-cache fill
// (storeRender) runs in autocommit on the separate single-connection writer
// (openWriteDB), so it commits independently and is visible to later requests,
// and a write waiting out an ingest can never eat into this read pool.
type dbReader interface {
	QueryRow(query string, args ...any) *sql.Row
	Query(query string, args ...any) (*sql.Rows, error)
}

// runServe starts the HTTP server.
func runServe(args []string) error {
	fs := flag.NewFlagSet("serve", flag.ContinueOnError)
	dbPath := fs.String("db", "", "path to the SQLite database (required)")
	addr := fs.String("addr", "127.0.0.1:8790", "listen address")
	baseURL := fs.String("base-url", "", "public base URL (for absolute links; optional)")
	if err := fs.Parse(args); err != nil {
		return err
	}
	if *dbPath == "" {
		return errors.New("--db is required")
	}

	db, err := openDB(*dbPath)
	if err != nil {
		return err
	}
	defer db.Close()

	writeDB, err := openWriteDB(*dbPath)
	if err != nil {
		return err
	}
	defer writeDB.Close()

	css, err := buildCSS()
	if err != nil {
		return err
	}

	s := &server{
		db:      db,
		writeDB: writeDB,
		dbPath:  *dbPath,
		baseURL: strings.TrimRight(*baseURL, "/"),
		css:     css,
	}

	mux := http.NewServeMux()
	// More specific patterns win over "GET /" (Go 1.22+ ServeMux precedence),
	// so these are not swallowed by the catch-all project handler.
	mux.HandleFunc("GET /robots.txt", s.handleRobots)
	mux.HandleFunc("GET /sitemap.xml", s.handleSitemap)
	mux.HandleFunc("GET /", s.handleRoot)

	fmt.Fprintf(stderr, "source-forge: listening on %s\n", *addr)
	return http.ListenAndServe(*addr, mux)
}

// handleRoot dispatches: "/" is the project index; "/<project>/..." is a path
// within a project (directory listing or file view).
func (s *server) handleRoot(w http.ResponseWriter, r *http.Request) {
	// One read-only snapshot for the whole request (see dbReader). Tying it to
	// the request context means a client disconnecting mid-render releases the
	// connection promptly instead of pinning it through a slow first render;
	// the deferred Rollback covers the normal return path (we never commit).
	tx, err := s.db.BeginTx(r.Context(), &sql.TxOptions{ReadOnly: true})
	if err != nil {
		httpError(w, err)
		return
	}
	defer tx.Rollback()

	reqPath := strings.TrimPrefix(r.URL.Path, "/")
	if reqPath == "" {
		s.serveProjectIndex(tx, w, r)
		return
	}

	// "/<project>.bundle" serves the clonable git bundle for a project. This is
	// a single top-level path with no slash, so handle it before the project
	// path split below.
	if !strings.Contains(reqPath, "/") && strings.HasSuffix(reqPath, ".bundle") {
		s.serveBundle(tx, w, r, strings.TrimSuffix(reqPath, ".bundle"))
		return
	}

	// "/<project>.tar.gz" serves the project's tree as a Nix-flake tarball
	// (`nix run https://…/<project>.tar.gz#pkg`). Also a slashless top-level
	// path, so handle it before the project split.
	if !strings.Contains(reqPath, "/") && strings.HasSuffix(reqPath, ".tar.gz") {
		s.serveTarball(tx, w, r, strings.TrimSuffix(reqPath, ".tar.gz"))
		return
	}

	// Split into <project>/<rest>.
	project, rest, _ := strings.Cut(reqPath, "/")
	rest = strings.Trim(rest, "/")

	// A trailing slash (or empty rest) means "directory".
	wantDir := rest == "" || strings.HasSuffix(r.URL.Path, "/")

	head, branch, ok := s.projectHead(tx, project)
	if !ok {
		http.NotFound(w, r)
		return
	}

	if wantDir {
		// "?full=" renders the whole subtree's file contents on one page.
		if r.URL.Query().Has("full") {
			s.serveFull(tx, w, r, project, head, rest)
			return
		}
		s.serveDir(tx, w, r, project, branch, head, rest)
		return
	}
	s.serveFile(tx, w, r, project, head, rest)
}

// projectHead returns the live generation and branch for a project.
func (s *server) projectHead(q dbReader, project string) (gen int64, branch string, ok bool) {
	err := q.QueryRow(
		`SELECT head_generation, branch FROM project WHERE name = ?`, project,
	).Scan(&gen, &branch)
	if err == sql.ErrNoRows {
		return 0, "", false
	}
	if err != nil {
		fmt.Fprintf(stderr, "projectHead(%q): %v\n", project, err)
		return 0, "", false
	}
	return gen, branch, true
}

// baseFor returns the site's absolute base URL (no trailing slash). It prefers
// the configured --base-url and otherwise reconstructs one from the request
// (scheme + Host), so absolute links always resolve.
func (s *server) baseFor(r *http.Request) string {
	if s.baseURL != "" {
		return s.baseURL
	}
	scheme := "http"
	if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
		scheme = "https"
	}
	return scheme + "://" + r.Host
}

// hostFor returns just the host part of baseFor, for appending to <title> (see
// writePageHeader) so a page copy/pasted or screenshotted out of context still
// shows which site it came from.
func (s *server) hostFor(r *http.Request) string {
	base := s.baseFor(r)
	if _, host, ok := strings.Cut(base, "://"); ok {
		return host
	}
	return base
}

// bundleURL returns the absolute URL of a project's bundle, so the clone hint
// always shows a working absolute URL.
func (s *server) bundleURL(r *http.Request, project string) string {
	return s.baseFor(r) + "/" + project + ".bundle"
}

// hasBundle reports whether a project has a stored bundle at its live
// generation, and its size.
func (s *server) hasBundle(q dbReader, project string, gen int64) (size int64, ok bool) {
	err := q.QueryRow(
		`SELECT size FROM bundle WHERE project = ? AND generation = ?`, project, gen,
	).Scan(&size)
	if err != nil {
		return 0, false
	}
	return size, true
}

// serveBundle streams the stored git bundle for a project as a download.
func (s *server) serveBundle(q dbReader, w http.ResponseWriter, r *http.Request, project string) {
	gen, _, ok := s.projectHead(q, project)
	if !ok {
		http.NotFound(w, r)
		return
	}
	var rowid, size int64
	err := q.QueryRow(
		`SELECT rowid, size FROM bundle WHERE project = ? AND generation = ?`,
		project, gen,
	).Scan(&rowid, &size)
	if err == sql.ErrNoRows {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		httpError(w, err)
		return
	}
	w.Header().Set("Content-Type", "application/x-git-bundle")
	w.Header().Set("Content-Disposition",
		fmt.Sprintf("attachment; filename=%q", project+".bundle"))
	w.Header().Set("Cache-Control", "no-cache")
	// Stream the blob straight from SQLite so a multi-MiB bundle is never held
	// wholly in memory. Once the copy starts the status/headers are committed,
	// so mid-stream errors can only be logged.
	if err := s.streamBlob(w, "bundle", "data", rowid, size); err != nil {
		fmt.Fprintf(stderr, "serveBundle(%q gen %d): %v\n", project, gen, err)
	}
}

// tarballURL returns the absolute URL of a project's flake tarball, so the
// "nix run" hint always shows a working absolute URL.
func (s *server) tarballURL(r *http.Request, project string) string {
	return s.baseFor(r) + "/" + project + ".tar.gz"
}

// hasTarball reports whether a project has a stored flake tarball at its live
// generation, and its size.
func (s *server) hasTarball(q dbReader, project string, gen int64) (size int64, ok bool) {
	err := q.QueryRow(
		`SELECT size FROM tarball WHERE project = ? AND generation = ?`, project, gen,
	).Scan(&size)
	if err != nil {
		return 0, false
	}
	return size, true
}

// serveTarball streams the stored flake tarball for a project as a gzip
// download. It is content-addressed by generation via an ETag: because Nix
// forces tarball-ttl to 0 for flakes, every `nix run` re-validates, so we
// answer a matching If-None-Match with 304 Not Modified (no body, no re-unpack)
// to keep repeat runs nearly free.
func (s *server) serveTarball(q dbReader, w http.ResponseWriter, r *http.Request, project string) {
	gen, _, ok := s.projectHead(q, project)
	if !ok {
		http.NotFound(w, r)
		return
	}
	var rowid, size, modified int64
	err := q.QueryRow(
		`SELECT rowid, size, modified FROM tarball WHERE project = ? AND generation = ?`,
		project, gen,
	).Scan(&rowid, &size, &modified)
	if err == sql.ErrNoRows {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		httpError(w, err)
		return
	}

	// The generation is a monotonic content stamp: a new ingest is a new
	// generation, and old generations are GC'd, so gen uniquely identifies the
	// live bytes. Use it as a strong ETag.
	etag := fmt.Sprintf("%q", fmt.Sprintf("%s-gen%d", project, gen))
	w.Header().Set("ETag", etag)
	w.Header().Set("Content-Type", "application/gzip")
	w.Header().Set("Content-Disposition",
		fmt.Sprintf("attachment; filename=%q", project+".tar.gz"))
	if modified > 0 {
		w.Header().Set("Last-Modified", time.Unix(modified, 0).UTC().Format(http.TimeFormat))
	}
	// Must revalidate every time: flakes pin by narHash, and a mutable
	// /<project>.tar.gz can change contents when a new generation is ingested.
	w.Header().Set("Cache-Control", "no-cache")

	if match := r.Header.Get("If-None-Match"); match != "" && etagMatch(match, etag) {
		w.WriteHeader(http.StatusNotModified)
		return
	}

	// Stream the blob straight from SQLite so a large tarball is never held
	// wholly in memory. Once the copy starts the status/headers are committed,
	// so mid-stream errors can only be logged.
	if err := s.streamBlob(w, "tarball", "data", rowid, size); err != nil {
		fmt.Fprintf(stderr, "serveTarball(%q gen %d): %v\n", project, gen, err)
	}
}

// etagMatch reports whether the ETag `etag` satisfies an If-None-Match header
// value, which is either "*" or a comma-separated list of (possibly weak)
// entity tags. A weak "W/" prefix is ignored for the comparison, which is
// correct for a conditional GET.
func etagMatch(ifNoneMatch, etag string) bool {
	strip := func(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "W/") }
	want := strip(etag)
	for _, part := range strings.Split(ifNoneMatch, ",") {
		got := strip(part)
		if got == "*" || got == want {
			return true
		}
	}
	return false
}

// streamBlob copies the BLOB in <table>.<column> at the given rowid to w,
// reading it incrementally so large values are never fully buffered in memory.
// It opens a short-lived read-only zombiezen connection per call (the
// database/sql driver in use cannot expose SQLite's incremental blob API): a
// dedicated connection means a slow client draining a big blob never blocks
// other readers, at the cost of one cheap WAL open per download.
//
// Content-Length is set from size before the first byte. The caller must set
// any other headers (Content-Type, Content-Disposition, …) beforehand and must
// not have written a body yet. If an error is returned after copying began, the
// response is already partially written and can only be logged.
func (s *server) streamBlob(w http.ResponseWriter, table, column string, rowid, size int64) error {
	conn, err := zsqlite.OpenConn(s.dbPath, zsqlite.OpenReadOnly, zsqlite.OpenWAL)
	if err != nil {
		return fmt.Errorf("open blob conn: %w", err)
	}
	defer conn.Close()

	blob, err := conn.OpenBlob("main", table, column, rowid, false)
	if err != nil {
		return fmt.Errorf("open blob %s.%s rowid %d: %w", table, column, rowid, err)
	}
	defer blob.Close()

	w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
	if _, err := io.Copy(w, blob); err != nil {
		return fmt.Errorf("stream blob %s.%s rowid %d: %w", table, column, rowid, err)
	}
	return nil
}

// handleRobots serves /robots.txt. Everything is crawlable except the noisy
// surfaces: the recursive ?full= views (which duplicate a whole subtree on one
// page) and the binary .bundle / .tar.gz downloads. It advertises the sitemap
// so crawlers can discover every canonical page.
func (s *server) handleRobots(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	fmt.Fprintf(w,
		"User-agent: *\n"+
			"Disallow: /*?full\n"+
			"Disallow: /*.bundle$\n"+
			"Disallow: /*.tar.gz$\n"+
			"\n"+
			"Sitemap: %s/sitemap.xml\n",
		s.baseFor(r))
}

// sitemapMaxURLs is the sitemap.org per-file cap (50,000 URLs / 50 MiB). We emit
// a single sitemap and stop at the URL limit; well within the size limit for
// any realistic tree.
const sitemapMaxURLs = 50000

// handleSitemap streams an XML sitemap of every canonical page: the site index,
// each project's directories and files, at their live generation. The noisy
// surfaces excluded in robots.txt (?full=, .bundle) are simply never emitted.
// <lastmod> comes from the git-derived mtime (a file's last-change time; a
// directory's newest descendant), omitted when unknown (0).
func (s *server) handleSitemap(w http.ResponseWriter, r *http.Request) {
	base := s.baseFor(r)

	// One read-only snapshot for the whole sitemap (see dbReader): the project
	// list and each project's file walk below must agree on the same
	// generation, or an ingest mid-crawl would drop a project's entries.
	q, err := s.db.BeginTx(r.Context(), &sql.TxOptions{ReadOnly: true})
	if err != nil {
		httpError(w, err)
		return
	}
	defer q.Rollback()

	// Fallible work first: gather projects (name + live generation + root time)
	// before any bytes are written, so a query error can still 500 cleanly.
	// head_generation = 0 means declared but never pushed (see `project add` in
	// project.go): there is no tree to walk, so such projects are excluded
	// here rather than emitting a lone, contentless project-root URL.
	type proj struct {
		name  string
		gen   int64
		mtime int64
	}
	rows, err := q.Query(
		`SELECT name, head_generation, root_mtime FROM project
		  WHERE head_generation > 0 ORDER BY name`)
	if err != nil {
		httpError(w, err)
		return
	}
	var projs []proj
	for rows.Next() {
		var p proj
		if err := rows.Scan(&p.name, &p.gen, &p.mtime); err != nil {
			rows.Close()
			httpError(w, err)
			return
		}
		projs = append(projs, p)
	}
	rows.Close()

	w.Header().Set("Content-Type", "application/xml; charset=utf-8")
	io.WriteString(w, `<?xml version="1.0" encoding="UTF-8"?>`+"\n")
	io.WriteString(w, `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`+"\n")

	n := 0
	emit := func(loc string, mtime int64) bool {
		if n >= sitemapMaxURLs {
			return false
		}
		writeSitemapURL(w, loc, mtime)
		n++
		return true
	}

	// The site index.
	emit(base+"/", 0)

	for _, p := range projs {
		if n >= sitemapMaxURLs {
			break
		}
		// Project root directory.
		if !emit(base+"/"+sitemapPath(p.name)+"/", p.mtime) {
			break
		}
		// Every directory and file in the live tree. Directories get a trailing
		// slash to match how they are linked and served (serveFile redirects a
		// dir path to the slash form).
		fr, err := q.Query(
			`SELECT path, is_dir, mtime FROM file
			  WHERE project = ? AND generation = ?
			  ORDER BY path`,
			p.name, p.gen)
		if err != nil {
			// Header already committed; log and stop this project's entries.
			fmt.Fprintf(stderr, "sitemap(%q): %v\n", p.name, err)
			break
		}
		stop := false
		for fr.Next() {
			var path string
			var isDir int
			var mtime int64
			if err := fr.Scan(&path, &isDir, &mtime); err != nil {
				fmt.Fprintf(stderr, "sitemap(%q) scan: %v\n", p.name, err)
				break
			}
			loc := base + "/" + sitemapPath(p.name) + "/" + sitemapPath(path)
			if isDir == 1 {
				loc += "/"
			}
			if !emit(loc, mtime) {
				stop = true
				break
			}
		}
		fr.Close()
		if stop {
			break
		}
	}

	if n >= sitemapMaxURLs {
		fmt.Fprintf(stderr, "sitemap: hit %d-URL cap; some pages omitted\n", sitemapMaxURLs)
	}
	io.WriteString(w, "</urlset>\n")
}

// sitemapPath percent-encodes a slash-separated tree path for use in a URL,
// escaping each segment (so spaces, '#', '?', non-ASCII, etc. are RFC-3986
// safe) while keeping the '/' separators literal.
func sitemapPath(p string) string {
	parts := strings.Split(p, "/")
	for i, seg := range parts {
		parts[i] = url.PathEscape(seg)
	}
	return strings.Join(parts, "/")
}

// writeSitemapURL writes one <url> entry, XML-escaping the (already
// percent-encoded) location and adding <lastmod> when mtime is known.
func writeSitemapURL(w io.Writer, loc string, mtime int64) {
	io.WriteString(w, "  <url><loc>")
	xml.EscapeText(w, []byte(loc))
	io.WriteString(w, "</loc>")
	if mtime > 0 {
		fmt.Fprintf(w, "<lastmod>%s</lastmod>",
			time.Unix(mtime, 0).UTC().Format(time.RFC3339))
	}
	io.WriteString(w, "</url>\n")
}

// statsLabel renders recursive directory stats as "SIZE · N dirs, M files",
// omitting either count when it is zero (e.g. "SIZE · M files").
func statsLabel(dirs, files, size int64) string {
	var parts []string
	if dirs > 0 {
		parts = append(parts, fmt.Sprintf("%d dirs", dirs))
	}
	if files > 0 {
		parts = append(parts, fmt.Sprintf("%d files", files))
	}
	if len(parts) == 0 {
		return humanSize(size)
	}
	return humanSize(size) + " · " + strings.Join(parts, ", ")
}

// serveProjectIndex lists all published projects, including any that have
// been declared (via `project add`) but never pushed to yet — recognisable by
// head_generation = 0, since generations start at 1 (see nextGeneration in
// ingest.go). These are shown with a "pending" marker instead of a
// misleading "0 files", so a botched or forgotten push is obvious from the
// index rather than looking identical to an empty project.
func (s *server) serveProjectIndex(q dbReader, w http.ResponseWriter, r *http.Request) {
	// Collect all rows first (fallible) before writing any output, so a query
	// error can still produce a clean 500 instead of a half-written page.
	type projRow struct {
		name, branch, description         string
		headGen                           int64
		rootDirs, rootFiles, rootCodeSize int64
	}
	rows, err := q.Query(
		`SELECT name, branch, head_generation, root_dirs, root_files, root_code_size, description
		   FROM project ORDER BY name`)
	if err != nil {
		httpError(w, err)
		return
	}
	defer rows.Close()
	var projs []projRow
	for rows.Next() {
		var p projRow
		if err := rows.Scan(&p.name, &p.branch, &p.headGen, &p.rootDirs, &p.rootFiles, &p.rootCodeSize, &p.description); err != nil {
			httpError(w, err)
			return
		}
		projs = append(projs, p)
	}

	s.writePageHeader(w, r, "Projects", nil, false)
	// The list is named by the page's own heading rather than by a duplicated
	// aria-label, so the two can never drift apart.
	io.WriteString(w,
		"<h1 id=\"page-title\">Projects</h1>\n"+
			"<ul class=\"listing\" aria-labelledby=\"page-title\">\n")
	for _, p := range projs {
		label := statsLabel(p.rootDirs, p.rootFiles, p.rootCodeSize)
		if p.headGen == 0 {
			label = "pending — not yet pushed"
		}
		fmt.Fprintf(w,
			"<li class=\"dir\"><a href=\"/%s/\">%s/</a> "+
				"<span class=\"muted\">(%s) — %s</span>",
			html.EscapeString(p.name), html.EscapeString(p.name),
			html.EscapeString(p.branch), html.EscapeString(label))
		// The description is stored and emitted UNESCAPED — see the
		// description column's comment in schema.go for why that is an
		// accepted trust boundary here.
		if p.description != "" {
			fmt.Fprintf(w, "<div class=\"proj-desc\">%s</div>", p.description)
		}
		io.WriteString(w, "</li>\n")
	}
	if len(projs) == 0 {
		io.WriteString(w, "<li class=\"muted\">no projects declared yet</li>\n")
	}
	io.WriteString(w, "</ul>\n")
	s.writePageFooter(w)
}

// dirEntry is one row in a directory listing: the file or directory actually
// displayed, which need not be a direct child of the directory being browsed.
//
// A chain of single-child directories is shown as one entry pointing at the
// deepest non-branching path (so "users" links straight to "users/Profpatsch"),
// and a directory's .source-forge may add `shortcut` entries pointing further
// down as well. All of that is resolved at ingest into the listing table, so
// the fields below already describe exactly what to show.
type dirEntry struct {
	// Full tree path of the displayed entry, e.g. "users/Profpatsch".
	path  string
	isDir bool
	size  int64
	// Last-change time: a file's own, a directory's newest descendant.
	mtime int64
	// Recursive aggregates (only meaningful when the entry is a directory).
	subFiles    int64
	subDirs     int64
	subCodeSize int64
	// Rendered HTML blurb from the directory's .source-forge, "" when none.
	description string
}

// dirSize fetches a directory's total (recursive) byte size, which gates the
// expensive ?full= render. This is the raw size including binaries, not the
// code-only figure shown in listings: it measures how much there would be to
// render, not how much of it is source.
func (s *server) dirSize(q dbReader, project string, gen int64, dir string) (size int64) {
	_ = q.QueryRow(
		`SELECT subtree_size FROM file
		  WHERE project = ? AND path = ? AND generation = ?`,
		project, dir, gen,
	).Scan(&size)
	return
}

// maxDirManpages caps how many of a directory's manpages are rendered below
// its listing. Each one costs a mandoc subprocess on a cache miss, so a
// directory holding a whole man/ tree would otherwise fan a single request out
// into dozens of them — the same bounded-work concern as fullMaxSize. Pages
// past the cap are still listed, and still browseable as ordinary files; only
// the rendered prose below the listing stops.
const maxDirManpages = 8

// sortManpages orders a directory's manpages for rendering below its listing,
// in place. The page a directory is "about" comes first: one whose basename
// matches the directory (or the project, at the root), so e.g. timetrack.1
// precedes sfttime.7 under timetrack/. Ties fall back to the lower section
// number, then the lower path, so the order is total and stable regardless of
// what order the listing produced the candidates in.
func sortManpages(pages []string, project, dir string) {
	// The name this directory would ideally have a manpage for.
	want := project
	if dir != "" {
		want = path.Base(dir)
	}
	nameMatches := func(p string) bool {
		base := path.Base(p)
		return base[:len(base)-len(path.Ext(base))] == want
	}

	sort.Slice(pages, func(i, j int) bool {
		a, b := pages[i], pages[j]
		if ma, mb := nameMatches(a), nameMatches(b); ma != mb {
			return ma
		}
		// Same match status: prefer the lower section, then the lower path.
		if se := strings.Compare(path.Ext(a), path.Ext(b)); se != 0 {
			return se < 0
		}
		return a < b
	})
}

// serveDir renders a directory listing, plus any README rendered above it.
func (s *server) serveDir(q dbReader, w http.ResponseWriter, r *http.Request, project, branch string, gen int64, dir string) {
	// gen == 0 means the project has been declared (see `project add` in
	// project.go) but never pushed to: there is no generation to have any file
	// rows at all, so every path within it is "not found" except the project
	// root itself, which gets a short pending notice instead of the ordinary
	// (and here misleadingly empty) listing.
	if gen == 0 {
		if dir != "" {
			http.NotFound(w, r)
			return
		}
		description := s.projectDescription(q, project)
		s.writePageHeader(w, r, project, nil, true)
		io.WriteString(w, breadcrumb(project, "", ""))
		fmt.Fprintf(w, "<h1 class=\"sr-only\">%s</h1>\n", html.EscapeString(project))
		if description != "" {
			io.WriteString(w, description)
		}
		fmt.Fprintf(w,
			"<p class=\"muted\">Declared, branch <code>%s</code> — "+
				"pending, nothing has been pushed yet.</p>\n",
			html.EscapeString(branch))
		s.writePageFooter(w)
		return
	}

	// Verify the directory exists (root dir "" always exists implicitly), and
	// take its total size and its own description in the same lookup: the
	// "full contents" link below needs the size and the header below shows the
	// description, so this saves two further queries.
	//
	// At the root, the project's description plays that part instead: the root
	// has no parent listing to be described in, so its .source-forge blurb is
	// stored on the project row (see storeDescriptions in ingest.go).
	var totalSize int64
	var description string
	if dir != "" {
		var isDir int
		err := q.QueryRow(
			`SELECT is_dir, subtree_size, description FROM file
			  WHERE project = ? AND path = ? AND generation = ?`,
			project, dir, gen,
		).Scan(&isDir, &totalSize, &description)
		if err == sql.ErrNoRows || (err == nil && isDir == 0) {
			http.NotFound(w, r)
			return
		}
		if err != nil {
			httpError(w, err)
			return
		}
	} else {
		_, _, totalSize = s.projectStats(q, project)
		description = s.projectDescription(q, project)
	}

	// The listing is stored, already ordered and already resolved: collapsed
	// single-child chains and any `shortcut` entries a directory declared in
	// its .source-forge were all worked out at ingest (see buildListing). Each
	// row names the file to display, so this is a plain join on the primary
	// key with no ordering or path arithmetic left to do at request time.
	rows, err := q.Query(
		`SELECT c.path, c.is_dir, c.size, c.mtime,
		        c.subtree_files, c.subtree_dirs, c.subtree_code_size, c.description
		   FROM listing l
		   JOIN file c
		     ON c.project = l.project
		    AND c.generation = l.generation
		    AND c.path = l.target
		  WHERE l.project = ? AND l.generation = ? AND l.dir = ?
		  ORDER BY l.seq`,
		project, gen, dir,
	)
	if err != nil {
		httpError(w, err)
		return
	}
	defer rows.Close()

	var entries []dirEntry
	var readme string     // path of a README.md in this dir, if any
	var manpages []string // paths of this dir's manpages, rendered after it
	for rows.Next() {
		var e dirEntry
		var isDir int
		if err := rows.Scan(&e.path, &isDir, &e.size, &e.mtime,
			&e.subFiles, &e.subDirs, &e.subCodeSize, &e.description); err != nil {
			httpError(w, err)
			return
		}
		e.isDir = isDir == 1
		entries = append(entries, e)

		// The README / manpages rendered below the listing must be THIS
		// directory's own files: an entry pointing deeper (a collapsed chain or
		// a shortcut) belongs to some other directory and is skipped. A file
		// that is a direct child always appears as itself, so none is missed.
		parent := path.Dir(e.path)
		if parent == "." {
			parent = ""
		}
		if e.isDir || parent != dir {
			continue
		}
		base := path.Base(e.path)
		if strings.EqualFold(base, "README.md") {
			readme = e.path
		}
		if isManpage(base) {
			manpages = append(manpages, e.path)
		}
	}
	sortManpages(manpages, project, dir)

	// Breadcrumb + title.
	title := project
	if dir != "" {
		title = project + "/" + dir
	}

	// All fallible queries above are done; begin streaming the page.
	s.writePageHeader(w, r, title, nil, false)
	io.WriteString(w, breadcrumb(project, dir, ""))
	// The heading is visually hidden (the breadcrumb above plays its part on
	// screen) but names the listing below it, via aria-labelledby rather than
	// a duplicated aria-label so the two can never drift apart.
	fmt.Fprintf(w, "<h1 class=\"sr-only\" id=\"page-title\">%s</h1>\n", html.EscapeString(title))

	// The directory's description (at the root, the project's) sits between
	// the header and the listing — above the clone/tarball instructions, where
	// those appear. Fetched above; stored and emitted UNESCAPED — see the
	// description column's comment in schema.go for why that is an accepted
	// trust boundary here.
	//
	// A directory's is inline HTML, since its main home is inside a listing
	// entry's line, so it is wrapped in a paragraph to stand on its own here.
	// The project's (dir == "") is stored as block content already.
	if description != "" {
		if dir != "" {
			fmt.Fprintf(w, "<p class=\"dir-desc\">%s</p>\n", description)
		} else {
			io.WriteString(w, description)
		}
	}

	// At the project root, offer the clonable git bundle if one exists.
	// A git bundle is a single file holding the full history; git cannot clone
	// it over HTTP directly (that triggers the smart-HTTP protocol), so the
	// instructions download it first, then clone/fetch from the local file.
	if dir == "" {
		if bundleSize, ok := s.hasBundle(q, project, gen); ok {
			bundleURL := s.bundleURL(r, project)
			fmt.Fprintf(w,
				"<div class=\"clone\">Clone the full history "+
					"(<a href=\"/%s.bundle\">%s.bundle</a>, a git bundle, %s):"+
					"<pre class=\"chroma\">curl -O %s\ngit clone %s.bundle %s</pre>"+
					"<p class=\"muted\">The clone keeps the bundle as its "+
					"<code>origin</code>; replace the file and "+
					"<code>git fetch</code> to update.</p></div>\n",
				html.EscapeString(project), html.EscapeString(project),
				html.EscapeString(humanSize(bundleSize)),
				html.EscapeString(bundleURL),
				html.EscapeString(project), html.EscapeString(project))
		}
		// Offer the Nix-flake tarball if one exists. Nix's tarball input scheme
		// fetches this static archive directly — no git server needed — so a
		// package can be run straight from the URL.
		if tarballSize, ok := s.hasTarball(q, project, gen); ok {
			tarballURL := s.tarballURL(r, project)
			fmt.Fprintf(w,
				"<div class=\"clone\">Run a package with Nix "+
					"(<a href=\"/%s.tar.gz\">%s.tar.gz</a>, a source tarball, %s):"+
					"<pre class=\"chroma\">nix run %s#&lt;package&gt;</pre>"+
					"<p class=\"muted\">The archive is a Nix flake; "+
					"<code>nix flake show %s</code> lists its packages.</p></div>\n",
				html.EscapeString(project), html.EscapeString(project),
				html.EscapeString(humanSize(tarballSize)),
				html.EscapeString(tarballURL),
				html.EscapeString(tarballURL))
		}
	}

	// Listing, named by the page heading emitted above.
	io.WriteString(w, "<ul class=\"listing\" aria-labelledby=\"page-title\">\n")
	for _, e := range entries {
		// The label is the entry's path relative to the directory being
		// browsed, so a collapsed chain or a shortcut reads as the path it
		// jumps to (e.g. "Profpatsch/git-blimey/") rather than just its
		// basename.
		label := strings.TrimPrefix(e.path, dir+"/")
		if dir == "" {
			label = e.path
		}
		if e.isDir {
			fmt.Fprintf(w,
				"<li class=\"dir\"><a href=\"/%s/%s/\">%s/</a> "+
					"<span class=\"muted\">%s%s</span>%s</li>\n",
				html.EscapeString(project), html.EscapeString(e.path),
				html.EscapeString(label),
				html.EscapeString(statsLabel(e.subDirs, e.subFiles, e.subCodeSize)),
				// The directory's own blurb, on the same line and in the same
				// muted style as the stats it follows. Inline HTML, stored and
				// emitted UNESCAPED — see the description column's comment in
				// schema.go for why that is an accepted trust boundary here.
				descSuffix(e.description),
				agoTime(e.mtime))
		} else {
			fmt.Fprintf(w,
				"<li class=\"file\"><a href=\"/%s/%s\">%s</a> "+
					"<span class=\"muted\">%s</span>%s</li>\n",
				html.EscapeString(project), html.EscapeString(e.path),
				html.EscapeString(label), humanSize(e.size), agoTime(e.mtime))
		}
	}
	io.WriteString(w, "</ul>\n")

	// Offer the recursive "full contents" view only for subtrees within the
	// size limit, so we never advertise a page that would render a huge tree.
	// The handler enforces the same limit for direct-URL requests.
	if totalSize > 0 && totalSize <= fullMaxSize {
		io.WriteString(w,
			"<p class=\"full-link\"><a href=\"?full=\">View full contents ↓</a></p>\n")
	}

	s.writeProse(q, w, project, gen, readme, manpages)

	s.writePageFooter(w)
}

// writeProse renders a directory's prose below (or, in the ?full= view, above)
// its listing: the README first when there is one, then every manpage the
// directory holds, each under a heading naming it. Both go through the render
// cache (markdown prose / mandoc prose respectively).
//
// A manpage is rendered whether or not a README is present: the two describe
// different things — a README introduces the directory, a manpage specifies the
// program — and a project that has both had, until now, its manpage hidden
// entirely by the README.
//
// Rendering is capped at maxDirManpages; see it for why. Manpages must already
// be ordered (sortManpages).
func (s *server) writeProse(q dbReader, w io.Writer, project string, gen int64, readme string, manpages []string) {
	if readme != "" {
		if h, ok := s.markdownFragment(q, project, gen, readme); ok {
			io.WriteString(w, h)
		}
	}
	if len(manpages) > maxDirManpages {
		manpages = manpages[:maxDirManpages]
	}
	for _, p := range manpages {
		if h, ok := s.manpageFragment(q, project, gen, p); ok {
			io.WriteString(w, h)
		}
	}
}

// fullMaxSize is the maximum total subtree size for which the recursive "full
// contents" view is offered and rendered. Directories above this limit hide the
// link AND refuse the ?full= page (a small "too large" notice is shown instead),
// so a scraper appending ?full= to every directory cannot trigger huge renders.
const fullMaxSize = 1 << 20 // 1 MiB

// projectStats returns the whole-tree recursive stats stored on the project row.
func (s *server) projectStats(q dbReader, project string) (dirs, files, size int64) {
	_ = q.QueryRow(
		`SELECT root_dirs, root_files, root_size FROM project WHERE name = ?`,
		project,
	).Scan(&dirs, &files, &size)
	return
}

// projectDescription returns a project's HTML blurb, declared by `description`
// in the root .source-forge and applied at ingest (see storeDescriptions in
// ingest.go and the description column's comment in schema.go), or "" when
// none is set. Callers emit it UNESCAPED — this function does no
// sanitisation, deliberately.
func (s *server) projectDescription(q dbReader, project string) string {
	var description string
	_ = q.QueryRow(
		`SELECT description FROM project WHERE name = ?`, project,
	).Scan(&description)
	return description
}

// serveFull renders the contents of every file under a directory (recursively)
// on a single page: text/source highlighted, markdown as prose, images inline,
// and other binaries as a download stub. The header shows the summed size.
//
// The subtree must be within fullMaxSize; larger directories get a small notice
// instead, so the expensive recursive render is never triggered by a crawler.
func (s *server) serveFull(q dbReader, w http.ResponseWriter, r *http.Request, project string, gen int64, dir string) {
	// Confirm the directory exists (root "" is always valid).
	if dir != "" {
		var isDir int
		err := q.QueryRow(
			`SELECT is_dir FROM file WHERE project = ? AND path = ? AND generation = ?`,
			project, dir, gen,
		).Scan(&isDir)
		if err == sql.ErrNoRows || (err == nil && isDir == 0) {
			http.NotFound(w, r)
			return
		}
		if err != nil {
			httpError(w, err)
			return
		}
	}

	title := project
	if dir != "" {
		title = project + "/" + dir
	}

	// Size guard: read the precomputed subtree size (one indexed lookup) and
	// refuse to render trees over the limit. This happens BEFORE the recursive
	// scan below, so an over-limit ?full= costs only this cheap check.
	var totalSize int64
	if dir == "" {
		_, _, totalSize = s.projectStats(q, project)
	} else {
		totalSize = s.dirSize(q, project, gen, dir)
	}
	if totalSize > fullMaxSize {
		s.writePageHeader(w, r, title, nil, true)
		io.WriteString(w, breadcrumb(project, dir, ""))
		fmt.Fprintf(w, "<h1 class=\"sr-only\">%s — full contents</h1>\n",
			html.EscapeString(title))
		fmt.Fprintf(w,
			"<p class=\"muted\">This directory is %s, larger than the %s limit "+
				"for the single-page full view. <a href=\".\">← browse the "+
				"listing</a> instead.</p>\n",
			html.EscapeString(humanSize(totalSize)),
			html.EscapeString(humanSize(fullMaxSize)))
		s.writePageFooter(w)
		return
	}

	// Metadata pass: collect the file list (path/size/type only — no content),
	// so the header can show an accurate count. This is fallible, so it runs
	// before any output is written.
	var rows *sql.Rows
	var err error
	if dir == "" {
		rows, err = q.Query(
			`SELECT path, size, is_binary, mime_type FROM file
			  WHERE project = ? AND generation = ? AND is_dir = 0
			  ORDER BY path`,
			project, gen,
		)
	} else {
		rows, err = q.Query(
			`SELECT path, size, is_binary, mime_type FROM file
			  WHERE project = ? AND generation = ? AND is_dir = 0
			    AND (path = ? OR path LIKE ? ESCAPE '\')
			  ORDER BY path`,
			project, gen, dir, likePrefix(dir)+"/%",
		)
	}
	if err != nil {
		httpError(w, err)
		return
	}
	type fullFile struct {
		path  string
		size  int64
		isBin bool
		mime  string
	}
	var files []fullFile
	for rows.Next() {
		var f fullFile
		var isBin int
		if err := rows.Scan(&f.path, &f.size, &isBin, &f.mime); err != nil {
			rows.Close()
			httpError(w, err)
			return
		}
		f.isBin = isBin == 1
		files = append(files, f)
	}
	rows.Close()

	// Collect the view-root's own README / manpages (direct children of dir) to
	// render at the top, mirroring the directory listing. Derived from the
	// already-collected file list, so no extra query. The promoted README is
	// also shown verbatim (highlighted source) at its lexicographic slot below;
	// so are the manpages, which are not markdown and so already render as
	// source there.
	var readme string
	var manpages []string
	for _, f := range files {
		parent := path.Dir(f.path)
		if parent == "." {
			parent = ""
		}
		if parent != dir {
			continue
		}
		base := path.Base(f.path)
		if strings.EqualFold(base, "README.md") {
			readme = f.path
		}
		if isManpage(base) {
			manpages = append(manpages, f.path)
		}
	}
	sortManpages(manpages, project, dir)

	// Stream the page. Each file's rendered fragment is fetched and written one
	// at a time, so we never hold the whole assembled page in memory.
	flusher, _ := w.(http.Flusher)
	s.writePageHeader(w, r, title, nil, true)
	io.WriteString(w, breadcrumb(project, dir, ""))
	fmt.Fprintf(w, "<h1 class=\"sr-only\">%s — full contents</h1>\n",
		html.EscapeString(title))
	fmt.Fprintf(w,
		"<p class=\"muted\">%d files · %s total. "+
			"<a href=\".\">← back to listing</a></p>\n",
		len(files), html.EscapeString(humanSize(totalSize)))

	// Rendered README and manpages promoted above the file dump, exactly as in
	// the directory listing.
	s.writeProse(q, w, project, gen, readme, manpages)
	if flusher != nil {
		flusher.Flush()
	}

	for _, f := range files {
		anchor := "f-" + f.path
		fmt.Fprintf(w,
			"<section class=\"full-file\" id=\"%s\">\n"+
				"<h2 class=\"full-path\"><a href=\"/%s/%s\">%s</a> "+
				"<span class=\"muted\">%s</span></h2>\n",
			html.EscapeString(anchor),
			html.EscapeString(project), html.EscapeString(f.path),
			html.EscapeString(f.path), html.EscapeString(humanSize(f.size)))

		switch {
		case f.isBin && strings.HasPrefix(f.mime, "image/"):
			// Inline the image via the existing raw file route.
			fmt.Fprintf(w,
				"<img class=\"full-image\" src=\"/%s/%s\" alt=\"%s\">\n",
				html.EscapeString(project), html.EscapeString(f.path),
				html.EscapeString(f.path))
		case f.isBin:
			// Non-image binary: a download stub, no inline content.
			fmt.Fprintf(w,
				"<p class=\"muted\">binary file (%s) — "+
					"<a href=\"/%s/%s\">download</a></p>\n",
				html.EscapeString(f.mime),
				html.EscapeString(project), html.EscapeString(f.path))
		default:
			// The promoted README is already rendered as prose at the top, so
			// here it appears verbatim (highlighted source) at its lexicographic
			// slot; every other text file gets its canonical display rendering
			// (nested markdown stays prose).
			var frag string
			var ok bool
			if f.path == readme {
				frag, ok = s.highlightFragment(q, project, gen, f.path)
			} else {
				frag, ok = s.displayFragment(q, project, gen, f.path)
			}
			if ok {
				io.WriteString(w, frag)
				io.WriteString(w, "\n")
			}
		}
		io.WriteString(w, "</section>\n")
		if flusher != nil {
			flusher.Flush()
		}
	}

	s.writePageFooter(w)
}

// likePrefix escapes SQLite LIKE metacharacters (% _ \) in a literal path so it
// can be used safely as a prefix with `ESCAPE '\'`.
func likePrefix(p string) string {
	r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
	return r.Replace(p)
}

// serveFile renders a single file: raw bytes for binaries, cached highlighted
// HTML (or markdown) for text.
func (s *server) serveFile(q dbReader, w http.ResponseWriter, r *http.Request, project string, gen int64, p string) {
	var isDir, isBin int
	var mimeType string
	var size, rowid int64
	err := q.QueryRow(
		`SELECT is_dir, is_binary, mime_type, size, rowid FROM file
		  WHERE project = ? AND path = ? AND generation = ?`,
		project, p, gen,
	).Scan(&isDir, &isBin, &mimeType, &size, &rowid)
	if err == sql.ErrNoRows {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		httpError(w, err)
		return
	}
	if isDir == 1 {
		// Redirect to the trailing-slash directory URL.
		http.Redirect(w, r, "/"+project+"/"+p+"/", http.StatusFound)
		return
	}

	// Binary: serve raw with the MIME detected at ingest, streamed straight from
	// the DB so large files (images, PDFs) are not buffered in memory. The
	// full-contents view inlines every image through this route, so this is the
	// hot path for binary payloads.
	if isBin == 1 {
		w.Header().Set("Content-Type", mimeType)
		w.Header().Set("Cache-Control", "public, max-age=3600")
		if err := s.streamBlob(w, "file", "content", rowid, size); err != nil {
			fmt.Fprintf(stderr, "serveFile(%q/%q): %v\n", project, p, err)
		}
		return
	}

	// Text: render (via cache) and serve.
	htmlFrag, ok := s.displayFragment(q, project, gen, p)
	if !ok {
		http.NotFound(w, r)
		return
	}
	s.writePage(w, r, project+"/"+p, &fileMeta{project: project, path: p, size: size}, htmlFrag)
}

// displayFragment returns the canonical "file view" rendering of a text file:
// markdown as prose, everything else chroma-highlighted. This is what a direct
// file request and the non-promoted entries of the ?full= view show.
func (s *server) displayFragment(q dbReader, project string, gen int64, p string) (string, bool) {
	if isMarkdown(p) {
		return s.markdownFragment(q, project, gen, p)
	}
	return s.highlightFragment(q, project, gen, p)
}

// highlightFragment returns a file's raw bytes chroma-highlighted (kind
// 'highlight'), using the render cache and populating it on a miss. This is the
// verbatim source rendering, used both for non-markdown file views and for the
// promoted README's in-place slot in the ?full= view.
func (s *server) highlightFragment(q dbReader, project string, gen int64, p string) (string, bool) {
	if htmlFrag, ok := s.cachedRender(q, project, gen, p, kindHighlight); ok {
		return htmlFrag, true
	}
	content, ok := s.fileContent(q, project, gen, p)
	if !ok {
		return "", false
	}
	htmlFrag := ""
	if h, err := highlightSource(p, content); err == nil {
		htmlFrag = h
	} else {
		htmlFrag = plainFallback(content)
	}
	s.storeRender(project, gen, p, kindHighlight, htmlFrag)
	return htmlFrag, true
}

// markdownFragment returns a markdown file rendered as prose (kind 'markdown'),
// wrapped in <section class="readme"> and cached. Used for the file view of a
// .md and for a directory's promoted README.
func (s *server) markdownFragment(q dbReader, project string, gen int64, p string) (string, bool) {
	if htmlFrag, ok := s.cachedRender(q, project, gen, p, kindMarkdown); ok {
		return htmlFrag, true
	}
	content, ok := s.fileContent(q, project, gen, p)
	if !ok {
		return "", false
	}
	htmlFrag := ""
	if h, err := renderMarkdown(content, project, p); err == nil {
		htmlFrag = "<section class=\"readme\">\n" + h + "</section>\n"
	} else {
		htmlFrag = plainFallback(content)
	}
	s.storeRender(project, gen, p, kindMarkdown, htmlFrag)
	return htmlFrag, true
}

// manpageFragment returns the rendered manpage HTML fragment for a .N file,
// using the render cache (kind 'manpage', distinct from the file's own
// highlighted source under kind 'highlight') when available and populating it
// on a miss. The fragment is wrapped in <section class="readme"> so it reuses
// the README styling, mirroring how markdown READMEs are rendered.
//
// The title heading (manpageTitleHTML) goes INSIDE that section, and so into
// the cached bytes: the section carries the border-top separating one prose
// block from the next, and a title emitted before it would be cut off from its
// own prose by that border, appearing to belong to whatever came above.
func (s *server) manpageFragment(q dbReader, project string, gen int64, p string) (string, bool) {
	if htmlFrag, ok := s.cachedRender(q, project, gen, p, kindManpage); ok {
		return htmlFrag, true
	}
	content, ok := s.fileContent(q, project, gen, p)
	if !ok {
		return "", false
	}
	h, err := renderManpage(content, project, p)
	if err != nil {
		fmt.Fprintf(stderr, "renderManpage(%q): %v\n", p, err)
		return "", false
	}
	htmlFrag := "<section class=\"readme\">\n" + manpageTitleHTML(project, p) + h + "</section>\n"
	s.storeRender(project, gen, p, kindManpage, htmlFrag)
	return htmlFrag, true
}

// fileContent fetches a file's raw content.
func (s *server) fileContent(q dbReader, project string, gen int64, p string) ([]byte, bool) {
	var content []byte
	err := q.QueryRow(
		`SELECT content FROM file WHERE project = ? AND path = ? AND generation = ?`,
		project, p, gen,
	).Scan(&content)
	if err != nil {
		if err != sql.ErrNoRows {
			fmt.Fprintf(stderr, "fileContent(%q): %v\n", p, err)
		}
		return nil, false
	}
	return content, true
}

// renderKind names one way a file's bytes are rendered into cached HTML, one
// value per distinct HTML output. It is stored verbatim in render_cache.kind
// (see the `kind` column), so a file can hold several cached renderings at once
// without colliding: e.g. a README.md is cached both as markdown prose (for the
// file view and the directory README) and as highlighted source (for its
// verbatim slot in the ?full= view). Append new kinds freely; never rename an
// existing value (old rows would orphan — harmless, they are re-rendered).
type renderKind string

const (
	kindHighlight renderKind = "highlight" // chroma-highlighted raw bytes
	kindMarkdown  renderKind = "markdown"  // goldmark prose, section.readme-wrapped
	kindManpage   renderKind = "manpage"   // mandoc prose, section.readme-wrapped
)

// cachedRender returns the cached HTML fragment for a file at a generation and
// render kind.
func (s *server) cachedRender(q dbReader, project string, gen int64, p string, kind renderKind) (string, bool) {
	var htmlFrag string
	err := q.QueryRow(
		`SELECT html FROM render_cache
		  WHERE project = ? AND path = ? AND generation = ? AND kind = ?`,
		project, p, gen, string(kind),
	).Scan(&htmlFrag)
	if err != nil {
		return "", false
	}
	return htmlFrag, true
}

// storeRender caches a rendered HTML fragment under a render kind. Errors are
// non-fatal (we just re-render next time).
//
// This deliberately writes through the pool rather than the request's read
// snapshot: that transaction is read-only, and the cache fill should commit on
// its own so later requests see it immediately rather than being tied to the
// lifetime of whichever page happened to render the file first.
//
// The write goes to the dedicated single-connection writer, so a write waiting
// out an ingest can never consume connections from the read pool, and our own
// writes queue behind each other instead of contending. Waiting for the lock is
// left entirely to that connection's busy_timeout, which is long enough to
// outlast an ingest; there is no retry loop on top, as that would just be a
// second, worse timeout.
//
// Failure is not silent: a miss here means the next request has to re-run the
// whole render (chroma, or a mandoc subprocess), so it is worth complaining
// about rather than quietly degrading.
func (s *server) storeRender(project string, gen int64, p string, kind renderKind, htmlFrag string) {
	_, err := s.writeDB.Exec(
		`INSERT OR REPLACE INTO render_cache (project, path, generation, kind, html)
		 VALUES (?, ?, ?, ?, ?)`,
		project, p, gen, string(kind), htmlFrag,
	)
	if err != nil {
		fmt.Fprintf(stderr, "storeRender(%q): %v (file will be re-rendered on the next request)\n", p, err)
	}
}

// buildCSS produces the combined stylesheet: the (unconditional) page layout
// plus one chroma block per theme, each guarded by a prefers-color-scheme
// media query.
//
// Both blocks must be guarded, and the two queries must be mutually exclusive.
// Chroma only emits a rule for the tokens a style actually overrides, so the
// two themes do not produce the same set of rules: github-dark says nothing
// about NameOther, Punctuation, NameAttribute, NameBuiltin or NameBuiltinPseudo,
// for instance. Emitting the light block unscoped (relying on the dark block to
// shadow it at equal specificity) therefore left exactly those tokens at their
// light colour on a dark background — identifiers and punctuation at #1f2328 on
// #0d1117, a contrast ratio of 1.2:1, i.e. all but invisible.
//
// The light query is spelled "not all and (prefers-color-scheme: dark)" rather
// than "(prefers-color-scheme: light)" so that a browser which supports neither
// query still gets the light theme, instead of unhighlighted text.
func buildCSS() ([]byte, error) {
	var buf bytes.Buffer
	buf.WriteString(pageCSS)

	// Same options as the formatter that produces the markup, or the
	// stylesheet does not match it; see chromaFormatterOptions.
	formatter := chromahtml.New(chromaFormatterOptions...)

	writeTheme := func(name, query string) error {
		style := styles.Get(name)
		if style == nil {
			return fmt.Errorf("unknown chroma style %q", name)
		}
		// Rendered to a scratch buffer first so a failure cannot leave a
		// half-written, unbalanced media block in the served stylesheet.
		var themeBuf bytes.Buffer
		if err := formatter.WriteCSS(&themeBuf, style); err != nil {
			return fmt.Errorf("write %s css: %w", name, err)
		}
		buf.WriteString("\n@media " + query + " {\n")
		buf.Write(themeBuf.Bytes())
		buf.WriteString("}\n")
		return nil
	}

	if err := writeTheme(lightStyle, "not all and (prefers-color-scheme: dark)"); err != nil {
		return nil, err
	}
	if err := writeTheme(darkStyle, "(prefers-color-scheme: dark)"); err != nil {
		return nil, err
	}

	return buf.Bytes(), nil
}

// --- HTML page shell ---------------------------------------------------------

type fileMeta struct {
	project string // project name, for building the breadcrumb links
	path    string // file path within the project (e.g. "dir/sub/file.nix")
	size    int64
}

// writePageHeader writes the HTML page shell up to the opening of <main> and
// sets the response headers. Handlers must perform all fallible work (queries
// that may 500) BEFORE calling this, since once the header is written the
// status code is committed and the body is streamed incrementally.
func (s *server) writePageHeader(w http.ResponseWriter, r *http.Request, title string, meta *fileMeta, noindex bool) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Header().Set("Cache-Control", "no-cache")

	io.WriteString(w, "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n")
	io.WriteString(w, "<meta charset=\"utf-8\">\n")
	io.WriteString(w, "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n")
	// Canonical listing/file pages are indexable; the recursive ?full= views are
	// kept out of the index (they duplicate every file on one page) but still
	// followed so crawlers reach the canonical per-file pages.
	if noindex {
		io.WriteString(w, "<meta name=\"robots\" content=\"noindex, follow\">\n")
	} else {
		io.WriteString(w, "<meta name=\"robots\" content=\"index, follow\">\n")
	}
	// The host is appended so a page copy/pasted, screenshotted, or opened from
	// browser history still shows which site (and, for anyone running their own
	// instance, which instance) it came from.
	fmt.Fprintf(w, "<title>%s — %s</title>\n", html.EscapeString(title), html.EscapeString(s.hostFor(r)))
	// The stylesheet is inlined rather than linked so every page is fully
	// self-contained (one request, no relative-URL pitfalls under nested paths).
	io.WriteString(w, "<style>\n")
	w.Write(s.css)
	io.WriteString(w, "\n</style>\n")
	io.WriteString(w, "</head>\n<body>\n<main>\n")
	if meta != nil {
		// The file page's navigable path: parent directories are linked and the
		// filename is the current (aria-current) crumb.
		dir, file := path.Split(meta.path)
		io.WriteString(w, "<div class=\"filehead\">")
		io.WriteString(w, breadcrumb(meta.project, strings.Trim(dir, "/"), file))
		io.WriteString(w, "</div>\n")
	}
}

// writePageFooter closes the streamed page shell.
func (s *server) writePageFooter(w http.ResponseWriter) {
	io.WriteString(w, "</main>\n</body>\n</html>\n")
}

// writePage wraps a fully-built HTML body fragment in the page shell. Kept for
// the simpler pages; the body string is written in one shot.
func (s *server) writePage(w http.ResponseWriter, r *http.Request, title string, meta *fileMeta, body string) {
	s.writePageHeader(w, r, title, meta, false)
	io.WriteString(w, body)
	s.writePageFooter(w)
}

// --- helpers -----------------------------------------------------------------

func httpError(w http.ResponseWriter, err error) {
	fmt.Fprintf(stderr, "source-forge: %v\n", err)
	http.Error(w, "internal error", http.StatusInternalServerError)
}

// breadcrumb builds an accessible breadcrumb navigation for a path within a
// project, following the WAI-ARIA breadcrumb pattern: a <nav aria-label> around
// an ordered list of crumbs. Every crumb except the last is a link; the final
// crumb (the current location) is a plain <span> marked aria-current="page".
// The "/" separators are emitted as real, aria-hidden text nodes: hidden from
// screen readers (which announce only the crumb labels) but part of the DOM
// text, so selecting the breadcrumb copies the full slash-separated path.
//
// leaf selects what the current (non-link) crumb is:
//   - leaf == "": a directory view. The last segment of dir (or the project
//     name when dir is empty) is the current crumb.
//   - leaf != "": a file view. Every segment of dir is linked and leaf (the
//     filename) is the current crumb.
func breadcrumb(project, dir, leaf string) string {
	// crumb is one entry in the trail: label plus the href of its directory
	// (empty href marks the current, non-link crumb).
	type crumb struct{ label, href string }
	var crumbs []crumb

	crumbs = append(crumbs, crumb{project, "/" + project + "/"})
	if dir != "" {
		acc := ""
		for _, part := range strings.Split(dir, "/") {
			acc = pathJoin(acc, part)
			crumbs = append(crumbs, crumb{part, "/" + project + "/" + acc + "/"})
		}
	}
	if leaf != "" {
		crumbs = append(crumbs, crumb{leaf, ""})
	}
	// The final crumb is always the current location: drop its link.
	crumbs[len(crumbs)-1].href = ""

	var b strings.Builder
	b.WriteString("<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>")
	for i, c := range crumbs {
		b.WriteString("<li>")
		if i > 0 {
			// Real separator text so the copied selection keeps the slashes;
			// aria-hidden keeps it out of the screen-reader announcement.
			b.WriteString("<span class=\"sep\" aria-hidden=\"true\">/</span>")
		}
		if c.href == "" {
			fmt.Fprintf(&b, "<span aria-current=\"page\">%s</span>",
				html.EscapeString(c.label))
		} else {
			fmt.Fprintf(&b, "<a href=\"%s\">%s</a>",
				html.EscapeString(c.href), html.EscapeString(c.label))
		}
		b.WriteString("</li>")
	}
	b.WriteString("</ol></nav>")
	return b.String()
}

func pathJoin(dir, name string) string {
	if dir == "" {
		return name
	}
	return dir + "/" + name
}

func humanSize(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	div, exp := int64(unit), 0
	for m := n / unit; m >= unit; m /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

// agoTime renders a trailing " <time class="ago">…</time>" for a listing entry,
// or "" when the mtime is unknown (0) so old data degrades gracefully.
//
// <time> is the element for exactly this: a human-readable instant whose
// machine-readable form differs from its text. The datetime attribute carries
// the full instant, so the "3 days ago" text — which is only true at the moment
// the page is rendered — is anchored to something exact for assistive tech and
// anything else parsing the page. The title attribute keeps the absolute date
// visible on hover.
func agoTime(mtime int64) string {
	if mtime <= 0 {
		return ""
	}
	t := time.Unix(mtime, 0).UTC()
	return fmt.Sprintf(" <time class=\"ago\" datetime=\"%s\" title=\"%s\">%s</time>",
		html.EscapeString(t.Format(time.RFC3339)),
		html.EscapeString(t.Format("2006-01-02")),
		html.EscapeString(humanAgo(time.Since(t))))
}

// descSuffix renders a listing entry's description as a continuation of the
// muted stats that precede it ("143.1 KiB · 14 files — An interactive …"), or
// "" when there is none.
//
// The separator is a real text node rather than CSS ::before content so that
// selecting and copying a listing keeps it, and aria-hidden so a screen reader
// reads the stats and the description as two phrases instead of announcing a
// dash between them — the same treatment the breadcrumb gives its "/".
//
// The description is inline HTML (see meta.go) and is emitted UNESCAPED, which
// is what lets it carry <code> and links; see the description column's comment
// in schema.go for why that trust boundary is accepted.
func descSuffix(description string) string {
	if description == "" {
		return ""
	}
	return "<span class=\"sep\" aria-hidden=\"true\"> — </span>" + description
}

// humanAgo renders a duration as a coarse "N unit(s) ago" string, picking the
// largest whole unit (years down to seconds). Future or ~now times render as
// "just now" (clock skew shouldn't produce alarming negative labels).
func humanAgo(d time.Duration) string {
	secs := int64(d.Seconds())
	if secs < 60 {
		return "just now"
	}
	type unit struct {
		name string
		secs int64
	}
	for _, u := range []unit{
		{"year", 365 * 24 * 3600},
		{"month", 30 * 24 * 3600},
		{"week", 7 * 24 * 3600},
		{"day", 24 * 3600},
		{"hour", 3600},
		{"minute", 60},
	} {
		if n := secs / u.secs; n >= 1 {
			if n == 1 {
				return "1 " + u.name + " ago"
			}
			return fmt.Sprintf("%d %ss ago", n, u.name)
		}
	}
	return "just now"
}