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 {
595 flex: none;595 flex: none;
596}596}
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
598/* Pagination controls for long lists. */669/* Pagination controls for long lists. */
599.pagination {670.pagination {
600 display: flex;671 display: flex;
crates/web/src/icons.rs +8 −0
@@ -34,6 +34,9 @@ pub enum Icon {
34 Compass,34 Compass,
35 MapPin,35 MapPin,
36 Mail,36 Mail,
37 Check,
38 X,
39 Scale,
37 Globe,40 Globe,
38 Download,41 Download,
39 Github,42 Github,
@@ -104,6 +107,11 @@ impl Icon {
104 Icon::Mail => {107 Icon::Mail => {
105 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"/>"#108 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"/>"#
106 }109 }
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 }
107 Icon::Globe => {115 Icon::Globe => {
108 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"/>"#116 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"/>"#
109 }117 }
crates/web/src/lib.rs +1 −0
@@ -19,6 +19,7 @@ mod git_http;
19mod icons;19mod icons;
20mod issues;20mod issues;
21mod layout;21mod layout;
22mod license;
22mod markdown;23mod markdown;
23mod pages;24mod pages;
24mod pulls;25mod 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)]
16pub(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).
33pub(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).
44pub(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) ----
95const COMMERCIAL: &str = "Commercial use";
96const MODIFY: &str = "Modification";
97const DISTRIBUTE: &str = "Distribution";
98const PRIVATE: &str = "Private use";
99const PATENT: &str = "Patent use";
100const LIABILITY: &str = "Liability";
101const WARRANTY: &str = "Warranty";
102const TRADEMARK: &str = "Trademark use";
103const KEEP_NOTICE: &str = "License and copyright notice";
104const STATE_CHANGES: &str = "State changes";
105const DISCLOSE_SOURCE: &str = "Disclose source";
106const SAME_LICENSE: &str = "Same license";
107const NETWORK_DISCLOSE: &str = "Network use is distribution";
108
109const PERMISSIVE: &[&str] = &[COMMERCIAL, MODIFY, DISTRIBUTE, PRIVATE];
110const PERMISSIVE_PATENT: &[&str] = &[COMMERCIAL, MODIFY, DISTRIBUTE, PATENT, PRIVATE];
111const NO_WARRANTY: &[&str] = &[LIABILITY, WARRANTY];
112
113const 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
122const 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
131const 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
140const 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
149const 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
158const 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
167const 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
176const 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
185const 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
194const 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
203const 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
218const 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)]
228mod 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(
621 attr_lang.as_deref(),621 attr_lang.as_deref(),
622 source_view,622 source_view,
623 );623 );
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();
624 let branches = branch_names(state, &ctx.repo.id).await;628 let branches = branch_names(state, &ctx.repo.id).await;
625 let body = html! {629 let body = html! {
626 (repo_header(state, &ctx, Tab::Code, &rev_name, &branches))630 (repo_header(state, &ctx, Tab::Code, &rev_name, &branches))
627 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))631 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))
632 @if let Some(info) = license {
633 (license_banner(info))
634 }
628 (content_body)635 (content_body)
629 };636 };
630 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());637 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
@@ -632,6 +639,46 @@ async fn blob_view(
632 Ok((jar, page(&chrome, &title, body)).into_response())639 Ok((jar, page(&chrome, &title, body)).into_response())
633}640}
634641
642/// The GitHub-style license summary banner: name, description, and the
643/// permissions / conditions / limitations columns. Informational, not legal
644/// advice.
645fn 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
635/// Render a blob: a rendered preview for markdown, highlighted code with line682/// Render a blob: a rendered preview for markdown, highlighted code with line
636/// anchors, an inline image, or a binary notice.683/// anchors, an inline image, or a binary notice.
637fn render_blob(684fn render_blob(
crates/web/src/tests.rs +36 −0
@@ -980,6 +980,42 @@ async fn new_repo_can_use_sha256_object_format() {
980}980}
981981
982#[tokio::test]982#[tokio::test]
983async 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]
983async fn pulls_404_when_disabled() {1019async fn pulls_404_when_disabled() {
984 let (state, app) = app().await;1020 let (state, app) = app().await;
985 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();1021 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();