impeccable fixes, scanner fixes

This commit is contained in:
Evan Jarrett
2026-04-12 20:36:57 -05:00
parent 25628dad2c
commit 2f30c22f0a
23 changed files with 644 additions and 264 deletions

1
.gitignore vendored
View File

@@ -36,3 +36,4 @@ pkg/hold/admin/public/css/style.css
.DS_Store
Thumbs.db
node_modules
.impeccable.md

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/url"
"regexp"
"strings"
"time"
)
@@ -195,6 +196,10 @@ func SearchRepositories(db DBTX, query string, limit, offset int, currentUserDID
return nil, 0, err
}
if err := PopulateRepoCardTags(db, cards); err != nil {
return nil, 0, err
}
return cards, total, nil
}
@@ -2143,6 +2148,10 @@ func GetRepoCards(db DBTX, limit int, currentUserDID string, sortOrder RepoCardS
return nil, err
}
if err := PopulateRepoCardTags(db, cards); err != nil {
return nil, err
}
return cards, nil
}
@@ -2216,6 +2225,10 @@ func GetUserRepoCards(db DBTX, userDID string, currentUserDID string) ([]RepoCar
return nil, err
}
if err := PopulateRepoCardTags(db, cards); err != nil {
return nil, err
}
return cards, nil
}
@@ -2561,11 +2574,120 @@ func GetRepoHoldDIDs(db DBTX, did, repository string, viewerDID string) ([]strin
return holds, rows.Err()
}
// TagNameDigest is a lightweight (tag, digest) pair used for dropdown
// population and default-selection heuristics.
type TagNameDigest struct {
Name string
Digest string
}
// shaTagPattern matches CI-style git-sha tags like "sha-937fa4c".
var shaTagPattern = regexp.MustCompile(`^sha-[0-9a-f]{6,40}$`)
// PickDefaultTag chooses the best display tag from a list of (name, digest)
// pairs ordered most-recent first.
//
// 1. Start with the newest tag.
// 2. If that newest tag looks like a git-sha tag, look for a sibling with
// the same digest that doesn't — happyview-style repos push both
// "sha-937fa4c" and "2.0.0-dev.45" pointing at the same image; we'd
// rather show the semver name.
// 3. If "latest" exists AND points to the same digest as the chosen tag,
// prefer "latest" as the friendliest label. A stale "latest" pointing
// at an old digest is bypassed.
func PickDefaultTag(tags []TagNameDigest) string {
if len(tags) == 0 {
return ""
}
chosen := tags[0]
if shaTagPattern.MatchString(chosen.Name) {
for _, t := range tags[1:] {
if t.Digest == chosen.Digest && !shaTagPattern.MatchString(t.Name) {
chosen = t
break
}
}
}
if chosen.Name != "latest" {
for _, t := range tags {
if t.Name == "latest" && t.Digest == chosen.Digest {
return "latest"
}
}
}
return chosen.Name
}
// PopulateRepoCardTags overrides each card's Tag field with the best display
// tag chosen by PickDefaultTag. Issues one batch query for all (handle, repository)
// pairs in the slice. No-op for an empty slice.
//
// RepoCardData doesn't carry the owner DID, so we join through users.handle.
// This is fine because (handle, repository) is unique within the appview.
func PopulateRepoCardTags(db DBTX, cards []RepoCardData) error {
if len(cards) == 0 {
return nil
}
type key struct{ handle, repo string }
placeholders := make([]string, 0, len(cards))
args := make([]any, 0, len(cards)*2)
for _, c := range cards {
placeholders = append(placeholders, "(?, ?)")
args = append(args, c.OwnerHandle, c.Repository)
}
q := `
SELECT u.handle, t.repository, t.tag, t.digest
FROM tags t
JOIN users u ON t.did = u.did
WHERE (u.handle, t.repository) IN (VALUES ` + strings.Join(placeholders, ",") + `)
ORDER BY t.repository, t.created_at DESC
`
rows, err := db.Query(q, args...)
if err != nil {
return err
}
defer rows.Close()
groups := make(map[key][]TagNameDigest)
for rows.Next() {
var handle, repo, tag, digest string
if err := rows.Scan(&handle, &repo, &tag, &digest); err != nil {
return err
}
k := key{handle, repo}
groups[k] = append(groups[k], TagNameDigest{Name: tag, Digest: digest})
}
if err := rows.Err(); err != nil {
return err
}
for i := range cards {
k := key{cards[i].OwnerHandle, cards[i].Repository}
if g, ok := groups[k]; ok && len(g) > 0 {
cards[i].Tag = PickDefaultTag(g)
}
}
return nil
}
// GetAllTagNames returns all tag names for a repository, ordered by most recent first.
// Filters out tags whose manifests live on holds the viewer can't access.
func GetAllTagNames(db DBTX, did, repository string, viewerDID string) ([]string, error) {
pairs, err := GetAllTagsWithDigests(db, did, repository, viewerDID)
if err != nil {
return nil, err
}
names := make([]string, len(pairs))
for i, p := range pairs {
names[i] = p.Name
}
return names, nil
}
// GetAllTagsWithDigests returns all tags for a repository with their manifest
// digests, ordered by most recent first. Filters out tags whose manifests live
// on holds the viewer can't access.
func GetAllTagsWithDigests(db DBTX, did, repository string, viewerDID string) ([]TagNameDigest, error) {
rows, err := db.Query(`
SELECT t.tag FROM tags t
SELECT t.tag, t.digest FROM tags t
JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
WHERE t.did = ? AND t.repository = ?
AND m.hold_endpoint IN `+accessibleHoldsSubquery+`
@@ -2576,15 +2698,15 @@ func GetAllTagNames(db DBTX, did, repository string, viewerDID string) ([]string
}
defer rows.Close()
var names []string
var out []TagNameDigest
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
var p TagNameDigest
if err := rows.Scan(&p.Name, &p.Digest); err != nil {
return nil, err
}
names = append(names, name)
out = append(out, p)
}
return names, rows.Err()
return out, rows.Err()
}
// GetLayerCountForManifest returns the number of layers for a manifest identified by digest.

View File

@@ -78,25 +78,20 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
viewerDID = vu.DID
}
// Fetch all tag names for the selector dropdown
allTags, err := db.GetAllTagNames(h.ReadOnlyDB, owner.DID, repository, viewerDID)
// Fetch all tags (with digests) for the dropdown and default-selection heuristics.
tagPairs, err := db.GetAllTagsWithDigests(h.ReadOnlyDB, owner.DID, repository, viewerDID)
if err != nil {
slog.Warn("Failed to fetch tag names", "error", err)
}
allTags := make([]string, len(tagPairs))
for i, p := range tagPairs {
allTags[i] = p.Name
}
// Determine which tag to show
selectedTagName := r.URL.Query().Get("tag")
if selectedTagName == "" {
// Default: "latest" if it exists, otherwise most recent
for _, t := range allTags {
if t == "latest" {
selectedTagName = "latest"
break
}
}
if selectedTagName == "" && len(allTags) > 0 {
selectedTagName = allTags[0] // most recent (already sorted DESC)
}
if selectedTagName == "" && len(tagPairs) > 0 {
selectedTagName = db.PickDefaultTag(tagPairs)
}
// Fetch the selected tag's full data

View File

@@ -36,10 +36,12 @@ type spdxPackage struct {
// sbomDetailsData is the template data for the sbom-details partial.
type sbomDetailsData struct {
Packages []sbomPackage
Total int
Error string
ScannedAt string
Packages []sbomPackage
Total int
Error string
ScannedAt string
Digest string // image digest (for download URLs)
HoldEndpoint string // hold DID (for download URLs)
}
type sbomPackage struct {
@@ -199,9 +201,11 @@ func FetchSbomDetails(ctx context.Context, holdEndpoint, digest string) sbomDeta
})
return sbomDetailsData{
Packages: packages,
Total: len(packages),
ScannedAt: scanRecord.ScannedAt,
Packages: packages,
Total: len(packages),
ScannedAt: scanRecord.ScannedAt,
Digest: digest,
HoldEndpoint: holdEndpoint,
}
}

View File

@@ -0,0 +1,155 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"atcr.io/pkg/atproto"
)
// ScanDownloadHandler serves raw scan blobs (SPDX JSON or Grype JSON) as file downloads.
// GET /api/scan-download?digest=sha256:...&holdEndpoint=did:web:...&type=vuln|sbom
type ScanDownloadHandler struct {
BaseUIHandler
}
func (h *ScanDownloadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
digest := r.URL.Query().Get("digest")
holdEndpoint := r.URL.Query().Get("holdEndpoint")
blobType := r.URL.Query().Get("type") // "vuln" or "sbom"
if digest == "" || holdEndpoint == "" || (blobType != "vuln" && blobType != "sbom") {
http.Error(w, "missing or invalid parameters", http.StatusBadRequest)
return
}
hold, err := ResolveHold(r.Context(), h.ReadOnlyDB, holdEndpoint)
if err != nil {
slog.Debug("Failed to resolve hold for download", "holdEndpoint", holdEndpoint, "error", err)
http.Error(w, "could not resolve hold", http.StatusBadGateway)
return
}
data, err := fetchScanBlob(r.Context(), hold.DID, hold.URL, digest, blobType)
if err != nil {
slog.Debug("Failed to fetch scan blob", "type", blobType, "error", err)
http.Error(w, err.Error(), http.StatusNotFound)
return
}
shortDigest := strings.TrimPrefix(digest, "sha256:")
if len(shortDigest) > 12 {
shortDigest = shortDigest[:12]
}
var filename, contentType string
switch blobType {
case "vuln":
filename = fmt.Sprintf("vulnerabilities-%s.json", shortDigest)
contentType = "application/json"
case "sbom":
filename = fmt.Sprintf("sbom-spdx-%s.json", shortDigest)
contentType = "application/spdx+json"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
if _, err := w.Write(data); err != nil {
slog.Debug("Failed to write scan download response", "error", err)
}
}
// fetchScanBlob fetches the raw scan blob bytes from a hold.
// blobType is "vuln" or "sbom".
func fetchScanBlob(ctx context.Context, holdDID, holdURL, digest, blobType string) ([]byte, error) {
rkey := strings.TrimPrefix(digest, "sha256:")
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// Fetch the scan record
scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
holdURL,
url.QueryEscape(holdDID),
url.QueryEscape(atproto.ScanCollection),
url.QueryEscape(rkey),
)
req, err := http.NewRequestWithContext(ctx, "GET", scanURL, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("hold unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("no scan record found")
}
var envelope struct {
Value json.RawMessage `json:"value"`
}
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
return nil, fmt.Errorf("parse scan record: %w", err)
}
var scanRecord atproto.ScanRecord
if err := json.Unmarshal(envelope.Value, &scanRecord); err != nil {
return nil, fmt.Errorf("parse scan record: %w", err)
}
// Select the appropriate blob CID
var blobCID string
switch blobType {
case "vuln":
if scanRecord.VulnReportBlob == nil || scanRecord.VulnReportBlob.Ref.String() == "" {
return nil, fmt.Errorf("no vulnerability report available")
}
blobCID = scanRecord.VulnReportBlob.Ref.String()
case "sbom":
if scanRecord.SbomBlob == nil || scanRecord.SbomBlob.Ref.String() == "" {
return nil, fmt.Errorf("no SBOM available")
}
blobCID = scanRecord.SbomBlob.Ref.String()
}
// Fetch the blob
blobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
holdURL,
url.QueryEscape(holdDID),
url.QueryEscape(blobCID),
)
blobReq, err := http.NewRequestWithContext(ctx, "GET", blobURL, nil)
if err != nil {
return nil, fmt.Errorf("build blob request: %w", err)
}
blobResp, err := http.DefaultClient.Do(blobReq)
if err != nil {
return nil, fmt.Errorf("fetch blob: %w", err)
}
defer blobResp.Body.Close()
if blobResp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("blob not accessible")
}
data, err := io.ReadAll(blobResp.Body)
if err != nil {
return nil, fmt.Errorf("read blob: %w", err)
}
return data, nil
}

View File

@@ -54,10 +54,12 @@ type grypePackage struct {
// vulnDetailsData is the template data for the vuln-details partial.
type vulnDetailsData struct {
Matches []vulnMatch
Summary vulnSummary
Error string // non-empty if something went wrong
ScannedAt string
Matches []vulnMatch
Summary vulnSummary
Error string // non-empty if something went wrong
ScannedAt string
Digest string // image digest (for download URLs)
HoldEndpoint string // hold DID (for download URLs)
}
type vulnMatch struct {
@@ -384,8 +386,10 @@ func FetchVulnDetails(ctx context.Context, holdEndpoint, digest string) vulnDeta
})
return vulnDetailsData{
Matches: matches,
Summary: summary,
ScannedAt: scanRecord.ScannedAt,
Matches: matches,
Summary: summary,
ScannedAt: scanRecord.ScannedAt,
Digest: digest,
HoldEndpoint: holdEndpoint,
}
}

File diff suppressed because one or more lines are too long

View File

@@ -135,6 +135,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
router.Get("/api/scan-results", (&uihandlers.BatchScanResultHandler{BaseUIHandler: base}).ServeHTTP)
router.Get("/api/vuln-details", (&uihandlers.VulnDetailsHandler{BaseUIHandler: base}).ServeHTTP)
router.Get("/api/sbom-details", (&uihandlers.SbomDetailsHandler{BaseUIHandler: base}).ServeHTTP)
router.Get("/api/scan-download", (&uihandlers.ScanDownloadHandler{BaseUIHandler: base}).ServeHTTP)
// Attestation details API endpoint (HTMX modal content)
router.Get("/api/attestation-details", middleware.OptionalAuth(deps.SessionStore, deps.Database)(

View File

@@ -97,6 +97,27 @@
:root {
--shadow-card-hover:
0 8px 25px oklch(67.1% 0.05 145 / 0.25), 0 4px 12px oklch(0% 0 0 / 0.1);
/* Dedicated star/favorite color — semantically distinct from `warning`
even if values currently coincide. Used by .text-star/.fill-star/etc. */
--color-star: oklch(82% 0.189 84.429);
}
/* ========================================
STAR / FAVORITE UTILITIES
Tailwind 4 @utility so the `!` important modifier and
variants (hover:, group-hover:) still work on these classes.
======================================== */
@utility text-star {
color: var(--color-star);
}
@utility stroke-star {
stroke: var(--color-star);
}
@utility fill-star {
fill: var(--color-star);
}
@utility border-star {
border-color: var(--color-star);
}
[data-theme="dark"] {
@@ -198,6 +219,10 @@
.icon.size-5 { width: 1.25rem; height: 1.25rem; }
.icon.size-6 { width: 1.5rem; height: 1.5rem; }
.icon.size-8 { width: 2rem; height: 2rem; }
.icon.size-10 { width: 2.5rem; height: 2.5rem; }
.icon.size-12 { width: 3rem; height: 3rem; }
.icon.size-16 { width: 4rem; height: 4rem; }
.icon.size-20 { width: 5rem; height: 5rem; }
/* Special size for slightly larger than 1rem (used in star/pull count) */
.icon.size-\[1\.1rem\] { width: 1.1rem; height: 1.1rem; }
@@ -430,6 +455,42 @@
display: block;
}
/* ----------------------------------------
RESPONSIVE TABLE — STACK ON MOBILE
Below md, each row becomes a labeled block.
Add data-label="..." to each <td>.
---------------------------------------- */
@media (max-width: 47.999rem) {
.table-stack-mobile thead {
@apply sr-only;
}
.table-stack-mobile,
.table-stack-mobile tbody,
.table-stack-mobile tr,
.table-stack-mobile td {
display: block;
width: 100%;
}
.table-stack-mobile tr {
@apply border border-base-300 rounded-md mb-3 p-3 bg-base-100;
}
.table-stack-mobile tr:nth-child(even) {
@apply bg-base-200;
}
.table-stack-mobile td {
@apply py-1;
border: 0;
}
.table-stack-mobile td::before {
content: attr(data-label);
@apply block text-xs font-semibold uppercase tracking-wide text-base-content/60 mb-0.5;
}
.table-stack-mobile td[data-label=""]::before,
.table-stack-mobile td:not([data-label])::before {
content: none;
}
}
/* ----------------------------------------
VULNERABILITY SEVERITY BOX STRIP
Docker Hub-style connected severity boxes
@@ -442,8 +503,8 @@
}
.vuln-strip > span:first-child { @apply rounded-l-sm; }
.vuln-strip > span:last-child { @apply rounded-r-sm; }
.vuln-box-critical { background-color: oklch(45% 0.16 20); color: oklch(97% 0.01 20); }
.vuln-box-high { background-color: oklch(58% 0.18 35); color: oklch(97% 0.01 35); }
.vuln-box-critical { background-color: oklch(45% 0.19 25); color: oklch(97% 0.01 25); }
.vuln-box-high { background-color: oklch(56% 0.19 50); color: oklch(97% 0.01 50); }
.vuln-box-medium { background-color: oklch(72% 0.15 70); color: oklch(25% 0.05 70); }
.vuln-box-low { background-color: oklch(80% 0.1 85); color: oklch(25% 0.05 85); }
}

View File

@@ -130,9 +130,34 @@ function copyToClipboard(text, btn) {
});
}
// Serialize a <table> (thead + tbody) as CSV (RFC 4180 quoting)
function tableToCSV(table) {
const escape = (s) => {
const v = (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim();
return /[",\n\r]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v;
};
const rowToCsv = (cells) => Array.from(cells).map((c) => escape(c.textContent)).join(',');
const lines = [];
const headRow = table.querySelector('thead tr');
if (headRow) lines.push(rowToCsv(headRow.querySelectorAll('th,td')));
table.querySelectorAll('tbody tr').forEach((tr) => {
lines.push(rowToCsv(tr.querySelectorAll('td,th')));
});
return lines.join('\n');
}
// Initialize copy buttons with data-cmd attribute and clickable cards with data-href
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('click', (e) => {
// Handle copy-as-CSV buttons (for vulnerability / SBOM tables)
const csvBtn = e.target.closest('button[data-copy-csv]');
if (csvBtn) {
const section = csvBtn.closest('[data-csv-section]');
const table = section && section.querySelector('table');
if (table) copyToClipboard(tableToCSV(table), csvBtn);
return;
}
// Handle copy buttons
const btn = e.target.closest('button[data-cmd]');
if (btn) {
@@ -453,97 +478,49 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// Featured carousel - scroll-based with proper wrap-around
// Deferred initialization to avoid blocking main thread during page load
// Featured carousel - relies on DaisyUI's native scroll-snap (.carousel +
// .carousel-item already set scroll-snap-type: x mandatory and snap-start).
// JS only handles next/prev buttons, auto-advance, and wrap-around. The
// browser handles all snapping precision after scrollBy().
function initFeaturedCarousel() {
const carousel = document.getElementById('featured-carousel');
const prevBtn = document.getElementById('carousel-prev');
const nextBtn = document.getElementById('carousel-next');
if (!carousel) return;
const items = Array.from(carousel.querySelectorAll('.carousel-item'));
const items = carousel.querySelectorAll('.carousel-item');
if (items.length === 0) return;
let intervalId = null;
const intervalMs = 5000;
// Cache dimensions to avoid forced reflow on every navigation
let cachedItemWidth = 0;
let cachedContainerWidth = 0;
let cachedScrollWidth = 0;
function updateCachedDimensions() {
const item = items[0];
if (!item) return;
// Batch all geometric reads together to minimize reflow
const style = getComputedStyle(carousel);
const gap = parseFloat(style.gap) || 24;
cachedItemWidth = item.offsetWidth + gap;
cachedContainerWidth = carousel.offsetWidth;
cachedScrollWidth = carousel.scrollWidth;
}
// Initial measurement after layout is stable (double-rAF)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
updateCachedDimensions();
startInterval();
});
});
// Update on resize (debounced to avoid excessive recalculation)
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(updateCachedDimensions, 150);
});
function getItemWidth() {
if (!cachedItemWidth) updateCachedDimensions();
return cachedItemWidth;
}
function getVisibleCount() {
if (!cachedContainerWidth || !cachedItemWidth) updateCachedDimensions();
return Math.round(cachedContainerWidth / cachedItemWidth) || 1;
}
function getMaxScroll() {
if (!cachedScrollWidth || !cachedContainerWidth) updateCachedDimensions();
return cachedScrollWidth - cachedContainerWidth;
// Read on demand — at most once per click or per 5s autoplay tick.
function step() {
const first = items[0];
const gap = parseFloat(getComputedStyle(carousel).gap) || 24;
return first.offsetWidth + gap;
}
function advance() {
const itemWidth = getItemWidth();
const maxScroll = getMaxScroll();
const currentScroll = carousel.scrollLeft;
// If we're at or near the end, wrap to start
if (currentScroll >= maxScroll - 10) {
const max = carousel.scrollWidth - carousel.clientWidth;
if (carousel.scrollLeft >= max - 10) {
carousel.scrollTo({ left: 0, behavior: 'smooth' });
} else {
carousel.scrollTo({ left: currentScroll + itemWidth, behavior: 'smooth' });
carousel.scrollBy({ left: step(), behavior: 'smooth' });
}
}
function retreat() {
const itemWidth = getItemWidth();
const maxScroll = getMaxScroll();
const currentScroll = carousel.scrollLeft;
// If we're at or near the start, wrap to end
if (currentScroll <= 10) {
carousel.scrollTo({ left: maxScroll, behavior: 'smooth' });
if (carousel.scrollLeft <= 10) {
carousel.scrollTo({ left: carousel.scrollWidth, behavior: 'smooth' });
} else {
carousel.scrollTo({ left: currentScroll - itemWidth, behavior: 'smooth' });
carousel.scrollBy({ left: -step(), behavior: 'smooth' });
}
}
if (prevBtn) prevBtn.addEventListener('click', () => { stopInterval(); retreat(); startInterval(); });
if (nextBtn) nextBtn.addEventListener('click', () => { stopInterval(); advance(); startInterval(); });
function startInterval() {
if (intervalId || items.length <= getVisibleCount()) return;
if (intervalId) return;
if (carousel.scrollWidth <= carousel.clientWidth + 10) return;
intervalId = setInterval(advance, intervalMs);
}
@@ -551,8 +528,13 @@ function initFeaturedCarousel() {
if (intervalId) { clearInterval(intervalId); intervalId = null; }
}
if (prevBtn) prevBtn.addEventListener('click', () => { stopInterval(); retreat(); startInterval(); });
if (nextBtn) nextBtn.addEventListener('click', () => { stopInterval(); advance(); startInterval(); });
carousel.addEventListener('mouseenter', stopInterval);
carousel.addEventListener('mouseleave', startInterval);
startInterval();
}
// Defer carousel setup to after critical path

View File

@@ -5,7 +5,7 @@
<div class="avatar{{ if not .User.Avatar }} avatar-placeholder{{ end }}">
{{ if .User.Avatar }}
<div class="w-7 rounded-full">
<img src="{{ resizeImage .User.Avatar 96 }}" alt="{{ .User.Handle }}" />
<img src="{{ resizeImage .User.Avatar 96 }}" alt="{{ .User.Handle }}" loading="lazy" />
</div>
{{ else }}
<div class="bg-secondary text-secondary-content w-7 rounded-full">

View File

@@ -6,7 +6,7 @@
*/}}
<div id="repo-avatar" class="relative shrink-0">
{{ if .IconURL }}
<img src="{{ resizeImage .IconURL 96 }}" alt="{{ .RepositoryName }}" class="w-20 rounded-lg object-cover">
<img src="{{ resizeImage .IconURL 96 }}" alt="{{ .RepositoryName }}" loading="lazy" class="w-20 rounded-lg object-cover">
{{ else }}
<div class="avatar avatar-placeholder">
<div class="bg-neutral text-neutral-content w-20 rounded-lg shadow-sm uppercase">
@@ -16,7 +16,7 @@
{{ end }}
{{ if .IsOwner }}
<label class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 hover:opacity-100 transition-opacity cursor-pointer rounded-lg" for="avatar-upload" aria-label="Upload repository icon">
{{ icon "plus" "size-8 text-white" }}
{{ icon "plus" "size-8 text-neutral-content" }}
</label>
<input type="hidden" id="avatar-repo" name="repo" value="{{ .RepositoryName }}">
<input type="file" id="avatar-upload" name="avatar"

View File

@@ -8,7 +8,7 @@
Display mode: renders as span (default)
*/}}
{{ if .Interactive }}
<button class="btn btn-sm gap-2 btn-ghost group border border-transparent hover:border-primary{{ if .IsStarred }} border-amber-400!{{ end }}"
<button class="btn btn-sm gap-2 btn-ghost group border border-transparent hover:border-primary{{ if .IsStarred }} border-star!{{ end }}"
id="star-btn"
hx-ext="json-enc"
{{ if .IsStarred }}
@@ -21,12 +21,12 @@
hx-on::before-request="this.disabled=true"
hx-on::after-request="if(event.detail.xhr.status===401) window.location='/auth/oauth/login'"
aria-label="{{ if .IsStarred }}Unstar{{ else }}Star{{ end }} {{ .Handle }}/{{ .Repository }}">
<svg class="icon size-4 text-amber-400 stroke-amber-400 transition-transform group-hover:scale-110{{ if .IsStarred }} fill-amber-400!{{ end }}" id="star-icon" aria-hidden="true"><use href="/icons.svg#star"></use></svg>
<svg class="icon size-4 text-star stroke-star transition-transform group-hover:scale-110{{ if .IsStarred }} fill-star!{{ end }}" id="star-icon" aria-hidden="true"><use href="/icons.svg#star"></use></svg>
<span id="star-count">{{ .StarCount }}</span>
</button>
{{ else }}
<span class="flex items-center gap-2 text-base-content/60">
<svg class="icon size-[1.1rem] text-amber-400 stroke-amber-400{{ if .IsStarred }} fill-amber-400!{{ end }}" aria-hidden="true"><use href="/icons.svg#star"></use></svg>
<svg class="icon size-[1.1rem] text-star stroke-star{{ if .IsStarred }} fill-star!{{ end }}" aria-hidden="true"><use href="/icons.svg#star"></use></svg>
<span class="font-semibold text-base-content">{{ .StarCount }}</span>
</span>
{{ end }}

View File

@@ -64,7 +64,7 @@
<div class="alert alert-info">
{{ icon "info" "size-5" }}
<span>If you have an existing <code class="bg-black/10 dark:bg-white/10 px-1 py-0.5 rounded text-sm font-mono">config.json</code>, add the <code class="bg-black/10 dark:bg-white/10 px-1 py-0.5 rounded text-sm font-mono">credHelpers</code> entry to your existing configuration.</span>
<span>If you have an existing <code class="bg-base-300 px-1 py-0.5 rounded text-sm font-mono">config.json</code>, add the <code class="bg-base-300 px-1 py-0.5 rounded text-sm font-mono">credHelpers</code> entry to your existing configuration.</span>
</div>
</section>

View File

@@ -176,34 +176,32 @@
<p>We will not discriminate against you for exercising your CCPA rights.</p>
<h3 class="text-lg font-medium mt-6">Categories of Personal Information Collected</h3>
<div class="overflow-x-auto mt-4">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Category</th>
<th>Examples</th>
<th>Collected</th>
</tr>
</thead>
<tbody>
<tr>
<td>Identifiers</td>
<td>DID, handle, IP address, device name</td>
<td>Yes</td>
</tr>
<tr>
<td>Internet activity</td>
<td>Access logs, usage data, actions performed</td>
<td>Yes</td>
</tr>
<tr>
<td>Geolocation</td>
<td>Approximate location via IP</td>
<td>Yes</td>
</tr>
</tbody>
</table>
</div>
<table class="table table-zebra w-full mt-4 table-stack-mobile">
<thead>
<tr>
<th>Category</th>
<th>Examples</th>
<th>Collected</th>
</tr>
</thead>
<tbody>
<tr>
<td data-label="Category">Identifiers</td>
<td data-label="Examples">DID, handle, IP address, device name</td>
<td data-label="Collected">Yes</td>
</tr>
<tr>
<td data-label="Category">Internet activity</td>
<td data-label="Examples">Access logs, usage data, actions performed</td>
<td data-label="Collected">Yes</td>
</tr>
<tr>
<td data-label="Category">Geolocation</td>
<td data-label="Examples">Approximate location via IP</td>
<td data-label="Collected">Yes</td>
</tr>
</tbody>
</table>
<p class="mt-4">We do not sell or share your personal information for cross-context behavioral advertising.</p>
</section>
@@ -211,54 +209,52 @@
<section>
<h2 class="text-xl font-semibold text-primary">Data Retention</h2>
<div class="overflow-x-auto mt-4">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Data Type</th>
<th>Service</th>
<th>Retention Period</th>
</tr>
</thead>
<tbody>
<tr>
<td>OAuth tokens</td>
<td>AppView</td>
<td>Until revoked or logout</td>
</tr>
<tr>
<td>Web UI session tokens</td>
<td>AppView</td>
<td>Until logout or expiration</td>
</tr>
<tr>
<td>Device tokens (credential helper)</td>
<td>AppView</td>
<td>Until revoked by user</td>
</tr>
<tr>
<td>Cached PDS data</td>
<td>AppView</td>
<td>Refreshed periodically; deleted on account deletion</td>
</tr>
<tr>
<td>Server logs</td>
<td>AppView</td>
<td>Currently ephemeral; this policy will be updated if log retention is implemented</td>
</tr>
<tr>
<td>Layer records</td>
<td>Hold PDS</td>
<td>Until you request deletion</td>
</tr>
<tr>
<td>OCI blobs</td>
<td>Hold Storage</td>
<td>Until no longer referenced (pruned within 30 days)</td>
</tr>
</tbody>
</table>
</div>
<table class="table table-zebra w-full mt-4 table-stack-mobile">
<thead>
<tr>
<th>Data Type</th>
<th>Service</th>
<th>Retention Period</th>
</tr>
</thead>
<tbody>
<tr>
<td data-label="Data Type">OAuth tokens</td>
<td data-label="Service">AppView</td>
<td data-label="Retention">Until revoked or logout</td>
</tr>
<tr>
<td data-label="Data Type">Web UI session tokens</td>
<td data-label="Service">AppView</td>
<td data-label="Retention">Until logout or expiration</td>
</tr>
<tr>
<td data-label="Data Type">Device tokens (credential helper)</td>
<td data-label="Service">AppView</td>
<td data-label="Retention">Until revoked by user</td>
</tr>
<tr>
<td data-label="Data Type">Cached PDS data</td>
<td data-label="Service">AppView</td>
<td data-label="Retention">Refreshed periodically; deleted on account deletion</td>
</tr>
<tr>
<td data-label="Data Type">Server logs</td>
<td data-label="Service">AppView</td>
<td data-label="Retention">Currently ephemeral; this policy will be updated if log retention is implemented</td>
</tr>
<tr>
<td data-label="Data Type">Layer records</td>
<td data-label="Service">Hold PDS</td>
<td data-label="Retention">Until you request deletion</td>
</tr>
<tr>
<td data-label="Data Type">OCI blobs</td>
<td data-label="Service">Hold Storage</td>
<td data-label="Retention">Until no longer referenced (pruned within 30 days)</td>
</tr>
</tbody>
</table>
</section>
<section>

View File

@@ -82,7 +82,7 @@
{{ if .SelectedTag }}
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
{{ icon "tag" "size-4 text-base-content/60" }}
{{ icon "tag" "size-6 text-base-content/60" }}
<select id="tag-selector" class="select select-sm select-bordered font-mono"
hx-get="/r/{{ .Owner.Handle }}/{{ .Repository.Name }}"
hx-target="#tag-content"
@@ -94,6 +94,18 @@
<option value="{{ . }}"{{ if eq . $.SelectedTag.Info.Tag.Tag }} selected{{ end }}>{{ . }}</option>
{{ end }}
</select>
{{ if gt (len .AllTags) 1 }}
<div class="dropdown dropdown-end" id="diff-dropdown">
<label tabindex="0" class="btn btn-ghost btn-sm gap-1" title="Compare with another tag">
{{ icon "git-compare" "size-4" }} Diff
</label>
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-10 w-56 p-2 shadow max-h-60 overflow-y-auto">
{{ range .AllTags }}
<li><a href="#" data-diff-to="{{ . }}" onclick="diffToTag(event, this)">{{ . }}</a></li>
{{ end }}
</ul>
</div>
{{ end }}
</div>
{{ if .SelectedTag.Info.IsMultiArch }}
<div id="platform-badges" class="flex flex-wrap items-center gap-1">
@@ -145,30 +157,30 @@
<div id="editor-write" class="editor-panel">
<!-- Toolbar -->
<div class="flex flex-wrap gap-1 mb-2 p-1 bg-base-200 rounded-lg">
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('heading')" title="Heading">
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" onclick="insertMd('heading')" title="Heading">
{{ icon "heading" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('bold')" title="Bold">
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" onclick="insertMd('bold')" title="Bold">
{{ icon "bold" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('italic')" title="Italic">
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" onclick="insertMd('italic')" title="Italic">
{{ icon "italic" "size-4" }}
</button>
<div class="divider divider-horizontal mx-0"></div>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('link')" title="Link">
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" onclick="insertMd('link')" title="Link">
{{ icon "link" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('image')" title="Image">
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" onclick="insertMd('image')" title="Image">
{{ icon "image" "size-4" }}
</button>
<div class="divider divider-horizontal mx-0"></div>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('ul')" title="Bulleted list">
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" onclick="insertMd('ul')" title="Bulleted list">
{{ icon "list" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('ol')" title="Numbered list">
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" onclick="insertMd('ol')" title="Numbered list">
{{ icon "list-ordered" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('code')" title="Code">
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" onclick="insertMd('code')" title="Code">
{{ icon "code" "size-4" }}
</button>
</div>
@@ -462,6 +474,19 @@
return '/api/repo-tags/' + el.dataset.owner + '/' + el.dataset.repo;
}
window.diffToTag = function(e, link) {
e.preventDefault();
var to = link.dataset.diffTo;
var content = document.getElementById('tag-content');
var selector = document.getElementById('tag-selector');
if (!content || !selector || !to) return;
var fromDigest = content.dataset.digest;
var currentTag = selector.value;
if (!fromDigest || to === currentTag) return;
window.location.href = '/diff/' + content.dataset.owner + '/' + content.dataset.repo +
'?from=' + encodeURIComponent(fromDigest) + '&to=' + encodeURIComponent(to);
};
window.switchRepoTab = function(tabId) {
window._activeRepoTab = tabId;
var section = document.getElementById('tag-content');

View File

@@ -18,23 +18,23 @@
</div>
<!-- Mobile tab bar (below lg) -->
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden mb-6">
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="user">
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden mb-6" role="tablist" aria-label="Settings sections" aria-orientation="horizontal">
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="user" role="tab" id="tab-mobile-user" aria-controls="tab-user" aria-selected="false" tabindex="-1">
{{ icon "user" "size-4" }} User
</button>
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="billing">
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="billing" role="tab" id="tab-mobile-billing" aria-controls="tab-billing" aria-selected="false" tabindex="-1">
{{ icon "credit-card" "size-4" }} Billing
</button>
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="storage">
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="storage" role="tab" id="tab-mobile-storage" aria-controls="tab-storage" aria-selected="false" tabindex="-1">
{{ icon "hard-drive" "size-4" }} Storage
</button>
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="devices">
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="devices" role="tab" id="tab-mobile-devices" aria-controls="tab-devices" aria-selected="false" tabindex="-1">
{{ icon "terminal" "size-4" }} Devices
</button>
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="webhooks">
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="webhooks" role="tab" id="tab-mobile-webhooks" aria-controls="tab-webhooks" aria-selected="false" tabindex="-1">
{{ icon "webhook" "size-4" }} Webhooks
</button>
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="advanced">
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="advanced" role="tab" id="tab-mobile-advanced" aria-controls="tab-advanced" aria-selected="false" tabindex="-1">
{{ icon "shield-check" "size-4" }} Advanced
</button>
</div>
@@ -42,13 +42,13 @@
<div class="flex gap-8">
<!-- Sidebar (lg and above) -->
<aside class="hidden lg:block w-56 shrink-0">
<ul class="menu bg-base-200 rounded-box w-full">
<li data-tab="user"><a href="#user">{{ icon "user" "size-4" }} User</a></li>
<li data-tab="billing"><a href="#billing">{{ icon "credit-card" "size-4" }} Billing</a></li>
<li data-tab="storage"><a href="#storage">{{ icon "hard-drive" "size-4" }} Storage</a></li>
<li data-tab="devices"><a href="#devices">{{ icon "terminal" "size-4" }} Devices</a></li>
<li data-tab="webhooks"><a href="#webhooks">{{ icon "webhook" "size-4" }} Webhooks</a></li>
<li data-tab="advanced"><a href="#advanced">{{ icon "shield-check" "size-4" }} Advanced</a></li>
<ul class="menu bg-base-200 rounded-box w-full" role="tablist" aria-label="Settings sections" aria-orientation="vertical">
<li data-tab="user" role="none"><a href="#user" role="tab" id="tab-desktop-user" aria-controls="tab-user" aria-selected="false" tabindex="-1">{{ icon "user" "size-4" }} User</a></li>
<li data-tab="billing" role="none"><a href="#billing" role="tab" id="tab-desktop-billing" aria-controls="tab-billing" aria-selected="false" tabindex="-1">{{ icon "credit-card" "size-4" }} Billing</a></li>
<li data-tab="storage" role="none"><a href="#storage" role="tab" id="tab-desktop-storage" aria-controls="tab-storage" aria-selected="false" tabindex="-1">{{ icon "hard-drive" "size-4" }} Storage</a></li>
<li data-tab="devices" role="none"><a href="#devices" role="tab" id="tab-desktop-devices" aria-controls="tab-devices" aria-selected="false" tabindex="-1">{{ icon "terminal" "size-4" }} Devices</a></li>
<li data-tab="webhooks" role="none"><a href="#webhooks" role="tab" id="tab-desktop-webhooks" aria-controls="tab-webhooks" aria-selected="false" tabindex="-1">{{ icon "webhook" "size-4" }} Webhooks</a></li>
<li data-tab="advanced" role="none"><a href="#advanced" role="tab" id="tab-desktop-advanced" aria-controls="tab-advanced" aria-selected="false" tabindex="-1">{{ icon "shield-check" "size-4" }} Advanced</a></li>
</ul>
<div class="mt-4 px-2 space-y-1 text-xs text-base-content/50">
<div class="break-all"><code>{{ .Profile.DID }}</code></div>
@@ -60,7 +60,7 @@
<div class="flex-1 min-w-0">
<!-- USER TAB -->
<div id="tab-user" class="settings-panel hidden space-y-6">
<div id="tab-user" class="settings-panel hidden space-y-6" role="tabpanel" aria-labelledby="tab-desktop-user" tabindex="0">
<section class="card bg-base-100 shadow-sm p-6 space-y-6">
<div>
<h2 class="text-xl font-semibold">Preferences</h2>
@@ -115,7 +115,7 @@
</div>
<!-- STORAGE TAB -->
<div id="tab-storage" class="settings-panel hidden space-y-4">
<div id="tab-storage" class="settings-panel hidden space-y-4" role="tabpanel" aria-labelledby="tab-desktop-storage" tabindex="0">
<!-- Holds -->
{{ if .AllHolds }}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
@@ -162,12 +162,12 @@
</div>
<!-- BILLING TAB -->
<div id="tab-billing" class="settings-panel hidden space-y-4">
<div id="tab-billing" class="settings-panel hidden space-y-4" role="tabpanel" aria-labelledby="tab-desktop-billing" tabindex="0">
{{ template "subscription_plans" .Subscription }}
</div>
<!-- DEVICES TAB -->
<div id="tab-devices" class="settings-panel hidden space-y-6">
<div id="tab-devices" class="settings-panel hidden space-y-6" role="tabpanel" aria-labelledby="tab-desktop-devices" tabindex="0">
<section class="card bg-base-100 shadow-sm p-6 space-y-6">
<div>
<h2 class="text-xl font-semibold">Authorized Devices</h2>
@@ -227,7 +227,7 @@
</div>
<!-- WEBHOOKS TAB -->
<div id="tab-webhooks" class="settings-panel hidden space-y-6">
<div id="tab-webhooks" class="settings-panel hidden space-y-6" role="tabpanel" aria-labelledby="tab-desktop-webhooks" tabindex="0">
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
<div>
<h2 class="text-xl font-semibold">Webhooks</h2>
@@ -240,7 +240,7 @@
</div>
<!-- ADVANCED TAB -->
<div id="tab-advanced" class="settings-panel hidden space-y-6">
<div id="tab-advanced" class="settings-panel hidden space-y-6" role="tabpanel" aria-labelledby="tab-desktop-advanced" tabindex="0">
<!-- Data Privacy Section -->
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
<h2 class="text-xl font-semibold">Data Privacy</h2>
@@ -305,7 +305,7 @@
// OCI client dropdown
// Tab switching
(function() {
var validTabs = ['user', 'storage', 'devices', 'webhooks', 'advanced'];
var validTabs = ['user', 'billing', 'storage', 'devices', 'webhooks', 'advanced'];
function switchSettingsTab(tabId) {
// Hide all panels
@@ -316,24 +316,24 @@
var panel = document.getElementById('tab-' + tabId);
if (panel) panel.classList.remove('hidden');
// Sidebar: toggle menu-active on <li> elements
// Sidebar: toggle menu-active + aria-selected + tabindex
document.querySelectorAll('.menu li[data-tab]').forEach(function(li) {
if (li.dataset.tab === tabId) {
li.classList.add('menu-active');
} else {
li.classList.remove('menu-active');
var active = li.dataset.tab === tabId;
li.classList.toggle('menu-active', active);
var a = li.querySelector('a[role="tab"]');
if (a) {
a.setAttribute('aria-selected', active ? 'true' : 'false');
a.setAttribute('tabindex', active ? '0' : '-1');
}
});
// Mobile: toggle btn-secondary/btn-ghost on buttons
// Mobile: toggle btn styles + aria-selected + tabindex
document.querySelectorAll('.settings-tab-mobile').forEach(function(btn) {
if (btn.dataset.tab === tabId) {
btn.classList.remove('btn-ghost');
btn.classList.add('btn-secondary');
} else {
btn.classList.remove('btn-secondary');
btn.classList.add('btn-ghost');
}
var active = btn.dataset.tab === tabId;
btn.classList.toggle('btn-ghost', !active);
btn.classList.toggle('btn-secondary', active);
btn.setAttribute('aria-selected', active ? 'true' : 'false');
btn.setAttribute('tabindex', active ? '0' : '-1');
});
// Update URL hash without adding history entry
@@ -348,25 +348,50 @@
return panel && !panel.classList.contains('hidden');
};
// Roving tab navigation (arrow keys, Home, End) per WAI-ARIA tabs pattern
function handleTabKeydown(tabs, orientation) {
return function(e) {
var prevKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
var nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
var idx = tabs.indexOf(e.currentTarget);
if (idx === -1) return;
var target = null;
if (e.key === prevKey) target = tabs[(idx - 1 + tabs.length) % tabs.length];
else if (e.key === nextKey) target = tabs[(idx + 1) % tabs.length];
else if (e.key === 'Home') target = tabs[0];
else if (e.key === 'End') target = tabs[tabs.length - 1];
if (!target) return;
e.preventDefault();
switchSettingsTab(target.dataset.tab || target.parentElement.dataset.tab);
target.focus();
};
}
document.addEventListener('DOMContentLoaded', function() {
// Read initial tab from hash
var hash = window.location.hash.replace('#', '') || 'user';
if (validTabs.indexOf(hash) === -1) hash = 'storage';
if (validTabs.indexOf(hash) === -1) hash = 'user';
// Mobile tab click handlers
document.querySelectorAll('.settings-tab-mobile').forEach(function(btn) {
// Mobile tab click + keyboard handlers
var mobileTabs = Array.prototype.slice.call(document.querySelectorAll('.settings-tab-mobile'));
var mobileKeydown = handleTabKeydown(mobileTabs, 'horizontal');
mobileTabs.forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
switchSettingsTab(this.dataset.tab);
});
btn.addEventListener('keydown', mobileKeydown);
});
// Sidebar tab click handlers
document.querySelectorAll('.menu li[data-tab] a').forEach(function(link) {
// Sidebar tab click + keyboard handlers
var sidebarTabs = Array.prototype.slice.call(document.querySelectorAll('.menu li[data-tab] a[role="tab"]'));
var sidebarKeydown = handleTabKeydown(sidebarTabs, 'vertical');
sidebarTabs.forEach(function(link) {
link.addEventListener('click', function(e) {
e.preventDefault();
switchSettingsTab(this.parentElement.dataset.tab);
});
link.addEventListener('keydown', sidebarKeydown);
});
// Activate initial tab

View File

@@ -15,7 +15,7 @@
{{ if .ViewedUser.Avatar }}
<div class="avatar">
<div class="w-20 rounded-full shadow">
<img src="{{ .ViewedUser.Avatar }}" alt="{{ .ViewedUser.Handle }}" />
<img src="{{ .ViewedUser.Avatar }}" alt="{{ .ViewedUser.Handle }}" loading="lazy" />
</div>
</div>
{{ else if .HasProfile }}

View File

@@ -26,7 +26,7 @@
<td class="text-center">
{{ if eq .Status "online" }}<span class="text-success" title="Online">&#9679;</span>
{{ else if eq .Status "offline" }}<span class="text-error" title="Offline">&#9679;</span>
{{ else }}<span class="text-base-content/30" title="Unknown">&#9679;</span>
{{ else }}<span class="text-base-content/50" title="Unknown" aria-label="Unknown status">&#9679;</span>
{{ end }}
</td>
<td class="text-right">

View File

@@ -73,29 +73,29 @@
<!-- Tab Navigation -->
<div class="border-b border-base-300 mt-6">
<nav class="flex gap-0" role="tablist">
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
<nav class="flex gap-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" role="tablist">
<button class="repo-tab shrink-0 whitespace-nowrap px-4 sm:px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
data-tab="overview"
role="tab"
onclick="switchRepoTab('overview')">
Overview
</button>
{{ if .SelectedTag }}
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
<button class="repo-tab shrink-0 whitespace-nowrap px-4 sm:px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
data-tab="layers"
role="tab"
id="layers-tab-btn"
onclick="switchRepoTab('layers')">
Layers
</button>
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
<button class="repo-tab shrink-0 whitespace-nowrap px-4 sm:px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
data-tab="vulns"
role="tab"
id="vulns-tab-btn"
onclick="switchRepoTab('vulns')">
Vulnerabilities
</button>
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
<button class="repo-tab shrink-0 whitespace-nowrap px-4 sm:px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
data-tab="sbom"
role="tab"
id="sbom-tab-btn"
@@ -103,29 +103,13 @@
SBOM
</button>
{{ end }}
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
<button class="repo-tab shrink-0 whitespace-nowrap px-4 sm:px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
data-tab="artifacts"
role="tab"
id="artifacts-tab-btn"
onclick="switchRepoTab('artifacts')">
Artifacts
</button>
{{ if and .SelectedTag (gt (len .AllTags) 1) }}
<div class="ml-auto flex items-center">
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm gap-1">
{{ icon "git-compare" "size-4" }} Diff
</label>
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-10 w-56 p-2 shadow max-h-60 overflow-y-auto">
{{ range .AllTags }}
{{ if ne . $.SelectedTag.Info.Tag.Tag }}
<li><a href="/diff/{{ $.Owner.Handle }}/{{ $.Repository.Name }}?from={{ $.SelectedTag.Info.Digest }}&to={{ . }}">{{ . }}</a></li>
{{ end }}
{{ end }}
</ul>
</div>
</div>
{{ end }}
</nav>
</div>
@@ -165,7 +149,7 @@
{{ else }}
{{ if .IsOwner }}
<div class="text-center py-12">
{{ icon "file-text" "size-12 text-base-content/20 mx-auto" }}
{{ icon "file-text" "size-12 text-base-content opacity-40 mx-auto" }}
<p class="text-base-content/60 mt-4">No README provided</p>
<p class="text-base-content/40 text-sm mt-1">Add a README to help users understand this image.</p>
<button class="btn btn-primary btn-sm mt-4" onclick="toggleOverviewEditor(true)">
@@ -174,7 +158,7 @@
</div>
{{ else }}
<div class="text-center py-12">
{{ icon "file-text" "size-12 text-base-content/20 mx-auto" }}
{{ icon "file-text" "size-12 text-base-content opacity-40 mx-auto" }}
<p class="text-base-content/60 mt-4">No README provided</p>
<p class="text-base-content/40 text-sm mt-1">Image metadata is shown above.</p>
</div>

View File

@@ -3,9 +3,21 @@
<p>{{ .Error }}</p>
{{ if .ScannedAt }}<p class="text-xs opacity-60">Scanned: {{ .ScannedAt }}</p>{{ end }}
{{ else }}
<div class="space-y-4">
<div class="space-y-4" data-csv-section data-csv-filename="sbom.csv">
<div class="flex flex-wrap items-center gap-3">
<span class="font-semibold text-sm">{{ .Total }} packages</span>
{{ if .Packages }}
<details class="dropdown dropdown-end ml-auto">
<summary class="btn btn-ghost btn-xs gap-1 list-none" aria-label="Export SBOM">
{{ icon "download" "size-3.5" }}
{{ icon "chevron-down" "size-3" }}
</summary>
<ul class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-300">
<li><button type="button" data-copy-csv>{{ icon "copy" "size-4" }} Copy as CSV</button></li>
<li><a href="/api/scan-download?type=sbom&digest={{ .Digest | urlquery }}&holdEndpoint={{ .HoldEndpoint | urlquery }}" download>{{ icon "download" "size-4" }} SPDX JSON</a></li>
</ul>
</details>
{{ end }}
</div>
{{ if .ScannedAt }}<p class="text-xs opacity-60">Scanned: {{ .ScannedAt }}</p>{{ end }}

View File

@@ -16,7 +16,7 @@
<p>{{ .Error }}</p>
{{ end }}
{{ else }}
<div class="space-y-4">
<div class="space-y-4" data-csv-section data-csv-filename="vulnerabilities.csv">
<!-- Summary badges -->
<div class="flex flex-wrap items-center gap-3">
<span class="font-semibold text-sm">{{ .Summary.Total }} vulnerabilities</span>
@@ -26,6 +26,18 @@
<span class="tooltip vuln-box-medium" data-tip="Medium">{{ .Summary.Medium }}</span>
<span class="tooltip vuln-box-low" data-tip="Low">{{ .Summary.Low }}</span>
</span>
{{ if .Matches }}
<details class="dropdown dropdown-end ml-auto">
<summary class="btn btn-ghost btn-xs gap-1 list-none" aria-label="Export vulnerabilities">
{{ icon "download" "size-3.5" }}
{{ icon "chevron-down" "size-3" }}
</summary>
<ul class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-300">
<li><button type="button" data-copy-csv>{{ icon "copy" "size-4" }} Copy as CSV</button></li>
<li><a href="/api/scan-download?type=vuln&digest={{ .Digest | urlquery }}&holdEndpoint={{ .HoldEndpoint | urlquery }}" download>{{ icon "download" "size-4" }} Grype Report</a></li>
</ul>
</details>
{{ end }}
</div>
{{ if .ScannedAt }}<p class="text-xs opacity-60">Scanned: {{ .ScannedAt }}</p>{{ end }}

View File

@@ -41,7 +41,7 @@ var (
// vulnDBRefreshAge is how long a cached DB is considered fresh.
// Set 1 day before the 5-day MaxAllowedBuiltAge so we refresh proactively.
const vulnDBRefreshAge = 4 * 24 * time.Hour
const vulnDBRefreshAge = 7 * 24 * time.Hour
// scanVulnerabilities scans an SBOM for vulnerabilities using Grype
func scanVulnerabilities(ctx context.Context, s *sbom.SBOM, vulnDBPath string) ([]byte, string, scanner.VulnerabilitySummary, error) {
@@ -166,7 +166,7 @@ func loadVulnDatabase(ctx context.Context, vulnDBPath string) (vulnerability.Pro
DBRootDir: vulnDBPath,
ValidateAge: true,
ValidateChecksum: true,
MaxAllowedBuiltAge: 5 * 24 * time.Hour, // 5 days
MaxAllowedBuiltAge: 14 * 24 * time.Hour, // 2 weeks — tolerates upstream publish gaps
}
// Try loading existing DB first (no network)
@@ -233,7 +233,7 @@ func updateVulnDatabase(vulnDBPath string) error {
DBRootDir: vulnDBPath,
ValidateAge: true,
ValidateChecksum: true,
MaxAllowedBuiltAge: 5 * 24 * time.Hour,
MaxAllowedBuiltAge: 14 * 24 * time.Hour,
}
downloader, err := distribution.NewClient(distConfig)