2 Commits
Author SHA1 Message Date
clawbot 8d55647270 Remove unsafe-inline from the Content-Security-Policy (closes #125)
check / check (push) Waiting to run
script-src and style-src now allow only 'self'. The generator page's
two inline onclick handlers, which selected the generated URL and
copied it, move into internal/static/generator.js and are attached
with addEventListener. The bundled Tailwind script, which built styles
in the browser and injected them at runtime, is replaced by a small
hand-written internal/static/style.css holding only the rules the
login and generator pages use; the templates carry a few plain class
names in place of Tailwind's. No build step. The pages keep their
layout, not every pixel of it.

Model: opus-5-5
2026-10-04 13:14:02 +00:00
clawbot 2690e151b6 Expect a Content-Security-Policy without unsafe-inline
The security headers test now expects script-src and style-src to
allow only 'self', and checks that the policy carries no
'unsafe-inline' at all. It fails until the login and generator pages
stop needing inline script and style.

Model: opus-5-5
2026-10-04 13:13:41 +00:00
13 changed files with 280 additions and 241 deletions
-4
View File
@@ -1,4 +0,0 @@
# Every PR adds an entry at the top of TODO.md's Completed Steps; union keeps
# both sides instead of conflicting. Git never reports a conflict here: read
# the merged entries after every merge or rebase.
TODO.md merge=union
+3 -2
View File
@@ -191,8 +191,9 @@ path under `/v1/` answers 200, in maintenance mode too.
- `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`, - `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`,
`uptime_seconds`, `uptime_human`, `version`, `appname` and `uptime_seconds`, `uptime_human`, `version`, `appname` and
`maintenance_mode`. Needs: nothing. Answers: 200, always. `maintenance_mode`. Needs: nothing. Answers: 200, always.
- `GET /static/<file>` — the script the login and generator pages load. Needs: - `GET /static/<file>` — the stylesheet and script the login and generator
nothing. Answers: 200, or 404 for a file that does not exist. pages load. Needs: nothing. Answers: 200, or 404 for a file that does not
exist.
- `GET /metrics` — Prometheus metrics (see Architecture). Needs: HTTP basic - `GET /metrics` — Prometheus metrics (see Architecture). Needs: HTTP basic
authentication with `metrics.username` and `metrics.password`. Answers: 200; authentication with `metrics.username` and `metrics.password`. Answers: 200;
401 without them; 404 when they are not set, as the route then does not exist. 401 without them; 404 when they are not set, as the route then does not exist.
+9 -19
View File
@@ -3,8 +3,6 @@
* branch per issue from `next` * branch per issue from `next`
* do the work in Next Step * do the work in Next Step
* move Next Step to the top of Completed Steps * move Next Step to the top of Completed Steps
* `TODO.md` merges with git's union merge (`.gitattributes`), which never
reports a conflict: read the merged entries after every merge or rebase
* move the top item of Future Steps into Next Step * move the top item of Future Steps into Next Step
* commit (`TODO.md` changes in the same commit as the work) * commit (`TODO.md` changes in the same commit as the work)
* open a PR based on `next` * open a PR based on `next`
@@ -31,23 +29,15 @@ P2: security: referer blacklist
# Completed Steps # Completed Steps
- 2026-10-04 `TODO.md` merges with git's union merge (closes #190): a root - 2026-10-04 the Content-Security-Policy allows no inline script or style
`.gitattributes`, copied from `sneak/prompts`, marks it `merge=union`, so two (closes #125): `script-src` and `style-src` are `'self'` only. The generator
branches that each add an entry at the top of Completed Steps merge without a page's two inline `onclick` handlers moved into
conflict and keep both entries. Git now never reports a conflict in `internal/static/generator.js`, attached with `addEventListener`; the bundled
`TODO.md`: a real one keeps both versions of the lines, and two entries that Tailwind script, which built styles in the browser, is replaced by a small
share an identical line can end up one inside the other, which a rebase can hand-written `internal/static/style.css` with only the rules the login and
do to an entry already on `next`. The Workflow above says to read the merged generator pages use, the templates carrying a few plain class names in place
entries after every merge or rebase. of Tailwind's. No build step. The pages keep their layout, not every pixel of
- 2026-10-04 `TestEvictionRunsOnPeriodicSchedule` no longer races the evictor it.
(closes #183): it wrote each variant file and then inserted its accounting row
by hand, and a reconciliation pass between the two adopted the file first, so
the insert failed. It now writes the files only, while holding the test
database's only connection so the evictor's startup pass waits after walking
the empty variant directory; a periodic reconciliation pass then adopts the
files and the eviction pass after it evicts them. No other test in
`internal/imgcache` inserts a row by hand after starting the evictor. Test
only.
- 2026-10-04 deployment guide and example Caddy config (closes #89): - 2026-10-04 deployment guide and example Caddy config (closes #89):
"Deployment" in `README.md` says what the reverse proxy in front of pixa must "Deployment" in `README.md` says what the reverse proxy in front of pixa must
do (terminate TLS; pass `Host`, `Origin` and `Referer` on unchanged; set do (terminate TLS; pass `Host`, `Origin` and `Referer` on unchanged; set
+15 -54
View File
@@ -681,11 +681,6 @@ func TestEvictionRunsUnderWritePressure(t *testing.T) {
assertNoDanglingReferences(t, cache) assertNoDanglingReferences(t, cache)
} }
// TestEvictionRunsOnPeriodicSchedule writes three variant files straight
// to disk, bypassing StoreVariant, so they have no accounting rows and no
// write-pressure notification fires. Only a periodic reconciliation pass
// can then adopt them, and only the eviction pass that follows it can
// evict them.
func TestEvictionRunsOnPeriodicSchedule(t *testing.T) { func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
t.Parallel() t.Parallel()
@@ -693,29 +688,13 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
cache, _ := newEvictionTestCache(t, limit) cache, _ := newEvictionTestCache(t, limit)
// Hold the test database's only connection, so the startup pass // Start the evictor while the cache is empty, then create tracked
// waits for it after walking the still empty variant directory: the // over-limit state WITHOUT going through the store methods, so no
// files written while it waits are first seen by a periodic pass. // write-pressure notification fires and only the periodic ticker
conn, err := cache.db.Conn(t.Context()) // can trigger eviction.
if err != nil {
t.Fatalf("failed to take the database connection: %v", err)
}
defer func() { _ = conn.Close() }()
cache.StartEviction(100 * time.Millisecond) cache.StartEviction(100 * time.Millisecond)
defer func() { _ = cache.StopEviction(t.Context()) }() defer func() { _ = cache.StopEviction(t.Context()) }()
deadline := time.Now().Add(5 * time.Second)
for cache.db.Stats().WaitCount == 0 {
if time.Now().After(deadline) {
t.Fatal("the startup pass never waited for the database")
}
time.Sleep(10 * time.Millisecond)
}
keys := []VariantKey{ keys := []VariantKey{
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree, testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
} }
@@ -724,43 +703,25 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
for i, key := range keys { for i, key := range keys {
content := bytes.Repeat([]byte{fills[i]}, 1000) content := bytes.Repeat([]byte{fills[i]}, 1000)
_, err = cache.variants.Store(key, bytes.NewReader(content), "image/webp") _, err := cache.variants.Store(key, bytes.NewReader(content), "image/webp")
if err != nil { if err != nil {
t.Fatalf("failed to store variant file: %v", err) t.Fatalf("failed to store variant file: %v", err)
} }
}
_ = conn.Close() _, err = cache.db.ExecContext(t.Context(),
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
// Only one of the 1000-byte files fits under the limit: wait until VALUES (?, ?, ?)`,
// the evictor has removed the other two. string(key), len(content), "image/webp",
stored := len(keys) )
deadline = time.Now().Add(5 * time.Second) if err != nil {
t.Fatalf("failed to insert variant accounting row: %v", err)
for stored > 1 && time.Now().Before(deadline) {
time.Sleep(25 * time.Millisecond)
stored = 0
for _, key := range keys {
if cache.variants.Exists(key) {
stored++
}
} }
} }
if stored > 1 { usage := waitForUsageAtOrBelow(t, cache, limit, 5*time.Second)
t.Fatalf("periodic schedule did not trigger eviction: %d of %d "+
"variant files still on disk, want at most 1", stored, len(keys))
}
usage, err := cache.UsageBytes(t.Context())
if err != nil {
t.Fatalf("UsageBytes failed: %v", err)
}
if usage > limit { if usage > limit {
t.Errorf("usage after eviction = %d, want <= %d", usage, limit) t.Errorf("periodic schedule did not trigger eviction: usage = %d, want <= %d",
usage, limit)
} }
assertNoDanglingReferences(t, cache) assertNoDanglingReferences(t, cache)
+3 -6
View File
@@ -35,13 +35,10 @@ const HSTSValue = "max-age=31536000; includeSubDomains"
// ContentSecurityPolicyValue is the Content-Security-Policy header value. // ContentSecurityPolicyValue is the Content-Security-Policy header value.
// default-src 'self' is the baseline and frame-ancestors 'none' is the primary // default-src 'self' is the baseline and frame-ancestors 'none' is the primary
// clickjacking control. 'unsafe-inline' is required in script-src and style-src // clickjacking control.
// because the served templates carry inline onclick handlers (generator page)
// and the bundled Tailwind asset injects a runtime <style> element; dropping it
// needs template changes outside this issue's scope.
const ContentSecurityPolicyValue = "default-src 'self'; " + const ContentSecurityPolicyValue = "default-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " + "script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " + "style-src 'self'; " +
"object-src 'none'; " + "object-src 'none'; " +
"base-uri 'self'; " + "base-uri 'self'; " +
"form-action 'self'; " + "form-action 'self'; " +
@@ -325,6 +325,13 @@ func TestSecurityHeaders_PolicyHeaders(t *testing.T) {
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)
// The login and generator pages load their script and stylesheet from
// /static, so the policy allows no inline script or style.
csp := rec.Header().Get("Content-Security-Policy")
if strings.Contains(csp, "unsafe-inline") {
t.Errorf("Content-Security-Policy allows unsafe-inline: %q", csp)
}
tests := []struct { tests := []struct {
header string header string
want string want string
@@ -333,8 +340,8 @@ func TestSecurityHeaders_PolicyHeaders(t *testing.T) {
{ {
"Content-Security-Policy", "Content-Security-Policy",
"default-src 'self'; " + "default-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " + "script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " + "style-src 'self'; " +
"object-src 'none'; " + "object-src 'none'; " +
"base-uri 'self'; " + "base-uri 'self'; " +
"form-action 'self'; " + "form-action 'self'; " +
+1 -1
View File
@@ -53,7 +53,7 @@ func (s *Server) SetupRoutes() {
// Robots.txt // Robots.txt
s.router.Get("/robots.txt", s.h.HandleRobotsTxt()) s.router.Get("/robots.txt", s.h.HandleRobotsTxt())
// Static files (Tailwind CSS, etc.) // The login and generator pages' stylesheet and script
s.router.Handle("/static/*", http.StripPrefix("/static/", static.Handler())) s.router.Handle("/static/*", http.StripPrefix("/static/", static.Handler()))
// Login/generator UI. The form routes carry CSRF protection; the // Login/generator UI. The form routes carry CSRF protection; the
+10
View File
@@ -0,0 +1,10 @@
// Generator page: a click on the generated URL selects it, and the Copy
// button copies it. Both are on the page only once a URL has been generated.
const generatedURL = document.getElementById("generated-url");
if (generatedURL) {
generatedURL.addEventListener("click", () => generatedURL.select());
document.getElementById("copy-url").addEventListener("click", () => {
navigator.clipboard.writeText(generatedURL.value);
});
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"net/http" "net/http"
) )
//go:embed *.js //go:embed *.css *.js
var files embed.FS var files embed.FS
// FS returns the embedded filesystem containing static files. // FS returns the embedded filesystem containing static files.
+190
View File
@@ -0,0 +1,190 @@
/* The login and generator pages. */
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: #f3f4f6;
font-family: system-ui, sans-serif;
line-height: 1.5;
}
h1 {
margin: 0;
font-size: 1.5rem;
line-height: 2rem;
font-weight: 700;
color: #1f2937;
}
label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
input,
select {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
box-shadow: 0 1px 2px rgb(0 0 0 / 5%);
font: inherit;
}
input:focus,
select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px #3b82f6;
}
button {
width: 100%;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
background: #2563eb;
color: #fff;
font: inherit;
cursor: pointer;
transition: background-color 0.15s;
}
button:hover {
background: #1d4ed8;
}
button:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
form > * + * {
margin-top: 1rem;
}
.card {
padding: 1.5rem;
border-radius: 0.5rem;
background: #fff;
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 10%),
0 2px 4px -2px rgb(0 0 0 / 10%);
}
.error {
margin-bottom: 1rem;
padding: 0.75rem 1rem;
border: 1px solid #f87171;
border-radius: 0.25rem;
background: #fee2e2;
color: #b91c1c;
}
/* Login page: the card centred on the screen. */
.login {
display: flex;
align-items: center;
justify-content: center;
}
.login .card {
width: 100%;
max-width: 28rem;
padding: 2rem;
}
.login h1 {
margin-bottom: 1.5rem;
text-align: center;
}
/* Generator page. */
.page {
max-width: 42rem;
margin: 0 auto;
padding: 2rem 1rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
header a {
font-size: 0.875rem;
color: #4b5563;
}
header a:hover {
color: #1f2937;
}
.result {
margin-bottom: 1.5rem;
padding: 1rem;
border: 1px solid #bbf7d0;
border-radius: 0.5rem;
background: #f0fdf4;
}
.result h2 {
margin: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: #166534;
}
.result div {
display: flex;
gap: 0.5rem;
}
.result input {
flex: 1;
border-color: #86efac;
box-shadow: none;
font-family: ui-monospace, monospace;
font-size: 0.875rem;
}
.result button {
width: auto;
padding: 0.5rem 0.75rem;
background: #16a34a;
font-size: 0.875rem;
}
.result button:hover {
background: #15803d;
}
.result p {
margin: 0.5rem 0 0;
font-size: 0.75rem;
color: #16a34a;
}
.columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.note {
margin-top: 1rem;
font-size: 0.75rem;
color: #6b7280;
text-align: center;
}
File diff suppressed because one or more lines are too long
+31 -57
View File
@@ -4,52 +4,47 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pixa - URL Generator</title> <title>Pixa - URL Generator</title>
<script src="/static/tailwind.js"></script> <link rel="stylesheet" href="/static/style.css">
</head> </head>
<body class="bg-gray-100 min-h-screen"> <body>
<div class="max-w-2xl mx-auto py-8 px-4"> <div class="page">
<div class="flex justify-between items-center mb-8"> <header>
<h1 class="text-2xl font-bold text-gray-800">Pixa URL Generator</h1> <h1>Pixa URL Generator</h1>
<a href="/logout" class="text-sm text-gray-600 hover:text-gray-800 underline"> <a href="/logout">
Logout Logout
</a> </a>
</div> </header>
{{if .GeneratedURL}} {{if .GeneratedURL}}
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-6"> <div class="result">
<h2 class="text-sm font-medium text-green-800 mb-2">Generated URL</h2> <h2>Generated URL</h2>
<div class="flex gap-2"> <div>
<input <input
type="text" type="text"
readonly readonly
value="{{.GeneratedURL}}" value="{{.GeneratedURL}}"
id="generated-url" id="generated-url"
class="flex-1 px-3 py-2 bg-white border border-green-300 rounded-md text-sm font-mono"
onclick="this.select()"
>
<button
onclick="navigator.clipboard.writeText(document.getElementById('generated-url').value)"
class="px-3 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
> >
<button id="copy-url">
Copy Copy
</button> </button>
</div> </div>
<p class="text-xs text-green-600 mt-2"> <p>
Expires: {{.ExpiresAt}} Expires: {{.ExpiresAt}}
</p> </p>
</div> </div>
{{end}} {{end}}
{{if .Error}} {{if .Error}}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6"> <div class="error">
{{.Error}} {{.Error}}
</div> </div>
{{end}} {{end}}
<form method="POST" action="/generate" class="bg-white rounded-lg shadow-md p-6 space-y-4"> <form method="POST" action="/generate" class="card">
{{ .CSRFField }} {{ .CSRFField }}
<div> <div>
<label for="url" class="block text-sm font-medium text-gray-700 mb-1"> <label for="url">
Source URL Source URL
</label> </label>
<input <input
@@ -59,13 +54,12 @@
required required
placeholder="https://example.com/image.jpg" placeholder="https://example.com/image.jpg"
value="{{.FormURL}}" value="{{.FormURL}}"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
> >
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="columns">
<div> <div>
<label for="width" class="block text-sm font-medium text-gray-700 mb-1"> <label for="width">
Width Width
</label> </label>
<input <input
@@ -76,11 +70,10 @@
max="8192" max="8192"
value="{{if .FormWidth}}{{.FormWidth}}{{else}}0{{end}}" value="{{if .FormWidth}}{{.FormWidth}}{{else}}0{{end}}"
placeholder="0 = original" placeholder="0 = original"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
> >
</div> </div>
<div> <div>
<label for="height" class="block text-sm font-medium text-gray-700 mb-1"> <label for="height">
Height Height
</label> </label>
<input <input
@@ -91,21 +84,16 @@
max="8192" max="8192"
value="{{if .FormHeight}}{{.FormHeight}}{{else}}0{{end}}" value="{{if .FormHeight}}{{.FormHeight}}{{else}}0{{end}}"
placeholder="0 = original" placeholder="0 = original"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
> >
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="columns">
<div> <div>
<label for="format" class="block text-sm font-medium text-gray-700 mb-1"> <label for="format">
Format Format
</label> </label>
<select <select id="format" name="format">
id="format"
name="format"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="orig" {{if eq .FormFormat "orig"}}selected{{end}}>Original</option> <option value="orig" {{if eq .FormFormat "orig"}}selected{{end}}>Original</option>
<option value="jpeg" {{if eq .FormFormat "jpeg"}}selected{{end}}>JPEG</option> <option value="jpeg" {{if eq .FormFormat "jpeg"}}selected{{end}}>JPEG</option>
<option value="png" {{if eq .FormFormat "png"}}selected{{end}}>PNG</option> <option value="png" {{if eq .FormFormat "png"}}selected{{end}}>PNG</option>
@@ -115,14 +103,10 @@
</select> </select>
</div> </div>
<div> <div>
<label for="quality" class="block text-sm font-medium text-gray-700 mb-1"> <label for="quality">
Quality Quality
</label> </label>
<select <select id="quality" name="quality">
id="quality"
name="quality"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="25" {{if eq .FormQuality "25"}}selected{{end}}>Potato</option> <option value="25" {{if eq .FormQuality "25"}}selected{{end}}>Potato</option>
<option value="50" {{if eq .FormQuality "50"}}selected{{end}}>Low</option> <option value="50" {{if eq .FormQuality "50"}}selected{{end}}>Low</option>
<option value="70" {{if eq .FormQuality "70"}}selected{{end}}>Medium</option> <option value="70" {{if eq .FormQuality "70"}}selected{{end}}>Medium</option>
@@ -132,16 +116,12 @@
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="columns">
<div> <div>
<label for="fit" class="block text-sm font-medium text-gray-700 mb-1"> <label for="fit">
Fit Mode Fit Mode
</label> </label>
<select <select id="fit" name="fit">
id="fit"
name="fit"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="cover" {{if eq .FormFit "cover"}}selected{{end}}>Cover</option> <option value="cover" {{if eq .FormFit "cover"}}selected{{end}}>Cover</option>
<option value="contain" {{if eq .FormFit "contain"}}selected{{end}}>Contain</option> <option value="contain" {{if eq .FormFit "contain"}}selected{{end}}>Contain</option>
<option value="fill" {{if eq .FormFit "fill"}}selected{{end}}>Fill</option> <option value="fill" {{if eq .FormFit "fill"}}selected{{end}}>Fill</option>
@@ -150,14 +130,10 @@
</select> </select>
</div> </div>
<div> <div>
<label for="ttl" class="block text-sm font-medium text-gray-700 mb-1"> <label for="ttl">
Expires In Expires In
</label> </label>
<select <select id="ttl" name="ttl">
id="ttl"
name="ttl"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="0" {{if or (eq .FormTTL "0") (eq .FormTTL "")}}selected{{end}}>Never</option> <option value="0" {{if or (eq .FormTTL "0") (eq .FormTTL "")}}selected{{end}}>Never</option>
<option value="60" {{if eq .FormTTL "60"}}selected{{end}}>1 minute</option> <option value="60" {{if eq .FormTTL "60"}}selected{{end}}>1 minute</option>
<option value="3600" {{if eq .FormTTL "3600"}}selected{{end}}>1 hour</option> <option value="3600" {{if eq .FormTTL "3600"}}selected{{end}}>1 hour</option>
@@ -169,17 +145,15 @@
</div> </div>
</div> </div>
<button <button type="submit">
type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Generate Encrypted URL Generate Encrypted URL
</button> </button>
</form> </form>
<p class="text-xs text-gray-500 mt-4 text-center"> <p class="note">
Generated URLs are encrypted and cannot be modified. They will expire at the specified time. Generated URLs are encrypted and cannot be modified. They will expire at the specified time.
</p> </p>
</div> </div>
<script src="/static/generator.js"></script>
</body> </body>
</html> </html>
+8 -12
View File
@@ -4,22 +4,22 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pixa - Login</title> <title>Pixa - Login</title>
<script src="/static/tailwind.js"></script> <link rel="stylesheet" href="/static/style.css">
</head> </head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center"> <body class="login">
<div class="bg-white p-8 rounded-lg shadow-md w-full max-w-md"> <div class="card">
<h1 class="text-2xl font-bold text-gray-800 mb-6 text-center">Pixa Image Proxy</h1> <h1>Pixa Image Proxy</h1>
{{if .Error}} {{if .Error}}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"> <div class="error">
{{.Error}} {{.Error}}
</div> </div>
{{end}} {{end}}
<form method="POST" action="/" class="space-y-4"> <form method="POST" action="/">
{{ .CSRFField }} {{ .CSRFField }}
<div> <div>
<label for="key" class="block text-sm font-medium text-gray-700 mb-1"> <label for="key">
Signing Key Signing Key
</label> </label>
<input <input
@@ -28,15 +28,11 @@
name="key" name="key"
required required
autocomplete="current-password" autocomplete="current-password"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Enter your signing key" placeholder="Enter your signing key"
> >
</div> </div>
<button <button type="submit">
type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Login Login
</button> </button>
</form> </form>