This commit is contained in:
alexandrev-tibco
2026-02-01 22:47:26 +01:00
parent 3692e46cf9
commit 6b405419cf
8 changed files with 1057 additions and 0 deletions
+626
View File
@@ -0,0 +1,626 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"image/color"
"log"
"math"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/fogleman/gg"
"golang.org/x/image/font"
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/gofont/goregular"
"golang.org/x/image/font/opentype"
"golang.org/x/text/currency"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
const (
imageWidth = 1200
imageHeight = 630
)
type shareData struct {
Name string `json:"n"`
RealAmount json.Number `json:"r"`
GoalAmount json.Number `json:"g"`
Currency string `json:"c"`
TargetDate string `json:"td"`
EstimateCompletion string `json:"ec"`
PrivateMode bool `json:"p"`
}
type shareView struct {
Title string
Description string
OGImageURL string
ShareURL string
GoalName string
CurrentAmount string
TargetAmount string
PercentText string
ProgressPercent float64
TargetDateLabel string
EstimateDateLabel string
PrivateMode bool
FormattedGoalAmount string
}
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8090"
}
http.HandleFunc("/share", shareHandler)
http.HandleFunc("/og", ogHandler)
log.Printf("og-service listening on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
func shareHandler(w http.ResponseWriter, r *http.Request) {
dataParam := r.URL.Query().Get("data")
if dataParam == "" {
http.Error(w, "missing data parameter", http.StatusBadRequest)
return
}
payload, err := decodeData(dataParam)
if err != nil {
http.Error(w, "invalid data parameter", http.StatusBadRequest)
return
}
view := buildShareView(payload, r, dataParam)
html := renderShareHTML(view)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(html))
}
func ogHandler(w http.ResponseWriter, r *http.Request) {
dataParam := r.URL.Query().Get("data")
if dataParam == "" {
http.Error(w, "missing data parameter", http.StatusBadRequest)
return
}
payload, err := decodeData(dataParam)
if err != nil {
http.Error(w, "invalid data parameter", http.StatusBadRequest)
return
}
pngBytes, err := renderOGImage(payload)
if err != nil {
http.Error(w, "failed to render image", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "public, max-age=3600")
_, _ = w.Write(pngBytes)
}
func decodeData(param string) (shareData, error) {
var data shareData
decoded, err := base64.RawURLEncoding.DecodeString(param)
if err != nil {
decoded, err = base64.URLEncoding.DecodeString(param)
if err != nil {
return data, err
}
}
dec := json.NewDecoder(bytes.NewReader(decoded))
dec.UseNumber()
if err := dec.Decode(&data); err != nil {
return data, err
}
data.Name = strings.TrimSpace(data.Name)
data.Currency = strings.ToUpper(strings.TrimSpace(data.Currency))
if data.Currency == "" {
data.Currency = "USD"
}
return data, nil
}
func buildShareView(data shareData, r *http.Request, rawParam string) shareView {
goalAmount, _ := numberToFloat(data.GoalAmount)
realAmount, _ := numberToFloat(data.RealAmount)
percent := 0.0
if goalAmount > 0 {
percent = (realAmount / goalAmount) * 100
}
percentText := "Progress unavailable"
if goalAmount > 0 {
percentText = fmt.Sprintf("%.0f%% complete", percent)
}
currencyTag := localeForCurrency(data.Currency)
formattedGoal := formatCurrency(goalAmount, data.Currency, currencyTag)
formattedReal := formatCurrency(realAmount, data.Currency, currencyTag)
currentAmount := formattedReal
if data.PrivateMode {
currentAmount = "Hidden"
}
goalName := data.Name
if goalName == "" {
goalName = "Investment Goal"
}
description := fmt.Sprintf("%s toward %s", percentText, formattedGoal)
if data.PrivateMode {
description = fmt.Sprintf("Goal progress: %s", percentText)
}
baseURL := buildBaseURL(r)
shareURL := fmt.Sprintf("%s/share?data=%s", baseURL, url.QueryEscape(rawParam))
ogImageURL := fmt.Sprintf("%s/og?data=%s", baseURL, url.QueryEscape(rawParam))
return shareView{
Title: fmt.Sprintf("Portfolio Journal — %s", goalName),
Description: description,
OGImageURL: ogImageURL,
ShareURL: shareURL,
GoalName: goalName,
CurrentAmount: currentAmount,
TargetAmount: formattedGoal,
PercentText: percentText,
ProgressPercent: percent,
TargetDateLabel: formatDateLabel("Target", data.TargetDate),
EstimateDateLabel: formatDateLabel("Est. completion", data.EstimateCompletion),
PrivateMode: data.PrivateMode,
FormattedGoalAmount: formattedGoal,
}
}
func renderShareHTML(view shareView) string {
htmlTemplate := `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>` + escapeHTML(view.Title) + `</title>
<meta name="title" content="` + escapeHTML(view.Title) + `">
<meta name="description" content="` + escapeHTML(view.Description) + `">
<meta name="author" content="Portfolio Journal">
<meta name="robots" content="noindex, nofollow">
<meta property="og:type" content="website">
<meta property="og:url" content="` + escapeHTML(view.ShareURL) + `">
<meta property="og:title" content="` + escapeHTML(view.Title) + `">
<meta property="og:description" content="` + escapeHTML(view.Description) + `">
<meta property="og:image" content="` + escapeHTML(view.OGImageURL) + `">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:site_name" content="Portfolio Journal">
<meta property="og:locale" content="en_US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="` + escapeHTML(view.ShareURL) + `">
<meta name="twitter:title" content="` + escapeHTML(view.Title) + `">
<meta name="twitter:description" content="` + escapeHTML(view.Description) + `">
<meta name="twitter:image" content="` + escapeHTML(view.OGImageURL) + `">
<meta name="apple-itunes-app" content="app-id=6744983373">
<link rel="canonical" href="` + escapeHTML(view.ShareURL) + `">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
color: #fff;
}
.card {
max-width: 620px;
width: 100%;
background: rgba(255,255,255,0.12);
border-radius: 24px;
padding: 40px;
backdrop-filter: blur(18px);
box-shadow: 0 24px 60px rgba(0,0,0,0.25);
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.75rem;
opacity: 0.8;
margin-bottom: 12px;
}
h1 {
font-size: 2rem;
margin-bottom: 16px;
}
.amounts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.amounts span {
display: block;
font-size: 0.9rem;
opacity: 0.8;
}
.amounts strong {
display: block;
font-size: 1.5rem;
margin-top: 6px;
}
.progress {
height: 14px;
border-radius: 999px;
background: rgba(255,255,255,0.25);
overflow: hidden;
margin-top: 16px;
}
.progress .bar {
height: 100%;
background: #fff;
width: ` + fmt.Sprintf("%.2f", math.Max(0, math.Min(100, view.ProgressPercent))) + `%;
}
.meta {
margin-top: 18px;
font-size: 0.9rem;
opacity: 0.85;
}
.cta {
margin-top: 28px;
display: inline-flex;
align-items: center;
gap: 10px;
background: #fff;
color: #5c5bd4;
padding: 12px 20px;
border-radius: 14px;
font-weight: 600;
text-decoration: none;
}
</style>
</head>
<body>
<div class="card">
<div class="eyebrow">Portfolio Journal</div>
<h1>` + escapeHTML(view.GoalName) + `</h1>
<div class="amounts">
<div>
<span>Current amount</span>
<strong>` + escapeHTML(view.CurrentAmount) + `</strong>
</div>
<div>
<span>Goal amount</span>
<strong>` + escapeHTML(view.TargetAmount) + `</strong>
</div>
</div>
<div>` + escapeHTML(view.PercentText) + `</div>
<div class="progress"><div class="bar"></div></div>
<div class="meta">` + escapeHTML(joinLabels(view.TargetDateLabel, view.EstimateDateLabel)) + `</div>
<a class="cta" href="https://apps.apple.com/app/portfolio-journal/id6744983373">Download on the App Store</a>
</div>
</body>
</html>`
return htmlTemplate
}
func renderOGImage(data shareData) ([]byte, error) {
goalAmount, _ := numberToFloat(data.GoalAmount)
realAmount, _ := numberToFloat(data.RealAmount)
percent := 0.0
if goalAmount > 0 {
percent = (realAmount / goalAmount) * 100
}
currencyTag := localeForCurrency(data.Currency)
formattedGoal := formatCurrency(goalAmount, data.Currency, currencyTag)
formattedReal := formatCurrency(realAmount, data.Currency, currencyTag)
if data.PrivateMode {
formattedReal = "Hidden"
}
goalName := data.Name
if goalName == "" {
goalName = "Investment Goal"
}
dc := gg.NewContext(imageWidth, imageHeight)
grad := gg.NewLinearGradient(0, 0, imageWidth, imageHeight)
grad.AddColorStop(0, color.RGBA{0x66, 0x7e, 0xea, 0xff})
grad.AddColorStop(1, color.RGBA{0x76, 0x4b, 0xa2, 0xff})
dc.SetFillStyle(grad)
dc.DrawRectangle(0, 0, imageWidth, imageHeight)
dc.Fill()
marginX := 80.0
marginY := 80.0
// App icon placeholder
dc.SetRGBA(1, 1, 1, 0.2)
dc.DrawRoundedRectangle(marginX, marginY, 96, 96, 22)
dc.Fill()
iconFace, err := loadFontFace(38, true)
if err != nil {
return nil, err
}
dc.SetFontFace(iconFace)
dc.SetRGBA(1, 1, 1, 0.9)
dc.DrawStringAnchored("PJ", marginX+48, marginY+52, 0.5, 0.5)
labelFace, err := loadFontFace(20, false)
if err != nil {
return nil, err
}
dc.SetFontFace(labelFace)
dc.SetRGBA(1, 1, 1, 0.8)
dc.DrawString("Portfolio Journal", marginX+120, marginY+34)
titleFace, err := loadFontFace(54, true)
if err != nil {
return nil, err
}
dc.SetFontFace(titleFace)
dc.SetRGBA(1, 1, 1, 1)
goalName = fitText(dc, goalName, imageWidth-160)
dc.DrawStringWrapped(goalName, marginX, marginY+130, 0, 0, imageWidth-160, 1.3, gg.AlignLeft)
valueFace, err := loadFontFace(36, true)
if err != nil {
return nil, err
}
labelSmallFace, err := loadFontFace(18, false)
if err != nil {
return nil, err
}
dc.SetFontFace(labelSmallFace)
dc.SetRGBA(1, 1, 1, 0.75)
dc.DrawString("Current", marginX, marginY+260)
dc.DrawString("Goal", marginX+420, marginY+260)
dc.SetFontFace(valueFace)
dc.SetRGBA(1, 1, 1, 1)
dc.DrawString(formattedReal, marginX, marginY+305)
dc.DrawString(formattedGoal, marginX+420, marginY+305)
percentText := "Progress unavailable"
if goalAmount > 0 {
percentText = fmt.Sprintf("%.0f%% complete", percent)
}
if data.PrivateMode {
percentText = strings.ReplaceAll(percentText, "complete", "of goal")
}
dc.SetFontFace(labelSmallFace)
dc.SetRGBA(1, 1, 1, 0.85)
dc.DrawString(percentText, marginX, marginY+360)
barWidth := 720.0
barHeight := 18.0
barX := marginX
barY := marginY + 380
progress := math.Max(0, math.Min(100, percent)) / 100
dc.SetRGBA(1, 1, 1, 0.25)
dc.DrawRoundedRectangle(barX, barY, barWidth, barHeight, 9)
dc.Fill()
dc.SetRGBA(1, 1, 1, 0.9)
dc.DrawRoundedRectangle(barX, barY, barWidth*progress, barHeight, 9)
dc.Fill()
meta := joinLabels(formatDateLabel("Target", data.TargetDate), formatDateLabel("Est. completion", data.EstimateCompletion))
if meta != "" {
dc.SetFontFace(labelSmallFace)
dc.SetRGBA(1, 1, 1, 0.7)
dc.DrawString(meta, marginX, marginY+430)
}
ctaFace, err := loadFontFace(20, true)
if err != nil {
return nil, err
}
dc.SetFontFace(ctaFace)
dc.SetRGBA(1, 1, 1, 0.9)
dc.DrawString("Available on the App Store", marginX, imageHeight-70)
buf := bytes.NewBuffer(nil)
if err := dc.EncodePNG(buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func numberToFloat(n json.Number) (float64, bool) {
if n.String() == "" {
return 0, false
}
value, err := n.Float64()
if err != nil {
return 0, false
}
return value, true
}
func formatCurrency(amount float64, code string, tag language.Tag) string {
unit, err := currency.ParseISO(code)
if err != nil {
unit = currency.USD
}
printer := message.NewPrinter(tag)
return printer.Sprintf("%v", currency.Amount(amount, unit))
}
func localeForCurrency(code string) language.Tag {
switch code {
case "EUR":
return language.MustParse("es-ES")
case "GBP":
return language.MustParse("en-GB")
case "JPY":
return language.MustParse("ja-JP")
case "CNY":
return language.MustParse("zh-CN")
case "HKD":
return language.MustParse("zh-HK")
case "KRW":
return language.MustParse("ko-KR")
case "INR":
return language.MustParse("en-IN")
case "BRL":
return language.MustParse("pt-BR")
case "MXN":
return language.MustParse("es-MX")
case "ARS":
return language.MustParse("es-AR")
case "CLP":
return language.MustParse("es-CL")
case "COP":
return language.MustParse("es-CO")
case "CAD":
return language.MustParse("en-CA")
case "AUD":
return language.MustParse("en-AU")
case "NZD":
return language.MustParse("en-NZ")
case "CHF":
return language.MustParse("de-CH")
case "SEK":
return language.MustParse("sv-SE")
case "NOK":
return language.MustParse("nb-NO")
case "DKK":
return language.MustParse("da-DK")
case "PLN":
return language.MustParse("pl-PL")
case "CZK":
return language.MustParse("cs-CZ")
case "HUF":
return language.MustParse("hu-HU")
case "TRY":
return language.MustParse("tr-TR")
case "ZAR":
return language.MustParse("en-ZA")
case "AED":
return language.MustParse("ar-AE")
default:
return language.MustParse("en-US")
}
}
func formatDateLabel(prefix, dateString string) string {
if dateString == "" {
return ""
}
parsed, err := time.Parse("2006-01-02", dateString)
if err != nil {
return ""
}
return fmt.Sprintf("%s: %s", prefix, parsed.Format("Jan 2, 2006"))
}
func joinLabels(primary, secondary string) string {
if primary == "" && secondary == "" {
return ""
}
if primary == "" {
return secondary
}
if secondary == "" {
return primary
}
return primary + " • " + secondary
}
func buildBaseURL(r *http.Request) string {
proto := r.Header.Get("X-Forwarded-Proto")
if proto == "" {
proto = "https"
}
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
return fmt.Sprintf("%s://%s", proto, host)
}
func escapeHTML(input string) string {
replacer := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
"\"", "&quot;",
"'", "&#39;",
)
return replacer.Replace(input)
}
func loadFontFace(size float64, bold bool) (font.Face, error) {
var fontBytes []byte
if bold {
fontBytes = gobold.TTF
} else {
fontBytes = goregular.TTF
}
parsed, err := opentype.Parse(fontBytes)
if err != nil {
return nil, err
}
face, err := opentype.NewFace(parsed, &opentype.FaceOptions{
Size: size,
DPI: 72,
Hinting: font.HintingFull,
})
if err != nil {
return nil, err
}
return face, nil
}
func fitText(dc *gg.Context, text string, maxWidth float64) string {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return ""
}
if width, _ := dc.MeasureString(trimmed); width <= maxWidth {
return trimmed
}
runes := []rune(trimmed)
for len(runes) > 0 {
candidate := string(runes) + "…"
if width, _ := dc.MeasureString(candidate); width <= maxWidth {
return candidate
}
runes = runes[:len(runes)-1]
}
return trimmed
}