diff --git a/og-service/Makefile b/og-service/Makefile new file mode 100644 index 0000000..096beb6 --- /dev/null +++ b/og-service/Makefile @@ -0,0 +1,18 @@ +APP_NAME=portfoliojournal-og +BUILD_DIR=bin +PORT?=8090 + +.PHONY: build run clean install-service + +build: + @mkdir -p $(BUILD_DIR) + GO111MODULE=on go build -o $(BUILD_DIR)/$(APP_NAME) . + +run: + PORT=$(PORT) GO111MODULE=on go run . + +clean: + @rm -rf $(BUILD_DIR) + +install-service: build + @echo "Install the binary and OpenRC service using the instructions in README.md" diff --git a/og-service/README.md b/og-service/README.md new file mode 100644 index 0000000..2443e74 --- /dev/null +++ b/og-service/README.md @@ -0,0 +1,81 @@ +# OG Share Service + +This service powers dynamic Open Graph share pages and images for Portfolio Journal. + +## Endpoints + +- `/share?data=BASE64URL` renders the HTML page with OG tags. +- `/og?data=BASE64URL` renders the 1200x630 PNG image. + +Payload (JSON, base64url-encoded): + +```json +{ + "n": "Goal name", + "r": 12500.5, + "g": 20000, + "c": "USD", + "td": "2026-12-31", + "ec": "2026-10-15", + "p": false +} +``` + +Fields: +- `n` goal name +- `r` current amount +- `g` goal amount +- `c` currency code (ISO 4217) +- `td` target date (YYYY-MM-DD, optional) +- `ec` estimated completion date (YYYY-MM-DD, optional) +- `p` privacy mode; when true, hides the current amount + +## Run locally + +```bash +cd og-service +GO111MODULE=on go run . +``` + +## OpenRC (Alpine) service + +Example steps (requires root): + +```bash +# Build and install the binary +make build +cp og-service/bin/portfoliojournal-og /usr/local/bin/portfoliojournal-og +chmod +x /usr/local/bin/portfoliojournal-og + +# Install the OpenRC script +cp og-service/portfoliojournal-og.openrc /etc/init.d/portfoliojournal-og +chmod +x /etc/init.d/portfoliojournal-og + +# Enable and start +rc-update add portfoliojournal-og default +rc-service portfoliojournal-og start +``` + +Or run the helper script (requires root): + +```bash +sudo sh og-service/install-openrc.sh +``` + +## NGINX example + +```nginx +location /share { + proxy_pass http://127.0.0.1:8090; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; +} + +location /og { + proxy_pass http://127.0.0.1:8090; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; +} +``` diff --git a/og-service/go.mod b/og-service/go.mod new file mode 100644 index 0000000..7888daf --- /dev/null +++ b/og-service/go.mod @@ -0,0 +1,9 @@ +module portfoliojournal.app/ogservice + +go 1.22 + +require ( + github.com/fogleman/gg v1.3.0 + golang.org/x/image v0.21.0 + golang.org/x/text v0.17.0 +) diff --git a/og-service/install-openrc.sh b/og-service/install-openrc.sh new file mode 100644 index 0000000..5cf1564 --- /dev/null +++ b/og-service/install-openrc.sh @@ -0,0 +1,25 @@ +#!/bin/sh +set -e + +APP_NAME="portfoliojournal-og" +SERVICE_FILE="/etc/init.d/${APP_NAME}" +BIN_TARGET="/usr/local/bin/${APP_NAME}" + +echo "Building ${APP_NAME}..." +make -C "$(dirname "$0")" build + +echo "Installing binary to ${BIN_TARGET}..." +cp "$(dirname "$0")/bin/${APP_NAME}" "${BIN_TARGET}" +chmod +x "${BIN_TARGET}" + +echo "Installing OpenRC service to ${SERVICE_FILE}..." +cp "$(dirname "$0")/${APP_NAME}.openrc" "${SERVICE_FILE}" +chmod +x "${SERVICE_FILE}" + +echo "Enabling service..." +rc-update add "${APP_NAME}" default + +echo "Starting service..." +rc-service "${APP_NAME}" start + +echo "Done." diff --git a/og-service/main.go b/og-service/main.go new file mode 100644 index 0000000..91b8af3 --- /dev/null +++ b/og-service/main.go @@ -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 := ` + + + + + ` + escapeHTML(view.Title) + ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
Portfolio Journal
+

