From b5fcd48dbdac923029b04fb89dd301c360dc5d93 Mon Sep 17 00:00:00 2001 From: Maxim Lebedev Date: Tue, 12 Dec 2023 18:52:03 +0600 Subject: [PATCH] :construction: Created first version from the 'home' code snippets --- .gitea/workflows/build-image.yaml | 44 ++ build/Dockerfile | 28 + go.mod | 12 + go.sum | 27 + internal/cmd/pay/cmd_pay.go | 78 +++ internal/common/common.go | 16 + internal/domain/config.go | 10 + .../payment/delivery/http/payment_http.go | 52 ++ internal/urlutil/shift_path.go | 22 + internal/urlutil/shift_path_test.go | 36 ++ locales/en/messages.gotext.json | 46 ++ locales/en/out.gotext.json | 46 ++ locales/ru/messages.gotext.json | 44 ++ locales/ru/out.gotext.json | 44 ++ locales_gen.go | 61 +++ main.go | 90 +++ web/static/manifest.webmanifest | 36 ++ web/static/styles.css | 160 ++++++ web/template/template.qtpl | 142 +++++ web/template/template.qtpl.go | 517 ++++++++++++++++++ 20 files changed, 1511 insertions(+) create mode 100644 .gitea/workflows/build-image.yaml create mode 100644 build/Dockerfile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cmd/pay/cmd_pay.go create mode 100644 internal/common/common.go create mode 100644 internal/domain/config.go create mode 100644 internal/payment/delivery/http/payment_http.go create mode 100644 internal/urlutil/shift_path.go create mode 100644 internal/urlutil/shift_path_test.go create mode 100644 locales/en/messages.gotext.json create mode 100644 locales/en/out.gotext.json create mode 100644 locales/ru/messages.gotext.json create mode 100644 locales/ru/out.gotext.json create mode 100644 locales_gen.go create mode 100644 main.go create mode 100644 web/static/manifest.webmanifest create mode 100644 web/static/styles.css create mode 100644 web/template/template.qtpl create mode 100644 web/template/template.qtpl.go diff --git a/.gitea/workflows/build-image.yaml b/.gitea/workflows/build-image.yaml new file mode 100644 index 0000000..43fb68e --- /dev/null +++ b/.gitea/workflows/build-image.yaml @@ -0,0 +1,44 @@ +--- +on: + push: + branches: + - master + - develop + +env: + DOCKER_REGISTRY: source.toby3d.me + +jobs: + docker: + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: https://gitea.com/actions/checkout@v3 + + - name: Set up QEMU + uses: https://gitea.com/docker/setup-qemu-action@v2 + + - name: Set up Docker BuildX + uses: https://gitea.com/docker/setup-buildx-action@v2 + + - name: Login to registry + uses: https://gitea.com/docker/login-action@v2 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ gitea.repository_owner }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and push + uses: https://gitea.com/docker/build-push-action@v4 + env: + ACTIONS_RUNTIME_TOKEN: "" # See https://gitea.com/gitea/act_runner/issues/119 + with: + context: . + file: ./build/Dockerfile + push: true + tags: ${{ env.DOCKER_REGISTRY }}/${{ gitea.repository }}:${{ gitea.ref_name }} diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..bcb620b --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 +# docker build --rm -f build/Dockerfile -t source.toby3d.me/toby3d/pay . + +# Build +FROM golang:alpine AS builder + +WORKDIR /app + +ENV CGO_ENABLED=0 +ENV GOFLAGS="-mod=vendor -buildvcs=true" + +COPY go.mod go.sum *.go ./ +COPY internal ./internal/ +COPY vendor ./vendor/ +COPY web ./web/ + +RUN go build -o ./pay + +# Run +FROM scratch + +WORKDIR / + +COPY --from=builder /app/pay /pay + +EXPOSE 3000 + +ENTRYPOINT ["/pay"] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e8da7e8 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module source.toby3d.me/toby3d/pay + +go 1.21.5 + +require ( + github.com/caarlos0/env/v10 v10.0.0 + github.com/google/go-cmp v0.6.0 + github.com/valyala/quicktemplate v1.7.0 + golang.org/x/text v0.14.0 +) + +require github.com/valyala/bytebufferpool v1.0.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2cc1a2a --- /dev/null +++ b/go.sum @@ -0,0 +1,27 @@ +github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= +github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/caarlos0/env/v10 v10.0.0 h1:yIHUBZGsyqCnpTkbjk8asUlx6RFhhEs+h7TOBdgdzXA= +github.com/caarlos0/env/v10 v10.0.0/go.mod h1:ZfulV76NvVPw3tm591U4SwL3Xx9ldzBP9aGxzeN7G18= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= +github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.30.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus= +github.com/valyala/quicktemplate v1.7.0 h1:LUPTJmlVcb46OOUY3IeD9DojFpAVbsG+5WFTcjMJzCM= +github.com/valyala/quicktemplate v1.7.0/go.mod h1:sqKJnoaOF88V07vkO+9FL8fb9uZg/VPSJnLYn+LmLk8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/cmd/pay/cmd_pay.go b/internal/cmd/pay/cmd_pay.go new file mode 100644 index 0000000..95b1792 --- /dev/null +++ b/internal/cmd/pay/cmd_pay.go @@ -0,0 +1,78 @@ +package pay + +import ( + "context" + "fmt" + "io/fs" + "log" + "net/http" + "strconv" + "time" + + "source.toby3d.me/toby3d/pay/internal/common" + "source.toby3d.me/toby3d/pay/internal/domain" + paymenthttpdelivery "source.toby3d.me/toby3d/pay/internal/payment/delivery/http" + "source.toby3d.me/toby3d/pay/internal/urlutil" +) + +type Service struct { + server *http.Server +} + +func New(logger *log.Logger, static fs.FS, config domain.Config) *Service { + handler := paymenthttpdelivery.NewHandler() + fileServer := http.FileServer(http.FS(static)) + + return &Service{ + server: &http.Server{ + Addr: config.Bind, + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch head, _ := urlutil.ShiftPath(r.URL.Path); head { + default: + parsed, err := strconv.ParseFloat(head, 64) + if err != nil { + fileServer.ServeHTTP(w, r) + + return + } + + // NOTE(toby3d): Take my money? No way! + if parsed < 0 { + http.Redirect(w, r, fmt.Sprint("/", parsed*-1), + http.StatusMovedPermanently) + + return + } + + fallthrough + case "": + handler.ServeHTTP(w, r) + case "manifest.webmanifest": + w.Header().Set(common.HeaderContentType, + common.MIMEApplicationManifestJSONCharsetUTF8) + fileServer.ServeHTTP(w, r) + } + }), + DisableGeneralOptionsHandler: false, + TLSConfig: nil, + ReadTimeout: 500 * time.Millisecond, + ReadHeaderTimeout: 500 * time.Millisecond, + WriteTimeout: 500 * time.Millisecond, + IdleTimeout: 0, + MaxHeaderBytes: 0, + TLSNextProto: nil, + ConnState: nil, + ErrorLog: logger, + BaseContext: nil, + ConnContext: nil, + }, + } +} + +func (s *Service) Run() error { + return s.server.ListenAndServe() +} + +func (s *Service) Stop(ctx context.Context) error { + return s.server.Shutdown(ctx) +} diff --git a/internal/common/common.go b/internal/common/common.go new file mode 100644 index 0000000..d5322b7 --- /dev/null +++ b/internal/common/common.go @@ -0,0 +1,16 @@ +package common + +const ( + HeaderAcceptLanguage string = "Accept-Language" + HeaderContentLanguage string = "Content-Language" + HeaderContentType string = "Content-Type" +) + +const ( + MIMETextHTML string = "text/html" + MIMETextHTMLCharsetUTF8 string = MIMETextHTML + "; " + charsetUTF8 + MIMEApplicationManifestJSON string = "application/manifest+json" + MIMEApplicationManifestJSONCharsetUTF8 string = MIMEApplicationManifestJSON + "; " + charsetUTF8 +) + +const charsetUTF8 string = "charset=UTF-8" diff --git a/internal/domain/config.go b/internal/domain/config.go new file mode 100644 index 0000000..f4f714e --- /dev/null +++ b/internal/domain/config.go @@ -0,0 +1,10 @@ +package domain + +import ( + "net/url" +) + +type Config struct { + BaseURL *url.URL `env:"BASE_URL" envDefault:"http://localhost:3000/"` + Bind string `env:"BIND" envDefault:":3000"` +} diff --git a/internal/payment/delivery/http/payment_http.go b/internal/payment/delivery/http/payment_http.go new file mode 100644 index 0000000..b2129c6 --- /dev/null +++ b/internal/payment/delivery/http/payment_http.go @@ -0,0 +1,52 @@ +package http + +import ( + "fmt" + "net/http" + "strconv" + + "golang.org/x/text/language" + "golang.org/x/text/message" + + "source.toby3d.me/toby3d/pay/internal/common" + "source.toby3d.me/toby3d/pay/internal/urlutil" + "source.toby3d.me/toby3d/pay/web/template" +) + +type Handler struct { + matcher language.Matcher +} + +func NewHandler() *Handler { + return &Handler{ + matcher: language.NewMatcher(append([]language.Tag{language.English}, + message.DefaultCatalog.Languages()...)), + } +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + tags, _, _ := language.ParseAcceptLanguage(r.Header.Get(common.HeaderAcceptLanguage)) + tag, _, _ := h.matcher.Match(tags...) + + var amount uint64 + if head, _ := urlutil.ShiftPath(r.URL.Path); head != "" { + parsed, err := strconv.ParseFloat(head, 64) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + + return + } + + // NOTE(toby3d): Take my money? No way! + if parsed < 0 { + http.Redirect(w, r, fmt.Sprint("/", parsed*-1), http.StatusMovedPermanently) + + return + } + + amount = uint64(parsed * 100) + } + + w.Header().Set(common.HeaderContentType, common.MIMETextHTMLCharsetUTF8) + template.WriteTemplate(w, template.NewContext(tag, amount)) +} diff --git a/internal/urlutil/shift_path.go b/internal/urlutil/shift_path.go new file mode 100644 index 0000000..39dbf71 --- /dev/null +++ b/internal/urlutil/shift_path.go @@ -0,0 +1,22 @@ +package urlutil + +import ( + "path" + "strings" +) + +// ShiftPath splits off the first component of p, which will be cleaned of +// relative components before processing. head will never contain a slash and +// tail will always be a rooted path without trailing slash. +// +// See: https://blog.merovius.de/posts/2017-06-18-how-not-to-use-an-http-router/ +func ShiftPath(p string) (head, tail string) { + p = path.Clean("/" + p) + + i := strings.Index(p[1:], "/") + 1 + if i <= 0 { + return p[1:], "/" + } + + return p[1:i], p[i:] +} diff --git a/internal/urlutil/shift_path_test.go b/internal/urlutil/shift_path_test.go new file mode 100644 index 0000000..0bcc3f2 --- /dev/null +++ b/internal/urlutil/shift_path_test.go @@ -0,0 +1,36 @@ +package urlutil_test + +import ( + "testing" + + "source.toby3d.me/toby3d/pay/internal/urlutil" +) + +func TestShiftPath(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + input, expectHead, expectTail string + }{ + "root": {"/", "", "/"}, + "file": {"/foo", "foo", "/"}, + "dir": {"/foo/", "foo", "/"}, + "dir-file": {"/foo/bar", "foo", "/bar"}, + "subdir": {"/foo/bar/", "foo", "/bar"}, + } { + name, tc := name, tc + + t.Run(name, func(t *testing.T) { + t.Parallel() + + head, tail := urlutil.ShiftPath(tc.input) + if head != tc.expectHead { + t.Errorf("ShiftPath(%s) = '%s', want '%s'", tc.input, head, tc.expectHead) + } + + if tail != tc.expectTail { + t.Errorf("ShiftPath(%s) = '%s', want '%s'", tc.input, tail, tc.expectTail) + } + }) + } +} diff --git a/locales/en/messages.gotext.json b/locales/en/messages.gotext.json new file mode 100644 index 0000000..a0dfbb3 --- /dev/null +++ b/locales/en/messages.gotext.json @@ -0,0 +1,46 @@ +{ + "language": "en", + "messages": [ + { + "id": "Donate ${Amount__100} to {Toby3d}", + "message": "Donate ${Amount__100} to {Toby3d}", + "translation": { + "select": { + "feature": "plural", + "arg": "Amount__100", + "cases": { + "=0": { + "msg": "Donate to {Toby3d}" + }, + "=1": { + "msg": "Donate onedollar™ to {Toby3d}" + }, + "other": { + "msg": "Donate ${Amount__100} to {Toby3d}" + } + } + } + }, + "translatorComment": "Copied from source.", + "placeholders": [ + { + "id": "Amount__100", + "string": "%[1].2f", + "type": "float64", + "underlyingType": "float64", + "argNum": 1, + "expr": "ctx.amount / 100" + }, + { + "id": "Toby3d", + "string": "%[2]s", + "type": "string", + "underlyingType": "string", + "argNum": 2, + "expr": "\"toby3d\"" + } + ], + "fuzzy": true + } + ] +} diff --git a/locales/en/out.gotext.json b/locales/en/out.gotext.json new file mode 100644 index 0000000..b868e5b --- /dev/null +++ b/locales/en/out.gotext.json @@ -0,0 +1,46 @@ +{ + "language": "en", + "messages": [ + { + "id": "Donate ${Amount__100} to {Toby3d}", + "message": "Donate ${Amount__100} to {Toby3d}", + "translation": { + "select": { + "feature": "plural", + "arg": "Amount__100", + "cases": { + "=0": { + "msg": "Donate to {Toby3d}" + }, + "=1": { + "msg": "Donate onedollar™ to {Toby3d}" + }, + "other": { + "msg": "Donate ${Amount__100} to {Toby3d}" + } + } + } + }, + "translatorComment": "Copied from source.", + "placeholders": [ + { + "id": "Amount__100", + "string": "%.2[1]f", + "type": "float64", + "underlyingType": "float64", + "argNum": 1, + "expr": "ctx.amount / 100" + }, + { + "id": "Toby3d", + "string": "%[2]s", + "type": "string", + "underlyingType": "string", + "argNum": 2, + "expr": "\"toby3d\"" + } + ], + "fuzzy": true + } + ] +} \ No newline at end of file diff --git a/locales/ru/messages.gotext.json b/locales/ru/messages.gotext.json new file mode 100644 index 0000000..fd1fc5a --- /dev/null +++ b/locales/ru/messages.gotext.json @@ -0,0 +1,44 @@ +{ + "language": "ru", + "messages": [ + { + "id": "Donate ${Amount__100} to {Toby3d}", + "message": "Donate ${Amount__100} to {Toby3d}", + "translation": { + "select": { + "feature": "plural", + "arg": "Amount__100", + "cases": { + "=0": { + "msg": "Пожертвовать {Toby3d}" + }, + "=1": { + "msg": "Пожертвовать долор™ {Toby3d}" + }, + "other": { + "msg": "Пожертвовать ${Amount__100} {Toby3d}" + } + } + } + }, + "placeholders": [ + { + "id": "Amount__100", + "string": "%[1].2f", + "type": "float64", + "underlyingType": "float64", + "argNum": 1, + "expr": "ctx.amount / 100" + }, + { + "id": "Toby3d", + "string": "%[2]s", + "type": "string", + "underlyingType": "string", + "argNum": 2, + "expr": "\"toby3d\"" + } + ] + } + ] +} diff --git a/locales/ru/out.gotext.json b/locales/ru/out.gotext.json new file mode 100644 index 0000000..db4cf62 --- /dev/null +++ b/locales/ru/out.gotext.json @@ -0,0 +1,44 @@ +{ + "language": "ru", + "messages": [ + { + "id": "Donate ${Amount__100} to {Toby3d}", + "message": "Donate ${Amount__100} to {Toby3d}", + "translation": { + "select": { + "feature": "plural", + "arg": "Amount__100", + "cases": { + "=0": { + "msg": "Пожертвовать {Toby3d}" + }, + "=1": { + "msg": "Пожертвовать долор™ {Toby3d}" + }, + "other": { + "msg": "Пожертвовать ${Amount__100} {Toby3d}" + } + } + } + }, + "placeholders": [ + { + "id": "Amount__100", + "string": "%.2[1]f", + "type": "float64", + "underlyingType": "float64", + "argNum": 1, + "expr": "ctx.amount / 100" + }, + { + "id": "Toby3d", + "string": "%[2]s", + "type": "string", + "underlyingType": "string", + "argNum": 2, + "expr": "\"toby3d\"" + } + ] + } + ] +} \ No newline at end of file diff --git a/locales_gen.go b/locales_gen.go new file mode 100644 index 0000000..96fa3d6 --- /dev/null +++ b/locales_gen.go @@ -0,0 +1,61 @@ +// Code generated by running "go generate" in golang.org/x/text. DO NOT EDIT. + +package main + +import ( + "golang.org/x/text/language" + "golang.org/x/text/message" + "golang.org/x/text/message/catalog" +) + +type dictionary struct { + index []uint32 + data string +} + +func (d *dictionary) Lookup(key string) (data string, ok bool) { + p, ok := messageKeyToIndex[key] + if !ok { + return "", false + } + start, end := d.index[p], d.index[p+1] + if start == end { + return "", false + } + return d.data[start:end], true +} + +func init() { + dict := map[string]catalog.Dictionary{ + "en": &dictionary{index: enIndex, data: enData}, + "ru": &dictionary{index: ruIndex, data: ruData}, + } + fallback := language.MustParse("en") + cat, err := catalog.NewFromMap(dict, catalog.Fallback(fallback)) + if err != nil { + panic(err) + } + message.DefaultCatalog = cat +} + +var messageKeyToIndex = map[string]int{ + "Donate $%.2f to %s": 0, +} + +var enIndex = []uint32{ // 2 elements + 0x00000000, 0x00000053, +} // Size: 32 bytes + +const enData string = "" + // Size: 83 bytes + "\x14\x01\x81\x01\x02=\x00\x10\x02Donate to %[2]s=\x01\x1d\x02Donate oned" + + "ollar™ to %[2]s\x00\x19\x02Donate $%.2[1]f to %[2]s" + +var ruIndex = []uint32{ // 2 elements + 0x00000000, 0x00000081, +} // Size: 32 bytes + +const ruData string = "" + // Size: 129 bytes + "\x14\x01\x81\x01\x02=\x00\x1f\x02Пожертвовать %[2]s=\x01-\x02Пожертвоват" + + "ь долор™ %[2]s\x00(\x02Пожертвовать $%.2[1]f %[2]s" + + // Total table size 276 bytes (0KiB); checksum: 3BFB37FB diff --git a/main.go b/main.go new file mode 100644 index 0000000..d78e732 --- /dev/null +++ b/main.go @@ -0,0 +1,90 @@ +//go:generate go install github.com/valyala/quicktemplate/qtc@master +//go:generate qtc -dir=web +//go:generate go install golang.org/x/text/cmd/gotext@master +//go:generate gotext -srclang=en update -lang=en,ru -out=locales_gen.go +package main + +import ( + "context" + "embed" + "flag" + "io/fs" + "log" + "os" + "os/signal" + "path/filepath" + "runtime" + "runtime/pprof" + "syscall" + + "github.com/caarlos0/env/v10" + + "source.toby3d.me/toby3d/pay/internal/cmd/pay" + "source.toby3d.me/toby3d/pay/internal/domain" +) + +//go:embed web/static/* +var embedStatic embed.FS + +var static fs.FS = os.DirFS(filepath.Join("web", "static")) + +func main() { + logger := log.New(os.Stdout, "pay\t", log.LstdFlags) + + // static, err := fs.Sub(static, filepath.Join("web", "static")) + // if err != nil { + // logger.Fatalln(err) + // } + + cpuProfilePath := flag.String("cpuprofile", "", "set path to saving CPU memory profile") + memProfilePath := flag.String("memprofile", "", "set path to saving pprof memory profile") + + flag.Parse() + + config := new(domain.Config) + if err := env.ParseWithOptions(config, env.Options{Prefix: "PAY_"}); err != nil { + logger.Fatalln(err) + } + + app := pay.New(logger, static, *config) + + done := make(chan os.Signal, 1) + signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + + if cpuProfilePath != nil && *cpuProfilePath != "" { + cpuProfile, err := os.Create(*cpuProfilePath) + if err != nil { + logger.Fatalln("could not create CPU profile:", err) + } + defer cpuProfile.Close() + + if err = pprof.StartCPUProfile(cpuProfile); err != nil { + logger.Fatalln("could not start CPU profile:", err) + } + defer pprof.StopCPUProfile() + } + + go app.Run() + + <-done + + if err := app.Stop(context.Background()); err != nil { + logger.Fatalln(err) + } + + if memProfilePath == nil && *memProfilePath == "" { + return + } + + memProfile, err := os.Create(*memProfilePath) + if err != nil { + logger.Fatalln("could not create memory profile:", err) + } + defer memProfile.Close() + + runtime.GC() // NOTE(toby3d): get up-to-date statistics + + if err = pprof.WriteHeapProfile(memProfile); err != nil { + logger.Fatalln("could not write memory profile:", err) + } +} diff --git a/web/static/manifest.webmanifest b/web/static/manifest.webmanifest new file mode 100644 index 0000000..0828c1e --- /dev/null +++ b/web/static/manifest.webmanifest @@ -0,0 +1,36 @@ +{ + "dir": "ltr", + "lang": "en", + "name": "NotDotPay", + "short_name": "not.pay", + "scope": "/", + "icons": [ + { + "sizes": "192x192", + "src": "/icon-192.png", + "type": "image/png", + "purpose": "maskable" + }, + { + "sizes": "512x512", + "src": "/icon-512.png", + "type": "image/png", + "purpose": "maskable" + } + ], + "display": "minimal-ui", + "orientation": "natural", + "start_url": ".", + "id": "/", + "theme_color": "#000", + "related_applications": [ + { + "platform": "play", + "url": "https://play.google.com/store/apps/details?id=com.paypal.android.p2pmobile", + "id": "com.paypal.android.p2pmobile" + } + ], + "prefer_related_applications": false, + "background_color": "#fff", + "shortcuts": [] +} diff --git a/web/static/styles.css b/web/static/styles.css new file mode 100644 index 0000000..b97efb2 --- /dev/null +++ b/web/static/styles.css @@ -0,0 +1,160 @@ +:root { + --ratio: 1.5; + --s-5: calc(var(--s-4) / var(--ratio)); + --s-4: calc(var(--s-3) / var(--ratio)); + --s-3: calc(var(--s-2) / var(--ratio)); + --s-2: calc(var(--s-1) / var(--ratio)); + --s-1: calc(var(--s0) / var(--ratio)); + --s0: 1rem; + --s1: calc(var(--s0) * var(--ratio)); + --s2: calc(var(--s1) * var(--ratio)); + --s3: calc(var(--s2) * var(--ratio)); + --s4: calc(var(--s3) * var(--ratio)); + --s5: calc(var(--s4) * var(--ratio)); + --measure: 60ch; + /* System font stack, see: https://systemfontstack.com/ */ + font-family: + -apple-system, + BlinkMacSystemFont, + avenir next, + avenir, + segoe ui, + helvetica neue, + helvetica, + Cantarell, + Ubuntu, + roboto, + noto, + arial, + sans-serif; + font-size: calc(1rem + 0.5vw); + line-height: var(--ratio); +} + +* { + box-sizing: border-box; + max-inline-size: var(--measure); +} + +html, +body, +div, +header, +nav, +main, +footer { + max-inline-size: none; +} + +/* Button */ +.button, +button { + --_background: var(--background, #ccc); + --_color: var(--color, #000); + /* NOTE(toby3d): reset everything */ + all: unset; + /* NOTE(toby3d): start from scratch */ + -moz-appearance: none; + -webkit-appearance: none; + align-items: baseline; + appearance: none; + background: var(--_background); + border-radius: 0.3125em; + border: 0; + color: var(--_color); + cursor: pointer; + display: inline-flex; + font-family: inherit; + font-size: 1rem; + justify-content: center; + min-width: 6.25em; + padding: 0.625em 1em; + text-align: center; + text-decoration: none; +} + +.button[href*='liberapay.com'], +button[href*='liberapay.com'] { + --background: #f6c915; + --color: #1a171b; +} + +.button[href*='paypal.me'], +button[href*='paypal.me'] { + --background: #ffd140; + --color: #001435; +} + +.button .icon, +.button svg, +button .icon, +button svg { + margin-inline-end: 0.25rem; + height: 0.75em; + height: 1cap; + width: 0.75em; + width: 1cap; +} + +/* Button behavior, see: https://bitsofco.de/when-do-the-hover-focus-and-active-pseudo-classes-apply/ */ +.button:hover, +button:hover { + /* TODO(toby3d): better color mathing with good WCAG grading */ + --background: #1d49aa; +} + +/* See: https://bitsofco.de/when-is-focus-visible-visible/ */ +.button:focus-visible, +.button:focus, +button:focus-visible, +button:focus { + outline: 4px solid #cbd6ee; +} + +/* See: https://bitsofco.de/when-is-focus-visible-visible/ */ +.button:focus:not(:focus-visible), +button:focus:not(:focus-visible) { + outline: none; +} + +.button:active, +button:active { + /* TODO(toby3d): better color mathing with good WCAG grading */ + --background: green; +} + +.button:not([href]), +button[disabled] { + /* TODO(toby3d): better color mathing with good WCAG grading */ + --background: #6c7589; + --color: #d2d5db; + cursor: not-allowed; +} + +.button:not([href]) .icon, +.button:not([href]) svg, +button[disabled] .icon, +button[disabled] svg { + filter: grayscale(1); +} + +/* Cluster */ +.cluster { + --_align: var(--align, flex-start); + --_justify: var(--justify, flex-start); + --_space: var(--space, var(--s1)); + align-items: var(--_align); + display: flex; + flex-wrap: wrap; + gap: var(--_space); + justify-content: var(--_justify); +} + +/* Utilities */ +.list-style-type\:none { + list-style-type: none; +} + +.padding-inline-start\:unset { + padding-inline-start: unset; +} diff --git a/web/template/template.qtpl b/web/template/template.qtpl new file mode 100644 index 0000000..ae09685 --- /dev/null +++ b/web/template/template.qtpl @@ -0,0 +1,142 @@ +{% package template %} + +{% import ( + "golang.org/x/text/language" + "golang.org/x/text/message" +) %} + +{% interface Page { + body() + dir() + lang() + t(format message.Reference, v ...any) + title() + head() +} %} + +{% code +type Context struct { + language language.Tag + printer *message.Printer + amount float64 +} + +func NewContext(lang language.Tag, amount uint64) *Context { + return &Context{ + language: lang, + printer: message.NewPrinter(lang), + amount: float64(amount), + } +} +%} + +{% stripspace %} +{% func (ctx Context) head() %} + + + + + +{% endfunc %} + +{% func (ctx Context) body() %} +

