owpengram-server/internal/web/server.go
2026-07-13 09:51:01 +03:00

1266 lines
45 KiB
Go

// Package web serves telesrv's read-only public link landing pages.
package web
import (
"context"
"errors"
"fmt"
"html/template"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"unicode/utf8"
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/links"
)
type Config struct {
Addr string
PublicBaseURL string
AppScheme string
WebBaseURL string
AppName string
DownloadURL string
StickerSets StickerSetResolver
Users UsernameResolver
Channels PublicChannelResolver
Privacy AnonymousPrivacyResolver
Photos ProfilePhotoResolver
}
type StickerSetResolver interface {
ResolveStickerSet(ctx context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error)
}
type UsernameResolver interface {
ByUsername(ctx context.Context, username string) (domain.User, bool, error)
}
// PublicChannelResolver exposes only the viewer-independent public username
// projection. viewerUserID is always zero for this anonymous Web endpoint.
type PublicChannelResolver interface {
ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error)
}
type AnonymousPrivacyResolver interface {
CanSeeAnonymous(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (bool, error)
}
type ProfilePhotoResolver interface {
CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (domain.Photo, bool, error)
GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error)
GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error)
}
func Start(ctx context.Context, cfg Config, logger *zap.Logger) (*http.Server, error) {
addr := strings.TrimSpace(cfg.Addr)
if addr == "" {
return nil, nil
}
if logger == nil {
logger = zap.NewNop()
}
handler, err := newHandler(cfg, logger)
if err != nil {
return nil, err
}
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 16 << 10,
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
go func() {
logger.Info("Public link Web endpoint enabled",
zap.String("addr", addr),
zap.String("public_base_url", cfg.PublicBaseURL),
zap.String("app_scheme", cfg.AppScheme),
zap.String("web_base_url", cfg.WebBaseURL),
zap.String("download_url", cfg.DownloadURL))
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Warn("Public link Web endpoint exited", zap.Error(err))
}
}()
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}()
return srv, nil
}
func NewHandler(cfg Config) (http.Handler, error) {
return newHandler(cfg, zap.NewNop())
}
func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) {
var err error
if cfg.StickerSets == nil {
return nil, fmt.Errorf("public Web sticker set resolver is nil")
}
if strings.TrimSpace(cfg.AppName) == "" {
cfg.AppName = links.DefaultAppName
}
if cfg.PublicBaseURL, err = links.ValidateBaseURL(cfg.PublicBaseURL); err != nil {
return nil, fmt.Errorf("public base URL: %w", err)
}
if cfg.AppScheme, err = links.ValidateAppScheme(cfg.AppScheme); err != nil {
return nil, fmt.Errorf("app scheme: %w", err)
}
// WebBaseURL is optional: leaving it unset disables the "Open in Web"
// button on the username landing page instead of falling back to a
// default URL that would point at someone else's Web client.
if strings.TrimSpace(cfg.WebBaseURL) != "" {
if cfg.WebBaseURL, err = links.ValidateBaseURL(cfg.WebBaseURL); err != nil {
return nil, fmt.Errorf("Web base URL: %w", err)
}
} else {
cfg.WebBaseURL = ""
}
if cfg.AppName, err = links.ValidateAppName(cfg.AppName); err != nil {
return nil, fmt.Errorf("app name: %w", err)
}
if strings.TrimSpace(cfg.DownloadURL) == "" {
cfg.DownloadURL = links.DefaultDownloadURL
}
if cfg.DownloadURL, err = links.ValidateBaseURL(cfg.DownloadURL); err != nil {
return nil, fmt.Errorf("download URL: %w", err)
}
if logger == nil {
logger = zap.NewNop()
}
h := &handler{
stickerSets: cfg.StickerSets,
users: cfg.Users,
channels: cfg.Channels,
privacy: cfg.Privacy,
photos: cfg.Photos,
publicBaseURL: cfg.PublicBaseURL,
appScheme: cfg.AppScheme,
webBaseURL: cfg.WebBaseURL,
appName: cfg.AppName,
downloadURL: cfg.DownloadURL,
logger: logger,
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", h.healthz)
mux.HandleFunc("GET /_public/assets/logo.png", h.brandLogo)
mux.HandleFunc("GET /_public/assets/fonts/{file}", h.brandFont)
mux.HandleFunc("GET /_public/avatar/{username}/{photoID}", h.publicAvatar)
mux.HandleFunc("GET /addstickers/{shortName}", h.addStickers)
mux.HandleFunc("GET /addemoji/{shortName}", h.addEmoji)
mux.HandleFunc("GET /addlist/{slug}", h.addList)
mux.HandleFunc("GET /{username}", h.usernameLink)
mux.HandleFunc("GET /{username}/{$}", h.usernameLink)
return publicSecurityHeaders(mux), nil
}
type handler struct {
stickerSets StickerSetResolver
users UsernameResolver
channels PublicChannelResolver
privacy AnonymousPrivacyResolver
photos ProfilePhotoResolver
publicBaseURL string
appScheme string
webBaseURL string
appName string
downloadURL string
logger *zap.Logger
}
func (h *handler) healthz(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok\n"))
}
func (h *handler) addStickers(w http.ResponseWriter, r *http.Request) {
h.serveSet(w, r, "addstickers")
}
func (h *handler) addEmoji(w http.ResponseWriter, r *http.Request) {
h.serveSet(w, r, "addemoji")
}
func (h *handler) addList(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimSpace(r.PathValue("slug"))
if !validSlugPath(slug) {
http.NotFound(w, r)
return
}
app := h.appURL("addlist", "slug", slug)
data := pageData{
AppName: h.appName,
Title: "Shared Folder",
KindLabel: "shared folder",
Subtitle: slug,
Description: "This page opens the app so you can preview and add this shared folder.",
CanonicalURL: h.publicURL("addlist", slug),
AppURL: template.URL(app),
DownloadURL: h.downloadURL,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=60")
if err := landingTemplate.Execute(w, data); err != nil {
http.Error(w, "render shared folder page failed", http.StatusInternalServerError)
}
}
func (h *handler) usernameLink(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.PathValue("username"))
if !validUsernamePath(username) {
h.serveUsernameNotFound(w, username)
return
}
params, ok := publicResolveQuery(r.URL.RawQuery)
if !ok {
http.Error(w, "public link query is too large or invalid", http.StatusRequestURITooLong)
return
}
peer, found, err := h.resolvePublicPeer(r.Context(), username)
if err != nil {
h.logger.Error("Public username lookup failed", zap.String("username", username), zap.Error(err))
http.Error(w, "username lookup failed", http.StatusInternalServerError)
return
}
if !found {
h.serveUsernameNotFound(w, username)
return
}
params.Set("domain", peer.username)
app := schemeURLValues(h.appScheme, "resolve", params)
description := peer.about
if description == "" {
description = peer.fallbackDescription(h.appName)
}
grad := avatarGradient(peer.id)
data := usernamePageData{
AppName: h.appName,
AppInitial: appInitial(h.appName),
Title: peer.title,
Username: peer.username,
Verified: peer.verified,
Extra: peer.extra(),
Description: description,
AboutText: peer.about,
CanonicalURL: h.publicUsernameURL(peer.username),
HomeURL: h.publicBaseURL + "/",
DownloadURL: h.downloadURL,
AppURL: template.URL(app),
ButtonLabel: peer.buttonLabel(),
Initials: peer.initials(),
GradFrom: grad[0],
GradTo: grad[1],
}
if h.webBaseURL != "" {
legacy := schemeURLValues("tg", "resolve", params)
data.WebURL = template.URL(publicWebAppURL(h.webBaseURL, legacy))
}
if peer.hasPhoto {
data.PhotoURL = h.publicAvatarURL(peer.username, peer.photo.ID)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=60, must-revalidate")
if err := usernameLandingTemplate.Execute(w, data); err != nil {
h.logger.Error("Render public username page failed", zap.String("username", peer.username), zap.Error(err))
}
}
const maxPublicAvatarBytes = 4 << 20
func (h *handler) publicAvatar(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.PathValue("username"))
photoID, err := strconv.ParseInt(r.PathValue("photoID"), 10, 64)
if err != nil || photoID <= 0 || !validUsernamePath(username) || h.photos == nil {
http.NotFound(w, r)
return
}
peer, found, err := h.resolvePublicPeer(r.Context(), username)
if err != nil {
h.logger.Error("Public avatar peer lookup failed", zap.String("username", username), zap.Error(err))
http.Error(w, "avatar lookup failed", http.StatusInternalServerError)
return
}
if !found || !peer.hasPhoto || peer.photo.ID != photoID {
http.NotFound(w, r)
return
}
size, inline, ok := bestPublicPhotoSize(peer.photo.Sizes)
if !ok {
http.NotFound(w, r)
return
}
etag := fmt.Sprintf("\"public-avatar-%d-%s-%d\"", peer.photo.ID, size.Type, size.Size)
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
data := inline
mimeType := ""
if len(data) == 0 {
chunk, found, err := h.photos.GetFile(r.Context(), domain.FileDownloadRequest{
LocationKey: fmt.Sprintf("photo:%d:%s", peer.photo.ID, size.Type),
Limit: maxPublicAvatarBytes + 1,
})
if err != nil {
h.logger.Error("Read public avatar blob failed", zap.String("username", username), zap.Int64("photo_id", photoID), zap.Error(err))
http.Error(w, "avatar read failed", http.StatusInternalServerError)
return
}
if !found || chunk.Total <= 0 || chunk.Total > maxPublicAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
h.logger.Warn("Public avatar blob is missing or outside bounds", zap.String("username", username), zap.Int64("photo_id", photoID), zap.Int64("total", chunk.Total), zap.Int("bytes", len(chunk.Bytes)))
http.NotFound(w, r)
return
}
data = chunk.Bytes
mimeType = chunk.MimeType
}
if len(data) == 0 || len(data) > maxPublicAvatarBytes {
http.NotFound(w, r)
return
}
detected := http.DetectContentType(data)
if !safePublicImageType(detected) {
h.logger.Warn("Public avatar blob is not a safe raster image", zap.String("username", username), zap.Int64("photo_id", photoID), zap.String("detected_type", detected), zap.String("stored_type", mimeType))
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", detected)
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
if peer.photo.Date > 0 {
w.Header().Set("Last-Modified", time.Unix(int64(peer.photo.Date), 0).UTC().Format(http.TimeFormat))
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
func (h *handler) serveSet(w http.ResponseWriter, r *http.Request, pathKind string) {
shortName := strings.TrimSpace(r.PathValue("shortName"))
if !validShortNamePath(shortName) {
http.NotFound(w, r)
return
}
set, docs, found, err := h.stickerSets.ResolveStickerSet(r.Context(), domain.StickerSetRef{
Kind: domain.StickerSetRefByShortName,
ShortName: shortName,
})
if err != nil {
http.Error(w, "sticker set lookup failed", http.StatusInternalServerError)
return
}
if !found || set.Deleted {
http.NotFound(w, r)
return
}
canonicalKind := linkKind(set)
if canonicalKind != pathKind {
http.Redirect(w, r, h.publicURL(canonicalKind, set.ShortName), http.StatusPermanentRedirect)
return
}
count := set.Count
if count == 0 {
count = len(docs)
}
app := h.appURL(canonicalKind, "set", set.ShortName)
data := pageData{
AppName: h.appName,
Title: fallbackTitle(set),
KindLabel: kindLabel(set),
Subtitle: fmt.Sprintf("@%s · %d %s", set.ShortName, count, itemNoun(set, count)),
Description: "This page opens the app so you can preview and install the set. Files are still fetched by the app through MTProto.",
CanonicalURL: h.publicURL(canonicalKind, set.ShortName),
AppURL: template.URL(app),
DownloadURL: h.downloadURL,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=60")
if err := landingTemplate.Execute(w, data); err != nil {
http.Error(w, "render sticker set page failed", http.StatusInternalServerError)
}
}
func (h *handler) publicURL(kind, value string) string {
return h.publicBaseURL + "/" + kind + "/" + url.PathEscape(value)
}
func (h *handler) publicUsernameURL(username string) string {
return h.publicBaseURL + "/" + url.PathEscape(username)
}
func (h *handler) publicAvatarURL(username string, photoID int64) string {
return h.publicBaseURL + "/_public/avatar/" + url.PathEscape(username) + "/" + strconv.FormatInt(photoID, 10)
}
type publicPeerKind string
const (
publicPeerUser publicPeerKind = "user"
publicPeerBot publicPeerKind = "bot"
publicPeerChannel publicPeerKind = "channel"
publicPeerSupergroup publicPeerKind = "supergroup"
)
type publicPeer struct {
id int64
kind publicPeerKind
username string
title string
about string
verified bool
memberCount int
photo domain.Photo
hasPhoto bool
}
func (h *handler) resolvePublicPeer(ctx context.Context, username string) (publicPeer, bool, error) {
var (
u domain.User
userOK bool
ch domain.Channel
chOK bool
err error
)
if h.users != nil {
u, userOK, err = h.users.ByUsername(ctx, username)
if err != nil {
return publicPeer{}, false, err
}
}
if h.channels != nil {
ch, chOK, err = h.channels.ResolvePublicChannelUsername(ctx, 0, username)
if err != nil {
return publicPeer{}, false, err
}
}
if userOK && chOK {
return publicPeer{}, false, fmt.Errorf("public username %q has multiple owners", username)
}
if userOK {
return h.publicUserPeer(ctx, username, u)
}
if chOK {
return h.publicChannelPeer(ctx, username, ch)
}
return publicPeer{}, false, nil
}
func (h *handler) publicUserPeer(ctx context.Context, requested string, u domain.User) (publicPeer, bool, error) {
if u.ID == 0 || !strings.EqualFold(strings.TrimSpace(u.Username), requested) || !validUsernamePath(u.Username) {
return publicPeer{}, false, fmt.Errorf("user username lookup returned invalid owner for %q", requested)
}
title := strings.TrimSpace(u.FirstName + " " + u.LastName)
if title == "" {
title = u.Username
}
if err := validatePublicPeerText(title, u.About); err != nil {
return publicPeer{}, false, fmt.Errorf("invalid public user %q: %w", u.Username, err)
}
about := strings.TrimSpace(u.About)
photoKind := domain.ProfilePhotoKindProfile
if !u.Bot && h.privacy != nil {
visible, err := h.privacy.CanSeeAnonymous(ctx, u.ID, domain.PrivacyKeyAbout)
if err != nil {
return publicPeer{}, false, fmt.Errorf("evaluate public about privacy: %w", err)
}
if !visible {
about = ""
}
visible, err = h.privacy.CanSeeAnonymous(ctx, u.ID, domain.PrivacyKeyProfilePhoto)
if err != nil {
return publicPeer{}, false, fmt.Errorf("evaluate public profile photo privacy: %w", err)
}
if !visible {
photoKind = domain.ProfilePhotoKindFallback
}
}
peer := publicPeer{
id: u.ID,
kind: publicPeerUser,
username: u.Username,
title: title,
about: about,
verified: u.Verified,
}
if u.Bot {
peer.kind = publicPeerBot
}
if h.photos != nil {
photo, found, err := h.photos.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, u.ID, photoKind)
if err != nil {
return publicPeer{}, false, fmt.Errorf("load public user photo: %w", err)
}
if found && photo.ID != 0 {
if _, _, renderable := bestPublicPhotoSize(photo.Sizes); renderable {
peer.photo, peer.hasPhoto = photo, true
}
}
}
return peer, true, nil
}
func (h *handler) publicChannelPeer(ctx context.Context, requested string, ch domain.Channel) (publicPeer, bool, error) {
if ch.ID == 0 || ch.Deleted || ch.ParticipantsCount < 0 || (!ch.Broadcast && !ch.Megagroup) || !strings.EqualFold(strings.TrimSpace(ch.Username), requested) || !validUsernamePath(ch.Username) {
return publicPeer{}, false, fmt.Errorf("channel username lookup returned invalid owner for %q", requested)
}
if err := validatePublicPeerText(ch.Title, ch.About); err != nil {
return publicPeer{}, false, fmt.Errorf("invalid public channel %q: %w", ch.Username, err)
}
peer := publicPeer{
id: ch.ID,
kind: publicPeerChannel,
username: ch.Username,
title: strings.TrimSpace(ch.Title),
about: strings.TrimSpace(ch.About),
verified: ch.Verified,
memberCount: ch.ParticipantsCount,
}
if ch.Megagroup {
peer.kind = publicPeerSupergroup
}
if h.photos != nil && ch.PhotoID != 0 {
photo, found, err := h.photos.GetPhoto(ctx, ch.PhotoID)
if err != nil {
return publicPeer{}, false, fmt.Errorf("load public channel photo: %w", err)
}
if !found {
return publicPeer{}, false, fmt.Errorf("channel %q current photo %d is missing", ch.Username, ch.PhotoID)
}
if photo.ID == ch.PhotoID {
if _, _, renderable := bestPublicPhotoSize(photo.Sizes); renderable {
peer.photo, peer.hasPhoto = photo, true
}
} else {
return publicPeer{}, false, fmt.Errorf("channel %q photo lookup returned id %d, want %d", ch.Username, photo.ID, ch.PhotoID)
}
}
return peer, true, nil
}
func validatePublicPeerText(title, about string) error {
if strings.TrimSpace(title) == "" || utf8.RuneCountInString(title) > 256 {
return fmt.Errorf("title is empty or too long")
}
if utf8.RuneCountInString(about) > 4096 {
return fmt.Errorf("about is too long")
}
return nil
}
func (p publicPeer) buttonLabel() string {
switch p.kind {
case publicPeerBot:
return "Start Bot"
case publicPeerChannel:
return "View Channel"
case publicPeerSupergroup:
return "View Group"
default:
return "Send Message"
}
}
func (p publicPeer) extra() string {
switch p.kind {
case publicPeerBot:
return "bot"
case publicPeerChannel:
return groupedDecimal(p.memberCount) + " " + plural(p.memberCount, "subscriber", "subscribers")
case publicPeerSupergroup:
return groupedDecimal(p.memberCount) + " " + plural(p.memberCount, "member", "members")
default:
return ""
}
}
func (p publicPeer) fallbackDescription(appName string) string {
switch p.kind {
case publicPeerBot:
return "Open " + appName + " to start a chat with this bot."
case publicPeerChannel:
return "Open " + appName + " to view and join this channel."
case publicPeerSupergroup:
return "Open " + appName + " to view and join this group."
default:
return "Open " + appName + " to send a message to @" + p.username + "."
}
}
func (p publicPeer) initials() string {
words := strings.Fields(p.title)
if len(words) == 0 {
words = []string{p.username}
}
first := []rune(words[0])
if len(first) == 0 {
return "T"
}
out := []rune{first[0]}
if len(words) > 1 {
last := []rune(words[len(words)-1])
if len(last) > 0 {
out = append(out, last[0])
}
}
return strings.ToUpper(string(out))
}
var publicAvatarGradients = [...][2]string{
{"#FF885E", "#FF516A"},
{"#FFCD6A", "#FFA85C"},
{"#82B1FF", "#665FFF"},
{"#A0DE7E", "#54CB68"},
{"#53EDD6", "#28C9B7"},
{"#72D5FD", "#2A9EF1"},
{"#E0A2F3", "#D669ED"},
}
func avatarGradient(id int64) [2]string {
if id < 0 {
id = -id
}
return publicAvatarGradients[id%int64(len(publicAvatarGradients))]
}
func groupedDecimal(n int) string {
if n < 0 {
n = 0
}
s := strconv.Itoa(n)
for i := len(s) - 3; i > 0; i -= 3 {
s = s[:i] + " " + s[i:]
}
return s
}
func plural(n int, one, many string) string {
if n == 1 {
return one
}
return many
}
func appInitial(name string) string {
for _, r := range name {
return strings.ToUpper(string(r))
}
return "T"
}
const (
maxPublicLinkRawQuery = 2048
maxPublicLinkParams = 16
maxPublicLinkValues = 2
maxPublicLinkValueLen = 512
)
func publicResolveQuery(raw string) (url.Values, bool) {
if len(raw) > maxPublicLinkRawQuery {
return nil, false
}
values, err := url.ParseQuery(raw)
if err != nil || len(values) > maxPublicLinkParams {
return nil, false
}
out := make(url.Values, len(values)+1)
for key, items := range values {
if strings.EqualFold(key, "domain") {
continue
}
if !validPublicQueryKey(key) || len(items) > maxPublicLinkValues {
return nil, false
}
for _, value := range items {
if len(value) > maxPublicLinkValueLen || !utf8.ValidString(value) {
return nil, false
}
out.Add(key, value)
}
}
return out, true
}
func validPublicQueryKey(key string) bool {
if key == "" || len(key) > 32 {
return false
}
for _, r := range key {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
continue
}
return false
}
return true
}
func bestPublicPhotoSize(sizes []domain.PhotoSize) (domain.PhotoSize, []byte, bool) {
var (
best domain.PhotoSize
bestBytes []byte
bestScore int64 = -1
)
for _, size := range sizes {
if !validPhotoSizeType(size.Type) {
continue
}
var inline []byte
switch size.Kind {
case domain.PhotoSizeKindCached:
if len(size.Bytes) == 0 || len(size.Bytes) > maxPublicAvatarBytes {
continue
}
inline = size.Bytes
case domain.PhotoSizeKindDefault, domain.PhotoSizeKindProgressive:
// Downloadable static raster size.
default:
continue
}
score := int64(size.W) * int64(size.H)
if score <= 0 {
score = int64(size.Size)
}
if score > bestScore {
best, bestBytes, bestScore = size, inline, score
}
}
return best, bestBytes, bestScore >= 0
}
func validPhotoSizeType(value string) bool {
if value == "" || len(value) > 8 {
return false
}
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
continue
}
return false
}
return true
}
func safePublicImageType(value string) bool {
switch value {
case "image/jpeg", "image/png", "image/gif", "image/webp":
return true
default:
return false
}
}
func schemeURLValues(scheme, kind string, values url.Values) string {
return (&url.URL{Scheme: scheme, Host: kind, RawQuery: values.Encode()}).String()
}
func publicWebAppURL(webBaseURL, legacyURL string) string {
return strings.TrimRight(webBaseURL, "/") + "/#?tgaddr=" + url.QueryEscape(legacyURL)
}
func publicSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; font-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
})
}
func validShortNamePath(shortName string) bool {
if shortName == "" || len(shortName) > 64 {
return false
}
for _, r := range shortName {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '_' || r == '.':
default:
return false
}
}
return true
}
func validSlugPath(slug string) bool {
return links.ValidChatlistSlug(slug)
}
func validUsernamePath(username string) bool {
if username == "" || len(username) < 5 || len(username) > 32 {
return false
}
for i, r := range username {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9', r == '_':
if i == 0 {
return false
}
default:
return false
}
}
return true
}
func linkKind(set domain.StickerSet) string {
if set.Kind == domain.StickerSetKindEmoji || set.Emojis {
return "addemoji"
}
return "addstickers"
}
func fallbackTitle(set domain.StickerSet) string {
if title := strings.TrimSpace(set.Title); title != "" {
return title
}
return set.ShortName
}
func kindLabel(set domain.StickerSet) string {
switch {
case set.Kind == domain.StickerSetKindEmoji || set.Emojis:
return "custom emoji set"
case set.Kind == domain.StickerSetKindMasks || set.Masks:
return "mask set"
default:
return "sticker set"
}
}
func itemNoun(set domain.StickerSet, count int) string {
if set.Kind == domain.StickerSetKindEmoji || set.Emojis {
if count == 1 {
return "custom emoji"
}
return "custom emoji"
}
if count == 1 {
return "sticker"
}
return "stickers"
}
func (h *handler) appURL(kind, key, value string) string {
return schemeURL(h.appScheme, kind, key, value)
}
func schemeURL(scheme, kind, key, value string) string {
return scheme + "://" + kind + "?" + key + "=" + url.QueryEscape(value)
}
type pageData struct {
AppName string
Title string
KindLabel string
Subtitle string
Description string
CanonicalURL string
DownloadURL string
AppURL template.URL
}
type usernamePageData struct {
AppName string
AppInitial string
Title string
Username string
Verified bool
Extra string
Description string
AboutText string
CanonicalURL string
HomeURL string
DownloadURL string
PhotoURL string
Initials string
GradFrom string
GradTo string
ButtonLabel string
AppURL template.URL
WebURL template.URL
}
func (h *handler) serveUsernameNotFound(w http.ResponseWriter, username string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=30, must-revalidate")
w.WriteHeader(http.StatusNotFound)
if err := usernameNotFoundTemplate.Execute(w, struct {
Username string
HomeURL string
AppName string
DownloadURL string
}{Username: username, HomeURL: h.publicBaseURL + "/", AppName: h.appName, DownloadURL: h.downloadURL}); err != nil {
h.logger.Error("Render public username not-found page failed", zap.String("username", username), zap.Error(err))
}
}
// publicPageStyleSheet is the shared visual system for every public landing
// page (username profile, not-found, sticker/addlist): CSS variables (light
// theme + dark-scheme override), the card/avatar/button component classes,
// and the floating background orbs. Fonts are self-hosted (no Google Fonts
// CDN call from visitors' browsers) via the /_public/assets/fonts/ route.
const publicPageStyleSheet = `
@font-face{font-family:'Plus Jakarta Sans';font-style:normal;font-weight:400;font-display:swap;src:url('/_public/assets/fonts/plus-jakarta-sans-400.woff2') format('woff2')}
@font-face{font-family:'Plus Jakarta Sans';font-style:normal;font-weight:500;font-display:swap;src:url('/_public/assets/fonts/plus-jakarta-sans-500.woff2') format('woff2')}
@font-face{font-family:'Plus Jakarta Sans';font-style:normal;font-weight:600;font-display:swap;src:url('/_public/assets/fonts/plus-jakarta-sans-600.woff2') format('woff2')}
@font-face{font-family:'Plus Jakarta Sans';font-style:normal;font-weight:700;font-display:swap;src:url('/_public/assets/fonts/plus-jakarta-sans-700.woff2') format('woff2')}
@font-face{font-family:'Plus Jakarta Sans';font-style:normal;font-weight:800;font-display:swap;src:url('/_public/assets/fonts/plus-jakarta-sans-800.woff2') format('woff2')}
:root{
--accent:#2563eb; --accent-bright:#38bdf8;
--grad:linear-gradient(135deg,#38bdf8 0%,#2563eb 55%,#1e40af 100%);
--bg:#f7f9fc; --surface:#ffffff; --surface-2:#f1f5f9; --line:#e2e8f0; --line-strong:#cbd5e1;
--ink:#050508; --body:#3f4b5c; --muted:#64748b;
--hero-glow:rgba(37,99,235,0.14); --hero-grid:rgba(5,5,8,0.04);
--radius:18px; --radius-lg:24px;
--shadow:0 28px 70px -36px rgba(5,5,8,0.35);
}
@media (prefers-color-scheme: dark){
:root{
--accent:#60a5fa; --accent-bright:#7dd3fc;
--grad:linear-gradient(135deg,#7dd3fc 0%,#3b82f6 50%,#2563eb 100%);
--bg:#08080e; --surface:#12121a; --surface-2:#181822; --line:#222228; --line-strong:#2e2e36;
--ink:#f8fafc; --body:#a8b4c4; --muted:#7b8798;
--hero-glow:rgba(59,130,246,0.25); --hero-grid:rgba(255,255,255,0.04);
--shadow:0 32px 80px -40px rgba(0,0,0,0.75);
}
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0; color:var(--body); background:var(--bg);
font-family:'Plus Jakarta Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
-webkit-font-smoothing:antialiased; line-height:1.65;
min-height:100vh; display:flex; flex-direction:column;
background-image:
radial-gradient(900px 480px at 50% -8%, var(--hero-glow) 0%, rgba(0,0,0,0) 70%),
linear-gradient(var(--hero-grid) 1px, transparent 1px),
linear-gradient(90deg, var(--hero-grid) 1px, transparent 1px);
background-size:auto, 44px 44px, 44px 44px;
background-attachment:fixed;
}
h1,h2,h3{color:var(--ink); letter-spacing:-0.03em; font-weight:800; margin:0}
a{color:var(--accent); text-decoration:none}
header{
display:flex; align-items:center; justify-content:space-between;
padding:18px 26px; max-width:1100px; width:100%; margin:0 auto;
}
.brand{display:flex; align-items:center; gap:11px; font-weight:800; font-size:19px; color:var(--ink); letter-spacing:-0.02em}
.brand img{width:34px; height:34px; border-radius:9px; display:block}
.btn{
display:inline-flex; align-items:center; justify-content:center; gap:8px;
font-weight:700; font-size:0.92rem; padding:12px 20px; border-radius:11px;
border:none; cursor:pointer; text-decoration:none; line-height:1; transition:transform .16s ease, box-shadow .16s ease, background .16s ease, border-color .16s ease, color .16s ease;
font-family:inherit;
}
.btn:hover{transform:translateY(-1px)}
.btn-primary{background:var(--grad); color:#fff; box-shadow:0 12px 28px -14px rgba(37,99,235,0.65); width:100%}
.btn-primary:hover{box-shadow:0 16px 36px -14px rgba(37,99,235,0.75)}
.btn-ghost{background:var(--surface); color:var(--ink); border:1px solid var(--line)}
.btn-ghost:hover{border-color:var(--accent); color:var(--accent)}
main{flex:1; display:flex; align-items:center; justify-content:center; padding:24px}
.card{
width:100%; max-width:430px; background:var(--surface);
border:1px solid var(--line); border-radius:var(--radius-lg); padding:38px 30px 30px;
text-align:center; box-shadow:var(--shadow);
}
.avatar-wrap{position:relative; width:124px; height:124px; margin:0 auto 20px}
.avatar{position:absolute; inset:0; width:124px; height:124px; border-radius:50%; display:block}
.avatar-photo{object-fit:cover; background:var(--surface-2)}
.name{font-size:25px; font-weight:800; line-height:1.2; color:var(--ink); word-break:break-word; display:inline-flex; align-items:center; gap:8px; justify-content:center}
.badge{width:20px; height:20px; flex:0 0 auto}
.sub{color:var(--muted); font-size:15px; margin-top:6px; font-weight:500}
.about{color:var(--body); font-size:15px; line-height:1.6; margin-top:18px; white-space:pre-wrap; word-break:break-word; text-align:center}
.actions{margin-top:26px; display:grid; gap:10px}
.scam{display:inline-block; margin-top:12px; color:#e5484d; border:1px solid rgba(229,72,77,0.25); background:rgba(229,72,77,0.08); font-size:12px; font-weight:700; letter-spacing:.4px; padding:4px 10px; border-radius:8px}
.nf-emoji{font-size:60px; line-height:1; margin-bottom:14px}
@media (max-width:480px){ .card{padding:30px 22px 24px} header{padding:16px 18px} }
/* Floating background orbs. */
.bg-orbs{position:fixed; inset:-60px; z-index:-1; pointer-events:none; transition:transform .3s ease-out; will-change:transform}
.bg-orb{position:absolute; border-radius:50%; filter:blur(100px); pointer-events:none}
.bg-orb--1{width:700px; height:700px; top:-15%; left:-10%; background:color-mix(in srgb,var(--accent-bright) 40%,transparent); animation:orbFloat1 20s ease-in-out infinite}
.bg-orb--2{width:600px; height:600px; top:25%; right:-15%; background:color-mix(in srgb,var(--accent) 38%,transparent); animation:orbFloat2 24s ease-in-out infinite}
.bg-orb--3{width:500px; height:500px; bottom:-15%; left:30%; background:color-mix(in srgb,var(--accent-bright) 30%,transparent); animation:orbFloat3 28s ease-in-out infinite}
@keyframes orbFloat1{0%,100%{transform:translate(0,0) scale(1)}33%{transform:translate(60px,-40px) scale(1.08)}66%{transform:translate(-30px,30px) scale(.92)}}
@keyframes orbFloat2{0%,100%{transform:translate(0,0) scale(1)}33%{transform:translate(-50px,-35px) scale(.93)}66%{transform:translate(45px,25px) scale(1.07)}}
@keyframes orbFloat3{0%,100%{transform:translate(0,0) scale(1)}33%{transform:translate(40px,45px) scale(1.06)}66%{transform:translate(-55px,-25px) scale(.94)}}
@media (max-width:720px){ .bg-orb{filter:blur(60px)} .bg-orb--1{width:350px;height:350px} .bg-orb--2{width:300px;height:300px} .bg-orb--3{width:250px;height:250px} }
@media (prefers-reduced-motion: reduce){ .bg-orb{animation:none} }
/* Drifting line-icon particles. */
.bg-icons{position:fixed; inset:0; z-index:-1; overflow:hidden; pointer-events:none; color:var(--accent)}
.bg-icon{position:absolute; left:0; top:0; will-change:transform}
`
// publicBgOrbsMarkup is the floating-orb + particle container markup, placed
// right after <body> on every public landing page.
const publicBgOrbsMarkup = `
<div class="bg-orbs" aria-hidden="true">
<div class="bg-orb bg-orb--1"></div>
<div class="bg-orb bg-orb--2"></div>
<div class="bg-orb bg-orb--3"></div>
</div>
<div class="bg-icons" aria-hidden="true"></div>`
// publicBgIconsScript drives the orb parallax and drifting line-icon
// particles. Self-contained vanilla JS, no external requests.
const publicBgIconsScript = `
<script>
(function(){
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var orbs = document.querySelector('.bg-orbs');
var box = document.querySelector('.bg-icons');
var mx = 0.5, my = 0.5;
window.addEventListener('pointermove', function(e){
mx = e.clientX / window.innerWidth;
my = e.clientY / window.innerHeight;
if (orbs) orbs.style.transform = 'translate(' + ((mx-0.5)*30) + 'px,' + ((my-0.5)*30) + 'px)';
}, { passive: true });
if (!box || reduce) return;
var defs = [
'<rect x="2.5" y="3.5" width="19" height="12" rx="2"/><path d="M8 20h8M12 15.5V20"/>',
'<rect x="3.5" y="4" width="17" height="6" rx="2"/><rect x="3.5" y="14" width="17" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/>',
'<rect x="3" y="5" width="18" height="14" rx="2.5"/><path d="M3 7l9 6.5L21 7"/>',
'<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3c2.6 2.5 4 5.7 4 9s-1.4 6.5-4 9c-2.6-2.5-4-5.7-4-9s1.4-6.5 4-9z"/>',
'<rect x="7" y="3" width="10" height="18" rx="2.4"/><path d="M11 18h2"/>',
'<path d="M8 6l-6 6 6 6M16 6l6 6-6 6"/>',
'<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/>',
'<path d="M12 3l7 3v5c0 4.5-3 7.6-7 9-4-1.4-7-4.5-7-9V6l7-3z"/><path d="M8.8 12.2l2.1 2.1 4.3-4.3"/>',
'<path d="M21.5 3.5l-19 7.5 5.5 2.5 3 5 3-2 5.5 5z"/><path d="M11 14l8.5-8.5"/>',
'<rect x="4.5" y="11" width="15" height="9" rx="2.2"/><path d="M8 11V8a4 4 0 0 1 7.5-1.9"/><path d="M12 15v2"/>',
'<path d="M12 3v12"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 20h14"/>',
'<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
'<path d="M12 2l3.1 6.3L22 9.5l-5 4.9 1.2 7L12 17.3 5.8 21.4 7 14.4l-5-4.9 6.9-1.2z"/>',
'<path d="M14.5 17.5L19 13l-4.5-4.5M9.5 6.5L5 11l4.5 4.5"/>',
'<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
'<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/><path d="M15 12h4M17 10v4"/>',
'<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
'<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>',
'<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/>',
'<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/>',
'<circle cx="12" cy="12" r="10"/><path d="M12 6v12M6 12h12"/>'
];
var SVGNS = 'http://www.w3.org/2000/svg';
var ps = [];
function build(){
box.textContent = '';
ps.length = 0;
var w = window.innerWidth, h = window.innerHeight, margin = 60;
var count = w < 720 ? 16 : 28;
for (var i = 0; i < count; i++){
var size = 22 + Math.random() * 30;
var el = document.createElementNS(SVGNS, 'svg');
el.setAttribute('class', 'bg-icon');
el.setAttribute('viewBox', '0 0 24 24');
el.setAttribute('fill', 'none');
el.setAttribute('stroke', 'currentColor');
el.setAttribute('stroke-width', '1.6');
el.setAttribute('stroke-linecap', 'round');
el.setAttribute('stroke-linejoin', 'round');
el.style.width = size + 'px';
el.style.height = size + 'px';
el.innerHTML = defs[i % defs.length];
var op = 0.18 + Math.random() * 0.12;
el.style.opacity = op;
box.appendChild(el);
ps.push({
x: margin + Math.random() * (w - margin * 2),
y: margin + Math.random() * (h - margin * 2),
vx: (Math.random() - 0.5) * 0.3,
vy: (Math.random() - 0.5) * 0.3,
size: size, rot: Math.random() * 360,
rotSpeed: (Math.random() - 0.5) * 0.08, el: el
});
}
}
function tick(){
var w = window.innerWidth, h = window.innerHeight;
for (var i = 0; i < ps.length; i++){
var a = ps[i];
a.x += a.vx; a.y += a.vy; a.rot += a.rotSpeed;
var half = a.size / 2;
if (a.x < half){ a.x = half; a.vx *= -1; }
if (a.x > w - half){ a.x = w - half; a.vx *= -1; }
if (a.y < half){ a.y = half; a.vy *= -1; }
if (a.y > h - half){ a.y = h - half; a.vy *= -1; }
for (var j = i + 1; j < ps.length; j++){
var b = ps[j];
var dx = b.x - a.x, dy = b.y - a.y;
var dist = Math.sqrt(dx * dx + dy * dy);
var minD = (a.size + b.size) / 2 + 20;
if (dist < minD && dist > 0.01){
var f = (minD - dist) / minD * 0.02, nx = dx / dist, ny = dy / dist;
a.vx -= nx * f; a.vy -= ny * f; b.vx += nx * f; b.vy += ny * f;
}
}
}
for (var k = 0; k < ps.length; k++){
var p = ps[k];
p.el.style.transform = 'translate(' + p.x + 'px,' + p.y + 'px) rotate(' + p.rot + 'deg)';
}
requestAnimationFrame(tick);
}
build();
window.addEventListener('resize', build);
requestAnimationFrame(tick);
})();
</script>`
var usernameLandingTemplate = template.Must(template.New("username-landing").Parse(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#2563eb">
<title>{{.Title}} (@{{.Username}}) - {{.AppName}}</title>
<meta name="description" content="{{.Description}}">
<meta name="robots" content="index,follow,max-image-preview:large">
<link rel="canonical" href="{{.CanonicalURL}}">
<link rel="icon" type="image/png" href="/_public/assets/logo.png">
<meta property="og:type" content="profile">
<meta property="og:site_name" content="{{.AppName}}">
<meta property="og:title" content="{{.Title}}">
<meta property="og:description" content="{{.Description}}">
<meta property="og:url" content="{{.CanonicalURL}}">
{{if .PhotoURL}}<meta property="og:image" content="{{.PhotoURL}}">{{end}}
<meta property="al:android:url" content="{{.AppURL}}">
<meta property="al:ios:url" content="{{.AppURL}}">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{.Title}}">
<meta name="twitter:description" content="{{.Description}}">
{{if .PhotoURL}}<meta name="twitter:image" content="{{.PhotoURL}}">{{end}}
<style>` + publicPageStyleSheet + `
.extra{color:var(--muted); font-size:14px; margin-top:2px}
</style>
</head>
<body>` + publicBgOrbsMarkup + `
<header>
<a class="brand" href="{{.HomeURL}}" aria-label="{{.AppName}} home">
<img src="/_public/assets/logo.png" alt="{{.AppName}}">
<span>{{.AppName}}</span>
</a>
<a class="btn btn-ghost" href="{{.DownloadURL}}">Download</a>
</header>
<main>
<div class="card">
<div class="avatar-wrap">
<svg class="avatar" viewBox="0 0 124 124" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs><linearGradient id="ag" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="{{.GradFrom}}"/><stop offset="1" stop-color="{{.GradTo}}"/>
</linearGradient></defs>
<circle cx="62" cy="62" r="62" fill="url(#ag)"/>
<text x="62" y="64" text-anchor="middle" dominant-baseline="central"
font-family="'Plus Jakarta Sans',Arial,sans-serif" font-size="46" font-weight="700" fill="#fff">{{.Initials}}</text>
</svg>
{{if .PhotoURL}}<img class="avatar avatar-photo" src="{{.PhotoURL}}" alt="" loading="eager" onerror="this.remove()">{{end}}
</div>
<div class="name">
<span>{{.Title}}</span>
{{if .Verified}}<svg class="badge" viewBox="0 0 24 24" fill="none" aria-label="Verified"><circle cx="12" cy="12" r="11" fill="#2563eb"/><path d="M7 12.5l3.2 3.2L17 9" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>{{end}}
</div>
<div class="sub">@{{.Username}}</div>
{{if .Extra}}<div class="extra">{{.Extra}}</div>{{end}}
{{if .AboutText}}<div class="about">{{.AboutText}}</div>{{end}}
<div class="actions">
<a class="btn btn-primary" href="{{.AppURL}}">{{.ButtonLabel}}</a>
{{if .WebURL}}<a class="btn btn-ghost" href="{{.WebURL}}">Open in Web</a>{{end}}
</div>
</div>
</main>` + publicBgIconsScript + `
</body>
</html>
`))
var usernameNotFoundTemplate = template.Must(template.New("username-not-found").Parse(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#2563eb">
<meta name="robots" content="noindex,nofollow">
<title>Username not found - {{.AppName}}</title>
<link rel="icon" type="image/png" href="/_public/assets/logo.png">
<style>` + publicPageStyleSheet + `</style>
</head>
<body>` + publicBgOrbsMarkup + `
<header>
<a class="brand" href="{{.HomeURL}}" aria-label="{{.AppName}} home">
<img src="/_public/assets/logo.png" alt="{{.AppName}}">
<span>{{.AppName}}</span>
</a>
<a class="btn btn-ghost" href="{{.DownloadURL}}">Download</a>
</header>
<main>
<div class="card">
<div class="nf-emoji">🤔</div>
<h2 style="font-size:22px">Nothing found</h2>
<div class="about">{{if .Username}}@{{.Username}} is not an active public {{.AppName}} username.{{else}}This is not a valid public {{.AppName}} username.{{end}}</div>
<div class="actions"><a class="btn btn-primary" href="{{.HomeURL}}">Back to {{.AppName}}</a></div>
</div>
</main>` + publicBgIconsScript + `
</body>
</html>
`))
var landingTemplate = template.Must(template.New("landing").Parse(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#2563eb">
<title>{{.Title}} - {{.AppName}}</title>
<link rel="canonical" href="{{.CanonicalURL}}">
<link rel="icon" type="image/png" href="/_public/assets/logo.png">
<meta property="og:title" content="{{.Title}}">
<meta property="og:description" content="{{.Description}}">
<meta property="og:url" content="{{.CanonicalURL}}">
<meta name="robots" content="noindex">
<style>` + publicPageStyleSheet + `
.kind{display:inline-block; color:var(--accent); background:color-mix(in srgb,var(--accent) 12%,transparent); font-size:12.5px; font-weight:700; letter-spacing:.3px; padding:4px 10px; border-radius:8px; margin-bottom:14px}
</style>
</head>
<body>` + publicBgOrbsMarkup + `
<header>
<a class="brand" href="{{.CanonicalURL}}" aria-label="{{.AppName}}">
<img src="/_public/assets/logo.png" alt="{{.AppName}}">
<span>{{.AppName}}</span>
</a>
<a class="btn btn-ghost" href="{{.DownloadURL}}">Download</a>
</header>
<main>
<div class="card">
<div class="kind">{{.KindLabel}}</div>
<h2 style="font-size:24px">{{.Title}}</h2>
{{if .Subtitle}}<div class="sub">{{.Subtitle}}</div>{{end}}
{{if .Description}}<div class="about">{{.Description}}</div>{{end}}
<div class="actions"><a class="btn btn-primary" href="{{.AppURL}}">Open in {{.AppName}}</a></div>
</div>
</main>` + publicBgIconsScript + `
</body>
</html>
`))