` + escapeHTML(view.GoalName) + `

+
+
+ Current amount + ` + escapeHTML(view.CurrentAmount) + ` +
+
+ Goal amount + ` + escapeHTML(view.TargetAmount) + ` +
+
+
` + escapeHTML(view.PercentText) + `
+
+
` + escapeHTML(joinLabels(view.TargetDateLabel, view.EstimateDateLabel)) + `
+ Download on the App Store +
+ +` + + 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 +} diff --git a/og-service/portfoliojournal-og.openrc b/og-service/portfoliojournal-og.openrc new file mode 100644 index 0000000..1f65d3f --- /dev/null +++ b/og-service/portfoliojournal-og.openrc @@ -0,0 +1,21 @@ +#!/sbin/openrc-run + +name="portfoliojournal-og" +description="Portfolio Journal Open Graph share service" + +command="/usr/local/bin/portfoliojournal-og" +command_args="" +command_background="yes" +output_log="/var/log/portfoliojournal-og.log" +error_log="/var/log/portfoliojournal-og.err" +pidfile="/run/portfoliojournal-og.pid" + +start_pre() { + checkpath --file --mode 0644 --owner root:root "$output_log" + checkpath --file --mode 0644 --owner root:root "$error_log" +} + +depend() { + need net + after firewall +} diff --git a/website-og-image-spec.md b/website-og-image-spec.md new file mode 100644 index 0000000..abd73f3 --- /dev/null +++ b/website-og-image-spec.md @@ -0,0 +1,74 @@ +# Open Graph Image Specification + +## Required Image: `og-share-card.png` + +Create this image and place it at: `https://portfoliojournal.app/images/og-share-card.png` + +### Dimensions +- **Size:** 1200 x 630 pixels (Facebook/LinkedIn optimal) +- **Format:** PNG or JPG +- **File size:** Under 1MB recommended + +### Design Recommendations + +``` ++----------------------------------------------------------+ +| | +| [App Icon] | +| | +| Portfolio Journal | +| ───────────────── | +| | +| Track your investment goals | +| and celebrate your wins. | +| | +| [Progress bar illustration] | +| | +| ★ Goal Tracking ★ Smart Predictions | +| ★ Portfolio Analytics ★ Privacy Focused | +| | +| [App Store Badge] | +| | ++----------------------------------------------------------+ +``` + +### Color Scheme +- **Background:** Gradient from #667eea to #764ba2 (match your app branding) +- **Text:** White +- **Accent:** Your app's accent color + +### Elements to Include +1. App icon (prominent, top-left or center) +2. App name "Portfolio Journal" +3. Tagline or value proposition +4. Visual element (progress bar, chart icon, or screenshot) +5. Feature highlights +6. App Store badge (optional) + +### Tools to Create +- Figma (free template available) +- Canva +- Adobe Illustrator/Photoshop + +## Testing Your OG Tags + +After deploying, test with these tools: +- **Facebook:** https://developers.facebook.com/tools/debug/ +- **Twitter:** https://cards-dev.twitter.com/validator +- **LinkedIn:** https://www.linkedin.com/post-inspector/ +- **General:** https://www.opengraph.xyz/ + +## Files to Deploy + +1. `/share/index.html` (or configure your server to serve `share.html` at `/share`) +2. `/images/og-share-card.png` +3. `/images/app-icon.png` (your app icon, 120x120 or larger) + +## Apple Smart App Banner + +The HTML includes this meta tag: +```html + +``` + +This shows a smart banner on iOS Safari prompting users to download your app. diff --git a/website-share-page.html b/website-share-page.html new file mode 100644 index 0000000..989b77b --- /dev/null +++ b/website-share-page.html @@ -0,0 +1,203 @@ + + + + + + Portfolio Journal - Track Your Investment Goals + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Portfolio Journal + +

Portfolio Journal

+

Track your investment goals and celebrate your financial milestones.

+ +
+ Goal Tracking + Portfolio Analytics + Smart Predictions + Privacy Focused +
+ + + + + + Download on App Store + + + +
+ + + + +