Files
at-container-registry/pkg/appview/handlers/opengraph.go
T

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)
}
}