initial sourcehut commit

Amolith created

Change summary

.gitignore                                |  10 
.golangci.toml                            | 114 +++++
.reuse/dep5                               |  10 
LICENSES/BSD-2-Clause.txt                 |   9 
LICENSES/BSD-3-Clause.txt                 |  11 
LICENSES/CC0-1.0.txt                      | 121 +++++
LICENSES/MIT.txt                          |   9 
README.md                                 | 154 ++++++
build-all.sh                              |  59 ++
go.mod                                    |  11 
go.mod.license                            |   3 
go.sum                                    |   8 
go.sum.license                            |   3 
justfile                                  |  26 +
main.go                                   | 269 ++++++++++++
screenshots/screenshot-dark.png           |   0 
screenshots/screenshot-dark.png.license   |   3 
screenshots/screenshot-light.png          |   0 
screenshots/screenshot-light.png.license  |   3 
static/404.html                           |  32 +
static/404.html.license                   |   3 
static/age.wasm                           |   0 
static/age.wasm.license                   |   3 
static/error.html                         |  31 +
static/error.html.license                 |   3 
static/form.html                          |  81 +++
static/form.html.license                  |   3 
static/pico.classless.min.css             |   0 
static/pico.classless.min.css.license     |   3 
static/pico.classless.min.css.map         |   0 
static/pico.classless.min.css.map.license |   3 
static/success.html                       |  31 +
static/success.html.license               |   3 
static/wasm_exec.js                       | 554 +++++++++++++++++++++++++
static/wasm_exec.js.license               |   3 
35 files changed, 1,576 insertions(+)

Detailed changes

.gitignore 🔗

@@ -0,0 +1,10 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# SPDX-License-Identifier: CC0-1.0
+
+out/
+config.toml
+/vendor/
+/vendor/
+
+/.idea/

.golangci.toml 🔗

@@ -0,0 +1,114 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# SPDX-License-Identifier: CC0-1.0
+
+[run]
+concurrency            =  8
+timeout                =  "30m"
+issues-exit-code       =  1
+tests                  =  true
+skip-dirs              =  ["frontend"]
+modules-download-mode  =  "readonly"
+go                     =  ""
+
+[output]
+print-issued-lines  =  false
+print-linter-name   =  true
+uniq-by-line        =  false
+path-prefix         =  ""
+sort-results        =  true
+
+[issues]
+max-issues-per-linter  =  0
+max-same-issues        =  0
+new                    =  false
+fix                    =  false
+
+[linters]
+fast    =  false
+eeable  =  [
+        "asasalint",
+        "asciicheck",
+        "bidichk",
+        "bodyclose",
+        "contextcheck",
+        "durationcheck",
+        "errcheck",
+        "errname",
+        "errorlint",
+        "exportloopref",
+        "gocritic",
+        "godot",
+        "gofumpt",
+        "goimports",
+        "gomoddirectives",
+        "gosec",
+        "gosimple",
+        "govet",
+        "ineffassign",
+        "misspell",
+        "nakedret",
+        "nilerr",
+        "nilnil",
+        "noctx",
+        "nolintlint",
+        "prealloc",
+        "predeclared",
+        "promlinter",
+        "reassign",
+        "revive",
+        "rowserrcheck",
+        "sqlclosecheck",
+        "stylecheck",
+        "tagliatelle",
+        "tenv",
+        "testableexamples",
+        "thelper",
+        "tparallel",
+        "unconvert",
+        "unparam",
+        "unused",
+        "usestdlibvars",
+        "wastedassign",
+        "containedctx",
+        "cyclop",
+        "decorder",
+        "depguard",
+        "dogsled",
+        "dupl",
+        "dupword",
+        "errchkjson",
+        "execinquery",
+        "exhaustive",
+        "exhaustruct",
+        "forcetypeassert",
+        "funlen",
+        "gci",
+        "gocheckcompilerdirectives",
+        "gocognit",
+        "gocyclo",
+        "godox",
+        "goerr113",
+        "gomnd",
+        "gomodguard",
+        "goprintffuncname",
+        "grouper",
+        "importas",
+        "interfacebloat",
+        "ireturn",
+        "lll",
+        "loggercheck",
+        "maintidx",
+        "makezero",
+        "musttag",
+        "nestif",
+        "nonamedreturns",
+        "nosprintfhostport",
+        "paralleltest",
+        "testpackage",
+        "typecheck",
+        "varnamelen",
+        "whitespace",
+        "wrapcheck",
+        "wsl"
+]

.reuse/dep5 🔗

@@ -0,0 +1,10 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: password-thing
+Upstream-Contact: Amolith <amolith@secluded.site>
+Source: https://git.sr.ht/~amolith/password-thing
+
+# Sample paragraph, commented out:
+#
+# Files: src/*
+# Copyright: $YEAR $NAME <$CONTACT>
+# License: ...

LICENSES/BSD-2-Clause.txt 🔗

@@ -0,0 +1,9 @@
+Copyright (c) <year> <owner> 
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

LICENSES/BSD-3-Clause.txt 🔗

@@ -0,0 +1,11 @@
+Copyright (c) <year> <owner>. 
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

LICENSES/CC0-1.0.txt 🔗

@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+    CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+    LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+    ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+    INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+    REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+    PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+    THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+    HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+  i. the right to reproduce, adapt, distribute, perform, display,
+     communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+     likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+     subject to the limitations in paragraph 4(a), below;
+  v. rights protecting the extraction, dissemination, use and reuse of data
+     in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+     European Parliament and of the Council of 11 March 1996 on the legal
+     protection of databases, and under any national implementation
+     thereof, including any amended or successor version of such
+     directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+     world based on applicable law or treaty, and any national
+     implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+    surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+    warranties of any kind concerning the Work, express, implied,
+    statutory or otherwise, including without limitation warranties of
+    title, merchantability, fitness for a particular purpose, non
+    infringement, or the absence of latent or other defects, accuracy, or
+    the present or absence of errors, whether or not discoverable, all to
+    the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+    that may apply to the Work or any use thereof, including without
+    limitation any person's Copyright and Related Rights in the Work.
+    Further, Affirmer disclaims responsibility for obtaining any necessary
+    consents, permissions or other rights required for any use of the
+    Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+    party to this document and has no duty or obligation with respect to
+    this CC0 or use of the Work.

