more pagespeed improvements, improve routing handler logic

This commit is contained in:
Evan Jarrett
2026-01-17 10:38:35 -06:00
parent dbe0efd949
commit b7ed0e7d5b
37 changed files with 633 additions and 400 deletions
+7 -6
View File
@@ -369,10 +369,11 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
slog.Info("Registered dynamic root file routes", "count", len(rootFiles), "files", rootFiles)
}
// Mount subdirectory routes with clean paths
mainRouter.Handle("/css/*", http.StripPrefix("/css/", appview.PublicSubdir("css")))
mainRouter.Handle("/js/*", http.StripPrefix("/js/", appview.PublicSubdir("js")))
mainRouter.Handle("/static/*", http.StripPrefix("/static/", appview.PublicSubdir("static")))
// Mount subdirectory routes with clean paths and long cache headers (1 year)
// Cache busting is handled via query string hashes in templates
mainRouter.Handle("/css/*", appview.CacheMiddleware(http.StripPrefix("/css/", appview.PublicSubdir("css")), 31536000))
mainRouter.Handle("/js/*", appview.CacheMiddleware(http.StripPrefix("/js/", appview.PublicSubdir("js")), 31536000))
mainRouter.Handle("/static/*", appview.CacheMiddleware(http.StripPrefix("/static/", appview.PublicSubdir("static")), 31536000))
slog.Info("UI enabled", "home", "/", "settings", "/settings")
}
@@ -448,11 +449,11 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
// Device authorization endpoints (public)
mainRouter.Handle("/auth/device/code", &uihandlers.DeviceCodeHandler{
Store: deviceStore,
BaseUIHandler: uihandlers.BaseUIHandler{DeviceStore: deviceStore},
AppViewBaseURL: baseURL,
})
mainRouter.Handle("/auth/device/token", &uihandlers.DeviceTokenHandler{
Store: deviceStore,
BaseUIHandler: uihandlers.BaseUIHandler{DeviceStore: deviceStore},
})
slog.Info("Auth endpoints enabled",
+9 -22
View File
@@ -2,7 +2,6 @@ package handlers
import (
"bytes"
"database/sql"
"errors"
"fmt"
"html/template"
@@ -12,18 +11,13 @@ import (
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
// StarRepositoryHandler handles starring a repository
type StarRepositoryHandler struct {
DB *sql.DB
Directory identity.Directory
Refresher *oauth.Refresher
Templates *template.Template
BaseUIHandler
}
func (h *StarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -70,7 +64,7 @@ func (h *StarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
// Check if HTMX request - return HTML component
if r.Header.Get("HX-Request") == "true" && h.Templates != nil {
// Get current star count and do optimistic increment
stats, _ := db.GetRepositoryStats(h.DB, ownerDID, repository)
stats, _ := db.GetRepositoryStats(h.ReadOnlyDB, ownerDID, repository)
starCount := 0
if stats != nil {
starCount = stats.StarCount
@@ -88,10 +82,7 @@ func (h *StarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
// UnstarRepositoryHandler handles unstarring a repository
type UnstarRepositoryHandler struct {
DB *sql.DB
Directory identity.Directory
Refresher *oauth.Refresher
Templates *template.Template
BaseUIHandler
}
func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -140,7 +131,7 @@ func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
// Check if HTMX request - return HTML component
if r.Header.Get("HX-Request") == "true" && h.Templates != nil {
// Get current star count and do optimistic decrement
stats, _ := db.GetRepositoryStats(h.DB, ownerDID, repository)
stats, _ := db.GetRepositoryStats(h.ReadOnlyDB, ownerDID, repository)
starCount := 0
if stats != nil {
starCount = stats.StarCount
@@ -159,9 +150,7 @@ func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
// CheckStarHandler checks if current user has starred a repository
type CheckStarHandler struct {
DB *sql.DB
Directory identity.Directory
Refresher *oauth.Refresher
BaseUIHandler
}
func (h *CheckStarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -208,8 +197,7 @@ func (h *CheckStarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// GetStatsHandler returns repository statistics
type GetStatsHandler struct {
DB *sql.DB
Directory identity.Directory
BaseUIHandler
}
func (h *GetStatsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -225,7 +213,7 @@ func (h *GetStatsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
// Get repository stats from database
stats, err := db.GetRepositoryStats(h.DB, ownerDID, repository)
stats, err := db.GetRepositoryStats(h.ReadOnlyDB, ownerDID, repository)
if err != nil {
http.Error(w, "Failed to fetch stats", http.StatusInternalServerError)
return
@@ -237,8 +225,7 @@ func (h *GetStatsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// ManifestDetailHandler returns detailed manifest information including platforms
type ManifestDetailHandler struct {
DB *sql.DB
Directory identity.Directory
BaseUIHandler
}
func (h *ManifestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -255,7 +242,7 @@ func (h *ManifestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
}
// Get manifest detail from database
manifest, err := db.GetManifestDetail(h.DB, ownerDID, repository, digest)
manifest, err := db.GetManifestDetail(h.ReadOnlyDB, ownerDID, repository, digest)
if err != nil {
if err.Error() == "manifest not found" {
http.Error(w, "Manifest not found", http.StatusNotFound)
+4 -2
View File
@@ -1,14 +1,13 @@
package handlers
import (
"html/template"
"log/slog"
"net/http"
)
// LoginHandler shows the OAuth login form
type LoginHandler struct {
Templates *template.Template
BaseUIHandler
}
func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -19,9 +18,11 @@ func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
data := struct {
PageData
ReturnTo string
Error string
}{
PageData: NewPageData(r, h.RegistryURL),
ReturnTo: returnTo,
Error: r.URL.Query().Get("error"),
}
@@ -34,6 +35,7 @@ func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// LoginSubmitHandler processes the login form submission
type LoginSubmitHandler struct {
BaseUIHandler
}
func (h *LoginSubmitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+41
View File
@@ -0,0 +1,41 @@
package handlers
import (
"database/sql"
"html/template"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/holdhealth"
"atcr.io/pkg/appview/readme"
"atcr.io/pkg/auth/oauth"
"github.com/bluesky-social/indigo/atproto/identity"
)
// BaseUIHandler contains all dependencies for UI handlers.
// Handlers embed this and use whatever fields they need.
// Route registration becomes simply &Handler{base} for everything.
type BaseUIHandler struct {
// Display
Templates *template.Template
RegistryURL string
// Database (handlers choose which to use)
DB *sql.DB // Write access
ReadOnlyDB *sql.DB // Read-only access
// Services
Refresher *oauth.Refresher
HealthChecker *holdhealth.Checker
ReadmeFetcher *readme.Fetcher
Directory identity.Directory
// Stores
SessionStore *db.SessionStore
DeviceStore *db.DeviceStore
OAuthStore *db.OAuthStore
// Config
DefaultHoldDID string
CompanyName string
Jurisdiction string
}
+1 -5
View File
@@ -2,7 +2,6 @@ package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
@@ -15,7 +14,6 @@ import (
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/oauth"
)
// DeleteAccountRequest represents the GDPR account deletion request
@@ -48,9 +46,7 @@ type HoldDeleteResult struct {
// DeleteAccountHandler handles GDPR account deletion requests
type DeleteAccountHandler struct {
DB *sql.DB
OAuthStore *db.OAuthStore
Refresher *oauth.Refresher
BaseUIHandler
}
func (h *DeleteAccountHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+30 -18
View File
@@ -18,9 +18,11 @@ func TestDeleteAccountHandler_Unauthorized(t *testing.T) {
defer database.Close()
handler := &DeleteAccountHandler{
DB: database,
OAuthStore: nil,
Refresher: nil,
BaseUIHandler: BaseUIHandler{
DB: database,
OAuthStore: nil,
Refresher: nil,
},
}
reqBody := DeleteAccountRequest{
@@ -55,9 +57,11 @@ func TestDeleteAccountHandler_MissingConfirmation(t *testing.T) {
}
handler := &DeleteAccountHandler{
DB: database,
OAuthStore: nil,
Refresher: nil,
BaseUIHandler: BaseUIHandler{
DB: database,
OAuthStore: nil,
Refresher: nil,
},
}
// Request without confirmation
@@ -93,9 +97,11 @@ func TestDeleteAccountHandler_WrongConfirmation(t *testing.T) {
}
handler := &DeleteAccountHandler{
DB: database,
OAuthStore: nil,
Refresher: nil,
BaseUIHandler: BaseUIHandler{
DB: database,
OAuthStore: nil,
Refresher: nil,
},
}
tests := []struct {
@@ -158,9 +164,11 @@ func TestDeleteAccountHandler_SuccessfulDeletion(t *testing.T) {
oauthStore := db.NewOAuthStore(database)
handler := &DeleteAccountHandler{
DB: database,
OAuthStore: oauthStore,
Refresher: nil, // No remote operations in this test
BaseUIHandler: BaseUIHandler{
DB: database,
OAuthStore: oauthStore,
Refresher: nil, // No remote operations in this test
},
}
reqBody := DeleteAccountRequest{
@@ -226,9 +234,11 @@ func TestDeleteAccountHandler_InvalidJSON(t *testing.T) {
}
handler := &DeleteAccountHandler{
DB: database,
OAuthStore: nil,
Refresher: nil,
BaseUIHandler: BaseUIHandler{
DB: database,
OAuthStore: nil,
Refresher: nil,
},
}
req := httptest.NewRequest("DELETE", "/api/account", bytes.NewReader([]byte("not json")))
@@ -277,9 +287,11 @@ func TestDeleteAccountHandler_DeletesHoldMembershipData(t *testing.T) {
oauthStore := db.NewOAuthStore(database)
handler := &DeleteAccountHandler{
DB: database,
OAuthStore: oauthStore,
Refresher: nil,
BaseUIHandler: BaseUIHandler{
DB: database,
OAuthStore: oauthStore,
Refresher: nil,
},
}
reqBody := DeleteAccountRequest{
+13 -17
View File
@@ -29,7 +29,7 @@ type DeviceCodeResponse struct {
// DeviceCodeHandler handles POST /auth/device/code
type DeviceCodeHandler struct {
Store *db.DeviceStore
BaseUIHandler
AppViewBaseURL string // e.g., "http://localhost:5000"
}
@@ -57,7 +57,7 @@ func (h *DeviceCodeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
userAgent := r.UserAgent()
// Create pending authorization
pending, err := h.Store.CreatePendingAuth(req.DeviceName, ip, userAgent)
pending, err := h.DeviceStore.CreatePendingAuth(req.DeviceName, ip, userAgent)
if err != nil {
http.Error(w, "failed to create authorization", http.StatusInternalServerError)
return
@@ -90,7 +90,7 @@ type DeviceTokenResponse struct {
// DeviceTokenHandler handles POST /auth/device/token
type DeviceTokenHandler struct {
Store *db.DeviceStore
BaseUIHandler
}
func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -106,7 +106,7 @@ func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
// Get pending authorization
pending, ok := h.Store.GetPendingByDeviceCode(req.DeviceCode)
pending, ok := h.DeviceStore.GetPendingByDeviceCode(req.DeviceCode)
if !ok {
resp := DeviceTokenResponse{
Error: "expired_token",
@@ -126,7 +126,7 @@ func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
// Approved! Get device from store to find handle
devices := h.Store.ListDevices(*pending.ApprovedDID)
devices := h.DeviceStore.ListDevices(*pending.ApprovedDID)
var handle string
for _, d := range devices {
@@ -148,8 +148,7 @@ func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// DeviceApprovalPageHandler handles GET /device
type DeviceApprovalPageHandler struct {
Store *db.DeviceStore
SessionStore *db.SessionStore
BaseUIHandler
}
func (h *DeviceApprovalPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -204,7 +203,7 @@ func (h *DeviceApprovalPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
}
// Get pending authorization
pending, ok := h.Store.GetPendingByUserCode(userCode)
pending, ok := h.DeviceStore.GetPendingByUserCode(userCode)
if !ok {
h.renderError(w, "Invalid or expired authorization code")
return
@@ -228,8 +227,7 @@ type DeviceApproveRequest struct {
// DeviceApproveHandler handles POST /device/approve
type DeviceApproveHandler struct {
Store *db.DeviceStore
SessionStore *db.SessionStore
BaseUIHandler
}
func (h *DeviceApproveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -264,7 +262,7 @@ func (h *DeviceApproveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
}
// Approve the device
_, err := h.Store.ApprovePending(req.UserCode, sess.DID, sess.Handle)
_, err := h.DeviceStore.ApprovePending(req.UserCode, sess.DID, sess.Handle)
if err != nil {
slog.Error("Failed to approve device", "component", "device/approve", "error", err)
http.Error(w, fmt.Sprintf("failed to approve: %v", err), http.StatusInternalServerError)
@@ -275,8 +273,7 @@ func (h *DeviceApproveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
// ListDevicesHandler handles GET /api/devices
type ListDevicesHandler struct {
Store *db.DeviceStore
SessionStore *db.SessionStore
BaseUIHandler
}
func (h *ListDevicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -299,15 +296,14 @@ func (h *ListDevicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
// Get devices for this user
devices := h.Store.ListDevices(sess.DID)
devices := h.DeviceStore.ListDevices(sess.DID)
render.JSON(w, r, devices)
}
// RevokeDeviceHandler handles DELETE /api/devices/{id}
type RevokeDeviceHandler struct {
Store *db.DeviceStore
SessionStore *db.SessionStore
BaseUIHandler
}
func (h *RevokeDeviceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -337,7 +333,7 @@ func (h *RevokeDeviceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
}
// Revoke device
if err := h.Store.RevokeDevice(sess.DID, deviceID); err != nil {
if err := h.DeviceStore.RevokeDevice(sess.DID, deviceID); err != nil {
http.Error(w, fmt.Sprintf("failed to revoke: %v", err), http.StatusInternalServerError)
return
}
+18 -29
View File
@@ -126,7 +126,7 @@ func TestDeviceCodeHandler_Success(t *testing.T) {
store := db.NewDeviceStore(database)
handler := &DeviceCodeHandler{
Store: store,
BaseUIHandler: BaseUIHandler{DeviceStore: store},
AppViewBaseURL: "http://localhost:5000",
}
@@ -172,7 +172,7 @@ func TestDeviceCodeHandler_DefaultDeviceName(t *testing.T) {
store := db.NewDeviceStore(database)
handler := &DeviceCodeHandler{
Store: store,
BaseUIHandler: BaseUIHandler{DeviceStore: store},
AppViewBaseURL: "http://localhost:5000",
}
@@ -207,7 +207,7 @@ func TestDeviceCodeHandler_MethodNotAllowed(t *testing.T) {
store := db.NewDeviceStore(database)
handler := &DeviceCodeHandler{
Store: store,
BaseUIHandler: BaseUIHandler{DeviceStore: store},
AppViewBaseURL: "http://localhost:5000",
}
@@ -226,7 +226,7 @@ func TestDeviceTokenHandler_AuthorizationPending(t *testing.T) {
store := db.NewDeviceStore(database)
handler := &DeviceTokenHandler{
Store: store,
BaseUIHandler: BaseUIHandler{DeviceStore: store},
}
// Create a pending authorization
@@ -266,7 +266,7 @@ func TestDeviceTokenHandler_ExpiredToken(t *testing.T) {
store := db.NewDeviceStore(database)
handler := &DeviceTokenHandler{
Store: store,
BaseUIHandler: BaseUIHandler{DeviceStore: store},
}
// Try to poll with invalid device code
@@ -300,7 +300,7 @@ func TestDeviceTokenHandler_Approved(t *testing.T) {
store := db.NewDeviceStore(database)
handler := &DeviceTokenHandler{
Store: store,
BaseUIHandler: BaseUIHandler{DeviceStore: store},
}
// Create a pending authorization
@@ -361,7 +361,7 @@ func TestDeviceTokenHandler_MethodNotAllowed(t *testing.T) {
store := db.NewDeviceStore(database)
handler := &DeviceTokenHandler{
Store: store,
BaseUIHandler: BaseUIHandler{DeviceStore: store},
}
req := httptest.NewRequest("GET", "/auth/device/token", nil)
@@ -381,8 +381,7 @@ func TestDeviceApprovalPageHandler_NotLoggedIn(t *testing.T) {
sessionStore := db.NewSessionStore(database)
handler := &DeviceApprovalPageHandler{
Store: store,
SessionStore: sessionStore,
BaseUIHandler: BaseUIHandler{DeviceStore: store, SessionStore: sessionStore},
}
req := httptest.NewRequest("GET", "/device?user_code=ABC123", nil)
@@ -420,8 +419,7 @@ func TestDeviceApprovalPageHandler_MissingUserCode(t *testing.T) {
sessionID, _ := sessionStore.Create("did:plc:test123", "test.bsky.social", "https://pds.example.com", 24*time.Hour)
handler := &DeviceApprovalPageHandler{
Store: store,
SessionStore: sessionStore,
BaseUIHandler: BaseUIHandler{DeviceStore: store, SessionStore: sessionStore},
}
req := httptest.NewRequest("GET", "/device", nil) // No user_code parameter
@@ -446,8 +444,7 @@ func TestDeviceApprovalPageHandler_MethodNotAllowed(t *testing.T) {
sessionStore := db.NewSessionStore(database)
handler := &DeviceApprovalPageHandler{
Store: store,
SessionStore: sessionStore,
BaseUIHandler: BaseUIHandler{DeviceStore: store, SessionStore: sessionStore},
}
req := httptest.NewRequest("POST", "/device?user_code=ABC123", nil)
@@ -467,8 +464,7 @@ func TestDeviceApproveHandler_Unauthorized(t *testing.T) {
sessionStore := db.NewSessionStore(database)
handler := &DeviceApproveHandler{
Store: store,
SessionStore: sessionStore,
BaseUIHandler: BaseUIHandler{DeviceStore: store, SessionStore: sessionStore},
}
reqBody := DeviceApproveRequest{
@@ -507,8 +503,7 @@ func TestDeviceApproveHandler_Deny(t *testing.T) {
sessionID, _ := sessionStore.Create("did:plc:test123", "test.bsky.social", "https://pds.example.com", 24*time.Hour)
handler := &DeviceApproveHandler{
Store: store,
SessionStore: sessionStore,
BaseUIHandler: BaseUIHandler{DeviceStore: store, SessionStore: sessionStore},
}
reqBody := DeviceApproveRequest{
@@ -548,8 +543,7 @@ func TestDeviceApproveHandler_MethodNotAllowed(t *testing.T) {
sessionStore := db.NewSessionStore(database)
handler := &DeviceApproveHandler{
Store: store,
SessionStore: sessionStore,
BaseUIHandler: BaseUIHandler{DeviceStore: store, SessionStore: sessionStore},
}
req := httptest.NewRequest("GET", "/device/approve", nil)
@@ -569,8 +563,7 @@ func TestListDevicesHandler_Unauthorized(t *testing.T) {
sessionStore := db.NewSessionStore(database)
handler := &ListDevicesHandler{
Store: store,
SessionStore: sessionStore,
BaseUIHandler: BaseUIHandler{DeviceStore: store, SessionStore: sessionStore},
}
req := httptest.NewRequest("GET", "/api/devices", nil)
@@ -606,8 +599,7 @@ func TestListDevicesHandler_Success(t *testing.T) {
store.ApprovePending(pending.UserCode, "did:plc:test123", "test.bsky.social")
handler := &ListDevicesHandler{
Store: store,
SessionStore: sessionStore,
BaseUIHandler: BaseUIHandler{DeviceStore: store, SessionStore: sessionStore},
}
req := httptest.NewRequest("GET", "/api/devices", nil)
@@ -641,8 +633,7 @@ func TestListDevicesHandler_MethodNotAllowed(t *testing.T) {
sessionStore := db.NewSessionStore(database)
handler := &ListDevicesHandler{
Store: store,
SessionStore: sessionStore,
BaseUIHandler: BaseUIHandler{DeviceStore: store, SessionStore: sessionStore},
}
req := httptest.NewRequest("POST", "/api/devices", nil)
@@ -662,8 +653,7 @@ func TestRevokeDeviceHandler_Unauthorized(t *testing.T) {
sessionStore := db.NewSessionStore(database)
handler := &RevokeDeviceHandler{
Store: store,
SessionStore: sessionStore,
BaseUIHandler: BaseUIHandler{DeviceStore: store, SessionStore: sessionStore},
}
req := httptest.NewRequest("DELETE", "/api/devices/device123", nil)
@@ -689,8 +679,7 @@ func TestRevokeDeviceHandler_MethodNotAllowed(t *testing.T) {
sessionStore := db.NewSessionStore(database)
handler := &RevokeDeviceHandler{
Store: store,
SessionStore: sessionStore,
BaseUIHandler: BaseUIHandler{DeviceStore: store, SessionStore: sessionStore},
}
req := httptest.NewRequest("GET", "/api/devices/device123", nil)
+1 -2
View File
@@ -7,8 +7,7 @@ import (
// NotFoundHandler handles 404 errors
type NotFoundHandler struct {
Templates *template.Template
RegistryURL string
BaseUIHandler
}
func (h *NotFoundHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+1 -4
View File
@@ -2,7 +2,6 @@ package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
@@ -15,7 +14,6 @@ import (
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/oauth"
)
// HoldExportResult represents the result of fetching export from a hold
@@ -37,8 +35,7 @@ type FullUserDataExport struct {
// ExportUserDataHandler handles GDPR data export requests
type ExportUserDataHandler struct {
DB *sql.DB
Refresher *oauth.Refresher
BaseUIHandler
}
func (h *ExportUserDataHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+5 -13
View File
@@ -4,14 +4,11 @@
package handlers
import (
"database/sql"
"html/template"
"log"
"net/http"
"strconv"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/holdhealth"
"atcr.io/pkg/appview/middleware"
)
@@ -24,9 +21,7 @@ type BenefitCard struct {
// HomeHandler handles the home page
type HomeHandler struct {
DB *sql.DB
Templates *template.Template
RegistryURL string
BaseUIHandler
}
func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -37,7 +32,7 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
// Fetch featured repositories (top 6 by score - carousel cycles through them)
featuredCards, err := db.GetRepoCards(h.DB, 6, currentUserDID, db.SortByScore)
featuredCards, err := db.GetRepoCards(h.ReadOnlyDB, 6, currentUserDID, db.SortByScore)
if err != nil {
log.Printf("Error fetching featured repos: %v", err)
featuredCards = []db.RepoCardData{}
@@ -45,7 +40,7 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
db.SetRegistryURL(featuredCards, h.RegistryURL)
// Fetch recently updated repositories (top 18 by last push - 6 rows)
recentCards, err := db.GetRepoCards(h.DB, 18, currentUserDID, db.SortByLastUpdate)
recentCards, err := db.GetRepoCards(h.ReadOnlyDB, 18, currentUserDID, db.SortByLastUpdate)
if err != nil {
log.Printf("Error fetching recent repos: %v", err)
recentCards = []db.RepoCardData{}
@@ -79,10 +74,7 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 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
RegistryURL string
HealthChecker *holdhealth.Checker
BaseUIHandler
}
func (h *RecentPushesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -100,7 +92,7 @@ func (h *RecentPushesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
}
// Get recent repositories using repo cards (sorted by last update)
repos, err := db.GetRepoCards(h.DB, limit+offset, currentUserDID, db.SortByLastUpdate)
repos, err := db.GetRepoCards(h.ReadOnlyDB, limit+offset, currentUserDID, db.SortByLastUpdate)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
+3 -10
View File
@@ -1,11 +1,9 @@
package handlers
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"net/http"
"strings"
@@ -14,15 +12,13 @@ import (
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
// DeleteTagHandler handles deleting a tag
type DeleteTagHandler struct {
DB *sql.DB
Refresher *oauth.Refresher
BaseUIHandler
}
func (h *DeleteTagHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -65,8 +61,7 @@ func (h *DeleteTagHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// DeleteManifestHandler handles deleting a manifest
type DeleteManifestHandler struct {
DB *sql.DB
Refresher *oauth.Refresher
BaseUIHandler
}
func (h *DeleteManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -162,9 +157,7 @@ func (h *DeleteManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
// UploadAvatarHandler handles uploading/updating a repository avatar
type UploadAvatarHandler struct {
DB *sql.DB
Refresher *oauth.Refresher
Templates *template.Template
BaseUIHandler
}
// validImageTypes are the allowed MIME types for avatars (matches lexicon)
+2 -2
View File
@@ -14,7 +14,7 @@ func TestDeleteTagHandler_Unauthorized(t *testing.T) {
defer database.Close()
handler := &DeleteTagHandler{
DB: database,
BaseUIHandler: BaseUIHandler{DB: database},
}
req := httptest.NewRequest("DELETE", "/alice/myapp/tags/latest", nil)
@@ -40,7 +40,7 @@ func TestDeleteManifestHandler_Unauthorized(t *testing.T) {
defer database.Close()
handler := &DeleteManifestHandler{
DB: database,
BaseUIHandler: BaseUIHandler{DB: database},
}
req := httptest.NewRequest("DELETE", "/alice/myapp/manifests/sha256:abc123", nil)
+1 -3
View File
@@ -1,14 +1,12 @@
package handlers
import (
"html/template"
"net/http"
)
// InstallHandler handles the /install page
type InstallHandler struct {
Templates *template.Template
RegistryURL string
BaseUIHandler
}
func (h *InstallHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+23
View File
@@ -0,0 +1,23 @@
package handlers
import (
"net/http"
)
// LearnMoreHandler handles the /learn-more page
type LearnMoreHandler struct {
BaseUIHandler
}
func (h *LearnMoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
data := struct {
PageData
}{
PageData: NewPageData(r, h.RegistryURL),
}
if err := h.Templates.ExecuteTemplate(w, "learn-more", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
+101
View File
@@ -0,0 +1,101 @@
package handlers
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"atcr.io/pkg/appview"
)
func TestLearnMoreHandler_RendersPage(t *testing.T) {
templates, err := appview.Templates()
if err != nil {
t.Fatalf("Failed to load templates: %v", err)
}
handler := &LearnMoreHandler{
BaseUIHandler: BaseUIHandler{
Templates: templates,
RegistryURL: "myregistry.example.com",
},
}
req := httptest.NewRequest("GET", "/learn-more", 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 registry URL is rendered
if !strings.Contains(body, "myregistry.example.com") {
t.Error("Expected body to contain registry URL 'myregistry.example.com'")
}
// Verify page title is present
if !strings.Contains(body, "Decentralized Container Registry") {
t.Error("Expected body to contain page title")
}
// Verify key sections are rendered
if !strings.Contains(body, "How It Works") {
t.Error("Expected body to contain 'How It Works' section")
}
if !strings.Contains(body, "Why Decentralized") {
t.Error("Expected body to contain 'Why Decentralized' section")
}
// Verify CTA links to /install
if !strings.Contains(body, `href="/install"`) {
t.Error("Expected body to contain link to /install")
}
}
func TestLearnMoreHandler_SEOMetadata(t *testing.T) {
templates, err := appview.Templates()
if err != nil {
t.Fatalf("Failed to load templates: %v", err)
}
handler := &LearnMoreHandler{
BaseUIHandler: BaseUIHandler{
Templates: templates,
RegistryURL: "atcr.io",
},
}
req := httptest.NewRequest("GET", "/learn-more", 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 canonical URL
if !strings.Contains(body, `rel="canonical"`) {
t.Error("Expected body to contain canonical link")
}
// Verify meta description
if !strings.Contains(body, `name="description"`) {
t.Error("Expected body to contain meta description")
}
// Verify Open Graph tags
if !strings.Contains(body, `property="og:title"`) {
t.Error("Expected body to contain og:title")
}
if !strings.Contains(body, `property="og:description"`) {
t.Error("Expected body to contain og:description")
}
}
+2 -9
View File
@@ -1,7 +1,6 @@
package handlers
import (
"html/template"
"net/http"
)
@@ -14,10 +13,7 @@ type LegalPageData struct {
// PrivacyPolicyHandler handles the /privacy page
type PrivacyPolicyHandler struct {
Templates *template.Template
RegistryURL string
CompanyName string
Jurisdiction string
BaseUIHandler
}
func (h *PrivacyPolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -35,10 +31,7 @@ func (h *PrivacyPolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
// TermsOfServiceHandler handles the /terms page
type TermsOfServiceHandler struct {
Templates *template.Template
RegistryURL string
CompanyName string
Jurisdiction string
BaseUIHandler
}
func (h *TermsOfServiceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+24 -16
View File
@@ -16,10 +16,12 @@ func TestPrivacyPolicyHandler_RendersTemplateVars(t *testing.T) {
}
handler := &PrivacyPolicyHandler{
Templates: templates,
RegistryURL: "myregistry.example.com",
CompanyName: "My Container Registry",
Jurisdiction: "State of California, United States",
BaseUIHandler: BaseUIHandler{
Templates: templates,
RegistryURL: "myregistry.example.com",
CompanyName: "My Container Registry",
Jurisdiction: "State of California, United States",
},
}
req := httptest.NewRequest("GET", "/privacy", nil)
@@ -57,10 +59,12 @@ func TestTermsOfServiceHandler_RendersTemplateVars(t *testing.T) {
}
handler := &TermsOfServiceHandler{
Templates: templates,
RegistryURL: "myregistry.example.com",
CompanyName: "My Container Registry",
Jurisdiction: "State of California, United States",
BaseUIHandler: BaseUIHandler{
Templates: templates,
RegistryURL: "myregistry.example.com",
CompanyName: "My Container Registry",
Jurisdiction: "State of California, United States",
},
}
req := httptest.NewRequest("GET", "/terms", nil)
@@ -106,10 +110,12 @@ func TestLegalHandlers_DefaultValues(t *testing.T) {
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",
BaseUIHandler: BaseUIHandler{
Templates: templates,
RegistryURL: "atcr.io",
CompanyName: "AT Container Registry",
Jurisdiction: "State of Texas, United States",
},
}
req := httptest.NewRequest("GET", "/privacy", nil)
@@ -128,10 +134,12 @@ func TestLegalHandlers_DefaultValues(t *testing.T) {
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",
BaseUIHandler: BaseUIHandler{
Templates: templates,
RegistryURL: "atcr.io",
CompanyName: "AT Container Registry",
Jurisdiction: "State of Texas, United States",
},
}
req := httptest.NewRequest("GET", "/terms", nil)
+1 -1
View File
@@ -10,7 +10,7 @@ import (
// This only clears the current UI session cookie - it does NOT revoke OAuth tokens
// OAuth sessions remain intact so other browser tabs/devices stay logged in
type LogoutHandler struct {
SessionStore *db.SessionStore
BaseUIHandler
}
func (h *LogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+2 -2
View File
@@ -16,7 +16,7 @@ func TestLogoutHandler_NoSession(t *testing.T) {
sessionStore := db.NewSessionStore(database)
handler := &LogoutHandler{
SessionStore: sessionStore,
BaseUIHandler: BaseUIHandler{SessionStore: sessionStore},
}
req := httptest.NewRequest("GET", "/auth/logout", nil)
@@ -56,7 +56,7 @@ func TestLogoutHandler_WithSession(t *testing.T) {
}
handler := &LogoutHandler{
SessionStore: sessionStore,
BaseUIHandler: BaseUIHandler{SessionStore: sessionStore},
}
req := httptest.NewRequest("GET", "/auth/logout", nil)
+1 -5
View File
@@ -2,19 +2,15 @@ package handlers
import (
"context"
"html/template"
"log/slog"
"net/http"
"net/url"
"time"
"atcr.io/pkg/appview/holdhealth"
)
// ManifestHealthHandler handles HTMX polling for manifest health status
type ManifestHealthHandler struct {
HealthChecker *holdhealth.Checker
Templates *template.Template
BaseUIHandler
}
func (h *ManifestHealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+7 -8
View File
@@ -1,7 +1,6 @@
package handlers
import (
"database/sql"
"fmt"
"log/slog"
"net/http"
@@ -15,7 +14,7 @@ import (
// RepoOGHandler generates OpenGraph images for repository pages
type RepoOGHandler struct {
DB *sql.DB
BaseUIHandler
}
func (h *RepoOGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -31,7 +30,7 @@ func (h *RepoOGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
// Get user info
user, err := db.GetUserByDID(h.DB, did)
user, err := db.GetUserByDID(h.ReadOnlyDB, did)
if err != nil || user == nil {
slog.Warn("Failed to get user for OG image", "did", did, "error", err)
// Use resolved handle even if user not in DB
@@ -39,14 +38,14 @@ func (h *RepoOGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
// Get repository stats
stats, err := db.GetRepositoryStats(h.DB, did, repository)
stats, err := db.GetRepositoryStats(h.ReadOnlyDB, did, repository)
if err != nil {
slog.Warn("Failed to get repo stats for OG image", "did", did, "repo", repository, "error", err)
stats = &db.RepositoryStats{}
}
// Get repository metadata (description, icon)
metadata, err := db.GetRepositoryMetadata(h.DB, did, repository)
metadata, err := db.GetRepositoryMetadata(h.ReadOnlyDB, did, repository)
if err != nil {
slog.Warn("Failed to get repo metadata for OG image", "did", did, "repo", repository, "error", err)
metadata = map[string]string{}
@@ -148,7 +147,7 @@ func (h *DefaultOGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// UserOGHandler generates OpenGraph images for user profile pages
type UserOGHandler struct {
DB *sql.DB
BaseUIHandler
}
func (h *UserOGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -163,14 +162,14 @@ func (h *UserOGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
// Get user info
user, err := db.GetUserByDID(h.DB, did)
user, err := db.GetUserByDID(h.ReadOnlyDB, did)
if err != nil || user == nil {
// Use resolved handle even if user not in DB
user = &db.User{DID: did, Handle: resolvedHandle}
}
// Get repository count
repos, err := db.GetUserRepositories(h.DB, did)
repos, err := db.GetUserRepositories(h.ReadOnlyDB, did)
repoCount := 0
if err == nil {
repoCount = len(repos)
+8 -18
View File
@@ -2,7 +2,6 @@ package handlers
import (
"context"
"database/sql"
"html/template"
"log/slog"
"net/http"
@@ -10,24 +9,15 @@ import (
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/holdhealth"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/appview/readme"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/go-chi/chi/v5"
)
// RepositoryPageHandler handles the public repository page
type RepositoryPageHandler struct {
DB *sql.DB
Templates *template.Template
RegistryURL string
Directory identity.Directory
Refresher *oauth.Refresher
HealthChecker *holdhealth.Checker
ReadmeFetcher *readme.Fetcher // For rendering repo page descriptions
BaseUIHandler
}
func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -42,7 +32,7 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
}
// Look up user by DID
owner, err := db.GetUserByDID(h.DB, did)
owner, err := db.GetUserByDID(h.ReadOnlyDB, did)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -54,19 +44,19 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
// Opportunistically update cached handle if it changed
if owner.Handle != resolvedHandle {
_ = db.UpdateUserHandle(h.DB, did, resolvedHandle)
_ = db.UpdateUserHandle(h.ReadOnlyDB, did, resolvedHandle)
owner.Handle = resolvedHandle
}
// Fetch tags with platform information
tagsWithPlatforms, err := db.GetTagsWithPlatforms(h.DB, owner.DID, repository)
tagsWithPlatforms, err := db.GetTagsWithPlatforms(h.ReadOnlyDB, owner.DID, repository)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Fetch top-level manifests (filters out platform-specific manifests)
manifests, err := db.GetTopLevelManifests(h.DB, owner.DID, repository, 50, 0)
manifests, err := db.GetTopLevelManifests(h.ReadOnlyDB, owner.DID, repository, 50, 0)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -145,7 +135,7 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
}
// Fetch repository metadata from annotations table
metadata, err := db.GetRepositoryMetadata(h.DB, owner.DID, repository)
metadata, err := db.GetRepositoryMetadata(h.ReadOnlyDB, owner.DID, repository)
if err != nil {
slog.Warn("Failed to fetch repository metadata", "error", err)
// Continue without metadata on error
@@ -161,7 +151,7 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
}
// Fetch star count
stats, err := db.GetRepositoryStats(h.DB, owner.DID, repository)
stats, err := db.GetRepositoryStats(h.ReadOnlyDB, owner.DID, repository)
if err != nil {
slog.Warn("Failed to fetch repository stats", "error", err)
// Continue with zero stats on error
@@ -191,7 +181,7 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
var readmeHTML template.HTML
// Try repo page record from database (synced from PDS via Jetstream)
repoPage, err := db.GetRepoPage(h.DB, owner.DID, repository)
repoPage, err := db.GetRepoPage(h.ReadOnlyDB, owner.DID, repository)
if err == nil && repoPage != nil {
// Use repo page avatar if present
if repoPage.AvatarCID != "" {
+3 -9
View File
@@ -1,8 +1,6 @@
package handlers
import (
"database/sql"
"html/template"
"net/http"
"strconv"
"strings"
@@ -13,9 +11,7 @@ import (
// SearchHandler handles the search page
type SearchHandler struct {
DB *sql.DB
Templates *template.Template
RegistryURL string
BaseUIHandler
}
func (h *SearchHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -37,9 +33,7 @@ func (h *SearchHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// SearchResultsHandler handles the HTMX request for search results
type SearchResultsHandler struct {
DB *sql.DB
Templates *template.Template
RegistryURL string
BaseUIHandler
}
func (h *SearchResultsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -86,7 +80,7 @@ func (h *SearchResultsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
currentUserDID = user.DID
}
repos, total, err := db.SearchRepositories(h.DB, query, limit, offset, currentUserDID)
repos, total, err := db.SearchRepositories(h.ReadOnlyDB, query, limit, offset, currentUserDID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
+2 -10
View File
@@ -1,7 +1,6 @@
package handlers
import (
"database/sql"
"encoding/json"
"html/template"
"log/slog"
@@ -14,7 +13,6 @@ import (
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/appview/storage"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
)
// HoldDisplay represents a hold for display in the UI
@@ -28,11 +26,7 @@ type HoldDisplay struct {
// SettingsHandler handles the settings page
type SettingsHandler struct {
Templates *template.Template
Refresher *oauth.Refresher
RegistryURL string
DB *sql.DB
DefaultHoldDID string
BaseUIHandler
}
func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -187,9 +181,7 @@ func deriveDisplayName(did string) string {
// UpdateDefaultHoldHandler handles updating the default hold
type UpdateDefaultHoldHandler struct {
Refresher *oauth.Refresher
Templates *template.Template
DB *sql.DB
BaseUIHandler
}
func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+1 -4
View File
@@ -3,21 +3,18 @@ package handlers
import (
"encoding/json"
"fmt"
"html/template"
"log/slog"
"net/http"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/appview/storage"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
)
// StorageHandler handles the storage quota API endpoint
// Returns an HTML partial for HTMX to swap into the settings page
type StorageHandler struct {
Templates *template.Template
Refresher *oauth.Refresher
BaseUIHandler
}
// QuotaStats mirrors the hold service response
+4 -8
View File
@@ -1,8 +1,6 @@
package handlers
import (
"database/sql"
"html/template"
"log"
"net/http"
@@ -14,9 +12,7 @@ import (
// UserPageHandler handles the public user page showing all images for a user
type UserPageHandler struct {
DB *sql.DB
Templates *template.Template
RegistryURL string
BaseUIHandler
}
func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -30,7 +26,7 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
// Look up user by DID
viewedUser, err := db.GetUserByDID(h.DB, did)
viewedUser, err := db.GetUserByDID(h.ReadOnlyDB, did)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -48,7 +44,7 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
} else if viewedUser.Handle != resolvedHandle {
// Opportunistically update cached handle if it changed
_ = db.UpdateUserHandle(h.DB, did, resolvedHandle)
_ = db.UpdateUserHandle(h.ReadOnlyDB, did, resolvedHandle)
viewedUser.Handle = resolvedHandle
}
@@ -59,7 +55,7 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
// Fetch repository cards for this user
cards, err := db.GetUserRepoCards(h.DB, viewedUser.DID, currentUserDID)
cards, err := db.GetUserRepoCards(h.ReadOnlyDB, viewedUser.DID, currentUserDID)
if err != nil {
log.Printf("Error fetching repo cards for user %s: %v", viewedUser.DID, err)
cards = []db.RepoCardData{}
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long
+55 -167
View File
@@ -41,250 +41,138 @@ type UIDependencies struct {
// RegisterUIRoutes registers all web UI and API routes on the provided router
func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
// Extract trimmed registry URL for templates
registryURL := trimRegistryURL(deps.BaseURL)
// Create base with all dependencies - handlers just embed this
base := uihandlers.BaseUIHandler{
Templates: deps.Templates,
RegistryURL: trimRegistryURL(deps.BaseURL),
DB: deps.Database,
ReadOnlyDB: deps.ReadOnlyDB,
Refresher: deps.Refresher,
HealthChecker: deps.HealthChecker,
ReadmeFetcher: deps.ReadmeFetcher,
Directory: deps.OAuthClientApp.Dir,
SessionStore: deps.SessionStore,
DeviceStore: deps.DeviceStore,
OAuthStore: deps.OAuthStore,
DefaultHoldDID: deps.DefaultHoldDID,
CompanyName: deps.LegalConfig.CompanyName,
Jurisdiction: deps.LegalConfig.Jurisdiction,
}
// OAuth login routes (public)
router.Get("/auth/oauth/login", (&uihandlers.LoginHandler{
Templates: deps.Templates,
}).ServeHTTP)
router.Post("/auth/oauth/login", (&uihandlers.LoginSubmitHandler{}).ServeHTTP)
router.Get("/auth/oauth/login", (&uihandlers.LoginHandler{BaseUIHandler: base}).ServeHTTP)
router.Post("/auth/oauth/login", (&uihandlers.LoginSubmitHandler{BaseUIHandler: base}).ServeHTTP)
// Public routes (with optional auth for navbar)
// SECURITY: Public pages use read-only DB
router.Get("/", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.HomeHandler{
DB: deps.ReadOnlyDB,
Templates: deps.Templates,
RegistryURL: registryURL,
},
&uihandlers.HomeHandler{BaseUIHandler: base},
).ServeHTTP)
router.Get("/api/recent-pushes", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.RecentPushesHandler{
DB: deps.ReadOnlyDB,
Templates: deps.Templates,
RegistryURL: registryURL,
HealthChecker: deps.HealthChecker,
},
&uihandlers.RecentPushesHandler{BaseUIHandler: base},
).ServeHTTP)
// SECURITY: Search uses read-only DB to prevent writes and limit access to sensitive tables
router.Get("/search", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.SearchHandler{
DB: deps.ReadOnlyDB,
Templates: deps.Templates,
RegistryURL: registryURL,
},
&uihandlers.SearchHandler{BaseUIHandler: base},
).ServeHTTP)
router.Get("/api/search-results", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.SearchResultsHandler{
DB: deps.ReadOnlyDB,
Templates: deps.Templates,
RegistryURL: registryURL,
},
&uihandlers.SearchResultsHandler{BaseUIHandler: base},
).ServeHTTP)
// Install page (public)
router.Get("/install", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.InstallHandler{
Templates: deps.Templates,
RegistryURL: registryURL,
},
&uihandlers.InstallHandler{BaseUIHandler: base},
).ServeHTTP)
// Learn more page (public)
router.Get("/learn-more", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.LearnMoreHandler{BaseUIHandler: base},
).ServeHTTP)
// Legal pages (public)
router.Get("/privacy", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.PrivacyPolicyHandler{
Templates: deps.Templates,
RegistryURL: registryURL,
CompanyName: deps.LegalConfig.CompanyName,
Jurisdiction: deps.LegalConfig.Jurisdiction,
},
&uihandlers.PrivacyPolicyHandler{BaseUIHandler: base},
).ServeHTTP)
router.Get("/terms", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.TermsOfServiceHandler{
Templates: deps.Templates,
RegistryURL: registryURL,
CompanyName: deps.LegalConfig.CompanyName,
Jurisdiction: deps.LegalConfig.Jurisdiction,
},
&uihandlers.TermsOfServiceHandler{BaseUIHandler: base},
).ServeHTTP)
// API route for repository stats (public, read-only)
router.Get("/api/stats/{handle}/{repository}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.GetStatsHandler{
DB: deps.ReadOnlyDB,
Directory: deps.OAuthClientApp.Dir,
},
&uihandlers.GetStatsHandler{BaseUIHandler: base},
).ServeHTTP)
// API routes for stars (require authentication)
// Returns HTML for HTMX requests, JSON for API clients
router.Post("/api/stars/{handle}/{repository}", middleware.RequireAuth(deps.SessionStore, deps.Database)(
&uihandlers.StarRepositoryHandler{
DB: deps.Database, // Needs write access
Directory: deps.OAuthClientApp.Dir,
Refresher: deps.Refresher,
Templates: deps.Templates,
},
&uihandlers.StarRepositoryHandler{BaseUIHandler: base},
).ServeHTTP)
router.Delete("/api/stars/{handle}/{repository}", middleware.RequireAuth(deps.SessionStore, deps.Database)(
&uihandlers.UnstarRepositoryHandler{
DB: deps.Database, // Needs write access
Directory: deps.OAuthClientApp.Dir,
Refresher: deps.Refresher,
Templates: deps.Templates,
},
&uihandlers.UnstarRepositoryHandler{BaseUIHandler: base},
).ServeHTTP)
router.Get("/api/stars/{handle}/{repository}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.CheckStarHandler{
DB: deps.ReadOnlyDB, // Read-only check
Directory: deps.OAuthClientApp.Dir,
Refresher: deps.Refresher,
},
&uihandlers.CheckStarHandler{BaseUIHandler: base},
).ServeHTTP)
// Manifest detail API endpoint
router.Get("/api/manifests/{handle}/{repository}/{digest}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.ManifestDetailHandler{
DB: deps.ReadOnlyDB,
Directory: deps.OAuthClientApp.Dir,
},
&uihandlers.ManifestDetailHandler{BaseUIHandler: base},
).ServeHTTP)
// Manifest health check API endpoint (HTMX polling)
router.Get("/api/manifest-health", (&uihandlers.ManifestHealthHandler{
HealthChecker: deps.HealthChecker,
Templates: deps.Templates,
}).ServeHTTP)
router.Get("/api/manifest-health", (&uihandlers.ManifestHealthHandler{BaseUIHandler: base}).ServeHTTP)
router.Get("/u/{handle}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.UserPageHandler{
DB: deps.ReadOnlyDB,
Templates: deps.Templates,
RegistryURL: registryURL,
},
&uihandlers.UserPageHandler{BaseUIHandler: base},
).ServeHTTP)
// OpenGraph image generation (public, cacheable)
router.Get("/og/home", (&uihandlers.DefaultOGHandler{}).ServeHTTP)
router.Get("/og/u/{handle}", (&uihandlers.UserOGHandler{
DB: deps.ReadOnlyDB,
}).ServeHTTP)
router.Get("/og/r/{handle}/{repository}", (&uihandlers.RepoOGHandler{
DB: deps.ReadOnlyDB,
}).ServeHTTP)
router.Get("/og/u/{handle}", (&uihandlers.UserOGHandler{BaseUIHandler: base}).ServeHTTP)
router.Get("/og/r/{handle}/{repository}", (&uihandlers.RepoOGHandler{BaseUIHandler: base}).ServeHTTP)
router.Get("/r/{handle}/{repository}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.RepositoryPageHandler{
DB: deps.ReadOnlyDB,
Templates: deps.Templates,
RegistryURL: registryURL,
Directory: deps.OAuthClientApp.Dir,
Refresher: deps.Refresher,
HealthChecker: deps.HealthChecker,
ReadmeFetcher: deps.ReadmeFetcher,
},
&uihandlers.RepositoryPageHandler{BaseUIHandler: base},
).ServeHTTP)
// Authenticated routes
router.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(deps.SessionStore, deps.Database))
r.Get("/settings", (&uihandlers.SettingsHandler{
Templates: deps.Templates,
Refresher: deps.Refresher,
RegistryURL: registryURL,
DB: deps.Database,
DefaultHoldDID: deps.DefaultHoldDID,
}).ServeHTTP)
r.Get("/settings", (&uihandlers.SettingsHandler{BaseUIHandler: base}).ServeHTTP)
r.Get("/api/storage", (&uihandlers.StorageHandler{BaseUIHandler: base}).ServeHTTP)
r.Post("/api/profile/default-hold", (&uihandlers.UpdateDefaultHoldHandler{BaseUIHandler: base}).ServeHTTP)
r.Get("/api/storage", (&uihandlers.StorageHandler{
Templates: deps.Templates,
Refresher: deps.Refresher,
}).ServeHTTP)
r.Post("/api/profile/default-hold", (&uihandlers.UpdateDefaultHoldHandler{
Refresher: deps.Refresher,
Templates: deps.Templates,
DB: deps.Database,
}).ServeHTTP)
r.Delete("/api/images/{repository}/tags/{tag}", (&uihandlers.DeleteTagHandler{
DB: deps.Database,
Refresher: deps.Refresher,
}).ServeHTTP)
r.Delete("/api/images/{repository}/manifests/{digest}", (&uihandlers.DeleteManifestHandler{
DB: deps.Database,
Refresher: deps.Refresher,
}).ServeHTTP)
r.Post("/api/images/{repository}/avatar", (&uihandlers.UploadAvatarHandler{
DB: deps.Database,
Refresher: deps.Refresher,
Templates: deps.Templates,
}).ServeHTTP)
r.Delete("/api/images/{repository}/tags/{tag}", (&uihandlers.DeleteTagHandler{BaseUIHandler: base}).ServeHTTP)
r.Delete("/api/images/{repository}/manifests/{digest}", (&uihandlers.DeleteManifestHandler{BaseUIHandler: base}).ServeHTTP)
r.Post("/api/images/{repository}/avatar", (&uihandlers.UploadAvatarHandler{BaseUIHandler: base}).ServeHTTP)
// Device approval page (authenticated)
r.Get("/device", (&uihandlers.DeviceApprovalPageHandler{
Store: deps.DeviceStore,
SessionStore: deps.SessionStore,
}).ServeHTTP)
r.Post("/device/approve", (&uihandlers.DeviceApproveHandler{
Store: deps.DeviceStore,
SessionStore: deps.SessionStore,
}).ServeHTTP)
r.Get("/device", (&uihandlers.DeviceApprovalPageHandler{BaseUIHandler: base}).ServeHTTP)
r.Post("/device/approve", (&uihandlers.DeviceApproveHandler{BaseUIHandler: base}).ServeHTTP)
// Device management routes
r.Get("/api/devices", (&uihandlers.ListDevicesHandler{
Store: deps.DeviceStore,
SessionStore: deps.SessionStore,
}).ServeHTTP)
r.Delete("/api/devices/{id}", (&uihandlers.RevokeDeviceHandler{
Store: deps.DeviceStore,
SessionStore: deps.SessionStore,
}).ServeHTTP)
r.Get("/api/devices", (&uihandlers.ListDevicesHandler{BaseUIHandler: base}).ServeHTTP)
r.Delete("/api/devices/{id}", (&uihandlers.RevokeDeviceHandler{BaseUIHandler: base}).ServeHTTP)
// GDPR data export
r.Get("/api/export-data", (&uihandlers.ExportUserDataHandler{
DB: deps.Database,
Refresher: deps.Refresher,
}).ServeHTTP)
r.Get("/api/export-data", (&uihandlers.ExportUserDataHandler{BaseUIHandler: base}).ServeHTTP)
// GDPR account deletion
r.Delete("/api/account", (&uihandlers.DeleteAccountHandler{
DB: deps.Database,
OAuthStore: deps.OAuthStore,
Refresher: deps.Refresher,
}).ServeHTTP)
r.Delete("/api/account", (&uihandlers.DeleteAccountHandler{BaseUIHandler: base}).ServeHTTP)
})
// Logout endpoint (supports both GET and POST)
// Only clears the current UI session cookie - does NOT revoke OAuth tokens
// OAuth sessions remain intact so other browser tabs/devices stay logged in
logoutHandler := &uihandlers.LogoutHandler{
SessionStore: deps.SessionStore,
}
logoutHandler := &uihandlers.LogoutHandler{BaseUIHandler: base}
router.Get("/auth/logout", logoutHandler.ServeHTTP)
router.Post("/auth/logout", logoutHandler.ServeHTTP)
// Custom 404 handler
router.NotFound(middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.NotFoundHandler{
Templates: deps.Templates,
RegistryURL: registryURL,
},
&uihandlers.NotFoundHandler{BaseUIHandler: base},
).ServeHTTP)
}
+1 -1
View File
@@ -133,7 +133,7 @@
.nav-search-form {
@apply absolute right-full mr-2;
@apply w-0 opacity-0 overflow-hidden;
@apply transition-all duration-300;
@apply transition-[width,opacity] duration-300;
}
.nav-search-wrapper.expanded .nav-search-form {
+26 -4
View File
@@ -2,8 +2,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="index, follow">
<meta name="theme-color" content="#570df8" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#661ae6" media="(prefers-color-scheme: dark)">
<meta name="theme-color" id="theme-color">
<meta property="og:locale" content="en_US">
<!-- Favicons -->
@@ -13,6 +12,10 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<!-- Resource hints for external domains -->
<link rel="preconnect" href="https://imgs.blue" crossorigin>
<link rel="dns-prefetch" href="https://imgs.blue">
<!-- Theme: apply early to prevent flash -->
<script>
(function() {
@@ -21,16 +24,35 @@
if (pref === 'light') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function updateThemeColor() {
var meta = document.getElementById('theme-color');
if (meta) {
var bg = getComputedStyle(document.documentElement).getPropertyValue('--color-base-100').trim();
if (bg) meta.setAttribute('content', bg);
}
}
var pref = localStorage.getItem('theme') || 'system';
var effective = getEffectiveTheme(pref);
document.documentElement.classList.toggle('dark', effective === 'dark');
document.documentElement.setAttribute('data-theme', effective);
// Update theme-color after styles are applied
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', updateThemeColor);
} else {
updateThemeColor();
}
// Also update when system preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateThemeColor);
})();
</script>
<!-- Tailwind CSS (built via npm run css:build) -->
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/style.css?v={{ assetHash "css/style.css" }}">
<!-- Bundled JS: HTMX + Lucide (tree-shaken) + Actor Typeahead + App -->
<script type="module" src="/js/bundle.min.js"></script>
<script type="module" src="/js/bundle.min.js?v={{ assetHash "js/bundle.min.js" }}"></script>
{{ end }}
+15 -4
View File
@@ -5,7 +5,18 @@
*/}}
<section class="hero bg-base-200 min-h-[60vh] py-16 pb-24 relative overflow-hidden">
<!-- Background mascot -->
<img src="/amathea_manatee.png" alt="" fetchpriority="high" class="absolute right-1/2 translate-x-1/2 top-[12%] md:top-[18%] lg:right-[20%] lg:top-[40%] -translate-y-1/2 w-96 md:w-md lg:w-xl opacity-30 pointer-events-none select-none" aria-hidden="true">
<picture>
<source srcset="/amathea_manatee-576w.webp 576w,
/amathea_manatee-768w.webp 768w,
/amathea_manatee-1152w.webp 1152w"
sizes="(max-width: 640px) 384px, (max-width: 1024px) 448px, 576px"
type="image/webp">
<img src="/amathea_manatee.png"
width="1408" height="768"
alt="" fetchpriority="high"
class="absolute right-1/2 translate-x-1/2 top-[12%] md:top-[18%] lg:right-[20%] lg:top-[40%] -translate-y-1/2 w-96 md:w-md lg:w-xl opacity-30 pointer-events-none select-none"
aria-hidden="true">
</picture>
<div class="hero-content text-center flex-col relative z-10">
<h1 class="text-4xl md:text-5xl font-bold">your registry <span class="text-primary">at</span> sea.</h1>
<p class="text-lg text-base-content/70 max-w-lg mt-4">
@@ -16,12 +27,12 @@
<div class="mockup-code bg-base-300 text-base-content text-left w-full max-w-lg text-base mt-8">
<pre data-prefix="$"><code>docker login {{ .RegistryURL }}</code></pre>
<pre data-prefix="$"><code>docker push {{ .RegistryURL }}/you/app</code></pre>
<pre data-prefix="#" class="text-base-content/50"><code>same docker, decentralized</code></pre>
<pre data-prefix="#" class="text-base-content/65"><code>same docker, decentralized</code></pre>
</div>
<div class="flex items-center justify-center gap-4 mt-8">
<a href="/auth/oauth/login?return_to=/" class="btn btn-primary btn-lg">Get Started</a>
<a href="/install" class="btn btn-ghost btn-lg">Learn More</a>
<a href="/learn-more" class="btn btn-ghost btn-lg">Learn More</a>
</div>
<!-- Benefit Cards -->
@@ -37,6 +48,6 @@
{{ end }}
</div>
</div>
<img src="/static/wave-pattern.svg" alt="" class="absolute bottom-0 left-0 w-full h-16 pointer-events-none" aria-hidden="true">
<img src="/static/wave-pattern.svg" width="1440" height="60" alt="" class="absolute bottom-0 left-0 w-full h-16 pointer-events-none" aria-hidden="true">
</section>
{{ end }}
+186
View File
@@ -0,0 +1,186 @@
{{ define "learn-more" }}
<!DOCTYPE html>
<html lang="en">
<head>
<title>About ATCR - Decentralized Container Registry on AT Protocol</title>
<meta name="description" content="Learn how ATCR brings Docker container registries to the decentralized web using AT Protocol. Own your data, use your identity.">
<link rel="canonical" href="https://{{ .RegistryURL }}/learn-more">
<meta property="og:title" content="About ATCR - Decentralized Container Registry">
<meta property="og:description" content="Docker meets the decentralized web. Push and pull container images using your AT Protocol identity.">
<meta property="og:url" content="https://{{ .RegistryURL }}/learn-more">
<meta property="og:type" content="website">
{{ template "head" . }}
</head>
<body>
{{ template "nav" . }}
<main class="container mx-auto px-4 py-8 max-w-4xl">
<!-- Hero Section -->
<section class="text-center mb-16">
<h1 class="text-4xl md:text-5xl font-bold mb-4">Docker meets the decentralized web</h1>
<p class="text-xl text-base-content/70 max-w-2xl mx-auto">
ATCR is an OCI-compliant container registry built on the AT Protocol.
Push and pull Docker images using your Bluesky identity.
</p>
</section>
<!-- How It Works -->
<section class="mb-16">
<h2 class="text-2xl font-bold mb-6">How It Works</h2>
<div class="bg-base-200 rounded-lg p-6 mb-6">
<h3 class="text-lg font-semibold mb-3">The Simple Version</h3>
<p class="text-base-content/80">
Push and pull container images just like any registry. The difference?
Your image metadata lives in your AT Protocol account, not a corporate database.
Your Bluesky handle becomes your namespace.
</p>
</div>
<div class="collapse collapse-arrow bg-base-100 border border-base-300">
<input type="checkbox" />
<div class="collapse-title font-medium">
Technical Details
</div>
<div class="collapse-content">
<div class="space-y-4 pt-2">
<p><strong>Manifests</strong> (small JSON metadata describing your images) are stored as AT Protocol records in your Personal Data Server (PDS). This means you own them.</p>
<p><strong>Blobs</strong> (the actual image layers) are stored in S3-compatible object storage. You can use our default storage or bring your own.</p>
<p>The <strong>AppView</strong> ({{ .RegistryURL }}) resolves your identity, routes requests, and speaks the OCI Distribution API that Docker understands.</p>
</div>
</div>
</div>
</section>
<!-- Why Decentralized -->
<section class="mb-16">
<h2 class="text-2xl font-bold mb-6">Why Decentralized?</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="card bg-base-100 border border-base-300 p-6">
<div class="text-primary mb-3">
<i data-lucide="database" class="size-8"></i>
</div>
<h3 class="font-semibold text-lg mb-2">Own Your Data</h3>
<p class="text-base-content/70">
Your image manifests are stored in your PDS, not our database.
You control your data. You can export or migrate it anytime.
</p>
</div>
<div class="card bg-base-100 border border-base-300 p-6">
<div class="text-primary mb-3">
<i data-lucide="fingerprint" class="size-8"></i>
</div>
<h3 class="font-semibold text-lg mb-2">Portable Identity</h3>
<p class="text-base-content/70">
Your Bluesky handle is your namespace. No separate registry account needed.
Your identity follows you across the AT Protocol ecosystem.
</p>
</div>
<div class="card bg-base-100 border border-base-300 p-6">
<div class="text-primary mb-3">
<i data-lucide="hard-drive" class="size-8"></i>
</div>
<h3 class="font-semibold text-lg mb-2">Bring Your Own Storage</h3>
<p class="text-base-content/70">
Use our default storage or deploy your own hold service.
Connect your own S3, Storj, Minio, or other compatible storage.
</p>
</div>
<div class="card bg-base-100 border border-base-300 p-6">
<div class="text-primary mb-3">
<i data-lucide="eye" class="size-8"></i>
</div>
<h3 class="font-semibold text-lg mb-2">Open & Auditable</h3>
<p class="text-base-content/70">
Built on open protocols. Fully OCI-compliant.
The source is public. Inspect everything.
</p>
</div>
</div>
</section>
<!-- Architecture Overview -->
<section class="mb-16">
<h2 class="text-2xl font-bold mb-6">Architecture Overview</h2>
<div class="bg-base-200 rounded-lg p-6">
<div class="flex flex-col md:flex-row items-center justify-between gap-6 text-center">
<div class="flex-1">
<div class="bg-base-100 rounded-lg p-4 border border-base-300">
<i data-lucide="container" class="size-8 text-primary mx-auto mb-2"></i>
<p class="font-semibold">Docker Client</p>
<p class="text-sm text-base-content/60">Push & Pull</p>
</div>
</div>
<div class="hidden md:block">
<i data-lucide="arrow-right" class="size-6 text-base-content/40"></i>
</div>
<div class="md:hidden">
<i data-lucide="arrow-down" class="size-6 text-base-content/40"></i>
</div>
<div class="flex-1">
<div class="bg-base-100 rounded-lg p-4 border border-primary">
<i data-lucide="server" class="size-8 text-primary mx-auto mb-2"></i>
<p class="font-semibold">AppView</p>
<p class="text-sm text-base-content/60">{{ .RegistryURL }}</p>
</div>
</div>
<div class="hidden md:flex flex-col gap-2">
<i data-lucide="arrow-right" class="size-6 text-base-content/40"></i>
<i data-lucide="arrow-right" class="size-6 text-base-content/40"></i>
</div>
<div class="md:hidden flex gap-4">
<i data-lucide="arrow-down" class="size-6 text-base-content/40"></i>
<i data-lucide="arrow-down" class="size-6 text-base-content/40"></i>
</div>
<div class="flex-1 flex flex-col gap-4">
<div class="bg-base-100 rounded-lg p-4 border border-base-300">
<i data-lucide="user" class="size-8 text-primary mx-auto mb-2"></i>
<p class="font-semibold">Your PDS</p>
<p class="text-sm text-base-content/60">Manifests</p>
</div>
<div class="bg-base-100 rounded-lg p-4 border border-base-300">
<i data-lucide="database" class="size-8 text-primary mx-auto mb-2"></i>
<p class="font-semibold">Hold Service</p>
<p class="text-sm text-base-content/60">Blobs</p>
</div>
</div>
</div>
</div>
<p class="text-base-content/70 mt-4 text-sm">
The AppView speaks OCI to Docker and ATProto to your PDS. Manifests go to your account. Blobs go to storage.
</p>
</section>
<!-- Get Started CTA -->
<section class="text-center bg-base-200 rounded-lg p-8">
<h2 class="text-2xl font-bold mb-4">Ready to Get Started?</h2>
<p class="text-base-content/70 mb-6 max-w-lg mx-auto">
Install the credential helper and start pushing images in minutes.
All you need is a Bluesky account.
</p>
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
<a href="/install" class="btn btn-primary btn-lg">Get Started</a>
<a href="https://tangled.org/@evan.jarrett.net/at-container-registry" target="_blank" rel="noopener" class="btn btn-ghost btn-lg">
<i data-lucide="github" class="size-5 mr-2"></i>
View Source
</a>
</div>
</section>
</main>
<div id="modal"></div>
{{ template "footer" . }}
</body>
</html>
{{ end }}
+34
View File
@@ -1,6 +1,7 @@
package appview
import (
"crypto/md5"
"embed"
"fmt"
"html/template"
@@ -13,6 +14,37 @@ import (
"atcr.io/pkg/appview/licenses"
)
// assetHashes stores MD5 hashes of embedded assets for cache busting
var assetHashes = make(map[string]string)
func init() {
// Compute MD5 hash of embedded assets at startup
files := []string{"css/style.css", "js/bundle.min.js"}
for _, f := range files {
data, err := publicFS.ReadFile("public/" + f)
if err != nil {
continue
}
assetHashes[f] = fmt.Sprintf("%x", md5.Sum(data))[:8]
}
}
// AssetHash returns the cache-busting hash for an asset path
func AssetHash(path string) string {
if hash, ok := assetHashes[path]; ok {
return hash
}
return ""
}
// CacheMiddleware adds Cache-Control headers to static file responses
func CacheMiddleware(h http.Handler, maxAge int) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
h.ServeHTTP(w, r)
})
}
//go:generate sh -c "command -v npm >/dev/null 2>&1 && cd ../.. && npm run build || echo 'npm not found, skipping build'"
//go:embed templates/**/*.html
@@ -119,6 +151,8 @@ func Templates() (*template.Template, error) {
parsed.Path = fmt.Sprintf("/cdn-cgi/image/width=%d%s", width, parsed.Path)
return parsed.String()
},
"assetHash": AssetHash,
}
tmpl := template.New("").Funcs(funcMap)