mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 13:17:09 +00:00
219 lines
7.5 KiB
Go
219 lines
7.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"atcr.io/pkg/appview/db"
|
|
"atcr.io/pkg/appview/ogcard"
|
|
"atcr.io/pkg/atproto"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// RepoOGHandler generates OpenGraph images for repository pages
|
|
type RepoOGHandler struct {
|
|
BaseUIHandler
|
|
}
|
|
|
|
func (h *RepoOGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
handle := chi.URLParam(r, "handle")
|
|
repository := strings.TrimPrefix(chi.URLParam(r, "*"), "/")
|
|
|
|
// Resolve handle to DID
|
|
did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), handle)
|
|
if err != nil {
|
|
slog.Warn("Failed to resolve identity for OG image", "handle", handle, "error", err)
|
|
http.Error(w, "User not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Get user info
|
|
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
|
|
user = &db.User{DID: did, Handle: resolvedHandle}
|
|
}
|
|
|
|
// Get repository stats
|
|
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.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{}
|
|
}
|
|
|
|
description := metadata["org.opencontainers.image.description"]
|
|
iconURL := metadata["io.atcr.icon"]
|
|
version := metadata["org.opencontainers.image.version"]
|
|
|
|
// Generate the OG image
|
|
card := ogcard.NewCard()
|
|
card.Fill(ogcard.ColorBackground)
|
|
layout := ogcard.StandardLayout()
|
|
|
|
// Draw icon/avatar on the left (prefer repo icon, then user avatar, then placeholder)
|
|
avatarURL := iconURL
|
|
if avatarURL == "" {
|
|
avatarURL = user.Avatar
|
|
}
|
|
card.DrawAvatarOrPlaceholder(avatarURL, layout.IconX, layout.IconY, ogcard.AvatarSize,
|
|
strings.ToUpper(string(repository[0])))
|
|
|
|
// Draw owner handle and repo name - wrap to new line if too long
|
|
ownerText := "@" + user.Handle + " / "
|
|
ownerWidth := card.MeasureText(ownerText, ogcard.FontTitle, false)
|
|
repoWidth := card.MeasureText(repository, ogcard.FontTitle, true)
|
|
combinedWidth := ownerWidth + repoWidth
|
|
|
|
textY := layout.TextY
|
|
if combinedWidth > layout.MaxWidth {
|
|
// Too long - put repo name on new line
|
|
card.DrawText("@"+user.Handle+" /", layout.TextX, textY, ogcard.FontTitle, ogcard.ColorMuted, ogcard.AlignLeft, false)
|
|
textY += ogcard.LineSpacingLarge
|
|
card.DrawText(repository, layout.TextX, textY, ogcard.FontTitle, ogcard.ColorText, ogcard.AlignLeft, true)
|
|
} else {
|
|
// Fits on one line
|
|
card.DrawText(ownerText, layout.TextX, textY, ogcard.FontTitle, ogcard.ColorMuted, ogcard.AlignLeft, false)
|
|
card.DrawText(repository, layout.TextX+float64(ownerWidth), textY, ogcard.FontTitle, ogcard.ColorText, ogcard.AlignLeft, true)
|
|
}
|
|
|
|
// Track current Y position for description
|
|
if description != "" {
|
|
textY += ogcard.LineSpacingSmall
|
|
card.DrawTextWrapped(description, layout.TextX, textY, ogcard.FontDescription, ogcard.ColorMuted, layout.MaxWidth, false)
|
|
}
|
|
|
|
// Stats and version at bottom
|
|
statsX := card.DrawStatWithIcon("star", fmt.Sprintf("%d", stats.StarCount),
|
|
ogcard.Padding, layout.StatsY, ogcard.ColorStar, ogcard.ColorText)
|
|
statsX = card.DrawStatWithIcon("arrow-down-to-line", fmt.Sprintf("%d pulls", stats.PullCount),
|
|
statsX, layout.StatsY, ogcard.ColorMuted, ogcard.ColorMuted)
|
|
|
|
// Version badge in the stats row
|
|
if version != "" {
|
|
badgeY := layout.StatsY - int(ogcard.FontBadge) - 4
|
|
card.DrawBadge(version, statsX, badgeY, ogcard.FontBadge, ogcard.ColorBadgeAccent, ogcard.ColorText)
|
|
}
|
|
|
|
// ATCR branding (bottom right)
|
|
card.DrawBranding()
|
|
|
|
// Set cache headers and content type
|
|
w.Header().Set("Content-Type", "image/png")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
|
|
if err := card.EncodePNG(w); err != nil {
|
|
slog.Error("Failed to encode OG image", "error", err)
|
|
http.Error(w, "Failed to generate image", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// DefaultOGHandler generates the default OpenGraph image for the home page
|
|
type DefaultOGHandler struct {
|
|
BaseUIHandler
|
|
}
|
|
|
|
func (h *DefaultOGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
// Generate the OG image
|
|
card := ogcard.NewCard()
|
|
card.Fill(ogcard.ColorBackground)
|
|
|
|
// Draw large centered brand title
|
|
centerY := float64(ogcard.CardHeight) / 2
|
|
card.DrawText(h.ClientShortName, float64(ogcard.CardWidth)/2, centerY-20, 96.0, ogcard.ColorText, ogcard.AlignCenter, true)
|
|
|
|
// Draw tagline below
|
|
card.DrawText("Distributed Container Registry", float64(ogcard.CardWidth)/2, centerY+60, ogcard.FontDescription, ogcard.ColorMuted, ogcard.AlignCenter, false)
|
|
|
|
// Draw subtitle
|
|
card.DrawText("Push and pull Docker images on the AT Protocol", float64(ogcard.CardWidth)/2, centerY+110, ogcard.FontStats, ogcard.ColorMuted, ogcard.AlignCenter, false)
|
|
|
|
// Set cache headers and content type (cache longer since it's static content)
|
|
w.Header().Set("Content-Type", "image/png")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
|
|
if err := card.EncodePNG(w); err != nil {
|
|
slog.Error("Failed to encode default OG image", "error", err)
|
|
http.Error(w, "Failed to generate image", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// UserOGHandler generates OpenGraph images for user profile pages
|
|
type UserOGHandler struct {
|
|
BaseUIHandler
|
|
}
|
|
|
|
func (h *UserOGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
handle := chi.URLParam(r, "handle")
|
|
|
|
// Resolve handle to DID
|
|
did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), handle)
|
|
if err != nil {
|
|
slog.Warn("Failed to resolve identity for OG image", "handle", handle, "error", err)
|
|
http.Error(w, "User not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Get user info
|
|
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.ReadOnlyDB, did)
|
|
repoCount := 0
|
|
if err == nil {
|
|
repoCount = len(repos)
|
|
}
|
|
|
|
// Generate the OG image
|
|
card := ogcard.NewCard()
|
|
card.Fill(ogcard.ColorBackground)
|
|
layout := ogcard.StandardLayout()
|
|
|
|
// Draw avatar on the left
|
|
firstChar := "?"
|
|
if len(user.Handle) > 0 {
|
|
firstChar = strings.ToUpper(string(user.Handle[0]))
|
|
}
|
|
card.DrawAvatarOrPlaceholder(user.Avatar, layout.IconX, layout.IconY, ogcard.AvatarSize, firstChar)
|
|
|
|
// Draw handle
|
|
handleText := "@" + user.Handle
|
|
card.DrawText(handleText, layout.TextX, layout.TextY, ogcard.FontTitle, ogcard.ColorText, ogcard.AlignLeft, true)
|
|
|
|
// Repository count below (using description font size)
|
|
textY := layout.TextY + ogcard.LineSpacingLarge
|
|
repoText := fmt.Sprintf("%d repositories", repoCount)
|
|
if repoCount == 1 {
|
|
repoText = "1 repository"
|
|
}
|
|
|
|
// Draw package icon with description-sized text
|
|
card.DrawIcon("package", int(layout.TextX), int(textY)-int(ogcard.FontDescription), int(ogcard.FontDescription), ogcard.ColorMuted)
|
|
card.DrawText(repoText, layout.TextX+42, textY, ogcard.FontDescription, ogcard.ColorMuted, ogcard.AlignLeft, false)
|
|
|
|
// ATCR branding (bottom right)
|
|
card.DrawBranding()
|
|
|
|
// Set cache headers and content type
|
|
w.Header().Set("Content-Type", "image/png")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
|
|
if err := card.EncodePNG(w); err != nil {
|
|
slog.Error("Failed to encode OG image", "error", err)
|
|
http.Error(w, "Failed to generate image", http.StatusInternalServerError)
|
|
}
|
|
}
|