mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 01:04:15 +00:00
more pagespeed improvements, improve routing handler logic
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user