mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-25 03:34:14 +00:00
fixup search page to use repocard. remove hardcoded values from privacy/terms/home
This commit is contained in:
@@ -123,3 +123,15 @@ ATCR_LOG_LEVEL=debug
|
||||
# ATProto relay endpoint for backfill sync API
|
||||
# Default: https://relay1.us-east.bsky.network
|
||||
# ATCR_RELAY_ENDPOINT=https://relay1.us-east.bsky.network
|
||||
|
||||
# ==============================================================================
|
||||
# Legal Page Customization (for self-hosted instances)
|
||||
# ==============================================================================
|
||||
|
||||
# Company/organization name displayed in legal pages (Terms, Privacy)
|
||||
# Default: AT Container Registry
|
||||
# ATCR_LEGAL_COMPANY_NAME=AT Container Registry
|
||||
|
||||
# Governing law jurisdiction for legal terms
|
||||
# Default: State of Texas, United States
|
||||
# ATCR_LEGAL_JURISDICTION=State of Texas, United States
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
bin/
|
||||
dist/
|
||||
tmp/
|
||||
appview
|
||||
hold
|
||||
./appview
|
||||
./hold
|
||||
|
||||
# Test artifacts
|
||||
.atcr-pids
|
||||
|
||||
@@ -211,6 +211,10 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
ReadmeFetcher: readmeFetcher,
|
||||
Templates: uiTemplates,
|
||||
DefaultHoldDID: defaultHoldDID,
|
||||
LegalConfig: routes.LegalConfig{
|
||||
CompanyName: cfg.Legal.CompanyName,
|
||||
Jurisdiction: cfg.Legal.Jurisdiction,
|
||||
},
|
||||
})
|
||||
|
||||
// Create OAuth server
|
||||
|
||||
@@ -146,6 +146,18 @@ S3_ENDPOINT=https://6vmss.upcloudobjects.com
|
||||
# Default: AT Container Registry
|
||||
# ATCR_CLIENT_NAME=AT Container Registry
|
||||
|
||||
# ==============================================================================
|
||||
# Legal Page Customization
|
||||
# ==============================================================================
|
||||
|
||||
# Company/organization name displayed in legal pages (Terms, Privacy)
|
||||
# Default: AT Container Registry
|
||||
ATCR_LEGAL_COMPANY_NAME=AT Container Registry
|
||||
|
||||
# Governing law jurisdiction for legal terms
|
||||
# Default: State of Texas, United States
|
||||
ATCR_LEGAL_JURISDICTION=State of Texas, United States
|
||||
|
||||
# ==============================================================================
|
||||
# Logging Configuration
|
||||
# ==============================================================================
|
||||
|
||||
@@ -29,6 +29,7 @@ type Config struct {
|
||||
Jetstream JetstreamConfig `yaml:"jetstream"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
CredentialHelper CredentialHelperConfig `yaml:"credential_helper"`
|
||||
Legal LegalConfig `yaml:"legal"`
|
||||
Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility
|
||||
}
|
||||
|
||||
@@ -129,6 +130,17 @@ type CredentialHelperConfig struct {
|
||||
TangledRepo string `yaml:"tangled_repo"`
|
||||
}
|
||||
|
||||
// LegalConfig defines legal page customization for self-hosted instances
|
||||
type LegalConfig struct {
|
||||
// CompanyName is the company/organization name displayed in legal pages
|
||||
// (from env: ATCR_LEGAL_COMPANY_NAME, default: "AT Container Registry")
|
||||
CompanyName string `yaml:"company_name"`
|
||||
|
||||
// Jurisdiction is the governing law jurisdiction for legal terms
|
||||
// (from env: ATCR_LEGAL_JURISDICTION, default: "State of Texas, United States")
|
||||
Jurisdiction string `yaml:"jurisdiction"`
|
||||
}
|
||||
|
||||
// LoadConfigFromEnv builds a complete configuration from environment variables
|
||||
// This follows the same pattern as the hold service (no config files, only env vars)
|
||||
func LoadConfigFromEnv() (*Config, error) {
|
||||
@@ -188,6 +200,10 @@ func LoadConfigFromEnv() (*Config, error) {
|
||||
// Credential helper configuration (hardcoded - no env vars needed)
|
||||
cfg.CredentialHelper.TangledRepo = "https://tangled.org/@evan.jarrett.net/at-container-registry"
|
||||
|
||||
// Legal page configuration (for self-hosted instances)
|
||||
cfg.Legal.CompanyName = getEnvOrDefault("ATCR_LEGAL_COMPANY_NAME", "AT Container Registry")
|
||||
cfg.Legal.Jurisdiction = getEnvOrDefault("ATCR_LEGAL_JURISDICTION", "State of Texas, United States")
|
||||
|
||||
// Build distribution configuration for compatibility with distribution library
|
||||
distConfig, err := buildDistributionConfig(cfg)
|
||||
if err != nil {
|
||||
|
||||
+95
-56
@@ -128,8 +128,9 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string, currentUs
|
||||
return pushes, total, nil
|
||||
}
|
||||
|
||||
// SearchPushes searches for pushes matching the query across handles, DIDs, repositories, and annotations
|
||||
func SearchPushes(db *sql.DB, query string, limit, offset int, currentUserDID string) ([]Push, int, error) {
|
||||
// SearchRepositories searches for repositories matching the query across handles, DIDs, repositories, and annotations
|
||||
// Returns RepoCardData (one per repository) instead of individual pushes/tags
|
||||
func SearchRepositories(db *sql.DB, query string, limit, offset int, currentUserDID string) ([]RepoCardData, int, error) {
|
||||
// Escape LIKE wildcards so they're treated literally
|
||||
query = escapeLikePattern(query)
|
||||
|
||||
@@ -137,83 +138,121 @@ func SearchPushes(db *sql.DB, query string, limit, offset int, currentUserDID st
|
||||
searchPattern := "%" + query + "%"
|
||||
|
||||
sqlQuery := `
|
||||
SELECT DISTINCT
|
||||
u.did,
|
||||
u.handle,
|
||||
t.repository,
|
||||
t.tag,
|
||||
t.digest,
|
||||
COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'org.opencontainers.image.title'), ''),
|
||||
COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'org.opencontainers.image.description'), ''),
|
||||
COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'io.atcr.icon'), ''),
|
||||
COALESCE(rs.pull_count, 0),
|
||||
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0),
|
||||
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = u.did AND repository = t.repository), 0),
|
||||
t.created_at,
|
||||
m.hold_endpoint,
|
||||
COALESCE(rp.avatar_cid, ''),
|
||||
COALESCE(m.artifact_type, 'container-image')
|
||||
FROM tags t
|
||||
JOIN users u ON t.did = u.did
|
||||
JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
|
||||
LEFT JOIN repository_stats rs ON t.did = rs.did AND t.repository = rs.repository
|
||||
LEFT JOIN repo_pages rp ON t.did = rp.did AND t.repository = rp.repository
|
||||
WHERE u.handle LIKE ? ESCAPE '\'
|
||||
OR u.did = ?
|
||||
OR t.repository LIKE ? ESCAPE '\'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM repository_annotations ra
|
||||
WHERE ra.did = u.did AND ra.repository = t.repository
|
||||
AND ra.value LIKE ? ESCAPE '\'
|
||||
)
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`
|
||||
WITH latest_manifests AS (
|
||||
SELECT did, repository, MAX(id) as latest_id
|
||||
FROM manifests
|
||||
GROUP BY did, repository
|
||||
),
|
||||
matching_repos AS (
|
||||
SELECT DISTINCT lm.did, lm.repository, lm.latest_id
|
||||
FROM latest_manifests lm
|
||||
JOIN users u ON lm.did = u.did
|
||||
WHERE u.handle LIKE ? ESCAPE '\'
|
||||
OR u.did = ?
|
||||
OR lm.repository LIKE ? ESCAPE '\'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM repository_annotations ra
|
||||
WHERE ra.did = lm.did AND ra.repository = lm.repository
|
||||
AND ra.value LIKE ? ESCAPE '\'
|
||||
)
|
||||
),
|
||||
repo_stats AS (
|
||||
SELECT
|
||||
mr.did,
|
||||
mr.repository,
|
||||
COALESCE(rs.pull_count, 0) as pull_count,
|
||||
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = mr.did AND repository = mr.repository), 0) as star_count
|
||||
FROM matching_repos mr
|
||||
LEFT JOIN repository_stats rs ON mr.did = rs.did AND mr.repository = rs.repository
|
||||
)
|
||||
SELECT
|
||||
m.did,
|
||||
u.handle,
|
||||
COALESCE(u.avatar, ''),
|
||||
m.repository,
|
||||
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.title'), ''),
|
||||
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''),
|
||||
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''),
|
||||
repo_stats.star_count,
|
||||
repo_stats.pull_count,
|
||||
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0),
|
||||
COALESCE(m.artifact_type, 'container-image'),
|
||||
COALESCE((SELECT tag FROM tags WHERE did = m.did AND repository = m.repository ORDER BY created_at DESC LIMIT 1), ''),
|
||||
COALESCE(m.digest, ''),
|
||||
COALESCE(rs.last_push, m.created_at),
|
||||
COALESCE(rp.avatar_cid, '')
|
||||
FROM matching_repos mr
|
||||
JOIN manifests m ON mr.latest_id = m.id
|
||||
JOIN users u ON m.did = u.did
|
||||
JOIN repo_stats ON m.did = repo_stats.did AND m.repository = repo_stats.repository
|
||||
LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
|
||||
LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
|
||||
ORDER BY COALESCE(rs.last_push, m.created_at) DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`
|
||||
|
||||
rows, err := db.Query(sqlQuery, currentUserDID, searchPattern, query, searchPattern, searchPattern, limit, offset)
|
||||
rows, err := db.Query(sqlQuery, searchPattern, query, searchPattern, searchPattern, currentUserDID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var pushes []Push
|
||||
var cards []RepoCardData
|
||||
for rows.Next() {
|
||||
var p Push
|
||||
var c RepoCardData
|
||||
var ownerDID string
|
||||
var isStarredInt int
|
||||
var avatarCID string
|
||||
if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint, &avatarCID, &p.ArtifactType); err != nil {
|
||||
var lastUpdatedStr sql.NullString
|
||||
|
||||
if err := rows.Scan(&ownerDID, &c.OwnerHandle, &c.OwnerAvatarURL, &c.Repository, &c.Title, &c.Description, &c.IconURL,
|
||||
&c.StarCount, &c.PullCount, &isStarredInt, &c.ArtifactType, &c.Tag, &c.Digest, &lastUpdatedStr, &avatarCID); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
p.IsStarred = isStarredInt > 0
|
||||
c.IsStarred = isStarredInt > 0
|
||||
if lastUpdatedStr.Valid {
|
||||
if t, err := parseTimestamp(lastUpdatedStr.String); err == nil {
|
||||
c.LastUpdated = t
|
||||
}
|
||||
}
|
||||
// Prefer repo page avatar over annotation icon
|
||||
if avatarCID != "" {
|
||||
p.IconURL = BlobCDNURL(p.DID, avatarCID)
|
||||
c.IconURL = BlobCDNURL(ownerDID, avatarCID)
|
||||
}
|
||||
pushes = append(pushes, p)
|
||||
|
||||
cards = append(cards, c)
|
||||
}
|
||||
|
||||
// Get total count
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Get total count of matching repositories
|
||||
countQuery := `
|
||||
SELECT COUNT(DISTINCT t.id)
|
||||
FROM tags t
|
||||
JOIN users u ON t.did = u.did
|
||||
JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
|
||||
WHERE u.handle LIKE ? ESCAPE '\'
|
||||
OR u.did = ?
|
||||
OR t.repository LIKE ? ESCAPE '\'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM repository_annotations ra
|
||||
WHERE ra.did = u.did AND ra.repository = t.repository
|
||||
AND ra.value LIKE ? ESCAPE '\'
|
||||
)
|
||||
`
|
||||
WITH latest_manifests AS (
|
||||
SELECT did, repository, MAX(id) as latest_id
|
||||
FROM manifests
|
||||
GROUP BY did, repository
|
||||
)
|
||||
SELECT COUNT(DISTINCT lm.did || '/' || lm.repository)
|
||||
FROM latest_manifests lm
|
||||
JOIN users u ON lm.did = u.did
|
||||
WHERE u.handle LIKE ? ESCAPE '\'
|
||||
OR u.did = ?
|
||||
OR lm.repository LIKE ? ESCAPE '\'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM repository_annotations ra
|
||||
WHERE ra.did = lm.did AND ra.repository = lm.repository
|
||||
AND ra.value LIKE ? ESCAPE '\'
|
||||
)
|
||||
`
|
||||
|
||||
var total int
|
||||
if err := db.QueryRow(countQuery, searchPattern, query, searchPattern, searchPattern).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return pushes, total, nil
|
||||
return cards, total, nil
|
||||
}
|
||||
|
||||
// GetUserRepositories fetches all repositories for a user
|
||||
|
||||
@@ -76,7 +76,8 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// RecentPushesHandler handles the HTMX request for recent pushes
|
||||
// RecentPushesHandler handles the HTMX request for recent repositories
|
||||
// Note: This endpoint returns repo cards (one per repository) instead of individual pushes
|
||||
type RecentPushesHandler struct {
|
||||
DB *sql.DB
|
||||
Templates *template.Template
|
||||
@@ -92,65 +93,49 @@ func (h *RecentPushesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
offset, _ = strconv.Atoi(o)
|
||||
}
|
||||
|
||||
userFilter := r.URL.Query().Get("user")
|
||||
if userFilter == "" {
|
||||
userFilter = r.URL.Query().Get("q")
|
||||
}
|
||||
|
||||
// Get current user DID (empty string if not logged in)
|
||||
var currentUserDID string
|
||||
if user := middleware.GetUser(r); user != nil {
|
||||
currentUserDID = user.DID
|
||||
}
|
||||
|
||||
pushes, total, err := db.GetRecentPushes(h.DB, limit, offset, userFilter, currentUserDID)
|
||||
// Get recent repositories using repo cards (sorted by last update)
|
||||
repos, err := db.GetRepoCards(h.DB, limit+offset, currentUserDID, db.SortByLastUpdate)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Check health status and filter out unreachable manifests for home page
|
||||
// Use GetCachedStatus only (no blocking) - background worker keeps cache fresh
|
||||
if h.HealthChecker != nil {
|
||||
reachablePushes := []db.Push{}
|
||||
for i := range pushes {
|
||||
if pushes[i].HoldEndpoint != "" {
|
||||
// Use cached status only - don't block on health checks
|
||||
cached := h.HealthChecker.GetCachedStatus(pushes[i].HoldEndpoint)
|
||||
if cached != nil {
|
||||
pushes[i].Reachable = cached.Reachable
|
||||
// Only show reachable pushes on home page
|
||||
if cached.Reachable {
|
||||
reachablePushes = append(reachablePushes, pushes[i])
|
||||
}
|
||||
} else {
|
||||
// No cached status - optimistically show it (background worker will check)
|
||||
pushes[i].Reachable = true
|
||||
reachablePushes = append(reachablePushes, pushes[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
pushes = reachablePushes
|
||||
} else {
|
||||
// If no health checker, assume all are reachable (backward compatibility)
|
||||
for i := range pushes {
|
||||
pushes[i].Reachable = true
|
||||
}
|
||||
// Apply offset manually (GetRepoCards doesn't support offset directly)
|
||||
if offset > 0 && offset < len(repos) {
|
||||
repos = repos[offset:]
|
||||
} else if offset >= len(repos) {
|
||||
repos = []db.RepoCardData{}
|
||||
}
|
||||
|
||||
// Limit results
|
||||
if len(repos) > limit {
|
||||
repos = repos[:limit]
|
||||
}
|
||||
|
||||
// Set registry URL on all cards
|
||||
db.SetRegistryURL(repos, h.RegistryURL)
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
Pushes []db.Push
|
||||
HasMore bool
|
||||
NextOffset int
|
||||
Repositories []db.RepoCardData
|
||||
SearchQuery string
|
||||
HasMore bool
|
||||
NextOffset int
|
||||
}{
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
Pushes: pushes,
|
||||
HasMore: offset+limit < total,
|
||||
NextOffset: offset + limit,
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
Repositories: repos,
|
||||
SearchQuery: "",
|
||||
HasMore: len(repos) == limit,
|
||||
NextOffset: offset + limit,
|
||||
}
|
||||
|
||||
if err := h.Templates.ExecuteTemplate(w, "push-list.html", data); err != nil {
|
||||
if err := h.Templates.ExecuteTemplate(w, "search-results.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -163,6 +164,7 @@ func (h *DeleteManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
||||
type UploadAvatarHandler struct {
|
||||
DB *sql.DB
|
||||
Refresher *oauth.Refresher
|
||||
Templates *template.Template
|
||||
}
|
||||
|
||||
// validImageTypes are the allowed MIME types for avatars (matches lexicon)
|
||||
@@ -266,5 +268,22 @@ func (h *UploadAvatarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// Return new avatar URL
|
||||
avatarURL := atproto.BlobCDNURL(user.DID, blobRef.Ref.Link)
|
||||
|
||||
// Check if this is an HTMX request
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
// Return HTML component for HTMX swap
|
||||
data := map[string]any{
|
||||
"IconURL": avatarURL,
|
||||
"RepositoryName": repo,
|
||||
"IsOwner": true, // Must be owner to upload
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if err := h.Templates.ExecuteTemplate(w, "repo-avatar", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// JSON response for non-HTMX clients (backward compat)
|
||||
render.JSON(w, r, map[string]string{"avatarURL": avatarURL})
|
||||
}
|
||||
|
||||
@@ -5,17 +5,26 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// LegalPageData contains data for legal pages (terms, privacy)
|
||||
type LegalPageData struct {
|
||||
PageData
|
||||
CompanyName string
|
||||
Jurisdiction string
|
||||
}
|
||||
|
||||
// PrivacyPolicyHandler handles the /privacy page
|
||||
type PrivacyPolicyHandler struct {
|
||||
Templates *template.Template
|
||||
RegistryURL string
|
||||
Templates *template.Template
|
||||
RegistryURL string
|
||||
CompanyName string
|
||||
Jurisdiction string
|
||||
}
|
||||
|
||||
func (h *PrivacyPolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
data := struct {
|
||||
PageData
|
||||
}{
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
data := LegalPageData{
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
CompanyName: h.CompanyName,
|
||||
Jurisdiction: h.Jurisdiction,
|
||||
}
|
||||
|
||||
if err := h.Templates.ExecuteTemplate(w, "privacy", data); err != nil {
|
||||
@@ -26,15 +35,17 @@ func (h *PrivacyPolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// TermsOfServiceHandler handles the /terms page
|
||||
type TermsOfServiceHandler struct {
|
||||
Templates *template.Template
|
||||
RegistryURL string
|
||||
Templates *template.Template
|
||||
RegistryURL string
|
||||
CompanyName string
|
||||
Jurisdiction string
|
||||
}
|
||||
|
||||
func (h *TermsOfServiceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
data := struct {
|
||||
PageData
|
||||
}{
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
data := LegalPageData{
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
CompanyName: h.CompanyName,
|
||||
Jurisdiction: h.Jurisdiction,
|
||||
}
|
||||
|
||||
if err := h.Templates.ExecuteTemplate(w, "terms", data); err != nil {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"atcr.io/pkg/appview"
|
||||
)
|
||||
|
||||
func TestPrivacyPolicyHandler_RendersTemplateVars(t *testing.T) {
|
||||
templates, err := appview.Templates()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load templates: %v", err)
|
||||
}
|
||||
|
||||
handler := &PrivacyPolicyHandler{
|
||||
Templates: templates,
|
||||
RegistryURL: "myregistry.example.com",
|
||||
CompanyName: "My Container Registry",
|
||||
Jurisdiction: "State of California, United States",
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/privacy", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
body := rr.Body.String()
|
||||
|
||||
// Verify company name is rendered
|
||||
if !strings.Contains(body, "My Container Registry") {
|
||||
t.Error("Expected body to contain company name 'My Container Registry'")
|
||||
}
|
||||
|
||||
// Verify registry URL is rendered
|
||||
if !strings.Contains(body, "myregistry.example.com") {
|
||||
t.Error("Expected body to contain registry URL 'myregistry.example.com'")
|
||||
}
|
||||
|
||||
// Verify hardcoded values are NOT present
|
||||
if strings.Contains(body, "AT Container Registry") {
|
||||
t.Error("Body should not contain hardcoded 'AT Container Registry'")
|
||||
}
|
||||
|
||||
// Note: We don't check for "atcr.io" because it may appear in code samples (io.atcr.*)
|
||||
}
|
||||
|
||||
func TestTermsOfServiceHandler_RendersTemplateVars(t *testing.T) {
|
||||
templates, err := appview.Templates()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load templates: %v", err)
|
||||
}
|
||||
|
||||
handler := &TermsOfServiceHandler{
|
||||
Templates: templates,
|
||||
RegistryURL: "myregistry.example.com",
|
||||
CompanyName: "My Container Registry",
|
||||
Jurisdiction: "State of California, United States",
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/terms", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
body := rr.Body.String()
|
||||
|
||||
// Verify company name is rendered
|
||||
if !strings.Contains(body, "My Container Registry") {
|
||||
t.Error("Expected body to contain company name 'My Container Registry'")
|
||||
}
|
||||
|
||||
// Verify registry URL is rendered
|
||||
if !strings.Contains(body, "myregistry.example.com") {
|
||||
t.Error("Expected body to contain registry URL 'myregistry.example.com'")
|
||||
}
|
||||
|
||||
// Verify jurisdiction is rendered
|
||||
if !strings.Contains(body, "State of California, United States") {
|
||||
t.Error("Expected body to contain jurisdiction 'State of California, United States'")
|
||||
}
|
||||
|
||||
// Verify hardcoded values are NOT present
|
||||
if strings.Contains(body, "AT Container Registry") {
|
||||
t.Error("Body should not contain hardcoded 'AT Container Registry'")
|
||||
}
|
||||
if strings.Contains(body, "State of Texas") {
|
||||
t.Error("Body should not contain hardcoded 'State of Texas'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegalHandlers_DefaultValues(t *testing.T) {
|
||||
// Test with the actual default values to ensure production behavior works
|
||||
templates, err := appview.Templates()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load templates: %v", err)
|
||||
}
|
||||
|
||||
t.Run("privacy with defaults", func(t *testing.T) {
|
||||
handler := &PrivacyPolicyHandler{
|
||||
Templates: templates,
|
||||
RegistryURL: "atcr.io",
|
||||
CompanyName: "AT Container Registry",
|
||||
Jurisdiction: "State of Texas, United States",
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/privacy", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "AT Container Registry") {
|
||||
t.Error("Expected body to contain 'AT Container Registry'")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("terms with defaults", func(t *testing.T) {
|
||||
handler := &TermsOfServiceHandler{
|
||||
Templates: templates,
|
||||
RegistryURL: "atcr.io",
|
||||
CompanyName: "AT Container Registry",
|
||||
Jurisdiction: "State of Texas, United States",
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/terms", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "State of Texas, United States") {
|
||||
t.Error("Expected body to contain 'State of Texas, United States'")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -51,16 +51,18 @@ func (h *SearchResultsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
// Return empty results if no query
|
||||
data := struct {
|
||||
PageData
|
||||
Pushes []db.Push
|
||||
HasMore bool
|
||||
NextOffset int
|
||||
Repositories []db.RepoCardData
|
||||
SearchQuery string
|
||||
HasMore bool
|
||||
NextOffset int
|
||||
}{
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
Pushes: []db.Push{},
|
||||
HasMore: false,
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
Repositories: []db.RepoCardData{},
|
||||
SearchQuery: "",
|
||||
HasMore: false,
|
||||
}
|
||||
|
||||
if err := h.Templates.ExecuteTemplate(w, "push-list.html", data); err != nil {
|
||||
if err := h.Templates.ExecuteTemplate(w, "search-results.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
@@ -84,25 +86,30 @@ func (h *SearchResultsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
currentUserDID = user.DID
|
||||
}
|
||||
|
||||
pushes, total, err := db.SearchPushes(h.DB, query, limit, offset, currentUserDID)
|
||||
repos, total, err := db.SearchRepositories(h.DB, query, limit, offset, currentUserDID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set registry URL on all cards
|
||||
db.SetRegistryURL(repos, h.RegistryURL)
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
Pushes []db.Push
|
||||
HasMore bool
|
||||
NextOffset int
|
||||
Repositories []db.RepoCardData
|
||||
SearchQuery string
|
||||
HasMore bool
|
||||
NextOffset int
|
||||
}{
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
Pushes: pushes,
|
||||
HasMore: offset+limit < total,
|
||||
NextOffset: offset + limit,
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
Repositories: repos,
|
||||
SearchQuery: query,
|
||||
HasMore: offset+limit < total,
|
||||
NextOffset: offset + limit,
|
||||
}
|
||||
|
||||
if err := h.Templates.ExecuteTemplate(w, "push-list.html", data); err != nil {
|
||||
if err := h.Templates.ExecuteTemplate(w, "search-results.html", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+4
-5
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 13 KiB |
@@ -16,6 +16,12 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// LegalConfig holds legal page customization settings
|
||||
type LegalConfig struct {
|
||||
CompanyName string
|
||||
Jurisdiction string
|
||||
}
|
||||
|
||||
// UIDependencies contains all dependencies needed for UI route registration
|
||||
type UIDependencies struct {
|
||||
Database *sql.DB
|
||||
@@ -30,6 +36,7 @@ type UIDependencies struct {
|
||||
ReadmeFetcher *readme.Fetcher
|
||||
Templates *template.Template
|
||||
DefaultHoldDID string
|
||||
LegalConfig LegalConfig
|
||||
}
|
||||
|
||||
// RegisterUIRoutes registers all web UI and API routes on the provided router
|
||||
@@ -91,15 +98,19 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
// Legal pages (public)
|
||||
router.Get("/privacy", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
|
||||
&uihandlers.PrivacyPolicyHandler{
|
||||
Templates: deps.Templates,
|
||||
RegistryURL: registryURL,
|
||||
Templates: deps.Templates,
|
||||
RegistryURL: registryURL,
|
||||
CompanyName: deps.LegalConfig.CompanyName,
|
||||
Jurisdiction: deps.LegalConfig.Jurisdiction,
|
||||
},
|
||||
).ServeHTTP)
|
||||
|
||||
router.Get("/terms", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
|
||||
&uihandlers.TermsOfServiceHandler{
|
||||
Templates: deps.Templates,
|
||||
RegistryURL: registryURL,
|
||||
Templates: deps.Templates,
|
||||
RegistryURL: registryURL,
|
||||
CompanyName: deps.LegalConfig.CompanyName,
|
||||
Jurisdiction: deps.LegalConfig.Jurisdiction,
|
||||
},
|
||||
).ServeHTTP)
|
||||
|
||||
@@ -220,6 +231,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
r.Post("/api/images/{repository}/avatar", (&uihandlers.UploadAvatarHandler{
|
||||
DB: deps.Database,
|
||||
Refresher: deps.Refresher,
|
||||
Templates: deps.Templates,
|
||||
}).ServeHTTP)
|
||||
|
||||
// Device approval page (authenticated)
|
||||
|
||||
@@ -324,69 +324,6 @@ function removeManifestElement(sanitizedId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Upload repository avatar
|
||||
async function uploadAvatar(input, repository) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Client-side validation
|
||||
const validTypes = ['image/png', 'image/jpeg', 'image/webp'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
alert('Please select a PNG, JPEG, or WebP image');
|
||||
return;
|
||||
}
|
||||
if (file.size > 3 * 1024 * 1024) {
|
||||
alert('Image must be less than 3MB');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/images/${repository}/avatar`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
window.location.href = '/auth/oauth/login';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Update the avatar image on the page
|
||||
const wrapper = document.querySelector('.repo-hero-icon-wrapper');
|
||||
if (!wrapper) return;
|
||||
|
||||
const existingImg = wrapper.querySelector('.repo-hero-icon');
|
||||
const placeholder = wrapper.querySelector('.repo-hero-icon-placeholder');
|
||||
|
||||
if (existingImg) {
|
||||
existingImg.src = data.avatarURL;
|
||||
} else if (placeholder) {
|
||||
const newImg = document.createElement('img');
|
||||
newImg.src = data.avatarURL;
|
||||
newImg.alt = repository;
|
||||
newImg.className = 'repo-hero-icon';
|
||||
placeholder.replaceWith(newImg);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error uploading avatar:', err);
|
||||
alert('Failed to upload avatar: ' + err.message);
|
||||
}
|
||||
|
||||
// Clear input so same file can be selected again
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const modal = document.getElementById('manifest-delete-modal');
|
||||
@@ -663,4 +600,3 @@ window.copyToClipboard = copyToClipboard;
|
||||
window.toggleOfflineManifests = toggleOfflineManifests;
|
||||
window.deleteManifest = deleteManifest;
|
||||
window.closeManifestDeleteModal = closeManifestDeleteModal;
|
||||
window.uploadAvatar = uploadAvatar;
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'actor-typeahead';
|
||||
// Lucide Icons (tree-shaken - only icons actually used in templates)
|
||||
import { createIcons } from 'lucide';
|
||||
import {
|
||||
Anchor,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowDownToLine,
|
||||
@@ -40,9 +39,14 @@ import {
|
||||
XCircle,
|
||||
} from 'lucide';
|
||||
|
||||
// Custom Helm icon (from Simple Icons - official Helm logo)
|
||||
const Helm = [
|
||||
['path', { d: 'M12.337 0c-.475 0-.861 1.016-.861 2.269 0 .527.069 1.011.183 1.396a8.514 8.514 0 0 0-3.961 1.22 5.229 5.229 0 0 0-.595-1.093c-.606-.866-1.34-1.436-1.79-1.43a.381.381 0 0 0-.217.066c-.39.273-.123 1.326.596 2.353.267.381.559.705.84.948a8.683 8.683 0 0 0-1.528 1.716h1.734a7.179 7.179 0 0 1 5.381-2.421 7.18 7.18 0 0 1 5.382 2.42h1.733a8.687 8.687 0 0 0-1.32-1.53c.35-.249.735-.643 1.078-1.133.719-1.027.986-2.08.596-2.353a.382.382 0 0 0-.217-.065c-.45-.007-1.184.563-1.79 1.43a4.897 4.897 0 0 0-.676 1.325 8.52 8.52 0 0 0-3.899-1.42c.12-.39.193-.887.193-1.429 0-1.253-.386-2.269-.862-2.269zM1.624 9.443v5.162h1.358v-1.968h1.64v1.968h1.357V9.443H4.62v1.838H2.98V9.443zm5.912 0v5.162h3.21v-1.108H8.893v-.95h1.64v-1.142h-1.64v-.84h1.853V9.443zm4.698 0v5.162h3.218v-1.362h-1.86v-3.8zm4.706 0v5.162h1.364v-2.643l1.357 1.225 1.35-1.232v2.65h1.365V9.443h-.614l-2.1 1.914-2.109-1.914zm-11.82 7.28a8.688 8.688 0 0 0 1.412 1.548 5.206 5.206 0 0 0-.841.948c-.719 1.027-.985 2.08-.596 2.353.39.273 1.289-.338 2.007-1.364a5.23 5.23 0 0 0 .595-1.092 8.514 8.514 0 0 0 3.961 1.219 5.01 5.01 0 0 0-.183 1.396c0 1.253.386 2.269.861 2.269.476 0 .862-1.016.862-2.269 0-.542-.072-1.04-.193-1.43a8.52 8.52 0 0 0 3.9-1.42c.121.4.352.865.675 1.327.719 1.026 1.617 1.637 2.007 1.364.39-.273.123-1.326-.596-2.353-.343-.49-.727-.885-1.077-1.135a8.69 8.69 0 0 0 1.202-1.36h-1.771a7.174 7.174 0 0 1-5.227 2.252 7.174 7.174 0 0 1-5.226-2.252z', fill: 'currentColor', stroke: 'none' }]
|
||||
];
|
||||
|
||||
// Create icons map for createIcons function
|
||||
const icons = {
|
||||
Anchor,
|
||||
Helm,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowDownToLine,
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
</a>
|
||||
<span class="text-base-content/30">·</span>
|
||||
<a href="https://tangled.org/evan.jarrett.net/at-container-registry" target="_blank" rel="noopener" class="link link-hover inline-flex items-center gap-1">
|
||||
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" alt="" class="size-3.5 icon-light">
|
||||
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" alt="" class="size-3.5 icon-dark">
|
||||
<img src="/static/tangled-black.svg" alt="" class="size-3.5 icon-light">
|
||||
<img src="/static/tangled-white.svg" alt="" class="size-3.5 icon-dark">
|
||||
Source
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
</p>
|
||||
|
||||
<div class="mockup-code bg-base-300 text-base-content text-left w-full max-w-lg text-base mt-8">
|
||||
<pre data-prefix="$"><code>docker login atcr.io</code></pre>
|
||||
<pre data-prefix="$"><code>docker push atcr.io/you/app</code></pre>
|
||||
<pre data-prefix="$"><code>docker login {{ .RegistryURL }}</code></pre>
|
||||
<pre data-prefix="$"><code>docker push {{ .RegistryURL }}/you/app</code></pre>
|
||||
<pre data-prefix="#" class="text-base-content/50"><code>same docker, decentralized</code></pre>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{{ define "repo-avatar" }}
|
||||
{{/*
|
||||
Repository avatar component - displays image or placeholder with upload overlay
|
||||
Required: .RepositoryName (string)
|
||||
Optional: .IconURL (string), .IsOwner (bool)
|
||||
*/}}
|
||||
<div id="repo-avatar" class="relative shrink-0">
|
||||
{{ if .IconURL }}
|
||||
<img src="{{ .IconURL }}" alt="{{ .RepositoryName }}" 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">
|
||||
<span class="text-4xl">{{ firstChar .RepositoryName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ 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">
|
||||
<i data-lucide="plus" class="size-8 text-white"></i>
|
||||
</label>
|
||||
<input type="file" id="avatar-upload" name="avatar"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
hx-post="/api/images/{{ .RepositoryName }}/avatar"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#repo-avatar"
|
||||
hx-on::after-request="if(event.detail.xhr.status===401) window.location='/auth/oauth/login'"
|
||||
class="hidden">
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -64,7 +64,7 @@
|
||||
{{ template "star" (dict "IsStarred" .IsStarred "StarCount" .StarCount) }}
|
||||
{{ template "pull-count" (dict "PullCount" .PullCount) }}
|
||||
{{ if eq .ArtifactType "helm-chart" }}
|
||||
<i data-lucide="anchor" class="size-5 text-helm" title="Helm chart"></i>
|
||||
<i data-lucide="helm" class="size-5 text-helm" title="Helm chart"></i>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ if not .LastUpdated.IsZero }}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{{ template "nav" . }}
|
||||
|
||||
<main class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<h1 class="text-3xl font-bold mb-2">Privacy Policy - AT Container Registry (atcr.io)</h1>
|
||||
<h1 class="text-3xl font-bold mb-2">Privacy Policy - {{ .CompanyName }} ({{ .RegistryURL }})</h1>
|
||||
<p class="text-base-content/60 mb-8"><em>Last updated: January 2025</em></p>
|
||||
|
||||
<div class="prose prose-sm max-w-none space-y-8">
|
||||
@@ -17,11 +17,11 @@
|
||||
<h2 class="text-xl font-semibold text-primary">Data We Collect and Store</h2>
|
||||
|
||||
<h3 class="text-lg font-medium mt-4">Data Stored on Your PDS (Controlled by You)</h3>
|
||||
<p>When you use AT Container Registry, records are written to your Personal Data Server (PDS) under the <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">io.atcr.*</code> namespace. This data is stored on infrastructure you or your PDS hosting provider controls. We do not control this data, and its retention and deletion is governed by your PDS provider's policies.</p>
|
||||
<p>When you use {{ .CompanyName }}, records are written to your Personal Data Server (PDS) under the <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">io.atcr.*</code> namespace. This data is stored on infrastructure you or your PDS hosting provider controls. We do not control this data, and its retention and deletion is governed by your PDS provider's policies.</p>
|
||||
|
||||
<h3 class="text-lg font-medium mt-6">Data Stored on Our Infrastructure</h3>
|
||||
|
||||
<p><strong>Layer Records:</strong> Our hold services (e.g., <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">hold01.atcr.io</code>) maintain records in their embedded PDS that reference container image layers you publish. These records are public and link your AT Protocol identity (DID) to content-addressed SHA identifiers.</p>
|
||||
<p><strong>Layer Records:</strong> Our hold services (e.g., <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">hold01.{{ .RegistryURL }}</code>) maintain records in their embedded PDS that reference container image layers you publish. These records are public and link your AT Protocol identity (DID) to content-addressed SHA identifiers.</p>
|
||||
|
||||
<p><strong>OCI Blobs:</strong> Container image layers are stored in our object storage (S3). These blobs are content-addressed and deduplicated—meaning identical layers uploaded by different users are stored only once.</p>
|
||||
|
||||
@@ -46,9 +46,9 @@
|
||||
<section>
|
||||
<h2 class="text-xl font-semibold text-primary">Our Services and Their Data</h2>
|
||||
|
||||
<p>AT Container Registry consists of multiple services, each with distinct data responsibilities:</p>
|
||||
<p>{{ .CompanyName }} consists of multiple services, each with distinct data responsibilities:</p>
|
||||
|
||||
<h3 class="text-lg font-medium mt-4">AppView (atcr.io)</h3>
|
||||
<h3 class="text-lg font-medium mt-4">AppView ({{ .RegistryURL }})</h3>
|
||||
<p>The registry frontend you interact with directly. Stores:</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-4">
|
||||
<li>OAuth sessions and tokens for authentication</li>
|
||||
@@ -58,13 +58,13 @@
|
||||
</ul>
|
||||
|
||||
<h3 class="text-lg font-medium mt-6">ATCR-Hosted Hold Services</h3>
|
||||
<p>Storage backends we operate (e.g., <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">hold01.atcr.io</code>). Each hold has an embedded PDS and stores:</p>
|
||||
<p>Storage backends we operate (e.g., <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">hold01.{{ .RegistryURL }}</code>). Each hold has an embedded PDS and stores:</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-4">
|
||||
<li>OCI blobs (container image layers) in object storage</li>
|
||||
<li>Layer records in the hold's embedded PDS linking your DID to blob references</li>
|
||||
<li>Crew membership records for access control</li>
|
||||
</ul>
|
||||
<p class="mt-2">Hold services on <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">*.atcr.io</code> domains are operated by us and covered by this policy.</p>
|
||||
<p class="mt-2">Hold services on <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">*.{{ .RegistryURL }}</code> domains are operated by us and covered by this policy.</p>
|
||||
|
||||
<h3 class="text-lg font-medium mt-6">User-Deployed Hold Services (BYOS)</h3>
|
||||
<p>You may use "Bring Your Own Storage" by deploying your own hold service. Data on user-deployed holds is governed by that operator's privacy policy, not ours. We can request deletion on your behalf but cannot guarantee it for services we do not control.</p>
|
||||
@@ -263,10 +263,10 @@
|
||||
<section>
|
||||
<h2 class="text-xl font-semibold text-primary">Important Notes on AT Protocol Architecture</h2>
|
||||
|
||||
<p>AT Container Registry is built on the AT Protocol, which has a unique data architecture:</p>
|
||||
<p>{{ .CompanyName }} is built on the AT Protocol, which has a unique data architecture:</p>
|
||||
|
||||
<ol class="list-decimal list-inside space-y-2 ml-4 mt-4">
|
||||
<li><strong>You control your data.</strong> Most data associated with your use of AT Container Registry is stored on your Personal Data Server (PDS), which you or your chosen provider controls.</li>
|
||||
<li><strong>You control your data.</strong> Most data associated with your use of {{ .CompanyName }} is stored on your Personal Data Server (PDS), which you or your chosen provider controls.</li>
|
||||
<li><strong>Public by design.</strong> AT Protocol data is designed to be public and distributed. Records you create, including container image references, are publicly visible and may be replicated across the network.</li>
|
||||
<li><strong>Content-addressed storage.</strong> OCI blobs are identified by their cryptographic hash. This means blob data is inherently pseudonymous—it cannot be attributed to you without the corresponding records that reference it.</li>
|
||||
<li><strong>Deletion limitations.</strong> Because AT Protocol is distributed, we cannot guarantee that copies of public records have not been made by other participants in the network. We can only delete data on infrastructure we control.</li>
|
||||
@@ -276,10 +276,10 @@
|
||||
<section>
|
||||
<h2 class="text-xl font-semibold text-primary">Bring Your Own Storage (BYOS)</h2>
|
||||
|
||||
<p>AT Container Registry supports "Bring Your Own Storage" where users can deploy their own hold services to store container image blobs. This section explains how BYOS affects your privacy rights.</p>
|
||||
<p>{{ .CompanyName }} supports "Bring Your Own Storage" where users can deploy their own hold services to store container image blobs. This section explains how BYOS affects your privacy rights.</p>
|
||||
|
||||
<h3 class="text-lg font-medium mt-4">ATCR-Hosted Holds</h3>
|
||||
<p>Hold services on <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">*.atcr.io</code> domains (e.g., <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">hold01.atcr.io</code>) are operated by us and fully covered by this privacy policy. We can fulfill all data access, export, and deletion requests for these services.</p>
|
||||
<p>Hold services on <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">*.{{ .RegistryURL }}</code> domains (e.g., <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">hold01.{{ .RegistryURL }}</code>) are operated by us and fully covered by this privacy policy. We can fulfill all data access, export, and deletion requests for these services.</p>
|
||||
|
||||
<h3 class="text-lg font-medium mt-6">User-Deployed Holds</h3>
|
||||
<p>If you use a hold service not operated by us:</p>
|
||||
@@ -303,7 +303,7 @@
|
||||
<h2 class="text-xl font-semibold text-primary">How to Exercise Your Rights</h2>
|
||||
|
||||
<h3 class="text-lg font-medium mt-4">Self-Service (via Settings)</h3>
|
||||
<p>Most data management can be done directly through your account settings at atcr.io:</p>
|
||||
<p>Most data management can be done directly through your account settings at {{ .RegistryURL }}:</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-4">
|
||||
<li><strong>Delete your data:</strong> Use the "Delete Account" button in settings. This will remove your layer records, cached data, and authentication tokens. You may also choose to have us delete <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">io.atcr.*</code> records from your PDS (requires active OAuth session).</li>
|
||||
<li><strong>Revoke device tokens:</strong> Manage and revoke credential helper devices in settings.</li>
|
||||
@@ -318,7 +318,7 @@
|
||||
<li>Questions about this policy</li>
|
||||
</ul>
|
||||
|
||||
<p class="mt-4"><strong>Email:</strong> <a href="mailto:privacy@atcr.io" class="link link-primary">privacy@atcr.io</a></p>
|
||||
<p class="mt-4"><strong>Email:</strong> <a href="mailto:privacy@{{ .RegistryURL }}" class="link link-primary">privacy@{{ .RegistryURL }}</a></p>
|
||||
|
||||
<p class="mt-2">Please include your AT Protocol DID or handle so we can verify your identity.</p>
|
||||
|
||||
@@ -330,8 +330,8 @@
|
||||
|
||||
<p>For questions about this privacy policy or to exercise your data rights, contact:</p>
|
||||
|
||||
<p class="mt-4"><strong>Email:</strong> <a href="mailto:privacy@atcr.io" class="link link-primary">privacy@atcr.io</a></p>
|
||||
<p><strong>Website:</strong> <a href="https://atcr.io" class="link link-primary">https://atcr.io</a></p>
|
||||
<p class="mt-4"><strong>Email:</strong> <a href="mailto:privacy@{{ .RegistryURL }}" class="link link-primary">privacy@{{ .RegistryURL }}</a></p>
|
||||
<p><strong>Website:</strong> <a href="https://{{ .RegistryURL }}" class="link link-primary">https://{{ .RegistryURL }}</a></p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -27,24 +27,7 @@
|
||||
<!-- Repository Header -->
|
||||
<div class="card bg-base-100 shadow-sm p-6 space-y-6 w-full">
|
||||
<div class="flex gap-4 items-start">
|
||||
<div class="relative shrink-0">
|
||||
{{ if .Repository.IconURL }}
|
||||
<img src="{{ .Repository.IconURL }}" alt="{{ .Repository.Name }}" 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">
|
||||
<span class="text-4xl">{{ firstChar .Repository.Name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ 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">
|
||||
<i data-lucide="plus" class="size-8 text-white"></i>
|
||||
</label>
|
||||
<input type="file" id="avatar-upload" accept="image/png,image/jpeg,image/webp"
|
||||
onchange="uploadAvatar(this, '{{ .Repository.Name }}')" hidden>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ template "repo-avatar" (dict "IconURL" .Repository.IconURL "RepositoryName" .Repository.Name "IsOwner" .IsOwner) }}
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-2xl font-bold">
|
||||
<a href="/u/{{ .Owner.Handle }}" class="link link-primary">{{ .Owner.Handle }}</a>
|
||||
@@ -149,7 +132,7 @@
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-mono font-semibold text-lg">{{ .Tag.Tag }}</span>
|
||||
{{ if eq .ArtifactType "helm-chart" }}
|
||||
<span class="badge badge-md badge-soft badge-helm"><i data-lucide="anchor" class="size-3"></i> Helm</span>
|
||||
<span class="badge badge-md badge-soft badge-helm"><i data-lucide="helm" class="size-3"></i> Helm chart</span>
|
||||
{{ else if .IsMultiArch }}
|
||||
<span class="badge badge-md badge-soft badge-accent">Multi-arch</span>
|
||||
{{ end }}
|
||||
@@ -217,11 +200,11 @@
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{{ if .IsManifestList }}
|
||||
<span class="flex items-center gap-1 font-medium"><i data-lucide="package" class="size-4"></i> Multi-arch</span>
|
||||
<span class="flex items-center gap-1 font-medium"><i data-lucide="package" class="size-5"></i> Multi-arch</span>
|
||||
{{ else if eq .ArtifactType "helm-chart" }}
|
||||
<span class="flex items-center gap-1 font-medium text-helm"><i data-lucide="anchor" class="size-4"></i> Helm Chart</span>
|
||||
<span class="flex items-center gap-1 font-medium text-helm"><i data-lucide="helm" class="size-5"></i> Helm Chart</span>
|
||||
{{ else }}
|
||||
<span class="flex items-center gap-1 font-medium"><i data-lucide="box" class="size-4"></i> Image</span>
|
||||
<span class="flex items-center gap-1 font-medium"><i data-lucide="box" class="size-5"></i> Image</span>
|
||||
{{ end }}
|
||||
{{ if .HasAttestations }}
|
||||
<span class="badge badge-md badge-soft badge-success"><i data-lucide="shield-check" class="size-3"></i> Attestations</span>
|
||||
|
||||
@@ -8,24 +8,22 @@
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
<main class="container mx-auto px-4">
|
||||
<div class="max-w-4xl mx-auto py-8">
|
||||
{{ if .SearchQuery }}
|
||||
<h1 class="text-3xl font-bold mb-6">Search Results for "{{ .SearchQuery }}"</h1>
|
||||
{{ else }}
|
||||
<h1 class="text-3xl font-bold mb-2">Search</h1>
|
||||
<p class="text-base-content/60 mb-6">Enter a search term to find images.</p>
|
||||
{{ end }}
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
{{ if .SearchQuery }}
|
||||
<h2 class="text-2xl font-bold mb-6">Search Results for "{{ .SearchQuery }}"</h2>
|
||||
{{ else }}
|
||||
<h2 class="text-2xl font-bold mb-2">Search</h2>
|
||||
<p class="text-base-content/60 mb-6">Enter a search term to find images.</p>
|
||||
{{ end }}
|
||||
|
||||
<div id="push-list" hx-get="/api/search-results?q={{ .SearchQuery }}" hx-trigger="load" hx-swap="innerHTML">
|
||||
<!-- Initial loading state -->
|
||||
{{ if .SearchQuery }}
|
||||
<div class="flex items-center gap-2 text-base-content/60">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span>Searching...</span>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div id="search-results" hx-get="/api/search-results?q={{ .SearchQuery }}" hx-trigger="load" hx-swap="innerHTML">
|
||||
<!-- Initial loading state -->
|
||||
{{ if .SearchQuery }}
|
||||
<div class="flex items-center gap-2 text-base-content/60">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span>Searching...</span>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
<h3 class="font-semibold">First Time Setup</h3>
|
||||
<ol class="list-decimal list-inside space-y-4 text-sm">
|
||||
<li>Install credential helper:
|
||||
<pre class="mt-2 p-3 bg-base-300 rounded-lg overflow-x-auto"><code>curl -fsSL atcr.io/static/install.sh | bash</code></pre>
|
||||
<pre class="mt-2 p-3 bg-base-300 rounded-lg overflow-x-auto"><code>curl -fsSL {{ .RegistryURL }}/static/install.sh | bash</code></pre>
|
||||
</li>
|
||||
<li>Configure Docker to use the helper. Add to <code class="cmd">~/.docker/config.json</code>:
|
||||
<pre class="mt-2 p-3 bg-base-300 rounded-lg overflow-x-auto"><code>{
|
||||
|
||||
@@ -9,15 +9,15 @@
|
||||
{{ template "nav" . }}
|
||||
|
||||
<main class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<h1 class="text-3xl font-bold mb-2">Terms of Service - AT Container Registry (atcr.io)</h1>
|
||||
<h1 class="text-3xl font-bold mb-2">Terms of Service - {{ .CompanyName }} ({{ .RegistryURL }})</h1>
|
||||
<p class="text-base-content/60 mb-8"><em>Last updated: January 2025</em></p>
|
||||
|
||||
<p class="mb-8">These Terms of Service ("Terms") govern your use of AT Container Registry ("atcr.io", "the Service", "we", "us", "our"). By using the Service, you agree to these Terms. If you do not agree, do not use the Service.</p>
|
||||
<p class="mb-8">These Terms of Service ("Terms") govern your use of {{ .CompanyName }} ("{{ .RegistryURL }}", "the Service", "we", "us", "our"). By using the Service, you agree to these Terms. If you do not agree, do not use the Service.</p>
|
||||
|
||||
<div class="prose prose-sm max-w-none space-y-8">
|
||||
<section>
|
||||
<h2 class="text-xl font-semibold text-primary">1. Description of Service</h2>
|
||||
<p>AT Container Registry is an OCI-compatible container registry built on the AT Protocol. The Service allows you to store, distribute, and manage container images using your AT Protocol identity.</p>
|
||||
<p>{{ .CompanyName }} is an OCI-compatible container registry built on the AT Protocol. The Service allows you to store, distribute, and manage container images using your AT Protocol identity.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -148,7 +148,7 @@
|
||||
<section>
|
||||
<h2 class="text-xl font-semibold text-primary">11. Indemnification</h2>
|
||||
|
||||
<p>You agree to indemnify and hold harmless AT Container Registry, its operators, and affiliates from any claims, damages, losses, or expenses (including reasonable legal fees) arising out of:</p>
|
||||
<p>You agree to indemnify and hold harmless {{ .CompanyName }}, its operators, and affiliates from any claims, damages, losses, or expenses (including reasonable legal fees) arising out of:</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-4">
|
||||
<li>Your use of the Service</li>
|
||||
<li>Your violation of these Terms</li>
|
||||
@@ -166,13 +166,13 @@
|
||||
<section>
|
||||
<h2 class="text-xl font-semibold text-primary">13. Governing Law</h2>
|
||||
|
||||
<p>These Terms shall be governed by and construed in accordance with the laws of the State of Texas, United States, without regard to conflict of law principles.</p>
|
||||
<p>These Terms shall be governed by and construed in accordance with the laws of the {{ .Jurisdiction }}, without regard to conflict of law principles.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-semibold text-primary">14. Dispute Resolution</h2>
|
||||
|
||||
<p>Any disputes arising out of or relating to these Terms or the Service shall first be attempted to be resolved through good-faith negotiation. If negotiation fails, disputes shall be resolved through binding arbitration or in the courts of Texas, at our discretion.</p>
|
||||
<p>Any disputes arising out of or relating to these Terms or the Service shall first be attempted to be resolved through good-faith negotiation. If negotiation fails, disputes shall be resolved through binding arbitration or in the courts of our jurisdiction, at our discretion.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -184,7 +184,7 @@
|
||||
<section>
|
||||
<h2 class="text-xl font-semibold text-primary">16. Entire Agreement</h2>
|
||||
|
||||
<p>These Terms, together with our <a href="/privacy" class="link link-primary">Privacy Policy</a>, constitute the entire agreement between you and AT Container Registry regarding your use of the Service.</p>
|
||||
<p>These Terms, together with our <a href="/privacy" class="link link-primary">Privacy Policy</a>, constitute the entire agreement between you and {{ .CompanyName }} regarding your use of the Service.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -192,8 +192,8 @@
|
||||
|
||||
<p>For questions about these Terms, contact us at:</p>
|
||||
|
||||
<p class="mt-4"><strong>Email:</strong> <a href="mailto:legal@atcr.io" class="link link-primary">legal@atcr.io</a></p>
|
||||
<p><strong>Website:</strong> <a href="https://atcr.io" class="link link-primary">https://atcr.io</a></p>
|
||||
<p class="mt-4"><strong>Email:</strong> <a href="mailto:legal@{{ .RegistryURL }}" class="link link-primary">legal@{{ .RegistryURL }}</a></p>
|
||||
<p><strong>Website:</strong> <a href="https://{{ .RegistryURL }}" class="link link-primary">https://{{ .RegistryURL }}</a></p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
{{ range .Pushes }}
|
||||
<div class="card p-4">
|
||||
<div class="flex items-start gap-4">
|
||||
{{ if .IconURL }}
|
||||
<img src="{{ .IconURL }}" alt="{{ .Repository }}" class="size-12 rounded-lg object-cover shrink-0">
|
||||
{{ else }}
|
||||
<div class="avatar avatar-placeholder">
|
||||
<div class="bg-neutral text-neutral-content size-12 rounded-lg shadow-sm">
|
||||
<span class="text-lg">{{ firstChar .Repository }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex justify-between items-center gap-4">
|
||||
<div class="truncate">
|
||||
<a href="/u/{{ .Handle }}" class="link link-primary font-medium">{{ .Handle }}</a>
|
||||
<span class="text-base-content/60">/</span>
|
||||
<a href="/r/{{ .Handle }}/{{ .Repository }}" class="link text-base-content font-medium hover:underline">{{ .Repository }}</a>
|
||||
<span class="text-base-content/60 mx-1">:</span>
|
||||
<span class="text-base-content/60">{{ .Tag }}</span>
|
||||
{{ if eq .ArtifactType "helm-chart" }}
|
||||
<span class="badge badge-xs badge-soft badge-helm"><i data-lucide="anchor"></i></span>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="flex items-center gap-4 shrink-0">
|
||||
{{ template "star" (dict "IsStarred" .IsStarred "StarCount" .StarCount) }}
|
||||
{{ template "pull-count" (dict "PullCount" .PullCount) }}
|
||||
</div>
|
||||
</div>
|
||||
{{ if .Description }}
|
||||
<p class="text-base-content/60 text-sm mt-1 m-0">{{ .Description }}</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 mt-3 pt-3 border-t border-base-300 text-base-content/60">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="font-mono text-sm truncate max-w-[200px]" title="{{ .Digest }}">{{ .Digest }}</code>
|
||||
<button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Digest }}')"><i data-lucide="copy" class="size-4"></i></button>
|
||||
</div>
|
||||
<time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}" class="text-sm">
|
||||
{{ timeAgo .CreatedAt }}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if eq (len .Pushes) 0 }}
|
||||
<div class="py-8 text-center">
|
||||
<p class="text-base-content/60">No pushes yet. Start using ATCR by pushing your first image!</p>
|
||||
<pre class="mt-4"><code class="font-mono text-sm">docker push {{ .RegistryURL }}/yourhandle/myapp:latest</code></pre>
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -0,0 +1,30 @@
|
||||
{{/* Search results partial - renders repository cards in a grid */}}
|
||||
{{ if gt (len .Repositories) 0 }}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{{ range .Repositories }}
|
||||
{{ template "repo-card" . }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{ if .HasMore }}
|
||||
<div class="mt-6 text-center">
|
||||
<button
|
||||
class="btn btn-outline"
|
||||
hx-get="/api/search-results?q={{ .SearchQuery }}&offset={{ .NextOffset }}"
|
||||
hx-trigger="click"
|
||||
hx-target="#search-results"
|
||||
hx-swap="beforeend"
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<div class="py-12 text-center">
|
||||
<div class="text-base-content/60 mb-4">
|
||||
<i data-lucide="search-x" class="size-12 mx-auto mb-4"></i>
|
||||
<p class="text-lg">No repositories found matching your search.</p>
|
||||
</div>
|
||||
<p class="text-base-content/40 text-sm">Try a different search term or browse the homepage.</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -550,7 +550,7 @@ func TestTemplates(t *testing.T) {
|
||||
"settings.html",
|
||||
"install.html",
|
||||
"manifest-modal",
|
||||
"push-list.html",
|
||||
"search-results.html",
|
||||
"health-badge",
|
||||
"alert",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user