Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c29b68e232 | ||
|
|
52db3cf0c5 | ||
|
|
aace8e78b3 | ||
|
|
9cddc4447e | ||
|
|
f8c437b83f | ||
|
|
04093f53ad |
@@ -0,0 +1,4 @@
|
||||
# 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
|
||||
@@ -191,9 +191,8 @@ path under `/v1/` answers 200, in maintenance mode too.
|
||||
- `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`,
|
||||
`uptime_seconds`, `uptime_human`, `version`, `appname` and
|
||||
`maintenance_mode`. Needs: nothing. Answers: 200, always.
|
||||
- `GET /static/<file>` — the stylesheet and script the login and generator
|
||||
pages load. Needs: nothing. Answers: 200, or 404 for a file that does not
|
||||
exist.
|
||||
- `GET /static/<file>` — the script the login and generator pages load. Needs:
|
||||
nothing. Answers: 200, or 404 for a file that does not exist.
|
||||
- `GET /metrics` — Prometheus metrics (see Architecture). Needs: HTTP basic
|
||||
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.
|
||||
@@ -414,10 +413,10 @@ pixa finds: `/etc/pixa/config.yml`, `/etc/pixa/config.yaml`,
|
||||
`~/.config/pixa/config.yml`, `~/.config/pixa/config.yaml`, then `config.yml`
|
||||
and `config.yaml` in the working directory. A named file that does not exist,
|
||||
cannot be read or does not parse aborts startup. Of the files pixa looks for on
|
||||
its own, one it finds but cannot read or parse aborts startup; one it cannot
|
||||
find, for any reason, is passed over without a message, even when the file is
|
||||
there in a directory pixa may not enter. With no file, pixa uses the environment
|
||||
and the defaults.
|
||||
its own, only one that does not exist is passed over, without a message. One
|
||||
that pixa cannot read or parse aborts startup, naming the file. So does one in a
|
||||
directory pixa may not enter, whether or not it is there, since pixa cannot
|
||||
tell. With no file, pixa uses the environment and the defaults.
|
||||
|
||||
| Variable | Config key | Meaning |
|
||||
| ------------------------------------ | ------------------------------- | ---------------------------------------------------------------------------- |
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* branch per issue from `next`
|
||||
* do the work in Next Step
|
||||
* 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
|
||||
* commit (`TODO.md` changes in the same commit as the work)
|
||||
* open a PR based on `next`
|
||||
@@ -29,15 +31,28 @@ P2: security: referer blacklist
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-04 the Content-Security-Policy allows no inline script or style
|
||||
(closes #125): `script-src` and `style-src` are `'self'` only. The generator
|
||||
page's two inline `onclick` handlers moved into
|
||||
`internal/static/generator.js`, attached with `addEventListener`; the bundled
|
||||
Tailwind script, which built styles in the browser, is replaced by a small
|
||||
hand-written `internal/static/style.css` with only the rules the login and
|
||||
generator pages use, the templates carrying a few plain class names in place
|
||||
of Tailwind's. No build step. The pages keep their layout, not every pixel of
|
||||
it.
|
||||
- 2026-10-04 `TODO.md` merges with git's union merge (closes #190): a root
|
||||
`.gitattributes`, copied from `sneak/prompts`, marks it `merge=union`, so two
|
||||
branches that each add an entry at the top of Completed Steps merge without a
|
||||
conflict and keep both entries. Git now never reports a conflict in
|
||||
`TODO.md`: a real one keeps both versions of the lines, and two entries that
|
||||
share an identical line can end up one inside the other, which a rebase can
|
||||
do to an entry already on `next`. The Workflow above says to read the merged
|
||||
entries after every merge or rebase.
|
||||
- 2026-10-04 `TestEvictionRunsOnPeriodicSchedule` no longer races the evictor
|
||||
(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 a config file pixa cannot read aborts startup (closes #176): of the
|
||||
places pixa looks for its config file on its own, only one where the file does
|
||||
not exist is passed over; any other error, such as a directory on the path
|
||||
that pixa may not enter, aborts startup naming the file, as a file that does
|
||||
not parse already did.
|
||||
- 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
|
||||
do (terminate TLS; pass `Host`, `Origin` and `Referer` on unchanged; set
|
||||
|
||||
+21
-11
@@ -4,6 +4,7 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/netip"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/smartconfig"
|
||||
@@ -778,19 +780,27 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
|
||||
for _, path := range configPaths {
|
||||
cleanPath := filepath.Clean(path)
|
||||
|
||||
// Only a config file that does not exist is skipped, including
|
||||
// one whose path runs through a file, such as under a HOME of
|
||||
// /dev/null. One that cannot be read or does not parse is a
|
||||
// fatal startup error.
|
||||
_, statErr := os.Stat(cleanPath)
|
||||
if statErr == nil {
|
||||
// A config file that exists but does not parse is a fatal
|
||||
// startup error, never something to skip over.
|
||||
sc, err := smartconfig.NewFromConfigPath(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
|
||||
}
|
||||
|
||||
log.Info("loaded config file", "path", path)
|
||||
|
||||
return sc, nil
|
||||
if errors.Is(statErr, fs.ErrNotExist) || errors.Is(statErr, syscall.ENOTDIR) {
|
||||
continue
|
||||
}
|
||||
|
||||
if statErr != nil {
|
||||
return nil, fmt.Errorf("failed to read config file %s: %w", path, statErr)
|
||||
}
|
||||
|
||||
sc, err := smartconfig.NewFromConfigPath(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
|
||||
}
|
||||
|
||||
log.Info("loaded config file", "path", path)
|
||||
|
||||
return sc, nil
|
||||
}
|
||||
|
||||
return nil, nil //nolint:nilnil // nil config is valid (use defaults)
|
||||
|
||||
@@ -564,6 +564,116 @@ func TestMalformedConfigFileAbortsStartup(t *testing.T) {
|
||||
t.Logf("got expected error: %v", err)
|
||||
}
|
||||
|
||||
// TestConfigFileInDirectoryPixaMayNotEnterAbortsStartup checks that a
|
||||
// config file pixa cannot read because it may not enter its directory
|
||||
// aborts startup instead of being passed over.
|
||||
func TestConfigFileInDirectoryPixaMayNotEnterAbortsStartup(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root may enter any directory")
|
||||
}
|
||||
|
||||
home := t.TempDir()
|
||||
configDir := filepath.Join(home, ".config", "pixa-test-nonexistent-app")
|
||||
configPath := filepath.Join(configDir, "config.yml")
|
||||
|
||||
err := os.MkdirAll(configDir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create config directory: %v", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(configPath, []byte(signingKeyLine), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
err = os.Chmod(configDir, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to remove the config directory's permissions: %v", err)
|
||||
}
|
||||
|
||||
// Give the directory back its permissions so t.TempDir can remove it.
|
||||
t.Cleanup(func() {
|
||||
//nolint:gosec // G302: a directory needs its execute bit to be removed
|
||||
_ = os.Chmod(configDir, 0o700)
|
||||
})
|
||||
|
||||
// The ~/.config candidate is the only one that exists: the appname
|
||||
// rules out /etc, and the working directory is empty.
|
||||
t.Setenv("PIXA_CONFIG_PATH", "")
|
||||
t.Setenv("HOME", home)
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
|
||||
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
||||
if err == nil {
|
||||
t.Fatalf("config file pixa cannot read must abort startup, got config: %v",
|
||||
sc)
|
||||
}
|
||||
|
||||
t.Logf("got expected error: %v", err)
|
||||
|
||||
if !strings.Contains(err.Error(), configPath) {
|
||||
t.Errorf("error %q does not name the config file %s", err.Error(), configPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigFileLinkingToItselfAbortsStartup checks that a config file
|
||||
// pixa cannot read for a reason other than not existing aborts startup,
|
||||
// as root too: a symbolic link to itself fails with "too many levels of
|
||||
// symbolic links".
|
||||
func TestConfigFileLinkingToItselfAbortsStartup(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
|
||||
err := os.Symlink("config.yml", filepath.Join(workDir, "config.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create symbolic link: %v", err)
|
||||
}
|
||||
|
||||
// Only the working directory's config.yml is there: the appname rules
|
||||
// out /etc, and HOME is empty.
|
||||
t.Setenv("PIXA_CONFIG_PATH", "")
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Chdir(workDir)
|
||||
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
|
||||
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
||||
if err == nil {
|
||||
t.Fatalf("config file pixa cannot read must abort startup, got config: %v",
|
||||
sc)
|
||||
}
|
||||
|
||||
t.Logf("got expected error: %v", err)
|
||||
|
||||
if !strings.Contains(err.Error(), "config.yml") {
|
||||
t.Errorf("error %q does not name the config file config.yml", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigPathThroughFileIsPassedOver checks that a config file path
|
||||
// that runs through a file, such as one under a HOME of /dev/null, is
|
||||
// passed over like one that does not exist, since no file can be there.
|
||||
func TestConfigPathThroughFileIsPassedOver(t *testing.T) {
|
||||
// No config file is there: the appname rules out /etc, HOME is
|
||||
// /dev/null, and the working directory is empty.
|
||||
t.Setenv("PIXA_CONFIG_PATH", "")
|
||||
t.Setenv("HOME", os.DevNull)
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
|
||||
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
||||
if err != nil {
|
||||
t.Fatalf("a config path through a file must be passed over, got error: %v",
|
||||
err)
|
||||
}
|
||||
|
||||
if sc != nil {
|
||||
t.Errorf("expected no config file, got config: %v", sc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureStateDirCreatesDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -681,6 +681,11 @@ func TestEvictionRunsUnderWritePressure(t *testing.T) {
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -688,13 +693,29 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
||||
|
||||
cache, _ := newEvictionTestCache(t, limit)
|
||||
|
||||
// Start the evictor while the cache is empty, then create tracked
|
||||
// over-limit state WITHOUT going through the store methods, so no
|
||||
// write-pressure notification fires and only the periodic ticker
|
||||
// can trigger eviction.
|
||||
// Hold the test database's only connection, so the startup pass
|
||||
// waits for it after walking the still empty variant directory: the
|
||||
// files written while it waits are first seen by a periodic pass.
|
||||
conn, err := cache.db.Conn(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to take the database connection: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
cache.StartEviction(100 * time.Millisecond)
|
||||
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{
|
||||
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
|
||||
}
|
||||
@@ -703,25 +724,43 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
||||
for i, key := range keys {
|
||||
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 {
|
||||
t.Fatalf("failed to store variant file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = cache.db.ExecContext(t.Context(),
|
||||
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
||||
VALUES (?, ?, ?)`,
|
||||
string(key), len(content), "image/webp",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert variant accounting row: %v", err)
|
||||
_ = conn.Close()
|
||||
|
||||
// Only one of the 1000-byte files fits under the limit: wait until
|
||||
// the evictor has removed the other two.
|
||||
stored := len(keys)
|
||||
deadline = time.Now().Add(5 * time.Second)
|
||||
|
||||
for stored > 1 && time.Now().Before(deadline) {
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
|
||||
stored = 0
|
||||
|
||||
for _, key := range keys {
|
||||
if cache.variants.Exists(key) {
|
||||
stored++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usage := waitForUsageAtOrBelow(t, cache, limit, 5*time.Second)
|
||||
if stored > 1 {
|
||||
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 {
|
||||
t.Errorf("periodic schedule did not trigger eviction: usage = %d, want <= %d",
|
||||
usage, limit)
|
||||
t.Errorf("usage after eviction = %d, want <= %d", usage, limit)
|
||||
}
|
||||
|
||||
assertNoDanglingReferences(t, cache)
|
||||
|
||||
@@ -35,10 +35,13 @@ const HSTSValue = "max-age=31536000; includeSubDomains"
|
||||
|
||||
// ContentSecurityPolicyValue is the Content-Security-Policy header value.
|
||||
// default-src 'self' is the baseline and frame-ancestors 'none' is the primary
|
||||
// clickjacking control.
|
||||
// clickjacking control. 'unsafe-inline' is required in script-src and style-src
|
||||
// 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'; " +
|
||||
"script-src 'self'; " +
|
||||
"style-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'; " +
|
||||
|
||||
@@ -325,13 +325,6 @@ func TestSecurityHeaders_PolicyHeaders(t *testing.T) {
|
||||
|
||||
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 {
|
||||
header string
|
||||
want string
|
||||
@@ -340,8 +333,8 @@ func TestSecurityHeaders_PolicyHeaders(t *testing.T) {
|
||||
{
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; " +
|
||||
"script-src 'self'; " +
|
||||
"style-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'; " +
|
||||
|
||||
@@ -53,7 +53,7 @@ func (s *Server) SetupRoutes() {
|
||||
// Robots.txt
|
||||
s.router.Get("/robots.txt", s.h.HandleRobotsTxt())
|
||||
|
||||
// The login and generator pages' stylesheet and script
|
||||
// Static files (Tailwind CSS, etc.)
|
||||
s.router.Handle("/static/*", http.StripPrefix("/static/", static.Handler()))
|
||||
|
||||
// Login/generator UI. The form routes carry CSRF protection; the
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed *.css *.js
|
||||
//go:embed *.js
|
||||
var files embed.FS
|
||||
|
||||
// FS returns the embedded filesystem containing static files.
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
/* 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
@@ -4,47 +4,52 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pixa - URL Generator</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/tailwind.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header>
|
||||
<h1>Pixa URL Generator</h1>
|
||||
<a href="/logout">
|
||||
<body class="bg-gray-100 min-h-screen">
|
||||
<div class="max-w-2xl mx-auto py-8 px-4">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-800">Pixa URL Generator</h1>
|
||||
<a href="/logout" class="text-sm text-gray-600 hover:text-gray-800 underline">
|
||||
Logout
|
||||
</a>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
{{if .GeneratedURL}}
|
||||
<div class="result">
|
||||
<h2>Generated URL</h2>
|
||||
<div>
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<h2 class="text-sm font-medium text-green-800 mb-2">Generated URL</h2>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
value="{{.GeneratedURL}}"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
<p class="text-xs text-green-600 mt-2">
|
||||
Expires: {{.ExpiresAt}}
|
||||
</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Error}}
|
||||
<div class="error">
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/generate" class="card">
|
||||
<form method="POST" action="/generate" class="bg-white rounded-lg shadow-md p-6 space-y-4">
|
||||
{{ .CSRFField }}
|
||||
<div>
|
||||
<label for="url">
|
||||
<label for="url" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Source URL
|
||||
</label>
|
||||
<input
|
||||
@@ -54,12 +59,13 @@
|
||||
required
|
||||
placeholder="https://example.com/image.jpg"
|
||||
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 class="columns">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="width">
|
||||
<label for="width" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Width
|
||||
</label>
|
||||
<input
|
||||
@@ -70,10 +76,11 @@
|
||||
max="8192"
|
||||
value="{{if .FormWidth}}{{.FormWidth}}{{else}}0{{end}}"
|
||||
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>
|
||||
<label for="height">
|
||||
<label for="height" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Height
|
||||
</label>
|
||||
<input
|
||||
@@ -84,16 +91,21 @@
|
||||
max="8192"
|
||||
value="{{if .FormHeight}}{{.FormHeight}}{{else}}0{{end}}"
|
||||
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 class="columns">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="format">
|
||||
<label for="format" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Format
|
||||
</label>
|
||||
<select id="format" name="format">
|
||||
<select
|
||||
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="jpeg" {{if eq .FormFormat "jpeg"}}selected{{end}}>JPEG</option>
|
||||
<option value="png" {{if eq .FormFormat "png"}}selected{{end}}>PNG</option>
|
||||
@@ -103,10 +115,14 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="quality">
|
||||
<label for="quality" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Quality
|
||||
</label>
|
||||
<select id="quality" name="quality">
|
||||
<select
|
||||
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="50" {{if eq .FormQuality "50"}}selected{{end}}>Low</option>
|
||||
<option value="70" {{if eq .FormQuality "70"}}selected{{end}}>Medium</option>
|
||||
@@ -116,12 +132,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="fit">
|
||||
<label for="fit" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Fit Mode
|
||||
</label>
|
||||
<select id="fit" name="fit">
|
||||
<select
|
||||
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="contain" {{if eq .FormFit "contain"}}selected{{end}}>Contain</option>
|
||||
<option value="fill" {{if eq .FormFit "fill"}}selected{{end}}>Fill</option>
|
||||
@@ -130,10 +150,14 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ttl">
|
||||
<label for="ttl" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Expires In
|
||||
</label>
|
||||
<select id="ttl" name="ttl">
|
||||
<select
|
||||
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="60" {{if eq .FormTTL "60"}}selected{{end}}>1 minute</option>
|
||||
<option value="3600" {{if eq .FormTTL "3600"}}selected{{end}}>1 hour</option>
|
||||
@@ -145,15 +169,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="note">
|
||||
<p class="text-xs text-gray-500 mt-4 text-center">
|
||||
Generated URLs are encrypted and cannot be modified. They will expire at the specified time.
|
||||
</p>
|
||||
</div>
|
||||
<script src="/static/generator.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,22 +4,22 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pixa - Login</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/tailwind.js"></script>
|
||||
</head>
|
||||
<body class="login">
|
||||
<div class="card">
|
||||
<h1>Pixa Image Proxy</h1>
|
||||
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-6 text-center">Pixa Image Proxy</h1>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="error">
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/">
|
||||
<form method="POST" action="/" class="space-y-4">
|
||||
{{ .CSRFField }}
|
||||
<div>
|
||||
<label for="key">
|
||||
<label for="key" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Signing Key
|
||||
</label>
|
||||
<input
|
||||
@@ -28,11 +28,15 @@
|
||||
name="key"
|
||||
required
|
||||
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"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button type="submit">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user