fabrica

hanna/fabrica

feat(web): license detection banner on LICENSE files

aefa2ff · hanna committed on 2026-07-25

Add a lightweight license detector (curated marker-phrase matching for MIT,
Apache-2.0, BSD-2/3-Clause, 0BSD, ISC, MPL-2.0, GPL-2/3.0, LGPL-3.0, AGPL-3.0,
Unlicense) and show a GitHub-style summary above a LICENSE/COPYING file: the
license name and SPDX id plus its permissions, conditions, and limitations,
with a "not legal advice" note. Unrecognized licenses simply show no banner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
6 files changed · +424 −0UnifiedSplit
assets/base.css +71 −0
@@ -595,6 +595,77 @@ body.drawer-open .drawer-backdrop {
595595 flex: none;
596596 }
597597
598+/* License summary banner shown above a LICENSE file. */
599+.license-banner {
600+ margin-bottom: 1rem;
601+}
602+.license-head {
603+ display: flex;
604+ align-items: center;
605+ gap: 0.75rem;
606+}
607+.license-head > .icon {
608+ width: 2rem;
609+ height: 2rem;
610+ flex: none;
611+ color: var(--fb-fg-muted);
612+}
613+.license-head h3 {
614+ margin: 0;
615+}
616+.license-eyebrow {
617+ margin: 0;
618+ font-size: 0.85em;
619+}
620+.license-desc {
621+ margin: 0.75rem 0;
622+}
623+.license-cols {
624+ display: grid;
625+ grid-template-columns: repeat(3, 1fr);
626+ gap: 1rem;
627+ padding-top: 0.75rem;
628+ border-top: 1px solid var(--fb-border);
629+}
630+.license-col h4 {
631+ margin: 0 0 0.5rem;
632+ font-size: 0.9rem;
633+}
634+.license-col ul {
635+ list-style: none;
636+ display: flex;
637+ flex-direction: column;
638+ gap: 0.3rem;
639+}
640+.license-col li {
641+ display: flex;
642+ align-items: center;
643+ gap: 0.4rem;
644+}
645+.license-col li .icon {
646+ width: 0.95rem;
647+ height: 0.95rem;
648+ flex: none;
649+}
650+.license-col li.ok .icon {
651+ color: var(--fb-success);
652+}
653+.license-col li.no .icon {
654+ color: var(--fb-danger);
655+}
656+.license-col li.cond .icon {
657+ color: var(--fb-accent);
658+}
659+.license-note {
660+ margin: 0.75rem 0 0;
661+ font-size: 0.82em;
662+}
663+@media (max-width: 40rem) {
664+ .license-cols {
665+ grid-template-columns: 1fr;
666+ }
667+}
668+
598669 /* Pagination controls for long lists. */
599670 .pagination {
600671 display: flex;
crates/web/src/icons.rs +8 −0
@@ -34,6 +34,9 @@ pub enum Icon {
3434 Compass,
3535 MapPin,
3636 Mail,
37+ Check,
38+ X,
39+ Scale,
3740 Globe,
3841 Download,
3942 Github,
@@ -104,6 +107,11 @@ impl Icon {
104107 Icon::Mail => {
105108 r#"<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>"#
106109 }
110+ Icon::Check => r#"<path d="M20 6 9 17l-5-5"/>"#,
111+ Icon::X => r#"<path d="M18 6 6 18"/><path d="m6 6 12 12"/>"#,
112+ Icon::Scale => {
113+ r#"<path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/><path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/><path d="M7 21h10"/><path d="M12 3v18"/><path d="M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"/>"#
114+ }
107115 Icon::Globe => {
108116 r#"<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>"#
109117 }
crates/web/src/lib.rs +1 −0
@@ -19,6 +19,7 @@ mod git_http;
1919 mod icons;
2020 mod issues;
2121 mod layout;
22+mod license;
2223 mod markdown;
2324 mod pages;
2425 mod pulls;
crates/web/src/license.rs +261 −0
@@ -0,0 +1,261 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Lightweight license detection for the file view.
6+//!
7+//! This is not a full SPDX matcher (à la GitHub's licensee): it recognizes a
8+//! curated set of common licenses by distinctive marker phrases and maps each to
9+//! a small table of permissions / conditions / limitations sourced from
10+//! [choosealicense.com](https://choosealicense.com). It is deliberately
11+//! conservative — an unrecognized or heavily modified license simply shows no
12+//! banner. The rights summary is informational, never legal advice.
13+
14+/// A recognized license and its rights summary.
15+#[derive(Debug, Clone, Copy)]
16+pub(crate) struct LicenseInfo {
17+ /// Human-readable name, e.g. "MIT License".
18+ pub name: &'static str,
19+ /// SPDX identifier, e.g. "MIT".
20+ pub spdx: &'static str,
21+ /// One-line plain description.
22+ pub description: &'static str,
23+ /// What the license permits.
24+ pub permissions: &'static [&'static str],
25+ /// What it requires.
26+ pub conditions: &'static [&'static str],
27+ /// What it limits.
28+ pub limitations: &'static [&'static str],
29+}
30+
31+/// Whether `path`'s filename looks like a license file (`LICENSE`, `LICENCE`,
32+/// `COPYING`, optionally with an extension, case-insensitive).
33+pub(crate) fn is_license_path(path: &str) -> bool {
34+ let name = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase();
35+ let stem = name.split('.').next().unwrap_or(&name);
36+ matches!(
37+ stem,
38+ "license" | "licence" | "copying" | "copyright" | "unlicense"
39+ )
40+}
41+
42+/// Detect the license from a file's text, or `None` if it is not one of the
43+/// recognized licenses. Matching is order-sensitive (most specific first).
44+pub(crate) fn detect(content: &str) -> Option<&'static LicenseInfo> {
45+ // Collapse whitespace and lowercase so wrapping/indentation doesn't matter.
46+ let normalized: String = content
47+ .to_ascii_lowercase()
48+ .split_whitespace()
49+ .collect::<Vec<_>>()
50+ .join(" ");
51+ let has = |needle: &str| normalized.contains(needle);
52+
53+ // Order matters: check more specific markers before generic ones.
54+ if has("gnu affero general public license") {
55+ return Some(&AGPL_3_0);
56+ }
57+ if has("gnu lesser general public license") {
58+ return Some(&LGPL_3_0);
59+ }
60+ if has("gnu general public license") {
61+ if has("version 2") {
62+ return Some(&GPL_2_0);
63+ }
64+ return Some(&GPL_3_0);
65+ }
66+ if has("mozilla public license version 2.0") {
67+ return Some(&MPL_2_0);
68+ }
69+ if has("apache license") && has("version 2.0") {
70+ return Some(&APACHE_2_0);
71+ }
72+ if has("this is free and unencumbered software released into the public domain") {
73+ return Some(&UNLICENSE);
74+ }
75+ if has("permission to use, copy, modify, and/or distribute this software") {
76+ // 0BSD drops ISC's "provided that the above copyright" retention clause.
77+ if has("provided that the above copyright") {
78+ return Some(&ISC);
79+ }
80+ return Some(&BSD_0);
81+ }
82+ if has("redistribution and use in source and binary forms") {
83+ if has("neither the name") {
84+ return Some(&BSD_3_CLAUSE);
85+ }
86+ return Some(&BSD_2_CLAUSE);
87+ }
88+ if has("permission is hereby granted, free of charge") {
89+ return Some(&MIT);
90+ }
91+ None
92+}
93+
94+// ---- Rights vocabulary (choosealicense.com labels) ----
95+const COMMERCIAL: &str = "Commercial use";
96+const MODIFY: &str = "Modification";
97+const DISTRIBUTE: &str = "Distribution";
98+const PRIVATE: &str = "Private use";
99+const PATENT: &str = "Patent use";
100+const LIABILITY: &str = "Liability";
101+const WARRANTY: &str = "Warranty";
102+const TRADEMARK: &str = "Trademark use";
103+const KEEP_NOTICE: &str = "License and copyright notice";
104+const STATE_CHANGES: &str = "State changes";
105+const DISCLOSE_SOURCE: &str = "Disclose source";
106+const SAME_LICENSE: &str = "Same license";
107+const NETWORK_DISCLOSE: &str = "Network use is distribution";
108+
109+const PERMISSIVE: &[&str] = &[COMMERCIAL, MODIFY, DISTRIBUTE, PRIVATE];
110+const PERMISSIVE_PATENT: &[&str] = &[COMMERCIAL, MODIFY, DISTRIBUTE, PATENT, PRIVATE];
111+const NO_WARRANTY: &[&str] = &[LIABILITY, WARRANTY];
112+
113+const MIT: LicenseInfo = LicenseInfo {
114+ name: "MIT License",
115+ spdx: "MIT",
116+ description: "A short, permissive license: do almost anything, provided the copyright and license notice are preserved.",
117+ permissions: PERMISSIVE,
118+ conditions: &[KEEP_NOTICE],
119+ limitations: NO_WARRANTY,
120+};
121+
122+const APACHE_2_0: LicenseInfo = LicenseInfo {
123+ name: "Apache License 2.0",
124+ spdx: "Apache-2.0",
125+ description: "A permissive license with an express grant of patent rights and requirements to note changes.",
126+ permissions: PERMISSIVE_PATENT,
127+ conditions: &[KEEP_NOTICE, STATE_CHANGES],
128+ limitations: &[TRADEMARK, LIABILITY, WARRANTY],
129+};
130+
131+const BSD_2_CLAUSE: LicenseInfo = LicenseInfo {
132+ name: "BSD 2-Clause License",
133+ spdx: "BSD-2-Clause",
134+ description: "A permissive license that requires preservation of the copyright and license notices.",
135+ permissions: PERMISSIVE,
136+ conditions: &[KEEP_NOTICE],
137+ limitations: NO_WARRANTY,
138+};
139+
140+const BSD_3_CLAUSE: LicenseInfo = LicenseInfo {
141+ name: "BSD 3-Clause License",
142+ spdx: "BSD-3-Clause",
143+ description: "A permissive license like BSD 2-Clause, also forbidding use of contributors' names to endorse derivatives.",
144+ permissions: PERMISSIVE,
145+ conditions: &[KEEP_NOTICE],
146+ limitations: NO_WARRANTY,
147+};
148+
149+const BSD_0: LicenseInfo = LicenseInfo {
150+ name: "BSD Zero Clause License",
151+ spdx: "0BSD",
152+ description: "A public-domain-equivalent license with no attribution requirement whatsoever.",
153+ permissions: PERMISSIVE,
154+ conditions: &[],
155+ limitations: NO_WARRANTY,
156+};
157+
158+const ISC: LicenseInfo = LicenseInfo {
159+ name: "ISC License",
160+ spdx: "ISC",
161+ description: "A permissive license functionally equivalent to MIT/BSD 2-Clause, with simpler language.",
162+ permissions: PERMISSIVE,
163+ conditions: &[KEEP_NOTICE],
164+ limitations: NO_WARRANTY,
165+};
166+
167+const MPL_2_0: LicenseInfo = LicenseInfo {
168+ name: "Mozilla Public License 2.0",
169+ spdx: "MPL-2.0",
170+ description: "A file-level copyleft license: modified files must stay open, but they can be combined with proprietary code.",
171+ permissions: PERMISSIVE_PATENT,
172+ conditions: &[DISCLOSE_SOURCE, KEEP_NOTICE, SAME_LICENSE],
173+ limitations: &[TRADEMARK, LIABILITY, WARRANTY],
174+};
175+
176+const GPL_2_0: LicenseInfo = LicenseInfo {
177+ name: "GNU General Public License v2.0",
178+ spdx: "GPL-2.0",
179+ description: "A strong copyleft license: derivatives must be released under the same terms with source available.",
180+ permissions: PERMISSIVE,
181+ conditions: &[DISCLOSE_SOURCE, KEEP_NOTICE, SAME_LICENSE, STATE_CHANGES],
182+ limitations: NO_WARRANTY,
183+};
184+
185+const GPL_3_0: LicenseInfo = LicenseInfo {
186+ name: "GNU General Public License v3.0",
187+ spdx: "GPL-3.0",
188+ description: "A strong copyleft license with a patent grant: derivatives must stay open under the same terms.",
189+ permissions: PERMISSIVE_PATENT,
190+ conditions: &[DISCLOSE_SOURCE, KEEP_NOTICE, SAME_LICENSE, STATE_CHANGES],
191+ limitations: NO_WARRANTY,
192+};
193+
194+const LGPL_3_0: LicenseInfo = LicenseInfo {
195+ name: "GNU Lesser General Public License v3.0",
196+ spdx: "LGPL-3.0",
197+ description: "A weaker copyleft: the library stays open under the same terms, but may be linked from other software.",
198+ permissions: PERMISSIVE_PATENT,
199+ conditions: &[DISCLOSE_SOURCE, KEEP_NOTICE, SAME_LICENSE, STATE_CHANGES],
200+ limitations: NO_WARRANTY,
201+};
202+
203+const AGPL_3_0: LicenseInfo = LicenseInfo {
204+ name: "GNU Affero General Public License v3.0",
205+ spdx: "AGPL-3.0",
206+ description: "Like GPLv3, but network use counts as distribution — users interacting over a network must get the source.",
207+ permissions: PERMISSIVE_PATENT,
208+ conditions: &[
209+ DISCLOSE_SOURCE,
210+ KEEP_NOTICE,
211+ NETWORK_DISCLOSE,
212+ SAME_LICENSE,
213+ STATE_CHANGES,
214+ ],
215+ limitations: NO_WARRANTY,
216+};
217+
218+const UNLICENSE: LicenseInfo = LicenseInfo {
219+ name: "The Unlicense",
220+ spdx: "Unlicense",
221+ description: "A public-domain dedication: do anything, with no conditions at all.",
222+ permissions: PERMISSIVE,
223+ conditions: &[],
224+ limitations: NO_WARRANTY,
225+};
226+
227+#[cfg(test)]
228+mod tests {
229+ use super::*;
230+
231+ #[test]
232+ fn recognizes_license_filenames() {
233+ assert!(is_license_path("LICENSE"));
234+ assert!(is_license_path("license.md"));
235+ assert!(is_license_path("path/to/COPYING"));
236+ assert!(is_license_path("LICENCE.txt"));
237+ assert!(!is_license_path("src/main.rs"));
238+ assert!(!is_license_path("readme.md"));
239+ }
240+
241+ #[test]
242+ fn detects_common_licenses() {
243+ assert_eq!(
244+ detect("Permission is hereby granted, free of charge, to any person").map(|l| l.spdx),
245+ Some("MIT")
246+ );
247+ assert_eq!(
248+ detect("Apache License\nVersion 2.0, January 2004").map(|l| l.spdx),
249+ Some("Apache-2.0")
250+ );
251+ assert_eq!(
252+ detect("GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007").map(|l| l.spdx),
253+ Some("GPL-3.0")
254+ );
255+ let zero_bsd = "BSD Zero Clause License\n\nPermission to use, copy, modify, \
256+ and/or distribute this software for any purpose with or without fee is \
257+ hereby granted.";
258+ assert_eq!(detect(zero_bsd).map(|l| l.spdx), Some("0BSD"));
259+ assert!(detect("just some random text file").is_none());
260+ }
261+}
crates/web/src/repo.rs +47 −0
@@ -621,10 +621,17 @@ async fn blob_view(
621621 attr_lang.as_deref(),
622622 source_view,
623623 );
624+ // For a license file, detect it and show a rights summary above the content.
625+ let license = (!blob.is_binary && crate::license::is_license_path(&path))
626+ .then(|| crate::license::detect(&String::from_utf8_lossy(&blob.content)))
627+ .flatten();
624628 let branches = branch_names(state, &ctx.repo.id).await;
625629 let body = html! {
626630 (repo_header(state, &ctx, Tab::Code, &rev_name, &branches))
627631 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))
632+ @if let Some(info) = license {
633+ (license_banner(info))
634+ }
628635 (content_body)
629636 };
630637 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
@@ -632,6 +639,46 @@ async fn blob_view(
632639 Ok((jar, page(&chrome, &title, body)).into_response())
633640 }
634641
642+/// The GitHub-style license summary banner: name, description, and the
643+/// permissions / conditions / limitations columns. Informational, not legal
644+/// advice.
645+fn license_banner(info: &crate::license::LicenseInfo) -> Markup {
646+ let column = |title: &str, items: &[&str], marker: Icon, class: &str| {
647+ html! {
648+ div class="license-col" {
649+ h4 { (title) }
650+ @if items.is_empty() {
651+ p class="muted license-none" { "None" }
652+ } @else {
653+ ul {
654+ @for item in items {
655+ li class=(class) { (icon(marker)) span { (item) } }
656+ }
657+ }
658+ }
659+ }
660+ }
661+ };
662+ html! {
663+ div class="card license-banner" {
664+ div class="license-head" {
665+ (icon(Icon::Scale))
666+ div {
667+ p class="muted license-eyebrow" { "This repository is licensed under the" }
668+ h3 { (info.name) " " span class="muted mono" { "(" (info.spdx) ")" } }
669+ }
670+ }
671+ p class="license-desc muted" { (info.description) }
672+ div class="license-cols" {
673+ (column("Permissions", info.permissions, Icon::Check, "ok"))
674+ (column("Conditions", info.conditions, Icon::Issue, "cond"))
675+ (column("Limitations", info.limitations, Icon::X, "no"))
676+ }
677+ p class="license-note muted" { "This is a summary, not legal advice." }
678+ }
679+ }
680+}
681+
635682 /// Render a blob: a rendered preview for markdown, highlighted code with line
636683 /// anchors, an inline image, or a binary notice.
637684 fn render_blob(
crates/web/src/tests.rs +36 −0
@@ -980,6 +980,42 @@ async fn new_repo_can_use_sha256_object_format() {
980980 }
981981
982982 #[tokio::test]
983+async fn license_file_shows_a_rights_banner() {
984+ let (state, app, _dir) = app_with_git().await;
985+ let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
986+ let repo = state
987+ .store
988+ .create_repo(NewRepo {
989+ owner_id: ada.id,
990+ group_id: None,
991+ name: "proj".to_string(),
992+ path: "proj".to_string(),
993+ description: None,
994+ visibility: model::Visibility::Public,
995+ default_branch: "main".to_string(),
996+ })
997+ .await
998+ .unwrap();
999+ let hook = state.config.storage.data_dir.join("fabrica");
1000+ git::create_bare(&state.config.storage.repo_dir, &repo.id, "main", &hook).unwrap();
1001+ let path = git::repo_path(&state.config.storage.repo_dir, &repo.id).unwrap();
1002+ let raw = git2::Repository::open_bare(&path).unwrap();
1003+ let mit = "MIT License\n\nPermission is hereby granted, free of charge, to any person \
1004+ obtaining a copy of this software.\n";
1005+ seed_commit(&raw, "main", &[], &[("LICENSE", mit)], "add license", 1);
1006+
1007+ let res = app
1008+ .oneshot(get("/ada/proj/-/blob/main/LICENSE"))
1009+ .await
1010+ .unwrap();
1011+ assert_eq!(res.status(), StatusCode::OK);
1012+ let html = body_string(res).await;
1013+ assert!(html.contains("MIT License"), "license name shown");
1014+ assert!(html.contains("Permissions"), "rights columns shown");
1015+ assert!(html.contains("license-banner"), "banner rendered");
1016+}
1017+
1018+#[tokio::test]
9831019 async fn pulls_404_when_disabled() {
9841020 let (state, app) = app().await;
9851021 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();