Merge remote-tracking branch 'upstream/main' into merge-gramsrv-2965f5d

This commit is contained in:
onysd 2026-07-20 23:43:51 +03:00
commit ebb0be38d9
355 changed files with 44640 additions and 2320 deletions

View file

@ -21,17 +21,19 @@ import (
)
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
Addr string
PublicBaseURL string
AppScheme string
WebBaseURL string
AppName string
DownloadURL string
StickerSets StickerSetResolver
Users UsernameResolver
Channels PublicChannelResolver
Privacy AnonymousPrivacyResolver
Photos ProfilePhotoResolver
UniqueGifts UniqueStarGiftResolver
GiftWithdrawals StarGiftWithdrawalResolver
}
type StickerSetResolver interface {
@ -61,6 +63,15 @@ type ProfilePhotoResolver interface {
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)
}
func Start(ctx context.Context, cfg Config, logger *zap.Logger) (*http.Server, error) {
addr := strings.TrimSpace(cfg.Addr)
if addr == "" {
@ -147,17 +158,19 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) {
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,
stickerSets: cfg.StickerSets,
users: cfg.Users,
channels: cfg.Channels,
privacy: cfg.Privacy,
photos: cfg.Photos,
uniqueGifts: cfg.UniqueGifts,
giftWithdrawals: cfg.GiftWithdrawals,
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)
@ -168,23 +181,88 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) {
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)
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
stickerSets StickerSetResolver
users UsernameResolver
channels PublicChannelResolver
privacy AnonymousPrivacyResolver
photos ProfilePhotoResolver
uniqueGifts UniqueStarGiftResolver
giftWithdrawals StarGiftWithdrawalResolver
publicBaseURL string
appScheme string
webBaseURL string
appName string
downloadURL string
logger *zap.Logger
}
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) {
@ -224,6 +302,63 @@ func (h *handler) addList(w http.ResponseWriter, r *http.Request) {
}
}
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) {
raw := strings.TrimSpace(r.PathValue("username"))
if strings.HasPrefix(raw, "+") {
@ -910,7 +1045,7 @@ func publicWebAppURL(webBaseURL, legacyURL string) string {
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("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 '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")
@ -940,6 +1075,23 @@ 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 {
if username == "" || len(username) < 5 || len(username) > 32 {
return false