update seamark theme, add 'delete all untagged' option on record page. add garbage collection flag for untagged

This commit is contained in:
Evan Jarrett
2026-03-16 20:26:56 -05:00
parent 8fb69497e3
commit e886192aeb
34 changed files with 1304 additions and 321 deletions
+2 -1
View File
@@ -45,7 +45,7 @@ ui:
# SQLite/libSQL database for OAuth sessions, stars, pull counts, and device approvals.
database_path: /var/lib/atcr/ui.db
# Visual theme name (e.g. "seamark"). Empty uses default atcr.io branding.
theme: ""
theme: "seamark"
# libSQL sync URL (libsql://...). Works with Turso cloud or self-hosted libsql-server. Leave empty for local-only SQLite.
libsql_sync_url: ""
# Auth token for libSQL sync. Required if libsql_sync_url is set.
@@ -74,6 +74,7 @@ jetstream:
relay_endpoints:
- https://relay1.us-east.bsky.network
- https://relay1.us-west.bsky.network
- https://zlay.waow.tech
# JWT authentication settings.
auth:
# RSA private key for signing registry JWTs issued to Docker clients.
+4
View File
@@ -15,6 +15,10 @@
"format": "uri",
"description": "Default hold endpoint for blob storage. If null, user has opted out of defaults."
},
"autoRemoveUntagged": {
"type": "boolean",
"description": "Automatically delete manifest records that become untagged after a tag overwrite. Layers are cleaned up by hold garbage collection."
},
"createdAt": {
"type": "string",
"format": "datetime",
+189 -189
View File
@@ -11,21 +11,21 @@
"actor-typeahead": "^0.1.2",
"htmx-ext-json-enc": "^2.0.3",
"htmx.org": "^2.0.8",
"lucide": "^0.575.0"
"lucide": "^0.577.0"
},
"devDependencies": {
"@tailwindcss/cli": "^4.2.0",
"@tailwindcss/cli": "^4.2.1",
"@tailwindcss/typography": "^0.5.19",
"daisyui": "^5.5.19",
"esbuild": "^0.27.3",
"esbuild": "^0.27.4",
"glob": "^13.0.6",
"tailwindcss": "^4.2"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
"cpu": [
"ppc64"
],
@@ -40,9 +40,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
"cpu": [
"arm"
],
@@ -57,9 +57,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
"cpu": [
"arm64"
],
@@ -74,9 +74,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
"cpu": [
"x64"
],
@@ -91,9 +91,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
"cpu": [
"arm64"
],
@@ -108,9 +108,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
"cpu": [
"x64"
],
@@ -125,9 +125,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
"cpu": [
"arm64"
],
@@ -142,9 +142,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
"cpu": [
"x64"
],
@@ -159,9 +159,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
"cpu": [
"arm"
],
@@ -176,9 +176,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
"cpu": [
"arm64"
],
@@ -193,9 +193,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
"cpu": [
"ia32"
],
@@ -210,9 +210,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
"cpu": [
"loong64"
],
@@ -227,9 +227,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
"cpu": [
"mips64el"
],
@@ -244,9 +244,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
"cpu": [
"ppc64"
],
@@ -261,9 +261,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
"cpu": [
"riscv64"
],
@@ -278,9 +278,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
"cpu": [
"s390x"
],
@@ -295,9 +295,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
"cpu": [
"x64"
],
@@ -312,9 +312,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
"cpu": [
"arm64"
],
@@ -329,9 +329,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
"cpu": [
"x64"
],
@@ -346,9 +346,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
"cpu": [
"arm64"
],
@@ -363,9 +363,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
"cpu": [
"x64"
],
@@ -380,9 +380,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
"cpu": [
"arm64"
],
@@ -397,9 +397,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
"cpu": [
"x64"
],
@@ -414,9 +414,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
"cpu": [
"arm64"
],
@@ -431,9 +431,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
"cpu": [
"ia32"
],
@@ -448,9 +448,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
"cpu": [
"x64"
],
@@ -824,28 +824,28 @@
}
},
"node_modules/@tailwindcss/cli": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.0.tgz",
"integrity": "sha512-C62SWDp+6Rj5DHJDlMyAqESpmljiQ35H4SncAcVn3Gm0rEPrKFDIdAheT74s9zAbrsa2D/L+jJaPgCO1fyZG6g==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.1.tgz",
"integrity": "sha512-b7MGn51IA80oSG+7fuAgzfQ+7pZBgjzbqwmiv6NO7/+a1sev32cGqnwhscT7h0EcAvMa9r7gjRylqOH8Xhc4DA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@parcel/watcher": "^2.5.1",
"@tailwindcss/node": "4.2.0",
"@tailwindcss/oxide": "4.2.0",
"@tailwindcss/node": "4.2.1",
"@tailwindcss/oxide": "4.2.1",
"enhanced-resolve": "^5.19.0",
"mri": "^1.2.0",
"picocolors": "^1.1.1",
"tailwindcss": "4.2.0"
"tailwindcss": "4.2.1"
},
"bin": {
"tailwindcss": "dist/index.mjs"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz",
"integrity": "sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
"integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -855,37 +855,37 @@
"lightningcss": "1.31.1",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.2.0"
"tailwindcss": "4.2.1"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.0.tgz",
"integrity": "sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz",
"integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.2.0",
"@tailwindcss/oxide-darwin-arm64": "4.2.0",
"@tailwindcss/oxide-darwin-x64": "4.2.0",
"@tailwindcss/oxide-freebsd-x64": "4.2.0",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.0",
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.0",
"@tailwindcss/oxide-linux-arm64-musl": "4.2.0",
"@tailwindcss/oxide-linux-x64-gnu": "4.2.0",
"@tailwindcss/oxide-linux-x64-musl": "4.2.0",
"@tailwindcss/oxide-wasm32-wasi": "4.2.0",
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.0",
"@tailwindcss/oxide-win32-x64-msvc": "4.2.0"
"@tailwindcss/oxide-android-arm64": "4.2.1",
"@tailwindcss/oxide-darwin-arm64": "4.2.1",
"@tailwindcss/oxide-darwin-x64": "4.2.1",
"@tailwindcss/oxide-freebsd-x64": "4.2.1",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1",
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.1",
"@tailwindcss/oxide-linux-arm64-musl": "4.2.1",
"@tailwindcss/oxide-linux-x64-gnu": "4.2.1",
"@tailwindcss/oxide-linux-x64-musl": "4.2.1",
"@tailwindcss/oxide-wasm32-wasi": "4.2.1",
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.1",
"@tailwindcss/oxide-win32-x64-msvc": "4.2.1"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.0.tgz",
"integrity": "sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz",
"integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==",
"cpu": [
"arm64"
],
@@ -900,9 +900,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.0.tgz",
"integrity": "sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz",
"integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==",
"cpu": [
"arm64"
],
@@ -917,9 +917,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.0.tgz",
"integrity": "sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz",
"integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==",
"cpu": [
"x64"
],
@@ -934,9 +934,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.0.tgz",
"integrity": "sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz",
"integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==",
"cpu": [
"x64"
],
@@ -951,9 +951,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.0.tgz",
"integrity": "sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz",
"integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==",
"cpu": [
"arm"
],
@@ -968,9 +968,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.0.tgz",
"integrity": "sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz",
"integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==",
"cpu": [
"arm64"
],
@@ -985,9 +985,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.0.tgz",
"integrity": "sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz",
"integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==",
"cpu": [
"arm64"
],
@@ -1002,9 +1002,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.0.tgz",
"integrity": "sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz",
"integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==",
"cpu": [
"x64"
],
@@ -1019,9 +1019,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.0.tgz",
"integrity": "sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz",
"integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==",
"cpu": [
"x64"
],
@@ -1036,9 +1036,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.0.tgz",
"integrity": "sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz",
"integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -1066,9 +1066,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.0.tgz",
"integrity": "sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
"integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==",
"cpu": [
"arm64"
],
@@ -1083,9 +1083,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.0.tgz",
"integrity": "sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz",
"integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==",
"cpu": [
"x64"
],
@@ -1129,9 +1129,9 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1175,9 +1175,9 @@
}
},
"node_modules/enhanced-resolve": {
"version": "5.19.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
"integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
"version": "5.20.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
"integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1189,9 +1189,9 @@
}
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1202,32 +1202,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
"@esbuild/aix-ppc64": "0.27.4",
"@esbuild/android-arm": "0.27.4",
"@esbuild/android-arm64": "0.27.4",
"@esbuild/android-x64": "0.27.4",
"@esbuild/darwin-arm64": "0.27.4",
"@esbuild/darwin-x64": "0.27.4",
"@esbuild/freebsd-arm64": "0.27.4",
"@esbuild/freebsd-x64": "0.27.4",
"@esbuild/linux-arm": "0.27.4",
"@esbuild/linux-arm64": "0.27.4",
"@esbuild/linux-ia32": "0.27.4",
"@esbuild/linux-loong64": "0.27.4",
"@esbuild/linux-mips64el": "0.27.4",
"@esbuild/linux-ppc64": "0.27.4",
"@esbuild/linux-riscv64": "0.27.4",
"@esbuild/linux-s390x": "0.27.4",
"@esbuild/linux-x64": "0.27.4",
"@esbuild/netbsd-arm64": "0.27.4",
"@esbuild/netbsd-x64": "0.27.4",
"@esbuild/openbsd-arm64": "0.27.4",
"@esbuild/openbsd-x64": "0.27.4",
"@esbuild/openharmony-arm64": "0.27.4",
"@esbuild/sunos-x64": "0.27.4",
"@esbuild/win32-arm64": "0.27.4",
"@esbuild/win32-ia32": "0.27.4",
"@esbuild/win32-x64": "0.27.4"
}
},
"node_modules/glob": {
@@ -1564,9 +1564,9 @@
}
},
"node_modules/lru-cache": {
"version": "11.2.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
"version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
@@ -1574,9 +1574,9 @@
}
},
"node_modules/lucide": {
"version": "0.575.0",
"resolved": "https://registry.npmjs.org/lucide/-/lucide-0.575.0.tgz",
"integrity": "sha512-+xwqZpvrqPioU8bSH49zH2xARfnKyZgIjdnfbex0CrURB3q4wNFhinYN1Z9Q3lE16Q/6N9iEXnStvyS3c70RKw==",
"version": "0.577.0",
"resolved": "https://registry.npmjs.org/lucide/-/lucide-0.577.0.tgz",
"integrity": "sha512-PpC/m5eOItp/WU/GlQPFBXDOhq6HibL73KzYP37OX3LM7VmzWQF8voEj8QRWUFvy9FIKfeDQkWYoyS1D/MdWFA==",
"license": "ISC"
},
"node_modules/magic-string": {
@@ -1590,9 +1590,9 @@
}
},
"node_modules/minimatch": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
"integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
@@ -1694,9 +1694,9 @@
}
},
"node_modules/tailwindcss": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz",
"integrity": "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
"integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
"dev": true,
"license": "MIT"
},
+3 -3
View File
@@ -16,10 +16,10 @@
"watch": "npm run css:watch & npm run js:watch"
},
"devDependencies": {
"@tailwindcss/cli": "^4.2.0",
"@tailwindcss/cli": "^4.2.1",
"@tailwindcss/typography": "^0.5.19",
"daisyui": "^5.5.19",
"esbuild": "^0.27.3",
"esbuild": "^0.27.4",
"glob": "^13.0.6",
"tailwindcss": "^4.2"
},
@@ -27,6 +27,6 @@
"actor-typeahead": "^0.1.2",
"htmx-ext-json-enc": "^2.0.3",
"htmx.org": "^2.0.8",
"lucide": "^0.575.0"
"lucide": "^0.577.0"
}
}
+68
View File
@@ -1252,6 +1252,25 @@ func UpdateFirehoseCursor(db DBTX, cursor int64) error {
return err
}
// IsManifestReferenced checks if a manifest digest is referenced as a child of
// any manifest list for the given user. Used to protect manifest list children
// from auto-removal (they are untagged but still needed by their parent list).
func IsManifestReferenced(db DBTX, did, digest string) (bool, error) {
var count int
err := db.QueryRow(`
SELECT COUNT(*) FROM manifest_references mr
JOIN manifests m ON mr.manifest_id = m.id
WHERE mr.digest = ? AND m.did = ?
LIMIT 1
`, digest, did).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// IsManifestTagged checks if a manifest has any tags
func IsManifestTagged(db DBTX, did, repository, digest string) (bool, error) {
var count int
@@ -1295,6 +1314,49 @@ func GetManifestTags(db DBTX, did, repository, digest string) ([]string, error)
return tags, nil
}
// GetUntaggedTopLevelManifestDigests returns digests of top-level manifests that have no tags.
// Uses the same filtering logic as GetTopLevelManifests (manifest lists + orphaned single-arch).
func GetUntaggedTopLevelManifestDigests(db DBTX, did, repository string) ([]string, error) {
rows, err := db.Query(`
WITH manifest_list_children AS (
SELECT DISTINCT mr.digest
FROM manifest_references mr
JOIN manifests m ON mr.manifest_id = m.id
WHERE m.did = ? AND m.repository = ?
)
SELECT m.digest
FROM manifests m
LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository
WHERE m.did = ? AND m.repository = ?
AND (
m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
OR
m.digest NOT IN (SELECT digest FROM manifest_list_children WHERE digest IS NOT NULL)
)
GROUP BY m.id
HAVING COUNT(t.tag) = 0
`, did, repository, did, repository)
if err != nil {
return nil, err
}
defer rows.Close()
var digests []string
for rows.Next() {
var digest string
if err := rows.Scan(&digest); err != nil {
return nil, err
}
digests = append(digests, digest)
}
if err := rows.Err(); err != nil {
return nil, err
}
return digests, nil
}
// GetAttestationDetails returns attestation manifests and their layers for a manifest list.
// Joins manifest_references (is_attestation=true) → manifests → layers.
func GetAttestationDetails(db DBTX, did, repository, manifestListDigest string) ([]AttestationDetail, error) {
@@ -1795,6 +1857,12 @@ func (h *HoldDIDDB) UpdateManifestHoldDID(did, oldHoldDID, newHoldDID string) (i
return UpdateManifestHoldDID(h.db, did, oldHoldDID, newHoldDID)
}
// IsManifestReferenced checks if a digest is a child of any manifest list for the user.
// Implements storage.ManifestReferenceChecker.
func (h *HoldDIDDB) IsManifestReferenced(did, digest string) (bool, error) {
return IsManifestReferenced(h.db, did, digest)
}
// RepoCardSortOrder specifies how repo cards should be sorted
type RepoCardSortOrder string
+67
View File
@@ -1307,3 +1307,70 @@ func TestDeleteUserData(t *testing.T) {
t.Errorf("Deleting non-existent user should not error, got: %v", err)
}
}
func TestIsManifestReferenced(t *testing.T) {
db, err := InitDB(":memory:", LibsqlConfig{})
if err != nil {
t.Fatalf("Failed to init database: %v", err)
}
defer db.Close()
// Insert test user
if err := UpsertUser(db, &User{
DID: "did:plc:test123",
Handle: "testuser.bsky.social",
PDSEndpoint: "https://test.pds.example.com",
LastSeen: time.Now(),
}); err != nil {
t.Fatalf("Failed to insert user: %v", err)
}
// Insert a manifest list
_, err = db.Exec(`
INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, "did:plc:test123", "myapp", "sha256:indexabc", "did:web:hold.example.com", 2,
"application/vnd.oci.image.index.v1+json", time.Now())
if err != nil {
t.Fatalf("Failed to insert manifest list: %v", err)
}
var manifestID int64
db.QueryRow(`SELECT id FROM manifests WHERE digest = ?`, "sha256:indexabc").Scan(&manifestID)
// Insert a child manifest reference
_, err = db.Exec(`
INSERT INTO manifest_references (manifest_id, digest, media_type, size, platform_architecture, platform_os, reference_index)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, manifestID, "sha256:childdef", "application/vnd.oci.image.manifest.v1+json", 1000, "amd64", "linux", 0)
if err != nil {
t.Fatalf("Failed to insert manifest reference: %v", err)
}
// Test 1: child digest should be referenced
referenced, err := IsManifestReferenced(db, "did:plc:test123", "sha256:childdef")
if err != nil {
t.Fatalf("IsManifestReferenced error: %v", err)
}
if !referenced {
t.Error("Expected sha256:childdef to be referenced as a manifest list child")
}
// Test 2: unrelated digest should NOT be referenced
referenced, err = IsManifestReferenced(db, "did:plc:test123", "sha256:unrelated")
if err != nil {
t.Fatalf("IsManifestReferenced error: %v", err)
}
if referenced {
t.Error("Expected sha256:unrelated to NOT be referenced")
}
// Test 3: same digest but different user should NOT be referenced
referenced, err = IsManifestReferenced(db, "did:plc:otheruser", "sha256:childdef")
if err != nil {
t.Fatalf("IsManifestReferenced error: %v", err)
}
if referenced {
t.Error("Expected sha256:childdef to NOT be referenced for different user")
}
}
+68
View File
@@ -179,6 +179,74 @@ func (h *DeleteManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusOK)
}
// deleteUntaggedRequest is the JSON body for delete untagged manifests requests
type deleteUntaggedRequest struct {
Repo string `json:"repo"`
}
// DeleteUntaggedManifestsHandler handles bulk-deleting all untagged manifests in a repository
type DeleteUntaggedManifestsHandler struct {
BaseUIHandler
}
func (h *DeleteUntaggedManifestsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req deleteUntaggedRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
digests, err := db.GetUntaggedTopLevelManifestDigests(h.DB, user.DID, req.Repo)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to query untagged manifests: %v", err), http.StatusInternalServerError)
return
}
if len(digests) == 0 {
render.JSON(w, r, map[string]int{"deleted": 0})
return
}
pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
deleted := 0
for _, digest := range digests {
rkey := strings.TrimPrefix(digest, "sha256:")
if err := pdsClient.DeleteRecord(r.Context(), atproto.ManifestCollection, rkey); err != nil {
if handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
return
}
render.Status(r, http.StatusInternalServerError)
render.JSON(w, r, map[string]any{
"error": fmt.Sprintf("Failed to delete manifest %s from PDS: %v", digest, err),
"deleted": deleted,
})
return
}
if err := db.DeleteManifest(h.DB, user.DID, req.Repo, digest); err != nil {
render.Status(r, http.StatusInternalServerError)
render.JSON(w, r, map[string]any{
"error": fmt.Sprintf("Failed to delete manifest %s from cache: %v", digest, err),
"deleted": deleted,
})
return
}
deleted++
}
render.JSON(w, r, map[string]int{"deleted": deleted})
}
// UploadAvatarHandler handles uploading/updating a repository avatar
type UploadAvatarHandler struct {
BaseUIHandler
+40 -4
View File
@@ -132,10 +132,11 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
PageData
Meta *PageMeta
Profile struct {
Handle string
DID string
PDSEndpoint string
DefaultHold string
Handle string
DID string
PDSEndpoint string
DefaultHold string
AutoRemoveUntagged bool
}
ActiveHold *HoldDisplay
OtherHolds []HoldDisplay
@@ -156,6 +157,7 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
data.Profile.DID = user.DID
data.Profile.PDSEndpoint = user.PDSEndpoint
data.Profile.DefaultHold = profile.DefaultHold
data.Profile.AutoRemoveUntagged = profile.AutoRemoveUntagged
if err := h.Templates.ExecuteTemplate(w, "settings", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -381,6 +383,40 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
}
}
// UpdateAutoRemoveUntaggedHandler handles toggling the auto-remove-untagged setting
type UpdateAutoRemoveUntaggedHandler struct {
BaseUIHandler
}
func (h *UpdateAutoRemoveUntaggedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Create ATProto client with session provider
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
// Fetch existing profile
profile, err := storage.GetProfile(r.Context(), client)
if err != nil || profile == nil {
http.Error(w, "Failed to fetch profile", http.StatusInternalServerError)
return
}
// Toggle the setting (checkbox sends value when checked, absent when unchecked)
profile.AutoRemoveUntagged = !profile.AutoRemoveUntagged
profile.UpdatedAt = time.Now()
if err := storage.UpdateProfile(r.Context(), client, profile); err != nil {
http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// refreshCaptainRecord fetches a hold's captain record via XRPC and caches it locally.
// This ensures badge tiers and other captain metadata are available immediately
// without waiting for Jetstream or the next backfill cycle.
+7
View File
@@ -772,6 +772,13 @@ func (p *Processor) ProcessStats(ctx context.Context, holdDID string, recordData
return fmt.Errorf("failed to unmarshal stats record: %w", err)
}
// Ensure the owner user exists in the users table (repository_stats has FK to users.did)
if !isDelete && statsRecord.OwnerDID != "" {
if err := p.EnsureUserExists(ctx, statsRecord.OwnerDID); err != nil {
return fmt.Errorf("failed to ensure stats owner user exists: %w", err)
}
}
if isDelete {
// Delete from in-memory cache
p.statsCache.Delete(holdDID, statsRecord.OwnerDID, statsRecord.Repository)
+59 -46
View File
@@ -170,10 +170,11 @@ func (vc *validationCache) getOrFetch(ctx context.Context, cacheKey string, fetc
// These are set by main.go during startup and copied into NamespaceResolver instances.
// After initialization, request handling uses the NamespaceResolver's instance fields.
var (
globalRefresher *oauth.Refresher
globalDatabase storage.HoldDIDLookup
globalAuthorizer auth.HoldAuthorizer
globalWebhookDispatcher storage.PushWebhookDispatcher
globalRefresher *oauth.Refresher
globalDatabase storage.HoldDIDLookup
globalAuthorizer auth.HoldAuthorizer
globalWebhookDispatcher storage.PushWebhookDispatcher
globalManifestRefChecker storage.ManifestReferenceChecker
)
// SetGlobalRefresher sets the OAuth refresher instance during initialization
@@ -188,6 +189,11 @@ func SetGlobalDatabase(database storage.HoldDIDLookup) {
globalDatabase = database
}
// SetGlobalManifestRefChecker sets the manifest reference checker during initialization
func SetGlobalManifestRefChecker(checker storage.ManifestReferenceChecker) {
globalManifestRefChecker = checker
}
// SetGlobalAuthorizer sets the authorizer instance during initialization
// Must be called before the registry starts serving requests
func SetGlobalAuthorizer(authorizer auth.HoldAuthorizer) {
@@ -216,15 +222,16 @@ func init() {
// NamespaceResolver wraps a namespace and resolves names
type NamespaceResolver struct {
distribution.Namespace
defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
baseURL string // Base URL for error messages (e.g., "https://atcr.io")
testMode bool // If true, fallback to default hold when user's hold is unreachable
refresher *oauth.Refresher // OAuth session manager (copied from global on init)
database storage.HoldDIDLookup // Database for hold DID lookups (copied from global on init)
authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
webhookDispatcher storage.PushWebhookDispatcher // Push webhook dispatcher (copied from global on init)
validationCache *validationCache // Request-level service token cache
readmeFetcher *readme.Fetcher // README fetcher for repo pages
defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
baseURL string // Base URL for error messages (e.g., "https://atcr.io")
testMode bool // If true, fallback to default hold when user's hold is unreachable
refresher *oauth.Refresher // OAuth session manager (copied from global on init)
database storage.HoldDIDLookup // Database for hold DID lookups (copied from global on init)
authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
webhookDispatcher storage.PushWebhookDispatcher // Push webhook dispatcher (copied from global on init)
manifestRefChecker storage.ManifestReferenceChecker // Manifest reference checker (copied from global on init)
validationCache *validationCache // Request-level service token cache
readmeFetcher *readme.Fetcher // README fetcher for repo pages
}
// initATProtoResolver initializes the name resolution middleware
@@ -251,16 +258,17 @@ func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ drive
// Copy shared services from globals into the instance
// This avoids accessing globals during request handling
return &NamespaceResolver{
Namespace: ns,
defaultHoldDID: defaultHoldDID,
baseURL: baseURL,
testMode: testMode,
refresher: globalRefresher,
database: globalDatabase,
authorizer: globalAuthorizer,
webhookDispatcher: globalWebhookDispatcher,
validationCache: newValidationCache(),
readmeFetcher: readme.NewFetcher(),
Namespace: ns,
defaultHoldDID: defaultHoldDID,
baseURL: baseURL,
testMode: testMode,
refresher: globalRefresher,
database: globalDatabase,
authorizer: globalAuthorizer,
webhookDispatcher: globalWebhookDispatcher,
manifestRefChecker: globalManifestRefChecker,
validationCache: newValidationCache(),
readmeFetcher: readme.NewFetcher(),
}, nil
}
@@ -297,7 +305,8 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
slog.Debug("Resolved identity", "component", "registry/middleware", "did", did, "pds", pdsEndpoint, "handle", handle)
// Query for hold DID - either user's hold or default hold service
holdDID := nr.findHoldDID(ctx, did, pdsEndpoint)
// Also returns the sailor profile so we can read preferences (e.g. AutoRemoveUntagged)
holdDID, sailorProfile := nr.findHoldDIDAndProfile(ctx, did, pdsEndpoint)
if holdDID == "" {
// This is a fatal configuration error - registry cannot function without a hold service
return nil, fmt.Errorf("no hold DID configured: ensure default_hold_did is set in middleware config")
@@ -476,22 +485,24 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
// 3. The refresher already caches sessions efficiently (in-memory + DB)
// 4. Caching the repository with a stale ATProtoClient causes refresh token errors
registryCtx := &storage.RegistryContext{
DID: did,
Handle: handle,
HoldDID: holdDID,
HoldURL: holdURL,
PDSEndpoint: pdsEndpoint,
Repository: repositoryName,
ServiceToken: serviceToken, // Cached service token from puller's PDS
ATProtoClient: atprotoClient,
AuthMethod: authMethod, // Auth method from JWT token
PullerDID: pullerDID, // Authenticated user making the request
PullerPDSEndpoint: pullerPDSEndpoint, // Puller's PDS for service token refresh
Database: nr.database,
Authorizer: nr.authorizer,
Refresher: nr.refresher,
ReadmeFetcher: nr.readmeFetcher,
WebhookDispatcher: nr.webhookDispatcher,
DID: did,
Handle: handle,
HoldDID: holdDID,
HoldURL: holdURL,
PDSEndpoint: pdsEndpoint,
Repository: repositoryName,
ServiceToken: serviceToken, // Cached service token from puller's PDS
ATProtoClient: atprotoClient,
AuthMethod: authMethod, // Auth method from JWT token
PullerDID: pullerDID, // Authenticated user making the request
PullerPDSEndpoint: pullerPDSEndpoint, // Puller's PDS for service token refresh
AutoRemoveUntagged: sailorProfile != nil && sailorProfile.AutoRemoveUntagged,
Database: nr.database,
Authorizer: nr.authorizer,
Refresher: nr.refresher,
ReadmeFetcher: nr.readmeFetcher,
WebhookDispatcher: nr.webhookDispatcher,
ManifestRefChecker: nr.manifestRefChecker,
}
return storage.NewRoutingRepository(repo, registryCtx), nil
@@ -512,12 +523,14 @@ func (nr *NamespaceResolver) BlobStatter() distribution.BlobStatter {
return nr.Namespace.BlobStatter()
}
// findHoldDID determines which hold DID to use for blob storage
// findHoldDIDAndProfile determines which hold DID to use for blob storage and
// returns the user's sailor profile (if available) for reading preferences like
// AutoRemoveUntagged without an extra PDS call.
// Priority order:
// 1. User's sailor profile defaultHold (if set)
// 2. AppView's default hold DID
// Returns a hold DID (e.g., "did:web:hold01.atcr.io"), or empty string if none configured
func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint string) string {
func (nr *NamespaceResolver) findHoldDIDAndProfile(ctx context.Context, did, pdsEndpoint string) (string, *atproto.SailorProfileRecord) {
// Create ATProto client (without auth - reading public records)
client := atproto.NewClient(pdsEndpoint, did, "")
@@ -533,16 +546,16 @@ func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint s
// In test mode, verify it's reachable before using it
if nr.testMode {
if nr.isHoldReachable(ctx, profile.DefaultHold) {
return profile.DefaultHold
return profile.DefaultHold, profile
}
slog.Debug("User's defaultHold unreachable, falling back to default", "component", "registry/middleware/testmode", "default_hold", profile.DefaultHold)
return nr.defaultHoldDID
return nr.defaultHoldDID, profile
}
return profile.DefaultHold
return profile.DefaultHold, profile
}
// No profile defaultHold - use AppView default
return nr.defaultHoldDID
return nr.defaultHoldDID, profile
}
// resolveSuccessor checks if a hold has declared a successor and returns it.
+4 -4
View File
@@ -160,7 +160,7 @@ func TestFindHoldDID_DefaultFallback(t *testing.T) {
}
ctx := context.Background()
holdDID := resolver.findHoldDID(ctx, "did:plc:test123", mockPDS.URL)
holdDID, _ := resolver.findHoldDIDAndProfile(ctx, "did:plc:test123", mockPDS.URL)
assert.Equal(t, "did:web:default.atcr.io", holdDID, "should fall back to default hold DID")
}
@@ -188,7 +188,7 @@ func TestFindHoldDID_SailorProfile(t *testing.T) {
}
ctx := context.Background()
holdDID := resolver.findHoldDID(ctx, "did:plc:test123", mockPDS.URL)
holdDID, _ := resolver.findHoldDIDAndProfile(ctx, "did:plc:test123", mockPDS.URL)
assert.Equal(t, "did:web:user.hold.io", holdDID, "should use sailor profile's defaultHold")
}
@@ -215,7 +215,7 @@ func TestFindHoldDID_Priority(t *testing.T) {
}
ctx := context.Background()
holdDID := resolver.findHoldDID(ctx, "did:plc:test123", mockPDS.URL)
holdDID, _ := resolver.findHoldDIDAndProfile(ctx, "did:plc:test123", mockPDS.URL)
// Profile should take priority over hold records and default
assert.Equal(t, "did:web:profile.hold.io", holdDID, "should prioritize sailor profile over hold records")
@@ -244,7 +244,7 @@ func TestFindHoldDID_TestModeFallback(t *testing.T) {
}
ctx := context.Background()
holdDID := resolver.findHoldDID(ctx, "did:plc:test123", mockPDS.URL)
holdDID, _ := resolver.findHoldDIDAndProfile(ctx, "did:plc:test123", mockPDS.URL)
// In test mode with unreachable hold, should fall back to default
assert.Equal(t, "did:web:default.atcr.io", holdDID, "should fall back to default in test mode when hold unreachable")
File diff suppressed because one or more lines are too long
+2
View File
@@ -158,6 +158,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
r.Get("/settings", (&uihandlers.SettingsHandler{BaseUIHandler: base}).ServeHTTP)
r.Get("/api/storage", (&uihandlers.StorageHandler{BaseUIHandler: base}).ServeHTTP)
r.Post("/api/profile/default-hold", (&uihandlers.UpdateDefaultHoldHandler{BaseUIHandler: base}).ServeHTTP)
r.Post("/api/profile/auto-remove-untagged", (&uihandlers.UpdateAutoRemoveUntaggedHandler{BaseUIHandler: base}).ServeHTTP)
// Subscription management
r.Get("/settings/subscription/checkout", (&uihandlers.SubscriptionCheckoutHandler{BaseUIHandler: base}).ServeHTTP)
@@ -165,6 +166,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
r.Delete("/api/tags", (&uihandlers.DeleteTagHandler{BaseUIHandler: base}).ServeHTTP)
r.Delete("/api/manifests", (&uihandlers.DeleteManifestHandler{BaseUIHandler: base}).ServeHTTP)
r.Delete("/api/manifests/untagged", (&uihandlers.DeleteUntaggedManifestsHandler{BaseUIHandler: base}).ServeHTTP)
r.Post("/api/avatar", (&uihandlers.UploadAvatarHandler{BaseUIHandler: base}).ServeHTTP)
// Webhook management
+2 -1
View File
@@ -231,9 +231,10 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
// Set global refresher for middleware
middleware.SetGlobalRefresher(s.Refresher)
// Set global database for hold DID lookups
// Set global database for hold DID lookups and manifest reference checks
holdDIDDB := db.NewHoldDIDDB(s.Database)
middleware.SetGlobalDatabase(holdDIDDB)
middleware.SetGlobalManifestRefChecker(holdDIDDB)
// Create RemoteHoldAuthorizer for hold authorization with caching
s.HoldAuthorizer = auth.NewRemoteHoldAuthorizer(s.Database, testMode)
+40
View File
@@ -352,6 +352,45 @@ async function confirmManifestDelete(repository, digest, sanitizedId) {
}
}
// Delete all untagged manifests in a repository
async function deleteUntaggedManifests(repository) {
const confirmBtn = document.getElementById('confirm-untagged-delete-btn');
const originalText = confirmBtn.textContent;
try {
confirmBtn.disabled = true;
confirmBtn.textContent = 'Deleting...';
const response = await fetch('/api/manifests/untagged', {
method: 'DELETE',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo: repository }),
});
const data = await response.json();
if (response.ok) {
document.getElementById('untagged-delete-modal').close();
showToast(`Deleted ${data.deleted} untagged manifest(s)`, 'success');
if (data.deleted > 0) {
location.reload();
}
confirmBtn.disabled = false;
confirmBtn.textContent = originalText;
} else {
alert(`Failed to delete untagged manifests: ${data.error || 'Unknown error'}`);
confirmBtn.disabled = false;
confirmBtn.textContent = originalText;
}
} catch (err) {
console.error('Error deleting untagged manifests:', err);
alert(`Error: ${err.message}`);
confirmBtn.disabled = false;
confirmBtn.textContent = originalText;
}
}
// Remove a manifest element from the DOM
function removeManifestElement(sanitizedId) {
const element = document.getElementById(`manifest-${sanitizedId}`);
@@ -758,6 +797,7 @@ window.closeSearch = closeSearch;
window.copyToClipboard = copyToClipboard;
window.toggleOfflineManifests = toggleOfflineManifests;
window.deleteManifest = deleteManifest;
window.deleteUntaggedManifests = deleteUntaggedManifests;
window.closeManifestDeleteModal = closeManifestDeleteModal;
window.openVulnDetails = openVulnDetails;
window.showToast = showToast;
+16 -5
View File
@@ -15,6 +15,13 @@ type PushWebhookDispatcher interface {
DispatchForPush(ctx context.Context, event PushWebhookEvent)
}
// ManifestReferenceChecker checks if a manifest digest is referenced as a child
// of a manifest list (multi-arch image). Used to protect manifest list children
// from auto-removal when their parent list is still tagged.
type ManifestReferenceChecker interface {
IsManifestReferenced(did, digest string) (bool, error)
}
// PushWebhookEvent contains the data needed to dispatch a push webhook.
type PushWebhookEvent struct {
OwnerDID string
@@ -53,10 +60,14 @@ type RegistryContext struct {
PullerDID string // Puller's DID - who is making the request (from JWT Subject)
PullerPDSEndpoint string // Puller's PDS endpoint URL
// Per-request user preferences
AutoRemoveUntagged bool // Whether to auto-delete untagged manifests on tag overwrite
// Shared services (same for all requests)
Database HoldDIDLookup // Database for hold DID lookups
Authorizer auth.HoldAuthorizer // Hold access authorization
Refresher *oauth.Refresher // OAuth session manager
ReadmeFetcher *readme.Fetcher // README fetcher for repo pages
WebhookDispatcher PushWebhookDispatcher // Push webhook dispatcher (nil if not configured)
Database HoldDIDLookup // Database for hold DID lookups
Authorizer auth.HoldAuthorizer // Hold access authorization
Refresher *oauth.Refresher // OAuth session manager
ReadmeFetcher *readme.Fetcher // README fetcher for repo pages
WebhookDispatcher PushWebhookDispatcher // Push webhook dispatcher (nil if not configured)
ManifestRefChecker ManifestReferenceChecker // Checks if digest is a manifest list child (nil-safe)
}
+91 -1
View File
@@ -210,11 +210,25 @@ func (s *ManifestStore) Put(ctx context.Context, manifest distribution.Manifest,
// Also handle tag if specified
var tag string
var oldDigest string // Track previous digest for auto-remove-untagged cleanup
for _, option := range options {
if tagOpt, ok := option.(distribution.WithTagOption); ok {
tag = tagOpt.Tag
tagRecord := atproto.NewTagRecord(s.ctx.ATProtoClient.DID(), s.ctx.Repository, tag, dgst.String())
tagRKey := atproto.RepositoryTagToRKey(s.ctx.Repository, tag)
// Before overwriting, capture the old digest so we can clean it up
if s.ctx.AutoRemoveUntagged {
if oldRec, getErr := s.ctx.ATProtoClient.GetRecord(ctx, atproto.TagCollection, tagRKey); getErr == nil {
var oldTag atproto.TagRecord
if json.Unmarshal(oldRec.Value, &oldTag) == nil {
if d, dErr := oldTag.GetManifestDigest(); dErr == nil {
oldDigest = d
}
}
}
}
tagRecord := atproto.NewTagRecord(s.ctx.ATProtoClient.DID(), s.ctx.Repository, tag, dgst.String())
_, err = s.ctx.ATProtoClient.PutRecord(ctx, atproto.TagCollection, tagRKey, tagRecord)
if err != nil {
return "", fmt.Errorf("failed to store tag in ATProto: %w", err)
@@ -222,6 +236,12 @@ func (s *ManifestStore) Put(ctx context.Context, manifest distribution.Manifest,
}
}
// Auto-remove untagged manifests: if the tag pointed to a different digest before,
// check if the old manifest is now untagged and clean it up
if s.ctx.AutoRemoveUntagged && oldDigest != "" && oldDigest != dgst.String() {
go s.cleanupUntaggedManifest(oldDigest)
}
// Notify hold about manifest push (for layer tracking, Bluesky posts, and stats)
// Do this asynchronously to avoid blocking the push
// Note: We notify even for tagless pushes (e.g., buildx platform manifests) to create layer records
@@ -697,3 +717,73 @@ func detectImageMimeType(contentType, url string) string {
// Unknown or unsupported type - reject
return ""
}
// cleanupUntaggedManifest checks if a manifest digest is now untagged and deletes it.
// Runs asynchronously after a tag overwrite. Protects manifest list children.
func (s *ManifestStore) cleanupUntaggedManifest(oldDigest string) {
defer func() {
if r := recover(); r != nil {
slog.Error("Panic in cleanupUntaggedManifest", "panic", r)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Re-check: list all tags for this repository and see if any still point to the old digest
records, err := s.ctx.ATProtoClient.ListRecords(ctx, atproto.TagCollection, 100)
if err != nil {
slog.Warn("Auto-remove: failed to list tags", "error", err)
return
}
for _, rec := range records {
var tagRecord atproto.TagRecord
if json.Unmarshal(rec.Value, &tagRecord) != nil {
continue
}
if tagRecord.Repository != s.ctx.Repository {
continue
}
d, dErr := tagRecord.GetManifestDigest()
if dErr == nil && d == oldDigest {
// Still tagged by another tag — do not delete
slog.Debug("Auto-remove: manifest still tagged, skipping",
"digest", oldDigest, "tag", tagRecord.Tag)
return
}
}
// Check if this digest is a child of a manifest list (multi-arch)
if s.ctx.ManifestRefChecker != nil {
referenced, err := s.ctx.ManifestRefChecker.IsManifestReferenced(s.ctx.DID, oldDigest)
if err != nil {
slog.Warn("Auto-remove: failed to check manifest references", "digest", oldDigest, "error", err)
return
}
if referenced {
slog.Debug("Auto-remove: manifest is a manifest list child, skipping",
"digest", oldDigest)
return
}
}
// Safe to delete — manifest is untagged and not referenced by any manifest list
dgst, err := digest.Parse(oldDigest)
if err != nil {
slog.Warn("Auto-remove: invalid digest", "digest", oldDigest, "error", err)
return
}
rkey := digestToRKey(dgst)
if err := s.ctx.ATProtoClient.DeleteRecord(ctx, atproto.ManifestCollection, rkey); err != nil {
slog.Warn("Auto-remove: failed to delete untagged manifest", "digest", oldDigest, "error", err)
return
}
slog.Info("Auto-removed untagged manifest",
"component", "manifest-store",
"digest", oldDigest,
"repository", s.ctx.Repository,
"did", s.ctx.DID)
}
+30 -4
View File
@@ -200,10 +200,19 @@
<div class="card bg-base-100 shadow-sm p-6 space-y-4">
<div class="flex flex-wrap justify-between items-center gap-4">
<h2 class="text-xl font-semibold">Manifests</h2>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" class="checkbox checkbox-sm" id="show-offline-toggle" onchange="toggleOfflineManifests()">
<span>Show offline images</span>
</label>
<div class="flex items-center gap-4">
{{ if $.IsOwner }}
<button class="btn btn-ghost btn-sm text-error"
onclick="document.getElementById('untagged-delete-modal').showModal()"
aria-label="Delete all untagged manifests">
{{ icon "trash-2" "size-4" }} Delete untagged
</button>
{{ end }}
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" class="checkbox checkbox-sm" id="show-offline-toggle" onchange="toggleOfflineManifests()">
<span>Show offline images</span>
</label>
</div>
</div>
{{ if .Manifests }}
<div class="space-y-4 manifests-list">
@@ -313,6 +322,23 @@
</form>
</dialog>
<!-- Untagged Manifests Delete Confirmation Modal -->
<dialog id="untagged-delete-modal" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">Delete Untagged Manifests</h3>
<p class="py-2">This will delete all untagged manifests in this repository.</p>
<p class="font-bold py-2 text-error">This action cannot be undone.</p>
<div class="modal-action">
<form method="dialog"><button class="btn">Cancel</button></form>
<button class="btn btn-error" id="confirm-untagged-delete-btn"
onclick="deleteUntaggedManifests('{{ .Repository.Name }}')">
Delete All Untagged
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- Vulnerability Details Modal -->
<dialog id="vuln-detail-modal" class="modal">
<div class="modal-box max-w-6xl">
+19
View File
@@ -80,6 +80,25 @@
No holds configured. Push an image to get started.
</div>
{{ end }}
<!-- Storage Preferences -->
<section class="card bg-base-200 shadow-sm p-6 space-y-4">
<h2 class="text-xl font-semibold">Storage Preferences</h2>
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" class="toggle toggle-primary mt-0.5"
hx-post="/api/profile/auto-remove-untagged"
hx-trigger="change"
hx-swap="none"
{{ if .Profile.AutoRemoveUntagged }}checked{{ end }}>
<div>
<span class="font-medium">Automatically remove untagged manifests</span>
<p class="text-sm text-base-content/60 mt-1">
When a tag is overwritten, the old manifest and its layers are cleaned up.
Multi-arch child manifests are preserved.
</p>
</div>
</label>
</section>
</div>
<!-- DEVICES TAB -->
+5
View File
@@ -335,6 +335,11 @@ type SailorProfileRecord struct {
// If null/empty, user has opted out of defaults
DefaultHold string `json:"defaultHold,omitempty"`
// AutoRemoveUntagged controls whether untagged manifests are automatically
// cleaned up. When true, manifests that lose all tags (e.g., after a tag
// overwrite) are deleted from PDS, and their layers are cleaned up by hold GC.
AutoRemoveUntagged bool `json:"autoRemoveUntagged,omitempty"`
// CreatedAt timestamp
CreatedAt time.Time `json:"createdAt"`
+1
View File
@@ -35,6 +35,7 @@ var KnownRelays = []KnownRelay{
{Name: "Feeds Blue", URL: "https://relay.feeds.blue"},
{Name: "Waow", URL: "https://relay.waow.tech"},
{Name: "Zlay", URL: "https://zlay.waow.tech"},
{Name: "Bassh", URL: "https://relay.bas.sh"},
}
// RelayHTTPError indicates the relay responded with a non-200 status code.
+168 -8
View File
@@ -578,13 +578,38 @@ func (gc *GarbageCollector) analyzeRecords(ctx context.Context) (*analysisResult
result.usersChecked++
gc.setProgress("manifests", fmt.Sprintf("Fetching manifests (%d/%d users)", result.usersChecked, totalUsers), gc.operationType)
manifests, err := gc.fetchUserManifests(ctx, did)
// Resolve PDS endpoint (needed for both manifests and optional profile/tag fetches)
pdsEndpoint, err := atproto.ResolveDIDToPDS(ctx, did)
if err != nil {
gc.logger.Warn("Failed to resolve PDS for user, treating their records as referenced",
"did", did, "error", err)
continue
}
manifests, err := gc.fetchUserManifestsFromEndpoint(ctx, did, pdsEndpoint, gc.pds.DID())
if err != nil {
gc.logger.Warn("Failed to fetch manifests for user, treating their records as referenced",
"did", did, "error", err)
continue
}
// If user opted into auto-remove-untagged, filter out untagged manifests
// so their layers are treated as orphaned and cleaned up
if profile, _ := gc.fetchUserProfile(ctx, pdsEndpoint, did); profile != nil && profile.AutoRemoveUntagged {
taggedDigests, tagErr := gc.fetchUserTags(ctx, pdsEndpoint, did)
if tagErr != nil {
gc.logger.Warn("Failed to fetch tags for auto-remove, keeping all manifests referenced",
"did", did, "error", tagErr)
} else {
before := len(manifests)
manifests = gc.filterUntaggedManifests(manifests, taggedDigests)
if filtered := before - len(manifests); filtered > 0 {
gc.logger.Info("Filtered untagged manifests for auto-remove user",
"did", did, "filtered", filtered, "remaining", len(manifests))
}
}
}
fetchedUsers[did] = true
for _, m := range manifests {
result.manifestsChecked++
@@ -835,18 +860,153 @@ func (gc *GarbageCollector) discoverUserDIDs(ctx context.Context) ([]string, err
return dids, nil
}
// fetchUserManifests fetches all io.atcr.manifest records for a user from their PDS,
// filtered to manifests that reference this hold.
func (gc *GarbageCollector) fetchUserManifests(ctx context.Context, userDID string) ([]*manifestInfo, error) {
pdsEndpoint, err := atproto.ResolveDIDToPDS(ctx, userDID)
// fetchUserProfile fetches the sailor profile from a user's PDS.
// Returns nil without error if the profile doesn't exist.
func (gc *GarbageCollector) fetchUserProfile(ctx context.Context, pdsEndpoint, userDID string) (*atproto.SailorProfileRecord, error) {
client := &http.Client{Timeout: 10 * time.Second}
reqURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=self",
pdsEndpoint, userDID, atproto.SailorProfileCollection)
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("resolve PDS for %s: %w", userDID, err)
return nil, fmt.Errorf("create request: %w", err)
}
return gc.fetchUserManifestsFromEndpoint(ctx, userDID, pdsEndpoint, gc.pds.DID())
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusNotFound {
return nil, nil // No profile
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("getRecord returned status %d", resp.StatusCode)
}
var envelope struct {
Value json.RawMessage `json:"value"`
}
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
var profile atproto.SailorProfileRecord
if err := json.Unmarshal(envelope.Value, &profile); err != nil {
return nil, fmt.Errorf("unmarshal profile: %w", err)
}
return &profile, nil
}
// fetchUserTags fetches all tag records from a user's PDS.
// Returns a map of digest → true for all tagged manifest digests.
func (gc *GarbageCollector) fetchUserTags(ctx context.Context, pdsEndpoint, userDID string) (map[string]bool, error) {
tagged := make(map[string]bool)
cursor := ""
client := &http.Client{Timeout: 30 * time.Second}
for {
reqURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s&limit=100",
pdsEndpoint, userDID, atproto.TagCollection)
if cursor != "" {
reqURL += "&cursor=" + cursor
}
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("http request: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("listRecords returned status %d", resp.StatusCode)
}
var listResult struct {
Records []struct {
Value json.RawMessage `json:"value"`
} `json:"records"`
Cursor string `json:"cursor,omitempty"`
}
if err := json.NewDecoder(resp.Body).Decode(&listResult); err != nil {
resp.Body.Close()
return nil, fmt.Errorf("decode response: %w", err)
}
resp.Body.Close()
for _, rec := range listResult.Records {
var tag atproto.TagRecord
if err := json.Unmarshal(rec.Value, &tag); err != nil {
continue
}
if d, err := tag.GetManifestDigest(); err == nil {
tagged[d] = true
}
}
if listResult.Cursor == "" {
break
}
cursor = listResult.Cursor
}
return tagged, nil
}
// filterUntaggedManifests removes untagged manifests from the list, preserving
// manifest list children that are referenced by tagged manifest lists.
func (gc *GarbageCollector) filterUntaggedManifests(manifests []*manifestInfo, taggedDigests map[string]bool) []*manifestInfo {
// Build a set of digests that are children of tagged manifest lists
childDigests := make(map[string]bool)
for _, m := range manifests {
// Check if this manifest is a manifest list AND is tagged
mDigest := extractDigestFromManifestURI(m.URI)
if mDigest == "" || !taggedDigests[mDigest] {
continue
}
// This is a tagged manifest list — protect its children
for _, ref := range m.Record.Manifests {
childDigests[ref.Digest] = true
}
}
// Filter: keep manifests that are tagged, or are children of tagged manifest lists
var kept []*manifestInfo
for _, m := range manifests {
mDigest := extractDigestFromManifestURI(m.URI)
if mDigest == "" {
kept = append(kept, m)
continue
}
if taggedDigests[mDigest] || childDigests[mDigest] {
kept = append(kept, m)
}
}
return kept
}
// extractDigestFromManifestURI extracts the digest from a manifest AT-URI.
// URI format: at://did:plc:xxx/io.atcr.manifest/{encoded_digest}
// Returns the full digest (e.g., "sha256:abc123...") or empty string.
func extractDigestFromManifestURI(uri string) string {
parts := parseATURI(uri)
if parts == nil || parts.Collection != atproto.ManifestCollection {
return ""
}
// The rkey is the digest without the "sha256:" prefix
return "sha256:" + parts.Rkey
}
// fetchUserManifestsFromEndpoint fetches manifests from a specific PDS endpoint.
// Separated from fetchUserManifests for testability (avoids DID resolution).
func (gc *GarbageCollector) fetchUserManifestsFromEndpoint(ctx context.Context, userDID, pdsEndpoint, holdDID string) ([]*manifestInfo, error) {
var manifests []*manifestInfo
cursor := ""
+300
View File
@@ -451,6 +451,306 @@ func TestFetchUserManifests_PDSError(t *testing.T) {
}
}
func TestExtractDigestFromManifestURI(t *testing.T) {
tests := []struct {
name string
uri string
expected string
}{
{
name: "valid manifest URI",
uri: "at://did:plc:user1/io.atcr.manifest/abc123def456",
expected: "sha256:abc123def456",
},
{
name: "wrong collection",
uri: "at://did:plc:user1/io.atcr.tag/abc123def456",
expected: "",
},
{
name: "invalid URI",
uri: "not-an-at-uri",
expected: "",
},
{
name: "empty URI",
uri: "",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractDigestFromManifestURI(tt.uri)
if got != tt.expected {
t.Errorf("extractDigestFromManifestURI(%q) = %q, want %q", tt.uri, got, tt.expected)
}
})
}
}
func TestFilterUntaggedManifests(t *testing.T) {
gc := &GarbageCollector{logger: newTestLogger()}
t.Run("keeps tagged manifests, removes untagged", func(t *testing.T) {
manifests := []*manifestInfo{
{URI: "at://did:plc:u1/io.atcr.manifest/aaa111", Record: &atproto.ManifestRecord{}},
{URI: "at://did:plc:u1/io.atcr.manifest/bbb222", Record: &atproto.ManifestRecord{}},
{URI: "at://did:plc:u1/io.atcr.manifest/ccc333", Record: &atproto.ManifestRecord{}},
}
tagged := map[string]bool{
"sha256:aaa111": true,
"sha256:ccc333": true,
}
result := gc.filterUntaggedManifests(manifests, tagged)
if len(result) != 2 {
t.Fatalf("expected 2 kept, got %d", len(result))
}
if result[0].URI != manifests[0].URI || result[1].URI != manifests[2].URI {
t.Errorf("unexpected manifests kept: %v, %v", result[0].URI, result[1].URI)
}
})
t.Run("preserves manifest list children", func(t *testing.T) {
// Manifest list (tagged) references child (untagged)
manifests := []*manifestInfo{
{
URI: "at://did:plc:u1/io.atcr.manifest/index111",
Record: &atproto.ManifestRecord{
Manifests: []atproto.ManifestReference{
{Digest: "sha256:child222", MediaType: "application/vnd.oci.image.manifest.v1+json"},
{Digest: "sha256:child333", MediaType: "application/vnd.oci.image.manifest.v1+json"},
},
},
},
{URI: "at://did:plc:u1/io.atcr.manifest/child222", Record: &atproto.ManifestRecord{}},
{URI: "at://did:plc:u1/io.atcr.manifest/child333", Record: &atproto.ManifestRecord{}},
{URI: "at://did:plc:u1/io.atcr.manifest/orphan444", Record: &atproto.ManifestRecord{}},
}
tagged := map[string]bool{
"sha256:index111": true, // Only the index is tagged
}
result := gc.filterUntaggedManifests(manifests, tagged)
if len(result) != 3 {
t.Fatalf("expected 3 kept (index + 2 children), got %d", len(result))
}
// orphan444 should be filtered out
for _, m := range result {
if extractDigestFromManifestURI(m.URI) == "sha256:orphan444" {
t.Error("orphan manifest should have been filtered out")
}
}
})
t.Run("removes everything when nothing is tagged", func(t *testing.T) {
manifests := []*manifestInfo{
{URI: "at://did:plc:u1/io.atcr.manifest/aaa111", Record: &atproto.ManifestRecord{}},
{URI: "at://did:plc:u1/io.atcr.manifest/bbb222", Record: &atproto.ManifestRecord{}},
}
tagged := map[string]bool{}
result := gc.filterUntaggedManifests(manifests, tagged)
if len(result) != 0 {
t.Fatalf("expected 0 kept, got %d", len(result))
}
})
t.Run("keeps everything when all are tagged", func(t *testing.T) {
manifests := []*manifestInfo{
{URI: "at://did:plc:u1/io.atcr.manifest/aaa111", Record: &atproto.ManifestRecord{}},
{URI: "at://did:plc:u1/io.atcr.manifest/bbb222", Record: &atproto.ManifestRecord{}},
}
tagged := map[string]bool{
"sha256:aaa111": true,
"sha256:bbb222": true,
}
result := gc.filterUntaggedManifests(manifests, tagged)
if len(result) != 2 {
t.Fatalf("expected 2 kept, got %d", len(result))
}
})
}
func TestFetchUserProfile(t *testing.T) {
gc := &GarbageCollector{logger: newTestLogger()}
t.Run("returns profile with autoRemoveUntagged", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"value": map[string]any{
"$type": "io.atcr.sailor.profile",
"defaultHold": "did:web:hold.example.com",
"autoRemoveUntagged": true,
"createdAt": "2025-01-01T00:00:00Z",
},
})
}))
defer server.Close()
profile, err := gc.fetchUserProfile(t.Context(), server.URL, "did:plc:test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if profile == nil {
t.Fatal("expected non-nil profile")
}
if !profile.AutoRemoveUntagged {
t.Error("expected AutoRemoveUntagged to be true")
}
if profile.DefaultHold != "did:web:hold.example.com" {
t.Errorf("expected DefaultHold %q, got %q", "did:web:hold.example.com", profile.DefaultHold)
}
})
t.Run("returns nil for 404", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
profile, err := gc.fetchUserProfile(t.Context(), server.URL, "did:plc:test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if profile != nil {
t.Error("expected nil profile for 404")
}
})
t.Run("returns error for server failure", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
_, err := gc.fetchUserProfile(t.Context(), server.URL, "did:plc:test")
if err == nil {
t.Fatal("expected error for 500 response")
}
})
}
func TestFetchUserTags(t *testing.T) {
gc := &GarbageCollector{logger: newTestLogger()}
t.Run("returns tagged digests", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"records": []map[string]any{
{
"uri": "at://did:plc:user1/io.atcr.tag/myapp_latest",
"cid": "bafyrei1",
"value": map[string]any{
"$type": "io.atcr.tag",
"repository": "myapp",
"tag": "latest",
"manifest": "at://did:plc:user1/io.atcr.manifest/abc123",
},
},
{
"uri": "at://did:plc:user1/io.atcr.tag/myapp_v1",
"cid": "bafyrei2",
"value": map[string]any{
"$type": "io.atcr.tag",
"repository": "myapp",
"tag": "v1",
"manifestDigest": "sha256:def456",
},
},
},
})
}))
defer server.Close()
tagged, err := gc.fetchUserTags(t.Context(), server.URL, "did:plc:user1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(tagged) != 2 {
t.Fatalf("expected 2 tagged digests, got %d", len(tagged))
}
if !tagged["sha256:abc123"] {
t.Error("expected sha256:abc123 to be tagged")
}
if !tagged["sha256:def456"] {
t.Error("expected sha256:def456 to be tagged")
}
})
t.Run("handles pagination", func(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
if callCount == 1 {
json.NewEncoder(w).Encode(map[string]any{
"records": []map[string]any{
{
"uri": "at://did:plc:user1/io.atcr.tag/myapp_latest",
"cid": "bafyrei1",
"value": map[string]any{
"$type": "io.atcr.tag",
"repository": "myapp",
"tag": "latest",
"manifestDigest": "sha256:aaa111",
},
},
},
"cursor": "page2",
})
} else {
json.NewEncoder(w).Encode(map[string]any{
"records": []map[string]any{
{
"uri": "at://did:plc:user1/io.atcr.tag/myapp_v2",
"cid": "bafyrei2",
"value": map[string]any{
"$type": "io.atcr.tag",
"repository": "myapp",
"tag": "v2",
"manifestDigest": "sha256:bbb222",
},
},
},
})
}
}))
defer server.Close()
tagged, err := gc.fetchUserTags(t.Context(), server.URL, "did:plc:user1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if callCount != 2 {
t.Errorf("expected 2 HTTP calls for pagination, got %d", callCount)
}
if len(tagged) != 2 {
t.Fatalf("expected 2 tagged digests, got %d", len(tagged))
}
})
t.Run("returns empty for no tags", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"records": []any{}})
}))
defer server.Close()
tagged, err := gc.fetchUserTags(t.Context(), server.URL, "did:plc:user1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(tagged) != 0 {
t.Errorf("expected 0 tagged digests, got %d", len(tagged))
}
})
}
func newTestLogger() *slog.Logger {
return slog.Default().With("component", "gc-test")
}
+6 -2
View File
@@ -13,6 +13,9 @@ import (
//go:embed public
var publicFS embed.FS
//go:embed templates
var templatesFS embed.FS
//go:embed theme.css
var themeCSS string
@@ -29,8 +32,9 @@ func branding() *appview.BrandingOverrides {
}
return &appview.BrandingOverrides{
PublicFS: prefixFS{sub: pubSub},
ExtraCSS: themeCSS,
PublicFS: prefixFS{sub: pubSub},
TemplatesFS: templatesFS,
ExtraCSS: themeCSS,
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+1 -51
View File
@@ -1,51 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg1"
width="1090"
height="1090"
viewBox="0 0 1090 1090"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="g1">
<path
id="path23"
style="display:inline;fill:#274b70;fill-opacity:1"
d="M -0.01317212,1090 H 1090 l 0.015,-185.73378 c 0,0 -12.771,11.11089 -20.3496,9.12889 -4.3399,-0.83912 -8.4264,-2.62038 -12.4391,-4.42115 -8.2227,-3.83234 -15.9956,-8.55376 -24.2327,-12.35744 -12.1338,-5.88311 -24.4765,-11.48724 -37.46146,-15.23384 -24.04271,-7.03785 -49.48404,-9.44127 -74.39516,-6.64199 -21.46625,2.40336 -42.12428,9.23019 -62.20106,16.93758 -14.49619,5.55198 -28.78226,11.664 -43.4538,16.75159 -8.35092,2.88186 -519.89836,-4.03589 -535.96117,6.81185 -12.16816,-5.81244 -56.21202,-23.50947 -68.89637,-28.15501 -20.04627,-7.7693 -41.27822,-12.93472 -62.83331,-13.52787 -22.97961,-0.73438 -46.005,3.80583 -67.270783,12.44855 -12.635563,5.02475 -24.794956,11.1329 -37.185172,16.71586 -1.900211,0.8456 -3.989605,1.74968 -5.981072,2.57383 -5.138432,2.143 -10.388702,4.0021 -15.690331,5.69636 -1.152227,0.39204 -2.306258,0.77931 -3.469222,1.13801 -16.8199417,6.89612 -21.52823655,1.93562 -21.52823655,1.93562 z" />
<path
id="path22"
style="display:inline;fill:#61bbe1;fill-opacity:1"
d="m 1090,926.95153 c -11.2114,26.3292 -50.8297,0.127 -60.3771,-4.59857 -12.4675,-6.04873 -24.8936,-12.37138 -38.17896,-16.46458 -19.03984,-5.98112 -39.16494,-8.4242 -59.08682,-7.39699 -10.29661,0.53472 -20.49911,2.30579 -30.47082,4.89414 -21.54762,5.52277 -41.98308,14.45497 -62.6372,22.55588 -8.62098,3.34518 -17.3029,6.57622 -26.1953,9.13144 -197.59116,111.70745 -346.02714,76.73205 -561.3711,-2.59824 -8.70256,-3.18696 -16.96793,-7.40804 -25.24965,-11.51468 -6.1587,-3.07626 -12.41209,-5.96242 -18.78301,-8.57403 -17.44031,-7.23104 -35.85399,-12.54162 -54.75191,-13.87663 -18.01617,-1.32455 -36.27571,1.03188 -53.3963,6.78028 -15.29882,5.02733 -29.671525,12.37439 -44.264446,19.09326 -5.527211,2.52184 -11.26033,5.02996 -17.057938,7.06927 -13.183102,2.84061 -34.243289,10.71153 -41.53281788,-0.58829 -1.17550402,95.48431 -0.10901225,61.28612 -2.2055e-4,159.80111 391.05404243,0.012 702.29186243,-0.01 1090,0 0.033,-36.1703 0.01,-163.71777 0.01,-163.71777 z" />
<path
style="display:inline;fill:#218dc2"
d="m -0.00162752,1090 0.0526526,-27.8286 c 0,0 8.40437732,1.7278 15.97151792,2.6512 33.41462,0.4097 67.31305,-18.9114 91.502107,-29.2702 42.58278,-16.7741 85.89024,-1.2978 117.63973,19.0385 47.09881,18.3937 90.87125,13.4381 132.26905,-11.8054 38.02332,-20.184 90.84811,-18.1331 125.2167,7.0418 43.66694,21.0765 94.45211,18.8173 136.0225,-5.3561 36.32638,-21.2688 85.698,-23.0792 121.33,0.7834 47.48382,27.5274 109.39196,24.1962 150.25816,-3.5602 40.37789,-20.5509 86.85688,-16.917 120.05141,5.6917 26.8896,12.678 56.3726,19.6659 83.0381,16.9163 0.01,8.4157 -0.023,25.7079 -0.023,25.7079 z m -0.06958216,-68.3613 0.02118531,-49.38008 C 29.381094,992.44117 85.126119,954.9885 112.62946,944.16913 c 37.52595,-13.38769 69.89895,-3.34366 111.10946,18.56913 21.87489,9.64713 42.18198,19.08184 55.90444,2.48181 4.83367,-5.84728 8.98261,-11.55836 9.64558,-5.60254 0.97166,8.72892 8.86558,25.52919 9.40053,27.45865 15.93711,16.97012 40.00103,19.47452 -13.41377,28.31252 -43.07485,1.5627 -79.23128,-34.82054 -129.36305,-36.75735 -42.32945,-1.92979 -74.486692,21.47215 -111.200168,31.39655 -14.06499,3.0773 -36.4032106,4.6821 -44.78369168,12.2874 z M 1090,966.75059 c 0.6213,2.2125 0.096,53.09981 0.096,53.09981 -37.5217,7.8169 -100.05213,-33.74134 -145.77011,-39.52717 -45.89414,-0.18586 -34.95376,3.05224 -80.71314,21.05997 -22.49136,10.7156 -66.08351,15.3854 -88.28248,7.3603 13.90226,-17.31704 24.01869,-30.93465 24.01869,-30.93465 43.21199,-1.76049 95.4792,-40.10599 140.87288,-39.58947 46.08879,0.091 63.62466,19.34873 94.44276,29.75887 50.7517,18.24454 55.3355,-1.22766 55.3355,-1.22766 z"
id="path19" />
<path
id="path17"
style="display:inline;fill:#274b70;fill-opacity:1;stroke:#0f1e30;stroke-width:0"
d="m 546.88086,307.24023 c -16.25149,0.22404 -32.29505,8.53489 -41.47839,22.00999 -6.11046,8.80977 -8.84246,19.73569 -8.27158,30.39314 l 0.006,44.10273 c -6.53042,0.0258 -13.06725,-0.085 -19.59316,0.13473 -10.06021,1.0502 -18.72833,9.8651 -19.48853,19.97117 -0.28915,4.67912 -0.0151,9.36983 -0.11467,14.05387 -0.0182,7.57124 0.085,15.14576 0.0642,22.71471 -0.59209,4.96824 -1.52336,9.88991 -2.40421,14.81234 -28.84204,126.17406 -57.68407,252.34811 -86.5261,378.52217 -7.39964,0.01 -14.82289,0.65866 -22.02965,2.38833 -19.0789,4.35412 -36.8719,14.78777 -49.40118,29.89879 -11.50346,13.82858 -18.51543,31.51251 -18.8269,49.55682 -0.17848,6.78628 -0.1065,13.57846 0.062,20.36364 0.7539,14.95505 5.88537,29.62215 14.46849,41.87946 7.14342,10.27248 16.61869,19.01988 27.73317,24.82958 6.57232,3.2652 14.34528,4.1644 21.43142,2.1489 5.37582,-1.4716 10.1551,-4.4325 15.06509,-6.9737 7.64527,-4.1151 15.34626,-8.3856 23.81815,-10.5515 1.50639,-0.4123 3.21632,-0.8292 4.81412,-1.1888 0.83855,-0.1801 1.96113,-0.4271 2.91476,-0.6072 3.86309,-0.7703 7.97186,-1.384 11.97662,-1.8571 6.71548,-0.7586 13.45217,-1.4014 20.20823,-1.6411 2.6321,-0.07 5.43867,0.061 7.94727,0.3242 2.73679,0.2833 5.49754,0.7503 8.10516,1.3374 3.96612,0.8894 7.84568,2.1308 11.64875,3.5591 0.74664,0.2883 1.7671,0.6836 2.61788,1.0185 9.12665,3.7142 17.91846,8.1848 26.95617,12.103 11.80678,5.194 24.08797,9.4496 36.80079,11.7704 10.14335,1.9097 20.48623,2.5615 30.79664,2.4353 6.8993,0.098 13.82287,0.3937 20.69938,-0.3504 13.79531,-1.2981 27.30912,-5.1527 39.8335,-11.0535 11.07992,-5.0151 21.70146,-10.9248 33.21204,-14.9489 3.49458,-1.2178 7.09987,-2.2522 10.63375,-3.0789 2.26759,-0.5319 4.31819,-0.9453 6.61939,-1.3509 8.66695,-1.5203 17.47663,-2.0074 26.26131,-2.2005 2.28766,-0.015 4.75741,0.073 6.92266,0.3024 9.55998,1.0753 18.85868,3.7706 27.86133,7.0918 1.39686,0.527 3.00255,1.141 4.46827,1.7344 3.03372,1.2215 5.99547,2.5027 8.89227,3.8323 5.47093,2.4963 11.0455,5.3158 16.45758,8.1839 2.94418,1.7125 6.18504,2.9322 9.5677,3.3964 6.47607,0.9567 13.20626,-0.529 18.81961,-3.854 4.24605,-2.4485 8.15298,-5.4463 11.90386,-8.5891 13.20276,-11.3051 23.11206,-26.63756 27.15884,-43.6049 1.89016,-7.78677 2.43703,-15.84137 2.20904,-23.8338 0.0533,-8.82633 0.12149,-17.77819 -2.0432,-26.39839 -4.20691,-18.34832 -15.1256,-34.95719 -29.95652,-46.48705 -15.31265,-12.03638 -34.55411,-18.84375 -53.99187,-19.52067 -1.63184,-0.0655 -3.26452,-0.10786 -4.89769,-0.12281 -2.6944,-10.95755 -5.01744,-21.9618 -7.54773,-32.96412 -26.97758,-119.45401 -53.95516,-238.90803 -80.93274,-358.36205 0.37669,-5.97323 0.055,-11.96025 0.15108,-17.93984 -0.0215,-7.33657 0.0878,-14.68165 -0.25963,-22.01062 -1.18679,-8.96759 -8.58959,-16.53035 -17.3563,-18.4236 -3.83894,-0.82157 -7.78811,-0.36734 -11.67929,-0.48047 -3.43828,0.004 -6.87656,0.007 -10.31484,0.0108 0,0 -1.3085,-61.14045 -2.00196,-61.375 -4.31867,-14.335 -15.6879,-26.23893 -29.66529,-31.51063 -6.4577,-2.50081 -13.40358,-3.70563 -20.32494,-3.6007 z" />
<path
id="path16"
style="display:inline;fill:#f9e008;fill-opacity:1;stroke:#0f1e30;stroke-width:0"
d="m 549.27734,329.25391 c -15.92457,-0.65336 -29.40908,10.71067 -30.11914,25.38281 -0.0103,0.32413 -0.0142,0.6484 -0.0117,0.97266 h -0.01 v 48.70898 h 57.73633 v -48.70898 h -0.0156 c -0.1224,-14.14529 -12.24206,-25.72681 -27.58008,-26.35547 z m -69.09375,96.48828 c -0.13313,0 -0.24023,0.1071 -0.24023,0.24023 v 31.37696 c 0,0.13312 0.1071,0.24023 0.24023,0.24023 h 136.05469 c 0.13313,0 0.24024,-0.10711 0.24024,-0.24023 v -31.37696 c 0,-0.13313 -0.10711,-0.24023 -0.24024,-0.24023 z m -2.62695,52.32617 -85.60547,374.51172 c 0.0242,-0.10566 194.25696,0.18573 291.36524,0.91992 11.45831,-0.002 20.72461,-0.083 20.69531,-0.21289 l -84.7207,-375.15234 c -0.0245,-0.1086 -141.73438,-0.0664 -141.73438,-0.0664 z M 368.41602,875.9375 c -37.49631,0 -67.6836,27.652 -67.6836,62 V 951.5 c 0,34.348 27.84432,52.0317 32.62489,52.5284 3.52213,0.3659 5.1766,-0.9263 7.49788,-2.0723 19.79102,-9.77106 27.51938,-18.73043 77.34759,-21.70852 49.30636,-2.17161 69.18528,34.67532 129.15625,32.40122 55.96201,3.7193 62.98649,-31.31252 131.14335,-32.71329 32.99055,-2.7514 73.77237,20.39069 81.18591,24.27319 1.62665,0.8519 3.7142,0.1598 4.33515,-0.084 2.94408,-1.1543 31.68359,-18.277 31.68359,-52.625 v -13.5625 c 0,-34.348 -30.18729,-62 -67.68359,-62 z" />
<path
id="rect1"
style="display:inline;fill:#edc506;fill-opacity:1;stroke:#0f1e30;stroke-width:0"
d="m 554.46094,339.76562 c -12.43766,0 -18.57617,9.82407 -18.57617,22.26172 -0.096,6.55599 0.0253,13.48685 0.0625,20.04297 l 0.0625,22.24805 h 40.86328 v -43.96484 c -0.69011,-11.78122 -10.39708,-20.5879 -22.39063,-20.5879 z m 20.28515,86.04493 v 31.77539 h 41.57227 c 0.0682,-0.0241 0.12242,-0.0783 0.14648,-0.14649 v -31.53711 c -0.0123,-0.0349 -0.0327,-0.0665 -0.0586,-0.0918 z m -0.23047,52.25586 65.05469,375.16211 c 0.12066,6.3e-4 0.24079,10e-4 0.36133,0.002 l 60.87305,0.17383 h 0.082 c 1.99029,-0.0323 3.13533,-0.0714 3.125,-0.11719 l -84.7207,-375.15234 h -0.002 l -0.002,-0.002 h -0.0137 c -0.0466,-0.003 -2.85909,-0.003 -3.0918,-0.006 l -39.17773,-0.0605 c -0.82434,-3.1e-4 -1.65212,2.8e-4 -2.48829,0 z m 68.49024,397.87109 c -0.30147,0.0156 -0.6021,0.033 -0.90234,0.0527 24.35837,2.97069 43.10546,23.61842 43.10546,48.80469 v 39.42578 c 0,5.42101 -0.86859,10.63255 -2.4746,15.50196 32.15427,-0.61129 69.85368,20.76837 76.95312,24.48637 1.62665,0.8519 3.71499,0.1598 4.33594,-0.084 2.1791,-0.8544 18.46765,-10.48342 26.79687,-29.34378 0.17803,-0.40966 0.3619,-0.80956 0.53321,-1.22852 0.43216,-1.04394 0.81832,-2.13589 1.19726,-3.23437 0.22188,-0.64262 0.44798,-1.2809 0.65234,-1.94336 0.43016,-1.41189 0.81813,-2.86004 1.14649,-4.35742 0.0453,-0.1957 0.095,-0.38674 0.13867,-0.58399 0.77367,-3.71825 1.21875,-7.69247 1.21875,-11.93359 v -13.5625 c 0,-4.55407 -0.53106,-8.98996 -1.53906,-13.25977 -1.10972,-4.46696 -2.682,-8.74713 -4.66406,-12.78711 -10.7061,-21.26405 -34.152,-35.95312 -61.48047,-35.95312 z" />
<path
id="rect17"
style="display:inline;fill:#ffffff;stroke:#0f1e30;stroke-width:0"
d="m 497.65234,507.57617 c -4.04177,-0.13328 -6.04125,2.16956 -7.48828,8.49805 L 425.46094,814.3457 c -0.8144,4.23538 -2.2246,10.49817 7.78515,11.6836 7.02508,0.69367 9.37804,-2.5307 10.51368,-9.25782 l 64.72461,-296.60351 c 1.45516,-8.75244 -0.5719,-9.68254 -6.05469,-11.70508 -1.85628,-0.52724 -3.43009,-0.84229 -4.77735,-0.88672 z m 48.67578,385.41211 -186.23437,0.0879 c -25.6219,-0.52968 -37.98443,24.9384 -41.18945,39.86328 -0.14877,9.59973 1.3897,17.02404 8.49804,17.25977 13.00856,-0.13018 8.62447,-17.92766 14.44727,-24.94141 4.94695,-5.95876 9.88516,-10.21097 19.39453,-12.11719 l 184.64258,-1.32617 c 10.15974,-0.0818 13.70554,-3.90915 13.74414,-10.60742 -0.051,-6.20127 -3.42521,-8.26815 -13.30274,-8.21875 z" />
<path
id="rect24"
style="fill:#f9e008;fill-opacity:1;stroke:#0f1e30;stroke-width:0"
d="m 240.01367,-547.80469 c 0,5.79888 4.66792,10.46875 10.4668,10.46875 h 24.74023 c 5.79888,0 10.46875,-4.66987 10.46875,-10.46875 0,-5.79887 -4.66987,-10.46679 -10.46875,-10.46679 h -24.74023 c -5.79888,0 -10.4668,4.66792 -10.4668,10.46679 z m 33.99414,-81.32031 c -0.0467,2.67523 0.9312,5.3694 2.94531,7.45508 l 17.18555,17.79687 c 4.02824,4.17137 10.62942,4.28605 14.80078,0.25782 4.17137,-4.02824 4.28605,-10.62942 0.25782,-14.80079 l -17.18555,-17.79687 c -4.02824,-4.17136 -10.62942,-4.28605 -14.80078,-0.25781 -2.08568,2.01412 -3.15643,4.67047 -3.20313,7.3457 z m 0.74414,162.32617 c 0.0467,2.67523 1.11745,5.33159 3.20313,7.34571 4.17136,4.02823 10.77254,3.91354 14.80078,-0.25782 l 17.1875,-17.79687 c 4.02824,-4.17136 3.9116,-10.77255 -0.25977,-14.80078 -4.17136,-4.02824 -10.77059,-3.91355 -14.79882,0.25781 l -17.1875,17.79687 c -2.01412,2.08568 -2.99201,4.77985 -2.94532,7.45508 z m 77.31836,-169.35742 c 0,5.79888 4.66988,10.4668 10.46875,10.4668 5.79888,0 10.4668,-4.66792 10.4668,-10.4668 v -24.74023 c 0,-5.79888 -4.66792,-10.4668 -10.4668,-10.4668 -5.79887,0 -10.46875,4.66792 -10.46875,10.4668 z m 1.03516,200.41016 c 0,5.79887 4.66987,10.46679 10.46875,10.46679 5.79887,0 10.4668,-4.66792 10.4668,-10.46679 v -24.74219 c 0,-5.79888 -4.66793,-10.4668 -10.4668,-10.4668 -5.79888,0 -10.46875,4.66792 -10.46875,10.4668 z"
transform="rotate(90)" />
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="48" height="48" viewBox="0 0 48 48" id="svg1" xml:space="preserve" xmlns:svg="http://www.w3.org/2000/svg"><defs id="defs1"></defs><g id="layer1" style="display:inline"><path id="rect24" style="fill:#f0802c;fill-opacity:1;stroke:none;stroke-width:1;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke" d="m 40,12 c -1.662,0 -3,1.338 -3,3 h 11 c 0,-1.662 -1.338,-3 -3,-3 z"></path><path id="path18" style="display:inline;fill:#e1e5e6;fill-opacity:1;stroke:none;stroke-width:1;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke" d="m 34,5 c -3.313708,0 -6,2.2385763 -6,5 V 25 L 0,40 H 28.25 C 37.479385,40 40,33.284271 40,25 V 10 C 40,7.2385763 37.313708,5 34,5 Z"></path><ellipse style="display:inline;fill:#586066;fill-opacity:1;stroke-width:1.302;stroke-linejoin:round;paint-order:fill markers stroke" id="path14" cx="35" cy="10.877969" rx="1.1974249" ry="1.1220315"></ellipse><path id="path15" style="display:inline;fill:#586066;fill-opacity:1;stroke:none;stroke-width:1.1547;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke" d="M 12,31 1,39 h 18 c 1.104569,0 2,-1.193907 2,-2.666667 0,-1.472759 -0.895431,-2.666666 -2,-2.666666 -0.495037,1.78e-4 -0.972434,0.24514 -1.339844,0.6875 z"></path><a id="a27"><path id="path26" style="fill:#a4adb4;fill-opacity:1;stroke:none;stroke-width:1.06667;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke" d="M 28,20 C 19.615996,22.218354 10,32 3.4999999,37 H 22.5 C 30,37 32,30 32,25 32,22 30,20 28,20 Z"></path></a></g></svg>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="48"
height="48"
viewBox="0 0 48 48"
version="1.1"
id="svg1"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><g
id="layer1"
style="display:inline"><path
id="rect24"
style="fill:#f0802c;fill-opacity:1;stroke:none;stroke-width:1;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke"
d="m 40,12 c -1.662,0 -3,1.338 -3,3 h 11 c 0,-1.662 -1.338,-3 -3,-3 z" /><path
id="path18"
style="display:inline;fill:#e1e5e6;fill-opacity:1;stroke:none;stroke-width:1;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke"
d="m 34,5 c -3.313708,0 -6,2.2385763 -6,5 V 25 L 0,40 H 28.25 C 37.479385,40 40,33.284271 40,25 V 10 C 40,7.2385763 37.313708,5 34,5 Z" /><ellipse
style="display:inline;fill:#586066;fill-opacity:1;stroke-width:1.302;stroke-linejoin:round;paint-order:fill markers stroke"
id="path14"
cx="35"
cy="10.877969"
rx="1.1974249"
ry="1.1220315" /><path
id="path15"
style="display:inline;fill:#586066;fill-opacity:1;stroke:none;stroke-width:1.1547;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke"
d="M 12,31 1,39 h 18 c 1.104569,0 2,-1.193907 2,-2.666667 0,-1.472759 -0.895431,-2.666666 -2,-2.666666 -0.495037,1.78e-4 -0.972434,0.24514 -1.339844,0.6875 z" /><a
id="a27"><path
id="path26"
style="fill:#a4adb4;fill-opacity:1;stroke:none;stroke-width:1.06667;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke"
d="M 28,20 C 19.615996,22.218354 10,32 3.4999999,37 H 22.5 C 30,37 32,30 32,25 32,22 30,20 28,20 Z" /></a></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,66 @@
{{ define "hero" }}
{{/*
Hero section component - displays landing page hero for non-authenticated users
*/}}
<section class="hero bg-base-200 min-h-[60vh] py-16 pb-24 relative overflow-hidden">
<img src="/static/seamark_seagull_buoy.png" alt="" class="hidden lg:block absolute left-[12%] top-1/3 w-60 h-auto z-10 pointer-events-none animate-rock" aria-hidden="true">
<div class="hero-content text-center flex-col relative z-10 w-full">
<h1 id="hero-heading" class="text-4xl md:text-5xl font-bold">your beacon <span class="text-primary">at</span> sea.</h1>
<script>
(function() {
var h = [
["guiding you ", "at", " sea."],
["your beacon ", "at", " sea."],
["never lost ", "at", " sea."],
["find your way ", "at", " sea."],
["charting courses ", "at", " sea."]
];
var pick = h[Math.floor(Math.random() * h.length)];
var el = document.getElementById("hero-heading");
el.innerHTML = pick[0] + '<span class="text-primary">' + pick[1] + '</span>' + pick[2];
})();
</script>
<p class="text-lg text-base-content/70 max-w-lg mt-4">
Push and pull Docker images on the AT Protocol.<br>
Browse public registries or control your data.
</p>
<div class="mockup-code bg-base-300 text-base-content text-left w-full max-w-lg text-base mt-8">
<pre data-prefix="$"><code>docker login {{ .RegistryURL }}</code></pre>
<pre data-prefix="$"><code>docker push {{ .RegistryURL }}/you/app</code></pre>
<pre data-prefix="#" class="text-base-content/65"><code>same docker, decentralized</code></pre>
</div>
<div class="flex items-center justify-center gap-4 mt-8">
<a href="/auth/oauth/login?return_to=/" class="btn btn-primary btn-lg">Get Started</a>
<a href="/learn-more" class="btn btn-ghost btn-lg" aria-label="How the {{ .ClientShortName }} container registry works">How It Works</a>
</div>
<!-- Benefit Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-12 w-full max-w-4xl">
<div class="card bg-base-100 shadow-sm p-6 text-center">
<div class="text-primary mb-4 flex justify-center">
{{ icon "ship" "size-8" }}
</div>
<h2 class="font-semibold text-lg">Works with Docker</h2>
<p class="text-base-content/70 mt-2">Use docker push &amp; pull. No new tools to learn.</p>
</div>
<div class="card bg-base-100 shadow-sm p-6 text-center">
<div class="text-primary mb-4 flex justify-center">
{{ icon "anchor" "size-8" }}
</div>
<h2 class="font-semibold text-lg">Your Data</h2>
<p class="text-base-content/70 mt-2">Join shared holds or captain your own storage.</p>
</div>
<div class="card bg-base-100 shadow-sm p-6 text-center">
<div class="text-primary mb-4 flex justify-center">
{{ icon "compass" "size-8" }}
</div>
<h2 class="font-semibold text-lg">Discover Images</h2>
<p class="text-base-content/70 mt-2">Browse and star public container registries.</p>
</div>
</div>
</div>
<img src="/static/wave-pattern.svg" width="1440" height="128" alt="" class="absolute bottom-0 left-0 w-full h-32 pointer-events-none" aria-hidden="true">
</section>
{{ end }}
+10
View File
@@ -12,3 +12,13 @@
--color-accent: oklch(66.6% 0.121 28);
--color-accent-content: oklch(28% 0.121 28);
}
@keyframes rock {
0%, 100% { transform: translateY(-50%) rotate(-3deg); }
50% { transform: translateY(-50%) rotate(3deg); }
}
.animate-rock {
animation: rock 3s ease-in-out infinite;
transform-origin: bottom center;
}