{%= ctx.title() %}

+ + +{% endfunc %} + +{% func (ctx Context) dir() %} + {% switch ctx.language %} + {% default %} + ltr + {% case language.Arabic, language.Persian, language.Hebrew, language.Urdu %} + rtl + {% endswitch %} +{% endfunc %} + +{% func (ctx Context) lang() %} + {% code base, _ := ctx.language.Base() %} + {%s base.String() %} +{% endfunc %} + +{% func (ctx Context) t(format message.Reference, v ...any) %} + {%s ctx.printer.Sprintf(format, v...) %} +{% endfunc %} + +{% func (ctx Context) title() %} + {%= ctx.t(`Donate $%.2f to %s`, ctx.amount/100, "toby3d") %} +{% endfunc %} + +{% func Template(p Page) %} + + + + + + {%= p.title() %} + {%= p.head() %} + + + {%= p.body() %} + + +{% endfunc %} + +{% func icon(id string) %} + {% switch id %} + {% case "liberapay" %} + + {% case "paypal" %} + + {% endswitch %} +{% endfunc %} +{% endstripspace %} diff --git a/web/template/template.qtpl.go b/web/template/template.qtpl.go new file mode 100644 index 0000000..ae78c43 --- /dev/null +++ b/web/template/template.qtpl.go @@ -0,0 +1,517 @@ +// Code generated by qtc from "template.qtpl". DO NOT EDIT. +// See https://github.com/valyala/quicktemplate for details. + +//line web/template/template.qtpl:1 +package template + +//line web/template/template.qtpl:3 +import ( + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +//line web/template/template.qtpl:8 +import ( + qtio422016 "io" + + qt422016 "github.com/valyala/quicktemplate" +) + +//line web/template/template.qtpl:8 +var ( + _ = qtio422016.Copy + _ = qt422016.AcquireByteBuffer +) + +//line web/template/template.qtpl:8 +type Page interface { +//line web/template/template.qtpl:8 + body() string +//line web/template/template.qtpl:8 + streambody(qw422016 *qt422016.Writer) +//line web/template/template.qtpl:8 + writebody(qq422016 qtio422016.Writer) +//line web/template/template.qtpl:8 + dir() string +//line web/template/template.qtpl:8 + streamdir(qw422016 *qt422016.Writer) +//line web/template/template.qtpl:8 + writedir(qq422016 qtio422016.Writer) +//line web/template/template.qtpl:8 + lang() string +//line web/template/template.qtpl:8 + streamlang(qw422016 *qt422016.Writer) +//line web/template/template.qtpl:8 + writelang(qq422016 qtio422016.Writer) +//line web/template/template.qtpl:8 + t(format message.Reference, v ...any) string +//line web/template/template.qtpl:8 + streamt(qw422016 *qt422016.Writer, format message.Reference, v ...any) +//line web/template/template.qtpl:8 + writet(qq422016 qtio422016.Writer, format message.Reference, v ...any) +//line web/template/template.qtpl:8 + title() string +//line web/template/template.qtpl:8 + streamtitle(qw422016 *qt422016.Writer) +//line web/template/template.qtpl:8 + writetitle(qq422016 qtio422016.Writer) +//line web/template/template.qtpl:8 + head() string +//line web/template/template.qtpl:8 + streamhead(qw422016 *qt422016.Writer) +//line web/template/template.qtpl:8 + writehead(qq422016 qtio422016.Writer) +//line web/template/template.qtpl:8 +} + +//line web/template/template.qtpl:18 +type Context struct { + language language.Tag + printer *message.Printer + amount float64 +} + +func NewContext(lang language.Tag, amount uint64) *Context { + return &Context{ + language: lang, + printer: message.NewPrinter(lang), + amount: float64(amount), + } +} + +//line web/template/template.qtpl:34 +func (ctx Context) streamhead(qw422016 *qt422016.Writer) { +//line web/template/template.qtpl:34 + qw422016.N().S(``) +//line web/template/template.qtpl:46 +} + +//line web/template/template.qtpl:46 +func (ctx Context) writehead(qq422016 qtio422016.Writer) { +//line web/template/template.qtpl:46 + qw422016 := qt422016.AcquireWriter(qq422016) +//line web/template/template.qtpl:46 + ctx.streamhead(qw422016) +//line web/template/template.qtpl:46 + qt422016.ReleaseWriter(qw422016) +//line web/template/template.qtpl:46 +} + +//line web/template/template.qtpl:46 +func (ctx Context) head() string { +//line web/template/template.qtpl:46 + qb422016 := qt422016.AcquireByteBuffer() +//line web/template/template.qtpl:46 + ctx.writehead(qb422016) +//line web/template/template.qtpl:46 + qs422016 := string(qb422016.B) +//line web/template/template.qtpl:46 + qt422016.ReleaseByteBuffer(qb422016) +//line web/template/template.qtpl:46 + return qs422016 +//line web/template/template.qtpl:46 +} + +//line web/template/template.qtpl:48 +func (ctx Context) streambody(qw422016 *qt422016.Writer) { +//line web/template/template.qtpl:48 + qw422016.N().S(`

