mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-20 16:40:29 +00:00
impeccable fixes, scanner fixes
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -36,3 +36,4 @@ pkg/hold/admin/public/css/style.css
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
node_modules
|
||||
.impeccable.md
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
155
pkg/appview/handlers/scan_download.go
Normal file
155
pkg/appview/handlers/scan_download.go
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
3
pkg/appview/public/js/bundle.min.js
vendored
3
pkg/appview/public/js/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -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)(
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<td class="text-center">
|
||||
{{ if eq .Status "online" }}<span class="text-success" title="Online">●</span>
|
||||
{{ else if eq .Status "offline" }}<span class="text-error" title="Offline">●</span>
|
||||
{{ else }}<span class="text-base-content/30" title="Unknown">●</span>
|
||||
{{ else }}<span class="text-base-content/50" title="Unknown" aria-label="Unknown status">●</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user