owpengram-server/internal/web/server.go

1295 lines
47 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
AppLinkBase string
WebBaseURL string
AppName string
StickerSets StickerSetResolver
Users UsernameResolver
Channels PublicChannelResolver
Privacy AnonymousPrivacyResolver
Photos ProfilePhotoResolver
UniqueGifts UniqueStarGiftResolver
GiftWithdrawals StarGiftWithdrawalResolver
ModerationAppeals ModerationAppealResolver
// TelegramLogin is the optional OIDC/Login HTTP adapter. Public Web owns
// the listener so discovery/auth/token and public links share the exact
// externally registered origin behind one reverse proxy.
TelegramLogin http.Handler
}
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)
}
type UniqueStarGiftResolver interface {
UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)
}
type StarGiftWithdrawalResolver interface {
ResolveWithdrawal(ctx context.Context, providerRequestID string) (domain.StarGiftWithdrawal, bool, error)
CompleteWithdrawal(ctx context.Context, providerRequestID string, date int) (domain.StarGiftWithdrawal, error)
}
type ModerationAppealResolver interface {
ResolveAppealLink(ctx context.Context, token string, now time.Time) (domain.ModerationAppealLink, bool, error)
Appeal(ctx context.Context, appealID int64) (domain.ModerationAppeal, bool, error)
SubmitAppealLink(ctx context.Context, token, text string, now time.Time) (domain.ModerationAppeal, 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("app_link_base", cfg.AppLinkBase),
zap.String("web_base_url", cfg.WebBaseURL))
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.WebBaseURL) == "" {
cfg.WebBaseURL = links.DefaultWebBaseURL
}
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)
}
appLinks, err := links.NewAppLinkBuilder(cfg.AppScheme, cfg.AppLinkBase)
if err != nil {
return nil, fmt.Errorf("app links: %w", err)
}
if cfg.WebBaseURL, err = links.ValidateBaseURL(cfg.WebBaseURL); err != nil {
return nil, fmt.Errorf("Web base URL: %w", err)
}
if cfg.AppName, err = links.ValidateAppName(cfg.AppName); err != nil {
return nil, fmt.Errorf("app name: %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,
uniqueGifts: cfg.UniqueGifts,
giftWithdrawals: cfg.GiftWithdrawals,
appeals: cfg.ModerationAppeals,
publicBaseURL: cfg.PublicBaseURL,
appLinks: appLinks,
webBaseURL: cfg.WebBaseURL,
appName: cfg.AppName,
logger: logger,
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", h.healthz)
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 /nft/{slug}", h.uniqueGift)
mux.HandleFunc("GET /nft/{slug}/{$}", h.uniqueGift)
mux.HandleFunc("GET /gift-withdrawal/{requestID}", h.starGiftWithdrawal)
mux.HandleFunc("POST /gift-withdrawal/{requestID}", h.completeStarGiftWithdrawal)
if cfg.ModerationAppeals != nil {
mux.HandleFunc("GET /appeal/{token}", h.moderationAppeal)
mux.HandleFunc("POST /appeal/{token}", h.moderationAppeal)
}
if cfg.TelegramLogin != nil {
mux.Handle("GET /.well-known/openid-configuration", cfg.TelegramLogin)
mux.Handle("GET /.well-known/jwks.json", cfg.TelegramLogin)
mux.Handle("GET /auth", cfg.TelegramLogin)
mux.Handle("GET /crossapp", cfg.TelegramLogin)
mux.Handle("GET /inapp", cfg.TelegramLogin)
mux.Handle("POST /auth/status", cfg.TelegramLogin)
mux.Handle("POST /token", cfg.TelegramLogin)
mux.Handle("GET /telegram-login.js", cfg.TelegramLogin)
mux.Handle("GET /js/telegram-login.js", cfg.TelegramLogin)
}
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
uniqueGifts UniqueStarGiftResolver
giftWithdrawals StarGiftWithdrawalResolver
appeals ModerationAppealResolver
publicBaseURL string
appLinks links.AppLinkBuilder
webBaseURL string
appName string
logger *zap.Logger
}
type moderationAppealPage struct {
AppName string
CaseID int64
ExpiresAt string
Submitted bool
AppealID int64
AppealStatus domain.ModerationAppealStatus
Error string
AppealText string
CanSubmit bool
}
var moderationAppealTemplate = template.Must(template.New("moderation-appeal").Parse(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer"><title>Moderation appeal · {{.AppName}}</title><style>
body{font:16px/1.5 system-ui,sans-serif;background:#f4f6f8;color:#17212b;margin:0;padding:24px}.card{max-width:620px;margin:6vh auto;background:#fff;border-radius:16px;padding:28px;box-shadow:0 8px 32px #0002}h1{margin-top:0}textarea{box-sizing:border-box;width:100%;min-height:160px;padding:12px;border:1px solid #ccd4dc;border-radius:10px;font:inherit}button{border:0;border-radius:10px;padding:12px 18px;background:#2481cc;color:#fff;font-weight:600;cursor:pointer}.meta{color:#53606d}.error{color:#b42318}.done{color:#18864b;font-weight:600}
</style></head><body><main class="card"><h1>Moderation appeal</h1><p class="meta">Case #{{.CaseID}} · link expires {{.ExpiresAt}}</p>
{{if .Submitted}}{{if eq .AppealStatus "granted"}}<p class="done">Your appeal #{{.AppealID}} was granted. Reversible restrictions are being removed through the audited action queue.</p>
{{else if eq .AppealStatus "rejected"}}<p class="error">Your appeal #{{.AppealID}} was not granted.</p>
{{else}}<p class="done">Your appeal #{{.AppealID}} has been submitted. It will be reviewed by a separate moderation step.</p>{{end}}
{{else}}{{if .Error}}<p class="error">{{.Error}}</p>{{end}}{{if .CanSubmit}}<p>Explain why the moderation decision should be reviewed. Do not include passwords, login codes, or payment details.</p><form method="post"><textarea name="appeal_text" maxlength="4000" required>{{.AppealText}}</textarea><p><button type="submit">Submit appeal</button></p></form>{{end}}{{end}}
</main></body></html>`))
func (h *handler) moderationAppeal(w http.ResponseWriter, r *http.Request) {
if h.appeals == nil {
http.NotFound(w, r)
return
}
token := r.PathValue("token")
now := time.Now().UTC()
link, found, err := h.appeals.ResolveAppealLink(r.Context(), token, now)
if err != nil || !found {
http.NotFound(w, r)
return
}
page := moderationAppealPage{
AppName: h.appName, CaseID: link.CaseID,
ExpiresAt: link.ExpiresAt.UTC().Format(time.RFC3339),
Submitted: link.AppealID > 0, AppealID: link.AppealID,
CanSubmit: link.AppealID == 0,
}
if link.AppealID > 0 {
appeal, appealFound, appealErr := h.appeals.Appeal(r.Context(), link.AppealID)
if appealErr != nil || !appealFound || appeal.CaseID != link.CaseID {
h.logger.Warn("resolve linked moderation appeal status failed",
zap.Int64("case_id", link.CaseID),
zap.Int64("appeal_id", link.AppealID),
zap.Error(appealErr))
http.Error(w, "The appeal status is temporarily unavailable.", http.StatusServiceUnavailable)
return
}
page.AppealStatus = appeal.Status
}
status := http.StatusOK
if r.Method == http.MethodPost && !page.Submitted {
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
if err := r.ParseForm(); err != nil {
page.Error = "The appeal form is too large or invalid."
status = http.StatusBadRequest
} else {
page.AppealText = strings.TrimSpace(r.FormValue("appeal_text"))
appeal, _, submitErr := h.appeals.SubmitAppealLink(
r.Context(), token, page.AppealText, now,
)
switch {
case submitErr == nil:
page.Submitted = true
page.CanSubmit = false
page.AppealID = appeal.ID
page.AppealStatus = appeal.Status
page.AppealText = ""
case errors.Is(submitErr, domain.ErrModerationCaseConflict):
page.Error = "The moderation action is still being finalized. Please retry shortly."
status = http.StatusConflict
case errors.Is(submitErr, domain.ErrModerationCaseInvalid):
page.Error = "The appeal text is invalid."
status = http.StatusBadRequest
default:
h.logger.Warn("moderation appeal submission failed",
zap.Int64("case_id", link.CaseID),
zap.Error(submitErr))
page.Error = "The appeal could not be submitted. Please retry."
status = http.StatusInternalServerError
}
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
w.WriteHeader(status)
if err := moderationAppealTemplate.Execute(w, page); err != nil {
h.logger.Warn("render moderation appeal page failed",
zap.Int64("case_id", link.CaseID), zap.Error(err))
}
}
type starGiftWithdrawalPage struct {
AppName string
Title string
Slug string
Status string
OwnerAddress string
GiftAddress string
ExpiresAt string
CanComplete bool
}
var starGiftWithdrawalTemplate = template.Must(template.New("star-gift-withdrawal").Parse(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Title}} · {{.AppName}}</title><style>
body{font:16px/1.5 system-ui,sans-serif;background:#f4f6f8;color:#17212b;margin:0;padding:32px}.card{max-width:560px;margin:8vh auto;background:#fff;border-radius:16px;padding:28px;box-shadow:0 8px 32px #0002}h1{margin-top:0}.meta{overflow-wrap:anywhere;color:#53606d}button{border:0;border-radius:10px;padding:12px 18px;background:#2481cc;color:#fff;font-weight:600;cursor:pointer}.done{color:#18864b;font-weight:600}
</style></head><body><main class="card"><h1>{{.Title}}</h1><p class="meta">Collectible: {{.Slug}}</p>
{{if .CanComplete}}<p>This export is handled only by {{.AppName}}'s internal ledger. No external blockchain or wallet is contacted.</p><form method="post"><button type="submit">Complete local export</button></form><p class="meta">Expires: {{.ExpiresAt}}</p>{{else}}<p class="done">Status: {{.Status}}</p>{{if .OwnerAddress}}<p class="meta">Owner address: {{.OwnerAddress}}</p><p class="meta">Gift address: {{.GiftAddress}}</p>{{end}}{{end}}
</main></body></html>`))
func (h *handler) starGiftWithdrawal(w http.ResponseWriter, r *http.Request) {
h.renderStarGiftWithdrawal(w, r, false)
}
func (h *handler) completeStarGiftWithdrawal(w http.ResponseWriter, r *http.Request) {
h.renderStarGiftWithdrawal(w, r, true)
}
func (h *handler) renderStarGiftWithdrawal(w http.ResponseWriter, r *http.Request, complete bool) {
requestID := strings.TrimSpace(r.PathValue("requestID"))
if h.giftWithdrawals == nil || requestID == "" || len(requestID) > 256 {
http.NotFound(w, r)
return
}
var withdrawal domain.StarGiftWithdrawal
var found bool
var err error
if complete {
withdrawal, err = h.giftWithdrawals.CompleteWithdrawal(r.Context(), requestID, int(time.Now().Unix()))
found = err == nil
} else {
withdrawal, found, err = h.giftWithdrawals.ResolveWithdrawal(r.Context(), requestID)
}
if err != nil || !found {
http.NotFound(w, r)
return
}
page := starGiftWithdrawalPage{AppName: h.appName, Title: withdrawal.Gift.Title, Slug: withdrawal.Gift.Slug,
Status: withdrawal.Status, OwnerAddress: withdrawal.Gift.OwnerAddress, GiftAddress: withdrawal.Gift.GiftAddress,
ExpiresAt: time.Unix(int64(withdrawal.ExpiresAt), 0).UTC().Format(time.RFC3339),
CanComplete: withdrawal.Status == "pending" && withdrawal.ExpiresAt > int(time.Now().Unix())}
if page.Title == "" {
page.Title = "Collectible gift export"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := starGiftWithdrawalTemplate.Execute(w, page); err != nil {
h.logger.Warn("render star gift withdrawal", zap.Error(err))
}
}
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),
LegacyTgURL: template.URL(legacyTgURL("addlist", "slug", slug)),
}
data.AppURLJS = template.JS(strconv.Quote(app))
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) uniqueGift(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if h.uniqueGifts == nil || !validStarGiftSlugPath(slug) {
http.NotFound(w, r)
return
}
unique, found, err := h.uniqueGifts.UniqueBySlug(r.Context(), slug)
if err != nil {
h.logger.Error("Public unique star gift lookup failed", zap.String("slug", slug), zap.Error(err))
http.Error(w, "collectible gift lookup failed", http.StatusInternalServerError)
return
}
if !found {
http.NotFound(w, r)
return
}
canonicalSlug := unique.Slug
if unique.ID <= 0 || unique.GiftID <= 0 || unique.Num <= 0 ||
!validStarGiftSlugPath(canonicalSlug) || !strings.EqualFold(slug, canonicalSlug) ||
!utf8.ValidString(unique.Title) || utf8.RuneCountInString(unique.Title) > domain.MaxStarGiftTitleRunes {
h.logger.Error("Public unique star gift resolver returned invalid aggregate",
zap.String("requested_slug", slug), zap.String("resolved_slug", canonicalSlug),
zap.Int64("unique_id", unique.ID), zap.Int64("gift_id", unique.GiftID), zap.Int("num", unique.Num))
http.Error(w, "collectible gift lookup failed", http.StatusInternalServerError)
return
}
if slug != canonicalSlug || strings.HasSuffix(r.URL.Path, "/") {
http.Redirect(w, r, h.publicURL("nft", canonicalSlug), http.StatusPermanentRedirect)
return
}
title := strings.TrimSpace(unique.Title)
if title == "" {
title = "Collectible gift"
}
subtitle := fmt.Sprintf("Collectible #%d", unique.Num)
if unique.AvailabilityIssued > 0 && unique.AvailabilityTotal >= unique.AvailabilityIssued {
subtitle += fmt.Sprintf(" · %s/%s issued", groupedDecimal(unique.AvailabilityIssued), groupedDecimal(unique.AvailabilityTotal))
}
app := h.appURL("nft", "slug", canonicalSlug)
data := pageData{
AppName: h.appName,
Title: title,
KindLabel: "collectible gift",
Subtitle: subtitle,
Description: "This collectible was created from a gift on " + h.appName + ". Open it in the app to view its current details.",
CanonicalURL: h.publicURL("nft", canonicalSlug),
AppURL: template.URL(app),
LegacyTgURL: template.URL(legacyTgURL("nft", "slug", canonicalSlug)),
}
data.AppURLJS = template.JS(strconv.Quote(app))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=60, must-revalidate")
if err := landingTemplate.Execute(w, data); err != nil {
h.logger.Error("Render public unique star gift page failed", zap.String("slug", canonicalSlug), zap.Error(err))
}
}
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
}
app := h.appLinks.BuildUsername(peer.username, params)
params.Set("domain", peer.username)
legacy := schemeURLValues("tg", "resolve", params)
description := peer.about
if description == "" {
description = peer.fallbackDescription(h.appName)
}
data := usernamePageData{
AppName: h.appName,
AppInitial: appInitial(h.appName),
Title: peer.title,
Username: peer.username,
Verified: peer.verified,
Extra: peer.extra(),
Description: description,
CanonicalURL: h.publicUsernameURL(peer.username),
HomeURL: h.publicBaseURL + "/",
AppURL: template.URL(app),
LegacyTgURL: template.URL(legacy),
WebURL: template.URL(publicWebAppURL(h.webBaseURL, legacy)),
ButtonLabel: peer.buttonLabel(),
Initials: peer.initials(),
}
if peer.hasPhoto {
data.PhotoURL = h.publicAvatarURL(peer.username, peer.photo.ID)
}
data.AppURLJS = template.JS(strconv.Quote(app))
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),
LegacyTgURL: template.URL(legacyTgURL(canonicalKind, "set", set.ShortName)),
}
data.AppURLJS = template.JS(strconv.Quote(app))
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 {
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) {
requested = domain.NormalizeUsername(requested)
if u.ID == 0 || !validUsernamePath(requested) {
return publicPeer{}, false, fmt.Errorf("user username lookup returned invalid owner for %q", requested)
}
canonicalUsername := requested
if strings.EqualFold(strings.TrimSpace(u.Username), requested) && validUsernamePath(u.Username) {
canonicalUsername = strings.TrimSpace(u.Username)
}
title := strings.TrimSpace(u.FirstName + " " + u.LastName)
if title == "" {
title = canonicalUsername
}
if err := validatePublicPeerText(title, u.About); err != nil {
return publicPeer{}, false, fmt.Errorf("invalid public user %q: %w", requested, 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{
kind: publicPeerUser,
username: canonicalUsername,
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) {
requested = domain.NormalizeUsername(requested)
if ch.ID == 0 || ch.Deleted || ch.ParticipantsCount < 0 || (!ch.Broadcast && !ch.Megagroup) || !validUsernamePath(requested) {
return publicPeer{}, false, fmt.Errorf("channel username lookup returned invalid owner for %q", requested)
}
canonicalUsername := requested
if strings.EqualFold(strings.TrimSpace(ch.Username), requested) && validUsernamePath(ch.Username) {
canonicalUsername = strings.TrimSpace(ch.Username)
}
if err := validatePublicPeerText(ch.Title, ch.About); err != nil {
return publicPeer{}, false, fmt.Errorf("invalid public channel %q: %w", requested, err)
}
peer := publicPeer{
kind: publicPeerChannel,
username: canonicalUsername,
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))
}
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:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'self'; 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 validStarGiftSlugPath(slug string) bool {
if slug == "" || len(slug) > domain.MaxStarGiftSlugBytes {
return false
}
for _, r := range slug {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '.' || r == '_' || r == '-':
default:
return false
}
}
return true
}
func validUsernamePath(username string) bool {
return domain.ValidCollectibleUsername(domain.NormalizeUsername(username))
}
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 h.appLinks.Build(kind, url.Values{key: []string{value}})
}
func legacyTgURL(kind, key, value string) string {
return schemeURL("tg", 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
AppURL template.URL
LegacyTgURL template.URL
AppURLJS template.JS
}
type usernamePageData struct {
AppName string
AppInitial string
Title string
Username string
Verified bool
Extra string
Description string
CanonicalURL string
HomeURL string
PhotoURL string
Initials string
ButtonLabel string
AppURL template.URL
LegacyTgURL template.URL
WebURL template.URL
AppURLJS template.JS
}
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
}{Username: username, HomeURL: h.publicBaseURL + "/", AppName: h.appName}); err != nil {
h.logger.Error("Render public username not-found page failed", zap.String("username", username), zap.Error(err))
}
}
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="#0e1621">
<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}}">
<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>
:root { color-scheme: dark; font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100svh; color: #f5f8fb; background:
radial-gradient(circle at 50% -20%, rgba(50, 161, 255, .25), transparent 42%), #0e1621; }
.shell { min-height: 100svh; display: grid; grid-template-rows: auto 1fr auto; }
.brand { display: flex; align-items: center; gap: 10px; width: fit-content; margin: 28px auto 0; color: #dceeff;
font-size: 17px; font-weight: 700; letter-spacing: .01em; text-decoration: none; }
.brand-mark { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; color: white;
background: linear-gradient(145deg, #52b8ff, #168de2); box-shadow: 0 8px 24px rgba(31, 151, 232, .3); }
main { display: grid; place-items: center; padding: 32px 18px; }
.card { width: min(100%, 420px); padding: 34px 30px 28px; text-align: center; border: 1px solid rgba(255,255,255,.08);
border-radius: 24px; background: rgba(23, 33, 43, .92); box-shadow: 0 28px 90px rgba(0,0,0,.34); backdrop-filter: blur(18px); }
.avatar { display: grid; place-items: center; width: 112px; height: 112px; margin: 0 auto 22px; overflow: hidden;
border-radius: 50%; background: linear-gradient(145deg, #47b7ff, #167bc1); box-shadow: 0 16px 44px rgba(10, 112, 183, .3); }
.avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
.initials { font-size: 38px; font-weight: 750; letter-spacing: -.04em; color: white; }
h1 { display: flex; align-items: center; justify-content: center; gap: 8px; margin: 0; font-size: clamp(25px, 7vw, 32px);
line-height: 1.18; letter-spacing: -.025em; overflow-wrap: anywhere; }
.verified { display: inline-grid; flex: 0 0 auto; place-items: center; width: 21px; height: 21px; border-radius: 50%;
color: #fff; background: #3aa8f7; font-size: 13px; font-weight: 900; }
.username { margin: 8px 0 0; color: #67bff9; font-size: 16px; overflow-wrap: anywhere; }
.extra { margin: 7px 0 0; color: #91a3b5; font-size: 14px; }
.description { margin: 22px auto 0; color: #c5d0da; font-size: 15px; line-height: 1.55; white-space: pre-line;
overflow-wrap: anywhere; }
.actions { display: grid; gap: 11px; margin-top: 28px; }
.button { display: inline-flex; align-items: center; justify-content: center; min-height: 48px; padding: 0 20px; border-radius: 13px;
font-size: 15px; font-weight: 720; text-decoration: none; transition: transform .16s ease, background .16s ease; }
.button:hover { transform: translateY(-1px); }
.primary { color: #fff; background: linear-gradient(135deg, #31a9f5, #168de2); box-shadow: 0 10px 28px rgba(22,141,226,.25); }
.secondary { color: #a9dafa; background: rgba(72, 164, 226, .12); border: 1px solid rgba(89, 180, 241, .15); }
.legacy { margin: 18px 0 0; color: #718395; font-size: 12px; }
.legacy a { color: #83bddd; text-decoration: none; }
footer { padding: 0 18px 26px; color: #657789; font-size: 12px; text-align: center; }
@media (max-width: 480px) {
.brand { margin-top: 20px; }
main { padding: 24px 14px; align-items: start; }
.card { padding: 28px 22px 24px; border-radius: 20px; }
.avatar { width: 96px; height: 96px; }
}
@media (prefers-reduced-motion: reduce) { .button { transition: none; } }
</style>
</head>
<body>
<div class="shell">
<a class="brand" href="{{.HomeURL}}" aria-label="{{.AppName}} home"><span class="brand-mark">{{.AppInitial}}</span><span>{{.AppName}}</span></a>
<main>
<article class="card">
<div class="avatar">{{if .PhotoURL}}<img src="{{.PhotoURL}}" alt="{{.Title}} profile photo" width="112" height="112">{{else}}<span class="initials" aria-hidden="true">{{.Initials}}</span>{{end}}</div>
<h1><span>{{.Title}}</span>{{if .Verified}}<span class="verified" title="Verified" aria-label="Verified">✓</span>{{end}}</h1>
<p class="username">@{{.Username}}</p>
{{if .Extra}}<p class="extra">{{.Extra}}</p>{{end}}
<p class="description">{{.Description}}</p>
<div class="actions">
<a class="button primary" href="{{.AppURL}}">{{.ButtonLabel}}</a>
<a class="button secondary" href="{{.WebURL}}">Open in Web</a>
</div>
<p class="legacy">Old test clients only: <a href="{{.LegacyTgURL}}">open with tg://</a></p>
</article>
</main>
<footer>If you have {{.AppName}}, this page can open the chat directly.</footer>
</div>
<script>window.setTimeout(function () { window.location.href = {{.AppURLJS}}; }, 250);</script>
</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">
<meta name="robots" content="noindex,nofollow"><title>Username not found - {{.AppName}}</title>
<style>:root{color-scheme:dark;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}body{margin:0;min-height:100svh;display:grid;place-items:center;padding:24px;background:#0e1621;color:#f5f8fb}.card{width:min(100%,420px);padding:34px 28px;border:1px solid rgba(255,255,255,.08);border-radius:22px;background:#17212b;text-align:center}h1{margin:0 0 12px;font-size:26px}p{margin:0;color:#9fb0bf;line-height:1.55;overflow-wrap:anywhere}a{display:inline-block;margin-top:24px;color:#67bff9;text-decoration:none}</style>
</head><body><main class="card"><h1>Username not found</h1><p>{{if .Username}}@{{.Username}} is not an active public {{.AppName}} username.{{else}}This is not a valid public {{.AppName}} username.{{end}}</p><a href="{{.HomeURL}}">Back to {{.AppName}}</a></main></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">
<title>{{.Title}} - {{.AppName}}</title>
<link rel="canonical" href="{{.CanonicalURL}}">
<meta property="og:title" content="{{.Title}}">
<meta property="og:description" content="{{.Description}}">
<meta property="og:url" content="{{.CanonicalURL}}">
<meta name="robots" content="noindex">
<style>
:root { color-scheme: light dark; font-family: Arial, Helvetica, sans-serif; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f4f7fb; color: #15202b; }
main { width: min(92vw, 460px); padding: 32px; border: 1px solid #d9e1ea; border-radius: 8px; background: #fff; box-shadow: 0 16px 48px rgba(21, 32, 43, .08); }
h1 { margin: 0 0 10px; font-size: 28px; line-height: 1.18; font-weight: 700; }
p { margin: 0 0 18px; line-height: 1.5; color: #4a5b6b; }
.meta { font-size: 14px; color: #6b7b8b; }
a.button { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 18px; border-radius: 6px; background: #1677c8; color: #fff; text-decoration: none; font-weight: 700; }
a.raw { color: #1677c8; overflow-wrap: anywhere; }
@media (prefers-color-scheme: dark) {
body { background: #111820; color: #e9eef5; }
main { background: #18222d; border-color: #293849; box-shadow: none; }
p, .meta { color: #aebdca; }
a.button { background: #45a3ff; color: #06131f; }
a.raw { color: #74baff; }
}
</style>
</head>
<body>
<main>
<p class="meta">{{.KindLabel}}</p>
<h1>{{.Title}}</h1>
<p class="meta">{{.Subtitle}}</p>
<p><a class="button" href="{{.AppURL}}">Open in {{.AppName}}</a></p>
<p>{{.Description}}</p>
<p class="meta">Old test clients only: <a class="raw" href="{{.LegacyTgURL}}">open with tg://</a></p>
<p class="meta"><a class="raw" href="{{.CanonicalURL}}">{{.CanonicalURL}}</a></p>
</main>
<script>
window.setTimeout(function () {
window.location.href = {{.AppURLJS}};
}, 250);
</script>
</body>
</html>
`))