`) +//line web/template/template.qtpl:49 + ctx.streamtitle(qw422016) +//line web/template/template.qtpl:49 + qw422016.N().S(`

`) +//line web/template/template.qtpl:72 +} + +//line web/template/template.qtpl:72 +func (ctx Context) writebody(qq422016 qtio422016.Writer) { +//line web/template/template.qtpl:72 + qw422016 := qt422016.AcquireWriter(qq422016) +//line web/template/template.qtpl:72 + ctx.streambody(qw422016) +//line web/template/template.qtpl:72 + qt422016.ReleaseWriter(qw422016) +//line web/template/template.qtpl:72 +} + +//line web/template/template.qtpl:72 +func (ctx Context) body() string { +//line web/template/template.qtpl:72 + qb422016 := qt422016.AcquireByteBuffer() +//line web/template/template.qtpl:72 + ctx.writebody(qb422016) +//line web/template/template.qtpl:72 + qs422016 := string(qb422016.B) +//line web/template/template.qtpl:72 + qt422016.ReleaseByteBuffer(qb422016) +//line web/template/template.qtpl:72 + return qs422016 +//line web/template/template.qtpl:72 +} + +//line web/template/template.qtpl:74 +func (ctx Context) streamdir(qw422016 *qt422016.Writer) { +//line web/template/template.qtpl:75 + switch ctx.language { +//line web/template/template.qtpl:76 + default: +//line web/template/template.qtpl:76 + qw422016.N().S(`ltr`) +//line web/template/template.qtpl:78 + case language.Arabic, language.Persian, language.Hebrew, language.Urdu: +//line web/template/template.qtpl:78 + qw422016.N().S(`rtl`) +//line web/template/template.qtpl:80 + } +//line web/template/template.qtpl:81 +} + +//line web/template/template.qtpl:81 +func (ctx Context) writedir(qq422016 qtio422016.Writer) { +//line web/template/template.qtpl:81 + qw422016 := qt422016.AcquireWriter(qq422016) +//line web/template/template.qtpl:81 + ctx.streamdir(qw422016) +//line web/template/template.qtpl:81 + qt422016.ReleaseWriter(qw422016) +//line web/template/template.qtpl:81 +} + +//line web/template/template.qtpl:81 +func (ctx Context) dir() string { +//line web/template/template.qtpl:81 + qb422016 := qt422016.AcquireByteBuffer() +//line web/template/template.qtpl:81 + ctx.writedir(qb422016) +//line web/template/template.qtpl:81 + qs422016 := string(qb422016.B) +//line web/template/template.qtpl:81 + qt422016.ReleaseByteBuffer(qb422016) +//line web/template/template.qtpl:81 + return qs422016 +//line web/template/template.qtpl:81 +} + +//line web/template/template.qtpl:83 +func (ctx Context) streamlang(qw422016 *qt422016.Writer) { +//line web/template/template.qtpl:84 + base, _ := ctx.language.Base() + +//line web/template/template.qtpl:85 + qw422016.E().S(base.String()) +//line web/template/template.qtpl:86 +} + +//line web/template/template.qtpl:86 +func (ctx Context) writelang(qq422016 qtio422016.Writer) { +//line web/template/template.qtpl:86 + qw422016 := qt422016.AcquireWriter(qq422016) +//line web/template/template.qtpl:86 + ctx.streamlang(qw422016) +//line web/template/template.qtpl:86 + qt422016.ReleaseWriter(qw422016) +//line web/template/template.qtpl:86 +} + +//line web/template/template.qtpl:86 +func (ctx Context) lang() string { +//line web/template/template.qtpl:86 + qb422016 := qt422016.AcquireByteBuffer() +//line web/template/template.qtpl:86 + ctx.writelang(qb422016) +//line web/template/template.qtpl:86 + qs422016 := string(qb422016.B) +//line web/template/template.qtpl:86 + qt422016.ReleaseByteBuffer(qb422016) +//line web/template/template.qtpl:86 + return qs422016 +//line web/template/template.qtpl:86 +} + +//line web/template/template.qtpl:88 +func (ctx Context) streamt(qw422016 *qt422016.Writer, format message.Reference, v ...any) { +//line web/template/template.qtpl:89 + qw422016.E().S(ctx.printer.Sprintf(format, v...)) +//line web/template/template.qtpl:90 +} + +//line web/template/template.qtpl:90 +func (ctx Context) writet(qq422016 qtio422016.Writer, format message.Reference, v ...any) { +//line web/template/template.qtpl:90 + qw422016 := qt422016.AcquireWriter(qq422016) +//line web/template/template.qtpl:90 + ctx.streamt(qw422016, format, v...) +//line web/template/template.qtpl:90 + qt422016.ReleaseWriter(qw422016) +//line web/template/template.qtpl:90 +} + +//line web/template/template.qtpl:90 +func (ctx Context) t(format message.Reference, v ...any) string { +//line web/template/template.qtpl:90 + qb422016 := qt422016.AcquireByteBuffer() +//line web/template/template.qtpl:90 + ctx.writet(qb422016, format, v...) +//line web/template/template.qtpl:90 + qs422016 := string(qb422016.B) +//line web/template/template.qtpl:90 + qt422016.ReleaseByteBuffer(qb422016) +//line web/template/template.qtpl:90 + return qs422016 +//line web/template/template.qtpl:90 +} + +//line web/template/template.qtpl:92 +func (ctx Context) streamtitle(qw422016 *qt422016.Writer) { +//line web/template/template.qtpl:93 + ctx.streamt(qw422016, `Donate $%.2f to %s`, ctx.amount/100, "toby3d") +//line web/template/template.qtpl:94 +} + +//line web/template/template.qtpl:94 +func (ctx Context) writetitle(qq422016 qtio422016.Writer) { +//line web/template/template.qtpl:94 + qw422016 := qt422016.AcquireWriter(qq422016) +//line web/template/template.qtpl:94 + ctx.streamtitle(qw422016) +//line web/template/template.qtpl:94 + qt422016.ReleaseWriter(qw422016) +//line web/template/template.qtpl:94 +} + +//line web/template/template.qtpl:94 +func (ctx Context) title() string { +//line web/template/template.qtpl:94 + qb422016 := qt422016.AcquireByteBuffer() +//line web/template/template.qtpl:94 + ctx.writetitle(qb422016) +//line web/template/template.qtpl:94 + qs422016 := string(qb422016.B) +//line web/template/template.qtpl:94 + qt422016.ReleaseByteBuffer(qb422016) +//line web/template/template.qtpl:94 + return qs422016 +//line web/template/template.qtpl:94 +} + +//line web/template/template.qtpl:96 +func StreamTemplate(qw422016 *qt422016.Writer, p Page) { +//line web/template/template.qtpl:96 + qw422016.N().S(``) +//line web/template/template.qtpl:102 + p.streamtitle(qw422016) +//line web/template/template.qtpl:102 + qw422016.N().S(``) +//line web/template/template.qtpl:103 + p.streamhead(qw422016) +//line web/template/template.qtpl:103 + qw422016.N().S(``) +//line web/template/template.qtpl:106 + p.streambody(qw422016) +//line web/template/template.qtpl:106 + qw422016.N().S(``) +//line web/template/template.qtpl:109 +} + +//line web/template/template.qtpl:109 +func WriteTemplate(qq422016 qtio422016.Writer, p Page) { +//line web/template/template.qtpl:109 + qw422016 := qt422016.AcquireWriter(qq422016) +//line web/template/template.qtpl:109 + StreamTemplate(qw422016, p) +//line web/template/template.qtpl:109 + qt422016.ReleaseWriter(qw422016) +//line web/template/template.qtpl:109 +} + +//line web/template/template.qtpl:109 +func Template(p Page) string { +//line web/template/template.qtpl:109 + qb422016 := qt422016.AcquireByteBuffer() +//line web/template/template.qtpl:109 + WriteTemplate(qb422016, p) +//line web/template/template.qtpl:109 + qs422016 := string(qb422016.B) +//line web/template/template.qtpl:109 + qt422016.ReleaseByteBuffer(qb422016) +//line web/template/template.qtpl:109 + return qs422016 +//line web/template/template.qtpl:109 +} + +//line web/template/template.qtpl:111 +func streamicon(qw422016 *qt422016.Writer, id string) { +//line web/template/template.qtpl:112 + switch id { +//line web/template/template.qtpl:113 + case "liberapay": +//line web/template/template.qtpl:113 + qw422016.N().S(``) +//line web/template/template.qtpl:124 + case "paypal": +//line web/template/template.qtpl:124 + qw422016.N().S(``) +//line web/template/template.qtpl:133 + qw422016.N().S(``) +//line web/template/template.qtpl:135 + qw422016.N().S(``) +//line web/template/template.qtpl:137 + qw422016.N().S(``) +//line web/template/template.qtpl:140 + } +//line web/template/template.qtpl:141 +} + +//line web/template/template.qtpl:141 +func writeicon(qq422016 qtio422016.Writer, id string) { +//line web/template/template.qtpl:141 + qw422016 := qt422016.AcquireWriter(qq422016) +//line web/template/template.qtpl:141 + streamicon(qw422016, id) +//line web/template/template.qtpl:141 + qt422016.ReleaseWriter(qw422016) +//line web/template/template.qtpl:141 +} + +//line web/template/template.qtpl:141 +func icon(id string) string { +//line web/template/template.qtpl:141 + qb422016 := qt422016.AcquireByteBuffer() +//line web/template/template.qtpl:141 + writeicon(qb422016, id) +//line web/template/template.qtpl:141 + qs422016 := string(qb422016.B) +//line web/template/template.qtpl:141 + qt422016.ReleaseByteBuffer(qb422016) +//line web/template/template.qtpl:141 + return qs422016 +//line web/template/template.qtpl:141 +}