| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "embed" |
| 5 | "encoding/json" |
| 6 | "html/template" |
| 7 | "io/fs" |
| 8 | "net/http" |
| 9 | "strings" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/gosuda/portal-tunnel/v2/portal/x402" |
| 13 | "github.com/gosuda/portal-tunnel/v2/types" |
| 14 | "github.com/gosuda/portal-tunnel/v2/utils" |
| 15 | ) |
| 16 | |
| 17 | //go:embed static/index.html static/photo.html static/style.css |
| 18 | var staticFiles embed.FS |
| 19 | |
| 20 | const paidPhotoPath = "/paid/photo" |
| 21 | |
| 22 | var ( |
| 23 | indexPage = template.Must(template.ParseFS(staticFiles, "static/index.html")) |
| 24 | photoPage = template.Must(template.ParseFS(staticFiles, "static/photo.html")) |
| 25 | ) |
| 26 | |
| 27 | type paymentHandlerConfig struct { |
| 28 | Metadata types.LeaseMetadata |
| 29 | Testnet bool |
| 30 | PayTo string |
| 31 | Amount string |
| 32 | MaxTimeoutSeconds int |
| 33 | RequestTimeout time.Duration |
| 34 | Endpoints []string |
| 35 | PhotoURL string |
| 36 | } |
| 37 | |
| 38 | type paymentHandler struct { |
| 39 | metadata types.LeaseMetadata |
| 40 | network string |
| 41 | networkName string |
| 42 | asset string |
| 43 | amount string |
| 44 | payTo string |
| 45 | photoURL string |
| 46 | } |
| 47 | |
| 48 | type paymentPageData struct { |
| 49 | PageTitle string |
| 50 | PageDescription string |
| 51 | URL string |
| 52 | OGImage string |
| 53 | ProtectedPath string |
| 54 | Network string |
| 55 | NetworkName string |
| 56 | Asset string |
| 57 | Amount string |
| 58 | PhotoURL string |
| 59 | RecipientAddress string |
| 60 | TransactionID string |
| 61 | ConfigJSON template.JS |
| 62 | } |
| 63 | |
| 64 | func newHandler(cfg paymentHandlerConfig) (http.Handler, error) { |
| 65 | handler := &paymentHandler{ |
| 66 | metadata: cfg.Metadata.Copy(), |
| 67 | photoURL: strings.TrimSpace(cfg.PhotoURL), |
| 68 | } |
| 69 | paidPhotoHandler, err := x402.NewUSDCPaymentHandler(types.X402Payment{ |
| 70 | Testnet: cfg.Testnet, |
| 71 | PayTo: cfg.PayTo, |
| 72 | Amount: cfg.Amount, |
| 73 | MaxTimeoutSeconds: cfg.MaxTimeoutSeconds, |
| 74 | RequestTimeout: cfg.RequestTimeout, |
| 75 | Endpoints: cfg.Endpoints, |
| 76 | ResourcePath: paidPhotoPath, |
| 77 | ResourceDescription: cfg.Metadata.Description, |
| 78 | ResourceMimeType: "text/html", |
| 79 | }, paidPhotoPath, http.MethodGet, handler.renderPaidPhoto) |
| 80 | if err != nil { |
| 81 | return nil, err |
| 82 | } |
| 83 | payment := paidPhotoHandler.Payment() |
| 84 | handler.network = payment.Network |
| 85 | handler.networkName = payment.NetworkName |
| 86 | handler.asset = payment.Asset |
| 87 | handler.amount = payment.Amount |
| 88 | handler.payTo = payment.PayTo |
| 89 | |
| 90 | staticFS, err := fs.Sub(staticFiles, "static") |
| 91 | if err != nil { |
| 92 | return nil, err |
| 93 | } |
| 94 | mux := http.NewServeMux() |
| 95 | mux.Handle("/static/style.css", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) |
| 96 | mux.HandleFunc(types.X402ClientPath, x402.ServeClientJS) |
| 97 | mux.Handle(types.X402PreparePath, paidPhotoHandler) |
| 98 | mux.HandleFunc("/", handler.handleIndex) |
| 99 | mux.Handle(paidPhotoPath, paidPhotoHandler) |
| 100 | return mux, nil |
| 101 | } |
| 102 | |
| 103 | func (h *paymentHandler) handleIndex(w http.ResponseWriter, r *http.Request) { |
| 104 | if r.URL.Path != "/" { |
| 105 | http.NotFound(w, r) |
| 106 | return |
| 107 | } |
| 108 | if !utils.RequireMethod(w, r, http.MethodGet) { |
| 109 | return |
| 110 | } |
| 111 | data := h.newPaymentPageData(r) |
| 112 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 113 | w.Header().Set("Cache-Control", "no-store") |
| 114 | _ = indexPage.Execute(w, data) |
| 115 | } |
| 116 | |
| 117 | func (h *paymentHandler) renderPaidPhoto(w http.ResponseWriter, r *http.Request, result types.X402PaymentResult) { |
| 118 | data := h.newPaymentPageData(r) |
| 119 | data.URL = utils.PublicURLForPath(r, paidPhotoPath) |
| 120 | data.TransactionID = result.TransactionID |
| 121 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 122 | w.Header().Set("Cache-Control", "no-store") |
| 123 | _ = photoPage.Execute(w, data) |
| 124 | } |
| 125 | |
| 126 | func (h *paymentHandler) newPaymentPageData(r *http.Request) paymentPageData { |
| 127 | description := strings.TrimSpace(h.metadata.Description) |
| 128 | if description == "" { |
| 129 | description = "Connect a Sui wallet, settle USDC with x402, and reveal the protected image." |
| 130 | } |
| 131 | config := map[string]any{ |
| 132 | "network": h.network, |
| 133 | "networkName": h.networkName, |
| 134 | "asset": h.asset, |
| 135 | "amount": h.amount, |
| 136 | "payTo": h.payTo, |
| 137 | "preparePath": types.X402PreparePath, |
| 138 | "protectedPath": paidPhotoPath, |
| 139 | } |
| 140 | configJSON, err := json.Marshal(config) |
| 141 | if err != nil { |
| 142 | configJSON = []byte("{}") |
| 143 | } |
| 144 | return paymentPageData{ |
| 145 | PageTitle: "Portal Sui Wallet Payment", |
| 146 | PageDescription: description, |
| 147 | URL: utils.PublicURLForPath(r, "/"), |
| 148 | OGImage: h.metadata.Thumbnail, |
| 149 | ProtectedPath: paidPhotoPath, |
| 150 | Network: h.network, |
| 151 | NetworkName: h.networkName, |
| 152 | Asset: h.asset, |
| 153 | Amount: x402.FormatUSDCAtomicAmount(h.amount), |
| 154 | PhotoURL: h.photoURL, |
| 155 | RecipientAddress: h.payTo, |
| 156 | ConfigJSON: template.JS(string(configJSON)), |
| 157 | } |
| 158 | } |