627 lines
16 KiB
Go
627 lines
16 KiB
Go
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/us/app/portfolio-journal/id6757678318?pt=123456&ct=homepage_other&mt=8">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(
|
|
"&", "&",
|
|
"<", "<",
|
|
">", ">",
|
|
"\"", """,
|
|
"'", "'",
|
|
)
|
|
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
|
|
}
|