LICENSES/MIT.txt 🔗

@@ -0,0 +1,9 @@
+MIT License
+
+Copyright (c) <year> <copyright holders>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

README.md 🔗

@@ -0,0 +1,154 @@
+<!--
+SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+
+SPDX-License-Identifier: CC0-1.0
+-->
+
+# wyrd
+
+[![Go report card status][goreportcard-badge]][goreportcard]
+[![REUSE status][reuse-shield]][reuse]
+[![scratchanitch.dev badge][scratchanitch-badge]][scratchanitch]
+[![XMPP Chat][xmpp-shield]][xmpp]
+
+_Receive credentials from clients in a reasonably secure manner_
+
+---
+
+Credentials are encrypted client-side with [agewasm] (a WebAssembly wrapper for
+[age]) before they're submitted to the server. The server receives that encrypted
+string and passes it to the email addresses listed in the config.
+
+[agewasm]: https://github.com/MarinX/agewasm
+[age]: https://github.com/FiloSottile/age
+
+## Installation
+
+### From binary
+
+Head to the [releases page] and download the latest binary for your platform.
+
+[releases page]: https://git.sr.ht/~amolith/wyrd/refs
+
+**NOTE:** binaries are built for as many platforms as possible, but only tested
+on Linux/amd64. If you find a bug on another platform, please [open an issue][todo].
+
+### From source
+
+```sh
+git clone https://git.sr.ht/~amolith/wyrd
+cd wyrd
+go build -o wyrd .
+```
+
+## Usage
+
+Execute the binary with the `-c` flag to specify the path to a `config.toml`
+file. If the file exists, it's read and parsed. If it doesn't exist, it's
+created with default values. Modify those values then re-execute the binary.
+
+```sh
+$ ./wyrd -h
+Usage of ./wyrd:
+  -c, --config string   Path to config file (default "config.toml")
+pflag: help requested
+```
+
+Here's an example `systemd` service file.
+
+```ini
+[Unit]
+Description=Password thing
+After=syslog.target
+After=network.target
+
+[Service]
+RestartSec=2s
+Type=simple
+User=wyrd
+Group=wyrd
+WorkingDirectory=/home/wyrd/
+ExecStart=/home/wyrd/wyrd -c /home/wyrd/config.toml
+Restart=always
+
+[Install]
+WantedBy=multi-user.target
+```
+
+## Screenshots
+
+### Light mode
+
+![Screenshot of the web UI with the system set to light mode](screenshots/screenshot-light.png)
+
+### Dark mode
+
+![Screenshot of the web UI with the system set to dark mode](screenshots/screenshot-dark.png)
+
+
+## Contributing
+
+Contributions are very much welcome! Please take a look at the [ticket
+tracker][todo] and see if there's anything you're interested in working on. If
+there's specific functionality you'd like to see implemented and it's not
+mentioned in the ticket tracker, please post to the [mailing list][email] and
+describe the feature.
+
+Questions, comments, and patches can always be sent to the [mailing
+list][email], but I'm also in the [IRC channel][irc]/[XMPP room][xmpp] pretty
+much 24/7. However, I might not see messages right away because I'm working on
+something else (or sleeping) so please stick around.
+
+If you're wanting to introduce a new feature and I don't feel like it fits with
+this project's goal, I encourage you to fork the repo and make whatever changes
+you like!
+
+- Email: [~amolith/public-inbox@lists.sr.ht][email]
+- IRC: [irc.libera.chat/##secluded][irc]
+- XMPP: [secluded@muc.secluded.site][xmpp]
+
+[email]: mailto:~amolith/public-inbox@lists.sr.ht
+[irc]: irc://irc.nixnet.services/##secluded
+[xmpp]: xmpp:secluded@muc.secluded.site?join
+[todo]: https://todo.sr.ht/~amolith/public-tracker?search=status%3Aopen%20label%3A%22wyrd%22
+
+_If you haven't used mailing lists before, please take a look at [SourceHut's
+documentation](https://man.sr.ht/lists.sr.ht/), especially the etiquette
+section._
+
+### Required tools
+
+- [Go](https://go.dev/)
+- [gofumpt](https://github.com/mvdan/gofumpt)
+  - Stricter formatting rules than the default `go fmt`
+- [golangci-lint](https://golangci-lint.run/)
+  - Aggregates various preinstalled Go linters, runs them in parallel, and makes
+    heavy use of the Go build cache
+- [Staticcheck](https://staticcheck.dev/)
+  - Uses static analysis to find bugs and performance issues, offer
+    simplifications, and enforce style rules
+
+### Suggested tools
+
+- [just](https://github.com/casey/just)
+  - Command runner to simplify use of the required tools
+
+### Configuring git for git send-email
+
+First, go through the tutorial on
+[git-send-email.io](https://git-send-email.io/).
+
+``` shell
+git config sendemail.to "~amolith/public-inbox@lists.sr.ht"
+```
+
+[goreportcard-badge]: https://goreportcard.com/badge/git.sr.ht/~amolith/wyrd
+[goreportcard]: https://goreportcard.com/report/git.sr.ht/~amolith/wyrd
+[reuse]: https://api.reuse.software/info/git.sr.ht/~amolith/wyrd
+[reuse-shield]: https://shields.io/reuse/compliance/git.sr.ht/~amolith/wyrd
+[fosspay]: https://secluded.site/donate/
+[fosspay-shield]: https://shields.io/badge/donate-fosspay-yellow
+[scratchanitch-badge]: https://img.shields.io/badge/scratchanitch-dev-FFC4B5
+[scratchanitch]: https://scratchanitch.dev
+[xmpp-shield]: https://img.shields.io/badge/XMPP-secluded@muc.secluded.site-dc5f1d?logo=xmpp
+[xmpp]: xmpp:secluded@muc.secluded.site?join

build-all.sh 🔗

@@ -0,0 +1,59 @@
+#!/usr/bin/env sh
+
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# SPDX-License-Identifier: CC0-1.0
+
+export CGO_ENABLED=0
+
+if ! git describe --tags --exact-match HEAD; then
+    echo "Not a tagged commit, refusing to build for all platforms."
+    exit 0
+fi
+
+TAG=$(git describe --tags --exact-match HEAD)
+NAME=$(basename "$(pwd)")
+
+mkdir -p "out/$TAG"
+
+while read -r LOOP_OS LOOP_ARCH; do
+    echo "Building $NAME-$LOOP_OS-$LOOP_ARCH"
+    GOOS="$LOOP_OS" GOARCH="$LOOP_ARCH" go build -ldflags="-s -w" -o "out/$TAG/$NAME-$LOOP_OS-$LOOP_ARCH"
+done <<EOF
+aix ppc64
+darwin amd64
+darwin arm64
+dragonfly amd64
+freebsd 386
+freebsd amd64
+freebsd arm
+illumos amd64
+linux 386
+linux amd64
+linux arm
+linux arm64
+linux loong64
+linux mips
+linux mipsle
+linux mips64
+linux mips64le
+linux ppc64
+linux ppc64le
+linux riscv64
+linux s390x
+netbsd 386
+netbsd amd64
+netbsd arm
+openbsd 386
+openbsd amd64
+openbsd arm
+openbsd arm64
+plan9 386
+plan9 amd64
+plan9 arm
+solaris amd64
+windows 386
+windows amd64
+windows arm
+windows arm64
+EOF

go.mod 🔗

@@ -0,0 +1,11 @@
+module git.sr.ht/~amolith/wyrd
+
+go 1.20
+
+require (
+	github.com/BurntSushi/toml v1.3.2
+	github.com/spf13/pflag v1.0.5
+	gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
+)
+
+require gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect

go.mod.license 🔗

@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+
+SPDX-License-Identifier: CC0-1.0

go.sum 🔗

@@ -0,0 +1,8 @@
+github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
+github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
+gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
+gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
+gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=

go.sum.license 🔗

@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+
+SPDX-License-Identifier: CC0-1.0

justfile 🔗

@@ -0,0 +1,26 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# SPDX-License-Identifier: CC0-1.0
+
+default: reuse lint test staticcheck
+
+reuse:
+    reuse lint
+
+lint:
+    # Linting Go code
+    go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
+    golangci-lint run
+
+test:
+    # Running tests
+    go test -v ./...
+
+staticcheck:
+    # Performing static analysis
+    go install honnef.co/go/tools/cmd/staticcheck@latest
+    staticcheck ./...
+
+clean:
+    # Cleaning up
+    rm -rf wyrd

main.go 🔗

@@ -0,0 +1,269 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: BSD-2-Clause
+
+package main
+
+import (
+	"embed"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"html/template"
+	"io"
+	"net/http"
+	"os"
+	"strings"
+
+	"github.com/BurntSushi/toml"
+	flag "github.com/spf13/pflag"
+	"gopkg.in/gomail.v2"
+)
+
+//go:embed static
+var fs embed.FS
+
+var flagConfig *string = flag.StringP("config", "c", "config.toml", "Path to config file")
+
+type (
+	Config struct {
+		Server     server
+		Org        org
+		Smtpconfig smtpconfig
+		Gotify     gotify
+	}
+	server struct {
+		Listen   string
+		Password string
+	}
+	org struct {
+		Name          string
+		Primarycolour string
+		Agepubkey     string
+		Notify        []string
+	}
+	smtpconfig struct {
+		Enabled  bool
+		Hostname string
+		Port     int
+		From     string
+		User     string
+		Password string
+	}
+	gotify struct {
+		Enabled  bool
+		Server   string
+		Token    string
+		Priority int
+	}
+)
+
+var config Config
+
+func main() {
+	flag.Parse()
+	// Check whether config exists
+	if _, err := os.Stat(*flagConfig); os.IsNotExist(err) {
+		fmt.Println("Creating config file")
+		err = createConfig()
+		if err != nil {
+			fmt.Println(err)
+			os.Exit(1)
+		}
+		fmt.Println("Config file created at " + *flagConfig + ", please edit it and restart the program.")
+		os.Exit(0)
+	}
+	_, err := toml.DecodeFile(*flagConfig, &config)
+	if err != nil {
+		fmt.Println(err)
+		os.Exit(1)
+	}
+	if config.Server.Password == "" {
+		fmt.Println("Password not set, please set it in the config file and restart the program.")
+		os.Exit(1)
+	}
+	fmt.Println("Listening on", config.Server.Listen)
+	mux := http.NewServeMux()
+	httpServer := &http.Server{
+		Addr:    config.Server.Listen,
+		Handler: mux,
+	}
+	mux.HandleFunc("/", fourOhFour)
+	mux.HandleFunc("/static/", static)
+	mux.HandleFunc("/success/", status)
+	mux.HandleFunc("/error/", status)
+	mux.HandleFunc("/404/", fourOhFour)
+	mux.HandleFunc("/"+config.Server.Password, form)
+	if err = httpServer.ListenAndServe(); errors.Is(err, http.ErrServerClosed) {
+		fmt.Println("Web server closed")
+	} else {
+		panic(err)
+	}
+}
+
+// Writes default config to file
+func createConfig() error {
+	_, err := os.Create(*flagConfig)
+	if err != nil {
+		return err
+	}
+	// Write default config to file
+	defaultConfig := `[server]
+listen    = "localhost:8257"
+password  = ""
+
+[org]
+name          = "Organisation Name"
+primarycolour = "#00897b"
+agepubkey     = ""
+notify        = ["credentials@example.com"]
+
+[smtpConfig]
+enabled  = true
+hostname = "smtp.example.com"
+port     = 587
+from     = "Credentials <noreply@example.com>"
+user     = "noreply@example.com"
+password = "super-secure-password"
+
+# Not implemented yet
+[gotify]
+enabled  = true
+server   = "https://notify.example.com"
+token    = "super-secure-token"
+priority = 4
+`
+	f, err := os.OpenFile(*flagConfig, os.O_APPEND|os.O_WRONLY, 0o600)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+	_, err = f.WriteString(defaultConfig)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+func static(writer http.ResponseWriter, request *http.Request) {
+	resource := strings.TrimPrefix(request.URL.Path, "/")
+	// if path ends in .css, set content type to text/css
+	if strings.HasSuffix(resource, ".css") {
+		writer.Header().Set("Content-Type", "text/css")
+	} else if strings.HasSuffix(resource, ".js") {
+		writer.Header().Set("Content-Type", "text/javascript")
+	}
+	home, err := fs.ReadFile(resource)
+	if err != nil {
+		fmt.Println(err)
+	}
+	if _, err = io.WriteString(writer, string(home)); err != nil {
+		fmt.Println(err)
+	}
+}
+
+func status(writer http.ResponseWriter, request *http.Request) {
+	path := strings.ReplaceAll(request.URL.Path, "/", "")
+	page, err := fs.ReadFile("static/" + path + ".html")
+	if err != nil {
+		fmt.Println(err)
+		http.Redirect(writer, request, "/404", http.StatusNotFound)
+		return
+	}
+	tmpl, err := template.New("page").Parse(string(page))
+	if err != nil {
+		fmt.Println(err)
+	}
+	writer.Header().Set("Content-Type", "text/html")
+	if err = tmpl.Execute(writer, config); err != nil {
+		fmt.Println(err)
+	}
+}
+
+func fourOhFour(writer http.ResponseWriter, request *http.Request) {
+	page, err := fs.ReadFile("static/404.html")
+	if err != nil {
+		fmt.Println(err)
+	}
+	tmpl, err := template.New("page").Parse(string(page))
+	if err != nil {
+		fmt.Println(err)
+	}
+	writer.Header().Set("Content-Type", "text/html")
+	if err = tmpl.Execute(writer, config); err != nil {
+		fmt.Println(err)
+	}
+}
+
+func form(writer http.ResponseWriter, request *http.Request) {
+	if request.Method == "GET" {
+		home, err := fs.ReadFile("static/form.html")
+		if err != nil {
+			fmt.Println(err)
+		}
+		tmpl, err := template.New("home").Parse(string(home))
+		if err != nil {
+			fmt.Println(err)
+		}
+		writer.Header().Set("Content-Type", "text/html")
+		if err = tmpl.Execute(writer, config); err != nil {
+			fmt.Println(err)
+		}
+		return
+	}
+
+	bodyBuf := new(strings.Builder)
+	_, err := io.Copy(bodyBuf, request.Body)
+	if err != nil {
+		fmt.Println(err)
+	}
+
+	body := bodyBuf.String()
+
+	correctHeader := "-----BEGIN AGE ENCRYPTED FILE-----\n"
+	correctFooter := "-----END AGE ENCRYPTED FILE-----\n"
+	correctBodyBeginning := "age-encryption.org/v1\n-> X25519"
+
+	if !strings.HasPrefix(body, correctHeader) {
+		fmt.Println("Malformed submission from " + request.RemoteAddr + ": header check failed")
+		http.Redirect(writer, request, "/error", http.StatusSeeOther)
+		return
+	}
+	if !strings.HasSuffix(body, correctFooter) {
+		fmt.Println("Malformed submission from " + request.RemoteAddr + ": footer check failed")
+		http.Redirect(writer, request, "/error", http.StatusSeeOther)
+		return
+	}
+
+	base64Body := strings.TrimPrefix(body, correctHeader)
+	base64Body = strings.TrimSuffix(base64Body, correctFooter)
+	base64Body = strings.ReplaceAll(base64Body, " ", "")
+	base64Body = strings.ReplaceAll(base64Body, "\n", "")
+	decodedBodyBytes, err := base64.StdEncoding.DecodeString(base64Body)
+	if err != nil {
+		fmt.Println("Malformed submission from " + request.RemoteAddr + ": base64 decoding failed")
+		http.Redirect(writer, request, "/error", http.StatusSeeOther)
+		return
+	}
+
+	decodedBody := string(decodedBodyBytes)
+
+	if !strings.HasPrefix(decodedBody, correctBodyBeginning) {
+		fmt.Println("Malformed submission from " + request.RemoteAddr + ": body check failed")
+		http.Redirect(writer, request, "/error", http.StatusSeeOther)
+		return
+	}
+
+	for _, to := range config.Org.Notify {
+		m := gomail.NewMessage()
+		m.SetHeader("From", config.Smtpconfig.From)
+		m.SetHeader("To", to)
+		m.SetHeader("Subject", "New credentials from client")
+		m.SetBody("text/plain", body+"\nThe text above is encrypted with age; please install age and use your\norganisation's private key to decrypt it.\n\nhttps://github.com/FiloSottile/age\n")
+		d := gomail.NewDialer(config.Smtpconfig.Hostname, config.Smtpconfig.Port, config.Smtpconfig.User, config.Smtpconfig.Password)
+		if err := d.DialAndSend(m); err != nil {
+			fmt.Println(err)
+		}
+	}
+	http.Redirect(writer, request, "/success", http.StatusSeeOther)
+}

static/404.html 🔗

@@ -0,0 +1,32 @@
+<!doctype html>
+<html lang="en-US">
+    <head>
+        <title>Credentials submission area | {{ .Org.Name }}</title>
+        <meta charset="utf-8">
+        <meta http-equiv="x-ua-compatible" content="ie=edge">
+        <meta name="description" content="Securely send credentials to {{ .Org.Name }}">
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+        <link rel="stylesheet" href="/static/pico.classless.min.css">
+        <style>
+         :root {
+             --primary: {{ .Org.Primarycolour }} !important;
+         }
+        </style>
+    </head>
+    <body>
+        <main>
+            <section>
+                <hgroup>
+                    <h2>Credentials submission area</h2>
+                    <h3>Securely submit credentials to {{ .Org.Name }}</h3>
+                </hgroup>
+            </section>
+            <section>
+                <h4>404: The page you have requested cannot be found.</h4>
+                <p>Please contact {{ .Org.Name }} for further assistance.</p>
+            </section>
+        </main>
+        <footer>
+        </footer>
+    </body>
+</html>

static/404.html.license 🔗

@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+
+SPDX-License-Identifier: CC0-1.0

static/error.html 🔗

@@ -0,0 +1,31 @@
+<!doctype html>
+<html lang="en-US">
+    <head>
+        <title>Credentials submission area | {{ .Org.Name }}</title>
+        <meta charset="utf-8">
+        <meta http-equiv="x-ua-compatible" content="ie=edge">
+        <meta name="description" content="Securely send credentials to {{ .Org.Name }}">
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+        <link rel="stylesheet" href="/static/pico.classless.min.css">
+        <style>
+         :root {
+             --primary: {{ .Org.Primarycolour }} !important;
+         }
+        </style>
+    </head>
+    <body>
+        <main>
+            <section>
+                <hgroup>
+                    <h2>Credentials submission area</h2>
+                    <h3>Securely submit credentials to {{ .Org.Name }}</h3>
+                </hgroup>
+            </section>
+            <section>
+                <h4>There was an error submitting your credentials. Please either <a href="#" onclick="window.history.back();">try again</a> or contact {{ .Org.Name }}.</h4>
+            </section>
+        </main>
+        <footer>
+        </footer>
+    </body>
+</html>

static/form.html 🔗

@@ -0,0 +1,81 @@
+<!DOCTYPE html>
+<html lang="en-US">
+    <head>
+        <title>Credentials submission area | {{ .Org.Name }}</title>
+        <meta charset="utf-8">
+        <meta http-equiv="x-ua-compatible" content="ie=edge">
+        <meta name="description" content="Securely send credentials to {{ .Org.Name }}">
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+        <link rel="stylesheet" href="/static/pico.classless.min.css">
+        <script src="/static/wasm_exec.js"></script>
+        <script>
+            const go = new Go();
+            WebAssembly.instantiateStreaming(fetch("/static/age.wasm"), go.importObject).then((result) => {
+                go.run(result.instance);
+            });
+        </script>
+        <style>
+         :root {
+             --primary: {{ .Org.Primarycolour }} !important;
+         }
+        </style>
+    </head>
+    <body>
+        <main>
+            <section>
+                <hgroup>
+                    <h2>Credentials submission area</h2>
+                    <h3>Securely submit credentials to {{ .Org.Name }}</h3>
+                </hgroup>
+            </section>
+            <section>
+                <p>This is a tool run by {{ .Org.Name }} that allows us to receive clients' credentials in a secure manner. Sending usernames and passwords over SMS or email is <i>not</i> secure and asking our clients to do so would be irresponsible. Please use this form when sending us usernames and passwords. We will never ask for them any other way.</p>
+            </section>
+            <section>
+                <form id="encryptform">
+                    <label for="orgname">Name</label>
+                    <input type="text" id="orgname" name="orgname" placeholder="Your name or that of your business/organisation" required>
+                    <label for="servicename">Service name</label>
+                    <input type="text" id="servicename" name="servicename" placeholder="Name of the service these credentials are for" required>
+                    <label for="username">Service username</label>
+                    <input type="text" id="username" name="username" placeholder="Username or email address you enter when logging in" required>
+                    <label for="password">Service password</label>
+                    <input type="password" id="password" name="password" placeholder="Password you enter when logging in" required>
+                    <label for="notes">Notes</label>
+                    <textarea rows="5" id="notes" name="notes" placeholder="Any additional notes about the service or these credentials"></textarea>
+                    <button type="submit">Submit</button>
+                </form>
+            </section>
+        </main>
+        <footer>
+        </footer>
+        <script>
+        document.getElementById("encryptform").addEventListener("submit", function(e) {
+            e.preventDefault();
+            const orgname = document.getElementById("orgname").value;
+            const servicename = document.getElementById("servicename").value;
+            const username = document.getElementById("username").value;
+            const password = document.getElementById("password").value;
+            const notes = document.getElementById("notes").value;
+            const message = `Age-encrypted credentials from ${orgname}\nService: ${servicename}\nUsername: ${username}\nPassword: ${password}\nNotes: ${notes}\n`;
+
+            const result = encrypt("{{ .Org.Agepubkey }}", message);
+
+            if (result.error) {
+                alert("An error occured while encrypting your data. Please contact {{ .Org.Name }}.");
+            } else {
+                fetch(window.location.href, {
+                    method: "POST",
+                    body: result.output
+                }).then((response) => {
+                    if (response.ok) {
+                        window.location.href = "/success";
+                    } else {
+                        alert("An error occured while submitting your data. Please contact {{ .Org.Name }}.");
+                    }
+                });
+            }
+        })
+        </script>
+    </body>
+</html>

static/form.html.license 🔗

@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+
+SPDX-License-Identifier: CC0-1.0

static/success.html 🔗

@@ -0,0 +1,31 @@
+<!doctype html>
+<html lang="en-US">
+    <head>
+        <title>Credentials submission area | {{ .Org.Name }}</title>
+        <meta charset="utf-8">
+        <meta http-equiv="x-ua-compatible" content="ie=edge">
+        <meta name="description" content="Securely send credentials to {{ .Org.Name }}">
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+        <link rel="stylesheet" href="/static/pico.classless.min.css">
+        <style>
+         :root {
+             --primary: {{ .Org.Primarycolour }} !important;
+         }
+        </style>
+    </head>
+    <body>
+        <main>
+            <section>
+                <hgroup>
+                    <h2>Credentials submission area</h2>
+                    <h3>Securely submit credentials to {{ .Org.Name }}</h3>
+                </hgroup>
+            </section>
+            <section>
+                <h4>Your credentials have been successfully submitted. Thank you!</h4>
+            </section>
+        </main>
+        <footer>
+        </footer>
+    </body>
+</html>

static/wasm_exec.js 🔗

@@ -0,0 +1,554 @@
+// Copyright 2018 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+"use strict";
+
+(() => {
+	const enosys = () => {
+		const err = new Error("not implemented");
+		err.code = "ENOSYS";
+		return err;
+	};
+
+	if (!globalThis.fs) {
+		let outputBuf = "";
+		globalThis.fs = {
+			constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused
+			writeSync(fd, buf) {
+				outputBuf += decoder.decode(buf);
+				const nl = outputBuf.lastIndexOf("\n");
+				if (nl != -1) {
+					console.log(outputBuf.substring(0, nl));
+					outputBuf = outputBuf.substring(nl + 1);
+				}
+				return buf.length;
+			},
+			write(fd, buf, offset, length, position, callback) {
+				if (offset !== 0 || length !== buf.length || position !== null) {
+					callback(enosys());
+					return;
+				}
+				const n = this.writeSync(fd, buf);
+				callback(null, n);
+			},
+			chmod(path, mode, callback) { callback(enosys()); },
+			chown(path, uid, gid, callback) { callback(enosys()); },
+			close(fd, callback) { callback(enosys()); },
+			fchmod(fd, mode, callback) { callback(enosys()); },
+			fchown(fd, uid, gid, callback) { callback(enosys()); },
+			fstat(fd, callback) { callback(enosys()); },
+			fsync(fd, callback) { callback(null); },
+			ftruncate(fd, length, callback) { callback(enosys()); },
+			lchown(path, uid, gid, callback) { callback(enosys()); },
+			link(path, link, callback) { callback(enosys()); },
+			lstat(path, callback) { callback(enosys()); },
+			mkdir(path, perm, callback) { callback(enosys()); },
+			open(path, flags, mode, callback) { callback(enosys()); },
+			read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
+			readdir(path, callback) { callback(enosys()); },
+			readlink(path, callback) { callback(enosys()); },
+			rename(from, to, callback) { callback(enosys()); },
+			rmdir(path, callback) { callback(enosys()); },
+			stat(path, callback) { callback(enosys()); },
+			symlink(path, link, callback) { callback(enosys()); },
+			truncate(path, length, callback) { callback(enosys()); },
+			unlink(path, callback) { callback(enosys()); },
+			utimes(path, atime, mtime, callback) { callback(enosys()); },
+		};
+	}
+
+	if (!globalThis.process) {
+		globalThis.process = {
+			getuid() { return -1; },
+			getgid() { return -1; },
+			geteuid() { return -1; },
+			getegid() { return -1; },
+			getgroups() { throw enosys(); },
+			pid: -1,
+			ppid: -1,
+			umask() { throw enosys(); },
+			cwd() { throw enosys(); },
+			chdir() { throw enosys(); },
+		}
+	}
+
+	if (!globalThis.crypto) {
+		throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
+	}
+
+	if (!globalThis.performance) {
+		throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
+	}
+
+	if (!globalThis.TextEncoder) {
+		throw new Error("globalThis.TextEncoder is not available, polyfill required");
+	}
+
+	if (!globalThis.TextDecoder) {
+		throw new Error("globalThis.TextDecoder is not available, polyfill required");
+	}
+
+	const encoder = new TextEncoder("utf-8");
+	const decoder = new TextDecoder("utf-8");
+
+	globalThis.Go = class {
+		constructor() {
+			this.argv = ["js"];
+			this.env = {};
+			this.exit = (code) => {
+				if (code !== 0) {
+					console.warn("exit code:", code);
+				}
+			};
+			this._exitPromise = new Promise((resolve) => {
+				this._resolveExitPromise = resolve;
+			});
+			this._pendingEvent = null;
+			this._scheduledTimeouts = new Map();
+			this._nextCallbackTimeoutID = 1;
+
+			const setInt64 = (addr, v) => {
+				this.mem.setUint32(addr + 0, v, true);
+				this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
+			}
+
+			const getInt64 = (addr) => {
+				const low = this.mem.getUint32(addr + 0, true);
+				const high = this.mem.getInt32(addr + 4, true);
+				return low + high * 4294967296;
+			}
+
+			const loadValue = (addr) => {
+				const f = this.mem.getFloat64(addr, true);
+				if (f === 0) {
+					return undefined;
+				}
+				if (!isNaN(f)) {
+					return f;
+				}
+
+				const id = this.mem.getUint32(addr, true);
+				return this._values[id];
+			}
+
+			const storeValue = (addr, v) => {
+				const nanHead = 0x7FF80000;
+
+				if (typeof v === "number" && v !== 0) {
+					if (isNaN(v)) {
+						this.mem.setUint32(addr + 4, nanHead, true);
+						this.mem.setUint32(addr, 0, true);
+						return;
+					}
+					this.mem.setFloat64(addr, v, true);
+					return;
+				}
+
+				if (v === undefined) {
+					this.mem.setFloat64(addr, 0, true);
+					return;
+				}
+
+				let id = this._ids.get(v);
+				if (id === undefined) {
+					id = this._idPool.pop();
+					if (id === undefined) {
+						id = this._values.length;
+					}
+					this._values[id] = v;
+					this._goRefCounts[id] = 0;
+					this._ids.set(v, id);
+				}
+				this._goRefCounts[id]++;
+				let typeFlag = 0;
+				switch (typeof v) {
+					case "object":
+						if (v !== null) {
+							typeFlag = 1;
+						}
+						break;
+					case "string":
+						typeFlag = 2;
+						break;
+					case "symbol":
+						typeFlag = 3;
+						break;
+					case "function":
+						typeFlag = 4;
+						break;
+				}
+				this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
+				this.mem.setUint32(addr, id, true);
+			}
+
+			const loadSlice = (addr) => {
+				const array = getInt64(addr + 0);
+				const len = getInt64(addr + 8);
+				return new Uint8Array(this._inst.exports.mem.buffer, array, len);
+			}
+
+			const loadSliceOfValues = (addr) => {
+				const array = getInt64(addr + 0);
+				const len = getInt64(addr + 8);
+				const a = new Array(len);
+				for (let i = 0; i < len; i++) {
+					a[i] = loadValue(array + i * 8);
+				}
+				return a;
+			}
+
+			const loadString = (addr) => {
+				const saddr = getInt64(addr + 0);
+				const len = getInt64(addr + 8);
+				return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
+			}
+
+			const timeOrigin = Date.now() - performance.now();
+			this.importObject = {
+				go: {
+					// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
+					// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
+					// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
+					// This changes the SP, thus we have to update the SP used by the imported function.
+
+					// func wasmExit(code int32)
+					"runtime.wasmExit": (sp) => {
+						sp >>>= 0;
+						const code = this.mem.getInt32(sp + 8, true);
+						this.exited = true;
+						delete this._inst;
+						delete this._values;
+						delete this._goRefCounts;
+						delete this._ids;
+						delete this._idPool;
+						this.exit(code);
+					},
+
+					// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
+					"runtime.wasmWrite": (sp) => {
+						sp >>>= 0;
+						const fd = getInt64(sp + 8);
+						const p = getInt64(sp + 16);
+						const n = this.mem.getInt32(sp + 24, true);
+						fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
+					},
+
+					// func resetMemoryDataView()
+					"runtime.resetMemoryDataView": (sp) => {
+						sp >>>= 0;
+						this.mem = new DataView(this._inst.exports.mem.buffer);
+					},
+
+					// func nanotime1() int64
+					"runtime.nanotime1": (sp) => {
+						sp >>>= 0;
+						setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
+					},
+
+					// func walltime() (sec int64, nsec int32)
+					"runtime.walltime": (sp) => {
+						sp >>>= 0;
+						const msec = (new Date).getTime();
+						setInt64(sp + 8, msec / 1000);
+						this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
+					},
+
+					// func scheduleTimeoutEvent(delay int64) int32
+					"runtime.scheduleTimeoutEvent": (sp) => {
+						sp >>>= 0;
+						const id = this._nextCallbackTimeoutID;
+						this._nextCallbackTimeoutID++;
+						this._scheduledTimeouts.set(id, setTimeout(
+							() => {
+								this._resume();
+								while (this._scheduledTimeouts.has(id)) {
+									// for some reason Go failed to register the timeout event, log and try again
+									// (temporary workaround for https://github.com/golang/go/issues/28975)
+									console.warn("scheduleTimeoutEvent: missed timeout event");
+									this._resume();
+								}
+							},
+							getInt64(sp + 8) + 1, // setTimeout has been seen to fire up to 1 millisecond early
+						));
+						this.mem.setInt32(sp + 16, id, true);
+					},
+
+					// func clearTimeoutEvent(id int32)
+					"runtime.clearTimeoutEvent": (sp) => {
+						sp >>>= 0;
+						const id = this.mem.getInt32(sp + 8, true);
+						clearTimeout(this._scheduledTimeouts.get(id));
+						this._scheduledTimeouts.delete(id);
+					},
+
+					// func getRandomData(r []byte)
+					"runtime.getRandomData": (sp) => {
+						sp >>>= 0;
+						crypto.getRandomValues(loadSlice(sp + 8));
+					},
+
+					// func finalizeRef(v ref)
+					"syscall/js.finalizeRef": (sp) => {
+						sp >>>= 0;
+						const id = this.mem.getUint32(sp + 8, true);
+						this._goRefCounts[id]--;
+						if (this._goRefCounts[id] === 0) {
+							const v = this._values[id];
+							this._values[id] = null;
+							this._ids.delete(v);
+							this._idPool.push(id);
+						}
+					},
+
+					// func stringVal(value string) ref
+					"syscall/js.stringVal": (sp) => {
+						sp >>>= 0;
+						storeValue(sp + 24, loadString(sp + 8));
+					},
+
+					// func valueGet(v ref, p string) ref
+					"syscall/js.valueGet": (sp) => {
+						sp >>>= 0;
+						const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
+						sp = this._inst.exports.getsp() >>> 0; // see comment above
+						storeValue(sp + 32, result);
+					},
+
+					// func valueSet(v ref, p string, x ref)
+					"syscall/js.valueSet": (sp) => {
+						sp >>>= 0;
+						Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
+					},
+
+					// func valueDelete(v ref, p string)
+					"syscall/js.valueDelete": (sp) => {
+						sp >>>= 0;
+						Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
+					},
+
+					// func valueIndex(v ref, i int) ref
+					"syscall/js.valueIndex": (sp) => {
+						sp >>>= 0;
+						storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
+					},
+
+					// valueSetIndex(v ref, i int, x ref)
+					"syscall/js.valueSetIndex": (sp) => {
+						sp >>>= 0;
+						Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
+					},
+
+					// func valueCall(v ref, m string, args []ref) (ref, bool)
+					"syscall/js.valueCall": (sp) => {
+						sp >>>= 0;
+						try {
+							const v = loadValue(sp + 8);
+							const m = Reflect.get(v, loadString(sp + 16));
+							const args = loadSliceOfValues(sp + 32);
+							const result = Reflect.apply(m, v, args);
+							sp = this._inst.exports.getsp() >>> 0; // see comment above
+							storeValue(sp + 56, result);
+							this.mem.setUint8(sp + 64, 1);
+						} catch (err) {
+							sp = this._inst.exports.getsp() >>> 0; // see comment above
+							storeValue(sp + 56, err);
+							this.mem.setUint8(sp + 64, 0);
+						}
+					},
+
+					// func valueInvoke(v ref, args []ref) (ref, bool)
+					"syscall/js.valueInvoke": (sp) => {
+						sp >>>= 0;
+						try {
+							const v = loadValue(sp + 8);
+							const args = loadSliceOfValues(sp + 16);
+							const result = Reflect.apply(v, undefined, args);
+							sp = this._inst.exports.getsp() >>> 0; // see comment above
+							storeValue(sp + 40, result);
+							this.mem.setUint8(sp + 48, 1);
+						} catch (err) {
+							sp = this._inst.exports.getsp() >>> 0; // see comment above
+							storeValue(sp + 40, err);
+							this.mem.setUint8(sp + 48, 0);
+						}
+					},
+
+					// func valueNew(v ref, args []ref) (ref, bool)
+					"syscall/js.valueNew": (sp) => {
+						sp >>>= 0;
+						try {
+							const v = loadValue(sp + 8);
+							const args = loadSliceOfValues(sp + 16);
+							const result = Reflect.construct(v, args);
+							sp = this._inst.exports.getsp() >>> 0; // see comment above
+							storeValue(sp + 40, result);
+							this.mem.setUint8(sp + 48, 1);
+						} catch (err) {
+							sp = this._inst.exports.getsp() >>> 0; // see comment above
+							storeValue(sp + 40, err);
+							this.mem.setUint8(sp + 48, 0);
+						}
+					},
+
+					// func valueLength(v ref) int
+					"syscall/js.valueLength": (sp) => {
+						sp >>>= 0;
+						setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
+					},
+
+					// valuePrepareString(v ref) (ref, int)
+					"syscall/js.valuePrepareString": (sp) => {
+						sp >>>= 0;
+						const str = encoder.encode(String(loadValue(sp + 8)));
+						storeValue(sp + 16, str);
+						setInt64(sp + 24, str.length);
+					},
+
+					// valueLoadString(v ref, b []byte)
+					"syscall/js.valueLoadString": (sp) => {
+						sp >>>= 0;
+						const str = loadValue(sp + 8);
+						loadSlice(sp + 16).set(str);
+					},
+
+					// func valueInstanceOf(v ref, t ref) bool
+					"syscall/js.valueInstanceOf": (sp) => {
+						sp >>>= 0;
+						this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
+					},
+
+					// func copyBytesToGo(dst []byte, src ref) (int, bool)
+					"syscall/js.copyBytesToGo": (sp) => {
+						sp >>>= 0;
+						const dst = loadSlice(sp + 8);
+						const src = loadValue(sp + 32);
+						if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
+							this.mem.setUint8(sp + 48, 0);
+							return;
+						}
+						const toCopy = src.subarray(0, dst.length);
+						dst.set(toCopy);
+						setInt64(sp + 40, toCopy.length);
+						this.mem.setUint8(sp + 48, 1);
+					},
+
+					// func copyBytesToJS(dst ref, src []byte) (int, bool)
+					"syscall/js.copyBytesToJS": (sp) => {
+						sp >>>= 0;
+						const dst = loadValue(sp + 8);
+						const src = loadSlice(sp + 16);
+						if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
+							this.mem.setUint8(sp + 48, 0);
+							return;
+						}
+						const toCopy = src.subarray(0, dst.length);
+						dst.set(toCopy);
+						setInt64(sp + 40, toCopy.length);
+						this.mem.setUint8(sp + 48, 1);
+					},
+
+					"debug": (value) => {
+						console.log(value);
+					},
+				}
+			};
+		}
+
+		async run(instance) {
+			if (!(instance instanceof WebAssembly.Instance)) {
+				throw new Error("Go.run: WebAssembly.Instance expected");
+			}
+			this._inst = instance;
+			this.mem = new DataView(this._inst.exports.mem.buffer);
+			this._values = [ // JS values that Go currently has references to, indexed by reference id
+				NaN,
+				0,
+				null,
+				true,
+				false,
+				globalThis,
+				this,
+			];
+			this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
+			this._ids = new Map([ // mapping from JS values to reference ids
+				[0, 1],
+				[null, 2],
+				[true, 3],
+				[false, 4],
+				[globalThis, 5],
+				[this, 6],
+			]);
+			this._idPool = [];   // unused ids that have been garbage collected
+			this.exited = false; // whether the Go program has exited
+
+			// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
+			let offset = 4096;
+
+			const strPtr = (str) => {
+				const ptr = offset;
+				const bytes = encoder.encode(str + "\0");
+				new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
+				offset += bytes.length;
+				if (offset % 8 !== 0) {
+					offset += 8 - (offset % 8);
+				}
+				return ptr;
+			};
+
+			const argc = this.argv.length;
+
+			const argvPtrs = [];
+			this.argv.forEach((arg) => {
+				argvPtrs.push(strPtr(arg));
+			});
+			argvPtrs.push(0);
+
+			const keys = Object.keys(this.env).sort();
+			keys.forEach((key) => {
+				argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
+			});
+			argvPtrs.push(0);
+
+			const argv = offset;
+			argvPtrs.forEach((ptr) => {
+				this.mem.setUint32(offset, ptr, true);
+				this.mem.setUint32(offset + 4, 0, true);
+				offset += 8;
+			});
+
+			// The linker guarantees global data starts from at least wasmMinDataAddr.
+			// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
+			const wasmMinDataAddr = 4096 + 8192;
+			if (offset >= wasmMinDataAddr) {
+				throw new Error("total length of command line and environment variables exceeds limit");
+			}
+
+			this._inst.exports.run(argc, argv);
+			if (this.exited) {
+				this._resolveExitPromise();
+			}
+			await this._exitPromise;
+		}
+
+		_resume() {
+			if (this.exited) {
+				throw new Error("Go program has already exited");
+			}
+			this._inst.exports.resume();
+			if (this.exited) {
+				this._resolveExitPromise();
+			}
+		}
+
+		_makeFuncWrapper(id) {
+			const go = this;
+			return function () {
+				const event = { id: id, this: this, args: arguments };
+				go._pendingEvent = event;
+				go._resume();
+				return event.result;
+			};
+		}
+	}
+})();