7ef89b5d2b2dd1670aee6f9e24f10714dfd5b3cf
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/pkg/web/homepage_test.go b/pkg/web/homepage_test.go
2index 793cbb8c5a75c1979325a8acd10a710b9689b085..fccc52bf2f5255d7951cecde8d33c46b062d7bd2 100644
3--- a/pkg/web/homepage_test.go
4+++ b/pkg/web/homepage_test.go
5@@ -54,7 +54,7 @@ func TestHomepageListsOnlyPublicVisibleRepositoriesAndProfile(t *testing.T) {
6 t.Errorf("homepage contains %q:\n%s", unexpected, body)
7 }
8 }
9- if got := w.Header().Get("Content-Security-Policy"); got != "default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" {
10+ if got := w.Header().Get("Content-Security-Policy"); got != "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" {
11 t.Errorf("Content-Security-Policy = %q", got)
12 }
13 if got := w.Header().Get("X-Content-Type-Options"); got != "nosniff" {
14diff --git a/pkg/web/pages/assets/site.css b/pkg/web/pages/assets/site.css
15index 491b93507d6a74f18eebeae38db6556c999b0eca..48609703a9d2d2cab8b8e727f4ea35cb49be118f 100644
16--- a/pkg/web/pages/assets/site.css
17+++ b/pkg/web/pages/assets/site.css
18@@ -187,23 +187,25 @@ code { color: #d5c4a1; font-size: 0.92em; }
19
20 .repository-meta { align-items: center; display: flex; flex-wrap: wrap; gap: 0.7rem; margin-top: 1.1rem; }
21 .repository-tabs { border-bottom: 1px solid var(--border); display: flex; flex-wrap: wrap; gap: 0.35rem; margin-bottom: 1.5rem; padding-bottom: 0.6rem; }
22-.repository-tabs a { border: 1px solid transparent; border-radius: 0.4rem; color: var(--muted); font-weight: 650; padding: 0.5rem 0.65rem; text-decoration: none; }
23-.repository-tabs a:hover { color: var(--text); }
24-
25-.clone-grid { display: grid; gap: 1rem; }
26-.clone-control { color: var(--muted); display: grid; font-size: 0.82rem; font-weight: 700; gap: 0.4rem; }
27-.clone-url {
28- background: var(--bg-hard);
29- border: 1px solid var(--border);
30+.repository-tabs a,
31+.repository-tab-button {
32+ align-items: center;
33+ background: transparent;
34+ border: 1px solid transparent;
35 border-radius: 0.4rem;
36- color: var(--text);
37- display: block;
38- font-size: 0.9rem;
39- min-height: 2.5rem;
40+ color: var(--muted);
41+ cursor: pointer;
42+ display: inline-flex;
43+ font: inherit;
44+ font-weight: 650;
45+ gap: 0.45rem;
46 padding: 0.5rem 0.65rem;
47- width: 100%;
48+ text-decoration: none;
49 }
50-.clone-url:focus-visible { border-color: var(--aqua); }
51+.repository-tabs a:hover,
52+.repository-tab-button:hover { color: var(--text); }
53+.repository-tab-button { margin-left: auto; }
54+.sr-only { height: 1px; margin: -1px; overflow: hidden; position: absolute; width: 1px; clip: rect(0, 0, 0, 0); white-space: nowrap; }
55
56 .breadcrumb { align-items: center; color: var(--muted); display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.25rem; overflow-wrap: anywhere; }
57 .toolbar { margin: 0 0 1rem; }
58@@ -278,13 +280,13 @@ tbody tr:hover { background: rgba(80, 73, 69, 0.5); }
59
60 @media (min-width: 48rem) {
61 .repository-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
62- .clone-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
63 }
64
65 @media (max-width: 35rem) {
66 .page-shell { padding-left: 1rem; padding-right: 1rem; padding-top: 1.5rem; }
67 .section-heading, .file-header { align-items: flex-start; flex-direction: column; }
68 .repository-tabs { display: grid; grid-template-columns: 1fr; }
69- .repository-tabs a { justify-content: flex-start; }
70+ .repository-tabs a,
71+ .repository-tab-button { justify-content: flex-start; margin-left: 0; }
72 .pagination { align-items: stretch; flex-direction: column; }
73 }
74diff --git a/pkg/web/pages/assets/site.js b/pkg/web/pages/assets/site.js
75new file mode 100644
76index 0000000000000000000000000000000000000000..a56d390d04955c89fa9893c86ed6dea5ce320499
77--- /dev/null
78+++ b/pkg/web/pages/assets/site.js
79@@ -0,0 +1,30 @@
80+async function copyText(text) {
81+ if (navigator.clipboard) {
82+ await navigator.clipboard.writeText(text);
83+ return;
84+ }
85+
86+ const input = document.createElement("textarea");
87+ input.value = text;
88+ input.style.position = "fixed";
89+ input.style.opacity = "0";
90+ document.body.append(input);
91+ input.select();
92+ const copied = document.execCommand("copy");
93+ input.remove();
94+ if (!copied) {
95+ throw new Error("clipboard copy failed");
96+ }
97+}
98+
99+for (const button of document.querySelectorAll("[data-copy]")) {
100+ button.addEventListener("click", async () => {
101+ const status = document.getElementById(button.getAttribute("aria-describedby"));
102+ try {
103+ await copyText(button.dataset.copy);
104+ status.textContent = "HTTP clone URL copied";
105+ } catch {
106+ status.textContent = "Could not copy the clone URL";
107+ }
108+ });
109+}
110diff --git a/pkg/web/pages/browser.go b/pkg/web/pages/browser.go
111index 64d8c6666227c88675959a9a5df293def42cfbfd..4049ca3d2d49bd8212a590493d09101877b17afd 100644
112--- a/pkg/web/pages/browser.go
113+++ b/pkg/web/pages/browser.go
114@@ -23,7 +23,6 @@ type repositoryPage struct {
115 Title string
116 Description string
117 Ref string
118- SSHCloneURL string
119 HTTPCloneURL string
120 TreeURL string
121 CommitsURL string
122@@ -91,7 +90,6 @@ func repositoryOverview(w http.ResponseWriter, r *http.Request) {
123 Title: title,
124 Description: repo.Description(),
125 Empty: empty,
126- SSHCloneURL: common.RepoURL(cfg.SSH.PublicURL, repo.Name()),
127 HTTPCloneURL: common.RepoURL(cfg.HTTP.PublicURL, repo.Name()),
128 BuildStatus: status[repo.Name()],
129 }
130diff --git a/pkg/web/pages/browser_test.go b/pkg/web/pages/browser_test.go
131index 7a6941211f488c8c27861279d0a162f82514ec16..6b6fed2d7a1a276e0ac9b3c709c9df5657a601ae 100644
132--- a/pkg/web/pages/browser_test.go
133+++ b/pkg/web/pages/browser_test.go
134@@ -34,7 +34,7 @@ func TestDocumentAndCloneStyles(t *testing.T) {
135 }
136 for _, expected := range []string{
137 ".markdown h1,", ".markdown blockquote", ".markdown pre", ".markdown table", ".markdown hr", ".markdown a", ".markdown img", "height: auto;", "max-width: 100%;",
138- ".clone-url", "font-family: ui-monospace", "min-height: 2.5rem;", "width: 100%;", ".clone-url:focus-visible",
139+ ".repository-tab-button", "margin-left: auto;", ".sr-only",
140 } {
141 if !strings.Contains(string(css), expected) {
142 t.Errorf("stylesheet missing %q", expected)
143@@ -42,12 +42,12 @@ func TestDocumentAndCloneStyles(t *testing.T) {
144 }
145
146 var rendered bytes.Buffer
147- if err := pageTemplates.ExecuteTemplate(&rendered, "repository", repositoryPage{SSHCloneURL: "git@example.test:team/project.git", HTTPCloneURL: "https://example.test/team/project.git"}); err != nil {
148+ if err := pageTemplates.ExecuteTemplate(&rendered, "repository", repositoryPage{HTTPCloneURL: "https://example.test/team/project.git"}); err != nil {
149 t.Fatal(err)
150 }
151 body := rendered.String()
152- if strings.Count(body, `class="clone-url"`) != 2 || !strings.Contains(body, `aria-label="SSH clone URL"`) || !strings.Contains(body, `aria-label="HTTP clone URL"`) || strings.Count(body, "readonly") != 2 {
153- t.Fatalf("clone controls are not styled, labeled readonly inputs: %s", body)
154+ if !strings.Contains(body, `data-copy="https://example.test/team/project.git"`) || !strings.Contains(body, `aria-label="Copy HTTP clone URL"`) || !strings.Contains(body, `site.js`) || strings.Contains(body, "SSH clone") {
155+ t.Fatalf("clone control is not a HTTP copy button: %s", body)
156 }
157 }
158
159diff --git a/pkg/web/pages/controller.go b/pkg/web/pages/controller.go
160index 0a9458456dd8040877882d1c1209c68fb5b31ff1..2eafa46cf300aef9cd519ce8ede2b0bf288e64f2 100644
161--- a/pkg/web/pages/controller.go
162+++ b/pkg/web/pages/controller.go
163@@ -26,6 +26,7 @@ func Controller(_ context.Context, r *mux.Router) {
164 // overview route must remain last so nested repository names are unambiguous.
165 r.HandleFunc("/", home).Methods(http.MethodGet)
166 r.HandleFunc("/@/assets/site.css", stylesheet).Methods(http.MethodGet)
167+ r.HandleFunc("/@/assets/site.js", script).Methods(http.MethodGet)
168 r.HandleFunc("/{repo:.*}/@/tree", treeBrowser).Methods(http.MethodGet)
169 r.HandleFunc("/{repo:.*}/@/raw", rawFile).Methods(http.MethodGet)
170 r.HandleFunc("/{repo:.*}/@/commits", commits).Methods(http.MethodGet)
171@@ -132,14 +133,22 @@ func loadProfile(ctx context.Context) template.HTML {
172 }
173
174 func stylesheet(w http.ResponseWriter, r *http.Request) {
175+ serveAsset(w, "assets/site.css", "text/css; charset=utf-8")
176+}
177+
178+func script(w http.ResponseWriter, r *http.Request) {
179+ serveAsset(w, "assets/site.js", "text/javascript; charset=utf-8")
180+}
181+
182+func serveAsset(w http.ResponseWriter, name, contentType string) {
183 SetContentSafetyHeaders(w)
184- css, err := fs.ReadFile(files, "assets/site.css")
185+ asset, err := fs.ReadFile(files, name)
186 if err != nil {
187- http.Error(w, "embedded stylesheet is unavailable", http.StatusInternalServerError)
188+ http.Error(w, "embedded asset is unavailable", http.StatusInternalServerError)
189 return
190 }
191
192- w.Header().Set("Content-Type", "text/css; charset=utf-8")
193+ w.Header().Set("Content-Type", contentType)
194 w.Header().Set("Cache-Control", "public, max-age=31536000")
195- _, _ = w.Write(css)
196+ _, _ = w.Write(asset)
197 }
198diff --git a/pkg/web/pages/render.go b/pkg/web/pages/render.go
199index d984c35db7fd261a1cd508e204377158b2cf7f90..445f249c2ad86a5c6ace63df00beb80cfb7902ab 100644
200--- a/pkg/web/pages/render.go
201+++ b/pkg/web/pages/render.go
202@@ -24,7 +24,7 @@ func SetContentSafetyHeaders(w http.ResponseWriter) {
203 // SetSecurityHeaders applies the common security policy for dynamic page responses.
204 func SetSecurityHeaders(w http.ResponseWriter) {
205 SetContentSafetyHeaders(w)
206- w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
207+ w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
208 }
209
210 // RenderMarkdown renders Markdown with raw HTML disabled and sanitizes the result.
211diff --git a/pkg/web/pages/templates/icons.gohtml b/pkg/web/pages/templates/icons.gohtml
212index 8147f63351502a640aa498fbd016843a4c26712f..a58211800b925888ea2c41679f4b16cd88a2a22e 100644
213--- a/pkg/web/pages/templates/icons.gohtml
214+++ b/pkg/web/pages/templates/icons.gohtml
215@@ -5,6 +5,7 @@
216 {{ define "icon-history" }}<svg class="icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 3v6h6"/><path d="M12 7v5l3 2"/></svg>{{ end }}
217 {{ define "icon-branch" }}<svg class="icon" aria-hidden="true" viewBox="0 0 24 24"><circle cx="6" cy="6" r="2"/><circle cx="18" cy="18" r="2"/><path d="M6 8v8a2 2 0 0 0 2 2h8"/><path d="M18 16V8a2 2 0 0 0-2-2H8"/></svg>{{ end }}
218 {{ define "icon-download" }}<svg class="icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/></svg>{{ end }}
219+{{ define "icon-copy" }}<svg class="icon" aria-hidden="true" viewBox="0 0 24 24"><rect width="13" height="13" x="8" y="8" rx="2" ry="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg>{{ end }}
220 {{ define "icon-arrow-left" }}<svg class="icon" aria-hidden="true" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></svg>{{ end }}
221 {{ define "icon-arrow-right" }}<svg class="icon" aria-hidden="true" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></svg>{{ end }}
222 {{ define "icon-file" }}<svg class="icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>{{ end }}
223diff --git a/pkg/web/pages/templates/repository.gohtml b/pkg/web/pages/templates/repository.gohtml
224index 2f03404e2d4070e6ad6a5dfadcdcc2e5f329d25d..d7ebe15b7b065bba04f6b8581e6cfbc83fb1b113 100644
225--- a/pkg/web/pages/templates/repository.gohtml
226+++ b/pkg/web/pages/templates/repository.gohtml
227@@ -5,6 +5,7 @@
228 <meta name="viewport" content="width=device-width, initial-scale=1">
229 <title>{{ .Title }} · {{ .ServerName }}</title>
230 <link rel="stylesheet" href="/@/assets/site.css">
231+ <script src="/@/assets/site.js" defer></script>
232 </head>
233 <body>
234 {{ template "site-header" . }}
235@@ -24,16 +25,11 @@
236 <a href="{{ .TreeURL }}">{{ template "icon-folder" }}<span>Files</span></a>
237 <a href="{{ .CommitsURL }}">{{ template "icon-history" }}<span>Commits</span></a>
238 <a href="{{ .RefsURL }}">{{ template "icon-branch" }}<span>Branches and tags</span></a>
239+ <button class="repository-tab-button" type="button" data-copy="{{ .HTTPCloneURL }}" aria-label="Copy HTTP clone URL" aria-describedby="clone-copy-status" title="Copy HTTP clone URL">{{ template "icon-copy" }}<span>Copy URL</span></button>
240 </nav>
241+ <span id="clone-copy-status" class="sr-only" aria-live="polite"></span>
242 {{ end }}
243- <section class="panel clone-panel" aria-labelledby="clone-heading">
244- <div class="section-heading"><div><p class="eyebrow">Get started</p><h2 id="clone-heading">Clone</h2></div></div>
245- <div class="clone-grid">
246- <label class="clone-control"><span>SSH</span><input class="clone-url" readonly value="{{ .SSHCloneURL }}" aria-label="SSH clone URL"></label>
247- <label class="clone-control"><span>HTTP</span><input class="clone-url" readonly value="{{ .HTTPCloneURL }}" aria-label="HTTP clone URL"></label>
248- </div>
249- </section>
250diff --git a/pkg/web/repository_browser_test.go b/pkg/web/repository_browser_test.go
251index fa62121211f54e00b24a64b59d8d4b34addaf5da..183347824d1b51fdb449b906349cc778a04afc3b 100644
252--- a/pkg/web/repository_browser_test.go
253+++ b/pkg/web/repository_browser_test.go
254@@ -89,7 +89,7 @@ func TestRepositoryBrowserOverviewAndTreeRoutes(t *testing.T) {
255 if overview.Code != http.StatusOK {
256 t.Fatalf("overview status = %d: %s", overview.Code, overview.Body.String())
257 }
258- for _, expected := range []string{"Nested project", "git@ssh.example:team/nested.git", "https://git.example/team/nested.git", "/team/nested/@/tree", "/team/nested/@/raw?path=images%2Ficon.png"} {
259+ for _, expected := range []string{"Nested project", "https://git.example/team/nested.git", "Copy HTTP clone URL", "/team/nested/@/tree", "/team/nested/@/raw?path=images%2Ficon.png"} {
260 if !strings.Contains(overview.Body.String(), expected) {
261 t.Errorf("overview missing %q: %s", expected, overview.Body.String())
262 }
263diff --git a/pkg/web/server_test.go b/pkg/web/server_test.go
264index 4c303d904d59741468abea6d6850e35cc1567bdd..6f8dee3fe3ab7afd0f00086865250c24f4049821 100644
265--- a/pkg/web/server_test.go
266+++ b/pkg/web/server_test.go
267@@ -38,6 +38,15 @@ func TestRouterWebUIShellIsOptIn(t *testing.T) {
268 is.Equal(w.Header().Get("X-Content-Type-Options"), "nosniff")
269 is.Equal(w.Header().Get("Referrer-Policy"), "same-origin")
270 is.True(strings.Contains(w.Body.String(), "font-family"))
271+
272+ w = httptest.NewRecorder()
273+ router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/@/assets/site.js", nil))
274+ is.Equal(w.Code, http.StatusOK)
275+ is.Equal(w.Header().Get("Content-Type"), "text/javascript; charset=utf-8")
276+ is.Equal(w.Header().Get("Cache-Control"), "public, max-age=31536000")
277+ is.Equal(w.Header().Get("X-Content-Type-Options"), "nosniff")
278+ is.Equal(w.Header().Get("Referrer-Policy"), "same-origin")
279+ is.True(strings.Contains(w.Body.String(), "navigator.clipboard"))
280 }
281
282 func TestRouterGoGetAndGitRoutesPrecedePages(t *testing.T) {