commit 8d86bdd33d0dadf59e51dbb7419e379d0786be5c
Author: Eric Bower
Date: Wed Jul 13 13:30:27 2022 -0400
initial release
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..fc43e30
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,14 @@
+*.log
+*.swp
+.env
+.envrc
+build/*
+!build/.gitkeep
+ssh_data/*
+!ssh_data/.gitkeep
+caddy_data/*
+!caddy_data/.gitkeep
+caddy_config/*
+!caddy_config/.gitkeep
+.env.prod
+*.bak
diff --git a/Caddyfile b/Caddyfile
new file mode 100644
index 0000000..8e6f6fb
--- /dev/null
+++ b/Caddyfile
@@ -0,0 +1,8 @@
+*.pastes.sh, pastes.sh {
+ reverse_proxy web:3000
+ tls hello@pastes.sh
+ tls {
+ dns cloudflare {env.CF_API_TOKEN}
+ }
+ encode zstd gzip
+}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..4f765cb
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,21 @@
+FROM golang:1.18.1-alpine3.15 AS builder
+
+RUN apk add --no-cache git
+
+WORKDIR /app
+COPY . ./
+
+RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ./build/ssh ./cmd/ssh
+RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ./build/web ./cmd/web
+
+FROM alpine:3.15 AS ssh
+WORKDIR /app
+COPY --from=0 /app/build/ssh ./
+CMD ["./ssh"]
+
+FROM alpine:3.15 AS web
+WORKDIR /app
+COPY --from=0 /app/build/web ./
+COPY --from=0 /app/html ./html
+COPY --from=0 /app/public ./public
+CMD ["./web"]
diff --git a/Dockerfile.caddy b/Dockerfile.caddy
new file mode 100644
index 0000000..b123f64
--- /dev/null
+++ b/Dockerfile.caddy
@@ -0,0 +1,8 @@
+FROM caddy:builder-alpine AS builder
+
+RUN xcaddy build \
+ --with github.com/caddy-dns/cloudflare
+
+FROM caddy:alpine
+
+COPY --from=builder /usr/bin/caddy /usr/bin/caddy
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..ea0c82c
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 Eric Bower
+
+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.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..cd7d4d5
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,76 @@
+PGDATABASE?="pastes"
+PGHOST?="db"
+PGUSER?="postgres"
+PORT?="5432"
+DB_CONTAINER?=pastessh_db_1
+
+test:
+ docker run --rm -v $(shell pwd):/app -w /app golangci/golangci-lint:latest golangci-lint run -E goimports -E godot
+.PHONY: test
+
+build:
+ go build -o build/web ./cmd/web
+ go build -o build/ssh ./cmd/ssh
+.PHONY: build
+
+format:
+ go fmt ./...
+.PHONY: format
+
+create:
+ docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) < ./db/setup.sql
+.PHONY: create
+
+teardown:
+ docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/teardown.sql
+.PHONY: teardown
+
+migrate:
+ docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220310_init.sql
+ docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220422_add_desc_to_user_and_post.sql
+ docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220426_add_index_for_filename.sql
+ docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220427_username_to_lower.sql
+ docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220523_timestamp_with_tz.sql
+.PHONY: migrate
+
+latest:
+ docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220523_timestamp_with_tz.sql
+.PHONY: latest
+
+psql:
+ docker exec -it $(DB_CONTAINER) psql -U $(PGUSER)
+.PHONY: psql
+
+dump:
+ docker exec -it $(DB_CONTAINER) pg_dump -U $(PGUSER) $(PGDATABASE) > ./backup.sql
+.PHONY: dump
+
+restore:
+ docker cp ./backup.sql $(DB_CONTAINER):/backup.sql
+ docker exec -it $(DB_CONTAINER) /bin/bash
+ # psql postgres -U postgres < /backup.sql
+.PHONY: restore
+
+bp-caddy:
+ docker build -t neurosnap/pastes-caddy -f Dockerfile.caddy .
+ docker push neurosnap/pastes-caddy
+.PHONY: bp-caddy
+
+bp-ssh:
+ docker build -t neurosnap/pastes-ssh --target ssh .
+ docker push neurosnap/pastes-ssh
+.PHONY: bp-ssh
+
+bp-web:
+ docker build -t neurosnap/pastes-web --target web .
+ docker push neurosnap/pastes-web
+.PHONY: bp-web
+
+bp: bp-ssh bp-web bp-caddy
+.PHONY: bp
+
+deploy:
+ docker system prune -f
+ docker-compose -f production.yml pull --ignore-pull-failures
+ docker-compose -f production.yml up --no-deps -d
+.PHONY: deploy
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..69b15f9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,108 @@
+# pastes.sh
+
+A pastebin service for hackers.
+
+## comms
+
+- [website](https://pico.sh)
+- [irc #pico.sh](irc://irc.libera.chat/#pico.sh)
+- [mailing list](https://lists.sr.ht/~erock/pico.sh)
+- [ticket tracker](https://todo.sr.ht/~erock/pico.sh)
+- [email](mailto:hello@pico.sh)
+
+## setup
+
+- golang `v1.18`
+
+You'll also need some environment variables
+
+```
+export POSTGRES_PASSWORD="secret"
+export DATABASE_URL="postgresql://postgres:secret@db/pastes?sslmode=disable"
+export PASTES_SSH_PORT=2222
+export PASTES_WEB_PORT=3000
+export PASTES_DOMAIN="pastes.sh"
+export PASTES_EMAIL="hello@pastes.sh"
+```
+
+I just use `direnv` which will load my `.env` file.
+
+## development
+
+### db
+
+I use `docker-compose` to standup a postgresql server. If you already have a
+server running you can skip this step.
+
+Copy example `.env`
+
+```bash
+cp .env.example .env
+```
+
+Then run docker compose.
+
+```bash
+docker-compose up -d
+```
+
+Then create the database and migrate
+
+```bash
+make create
+make migrate
+```
+
+### build the apps
+
+```bash
+make build
+```
+
+### run the apps
+
+There are two apps: an ssh and web server.
+
+```bash
+./build/ssh
+```
+
+Default port for ssh server is `2222`.
+
+```bash
+./build/web
+```
+
+Default port for web server is `3000`.
+
+### subdomains
+
+Since we use subdomains for blogs, you'll need to update your `/etc/hosts` file
+to accommodate.
+
+```bash
+# /etc/hosts
+127.0.0.1 pastes.test
+127.0.0.1 erock.pastes.test
+```
+
+Wildcards are not support in `/etc/hosts` so you'll have to add a subdomain for
+each blog in development. For this example you'll also want to change the domain
+env var to `PASTES_DOMAIN=pastes.test`.
+
+## deployment
+
+I use `docker-compose` for deployment. First you need `.env.prod`.
+
+```bash
+cp .env.example .env.prod
+```
+
+The `production.yml` file in this repo uses my docker hub images for deployment.
+
+```bash
+docker-compose -f production.yml up -d
+```
+
+If you want to deploy using your own domain then you'll need to edit the
+`Caddyfile` with your domain.
diff --git a/build/.gitkeep b/build/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/cmd/ssh/main.go b/cmd/ssh/main.go
new file mode 100644
index 0000000..3d6be10
--- /dev/null
+++ b/cmd/ssh/main.go
@@ -0,0 +1,94 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "git.sr.ht/~erock/pastes.sh/internal"
+ "git.sr.ht/~erock/wish/cms"
+ "git.sr.ht/~erock/wish/cms/db/postgres"
+ "git.sr.ht/~erock/wish/proxy"
+ "git.sr.ht/~erock/wish/send/scp"
+ "git.sr.ht/~erock/wish/send/sftp"
+ "github.com/charmbracelet/wish"
+ bm "github.com/charmbracelet/wish/bubbletea"
+ lm "github.com/charmbracelet/wish/logging"
+ "github.com/gliderlabs/ssh"
+)
+
+type SSHServer struct{}
+
+func (me *SSHServer) authHandler(ctx ssh.Context, key ssh.PublicKey) bool {
+ return true
+}
+
+func createRouter(handler *internal.DbHandler) proxy.Router {
+ return func(sh ssh.Handler, s ssh.Session) []wish.Middleware {
+ cmd := s.Command()
+ mdw := []wish.Middleware{}
+
+ if len(cmd) == 0 {
+ mdw = append(mdw,
+ bm.Middleware(cms.Middleware(&handler.Cfg.ConfigCms, handler.Cfg)),
+ lm.Middleware(),
+ )
+ } else if cmd[0] == "scp" {
+ mdw = append(mdw, scp.Middleware(handler))
+ }
+
+ return mdw
+ }
+}
+
+func withProxy(handler *internal.DbHandler) ssh.Option {
+ return func(server *ssh.Server) error {
+ err := sftp.SSHOption(handler)(server)
+ if err != nil {
+ return err
+ }
+
+ return proxy.WithProxy(createRouter(handler))(server)
+ }
+}
+
+func main() {
+ host := internal.GetEnv("PASTES_HOST", "0.0.0.0")
+ port := internal.GetEnv("PASTES_SSH_PORT", "2222")
+ cfg := internal.NewConfigSite()
+ logger := cfg.Logger
+ dbh := postgres.NewDB(&cfg.ConfigCms)
+ defer dbh.Close()
+ handler := internal.NewDbHandler(dbh, cfg)
+
+ sshServer := &SSHServer{}
+ s, err := wish.NewServer(
+ wish.WithAddress(fmt.Sprintf("%s:%s", host, port)),
+ wish.WithHostKeyPath("ssh_data/term_info_ed25519"),
+ wish.WithPublicKeyAuth(sshServer.authHandler),
+ withProxy(handler),
+ )
+ if err != nil {
+ logger.Fatal(err)
+ }
+
+ done := make(chan os.Signal, 1)
+ signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
+ logger.Infof("Starting SSH server on %s:%s", host, port)
+ go func() {
+ if err = s.ListenAndServe(); err != nil {
+ logger.Fatal(err)
+ }
+ }()
+
+ <-done
+ logger.Info("Stopping SSH server")
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer func() { cancel() }()
+ if err := s.Shutdown(ctx); err != nil {
+ logger.Fatal(err)
+ }
+}
diff --git a/cmd/web/main.go b/cmd/web/main.go
new file mode 100644
index 0000000..ce8e4a1
--- /dev/null
+++ b/cmd/web/main.go
@@ -0,0 +1,7 @@
+package main
+
+import "git.sr.ht/~erock/pastes.sh/internal"
+
+func main() {
+ internal.StartApiServer()
+}
diff --git a/db/migrations/20220310_init.sql b/db/migrations/20220310_init.sql
new file mode 100644
index 0000000..5ac6eb8
--- /dev/null
+++ b/db/migrations/20220310_init.sql
@@ -0,0 +1,40 @@
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+
+CREATE TABLE IF NOT EXISTS app_users (
+ id uuid NOT NULL DEFAULT uuid_generate_v4(),
+ name character varying(50),
+ created_at timestamp without time zone NOT NULL DEFAULT NOW(),
+ CONSTRAINT unique_name UNIQUE (name),
+ CONSTRAINT app_user_pkey PRIMARY KEY (id)
+);
+
+CREATE TABLE IF NOT EXISTS public_keys (
+ id uuid NOT NULL DEFAULT uuid_generate_v4(),
+ user_id uuid NOT NULL,
+ public_key varchar(2048) NOT NULL,
+ created_at timestamp without time zone NOT NULL DEFAULT NOW(),
+ CONSTRAINT user_public_keys_pkey PRIMARY KEY (id),
+ CONSTRAINT unique_key_for_user UNIQUE (user_id, public_key),
+ CONSTRAINT fk_user_public_keys_owner
+ FOREIGN KEY(user_id)
+ REFERENCES app_users(id)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS posts (
+ id uuid NOT NULL DEFAULT uuid_generate_v4(),
+ user_id uuid NOT NULL,
+ title character varying(255) NOT NULL,
+ text text NOT NULL DEFAULT '',
+ publish_at timestamp without time zone NOT NULL DEFAULT NOW(),
+ created_at timestamp without time zone NOT NULL DEFAULT NOW(),
+ updated_at timestamp without time zone NOT NULL DEFAULT NOW(),
+ CONSTRAINT posts_pkey PRIMARY KEY (id),
+ CONSTRAINT unique_title_for_user UNIQUE (user_id, title),
+ CONSTRAINT fk_posts_app_users
+ FOREIGN KEY(user_id)
+ REFERENCES app_users(id)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE
+);
diff --git a/db/migrations/20220422_add_desc_to_user_and_post.sql b/db/migrations/20220422_add_desc_to_user_and_post.sql
new file mode 100644
index 0000000..709ef3c
--- /dev/null
+++ b/db/migrations/20220422_add_desc_to_user_and_post.sql
@@ -0,0 +1,8 @@
+ALTER TABLE app_users ADD COLUMN bio character varying(150) NOT NULL DEFAULT '';
+ALTER TABLE posts ADD COLUMN description character varying(150) NOT NULL DEFAULT '';
+ALTER TABLE posts ADD COLUMN filename character varying(255);
+
+UPDATE posts SET filename = title;
+
+ALTER TABLE posts ADD CONSTRAINT unique_filename_for_user UNIQUE (user_id, filename);
+ALTER TABLE posts DROP CONSTRAINT unique_title_for_user;
diff --git a/db/migrations/20220426_add_index_for_filename.sql b/db/migrations/20220426_add_index_for_filename.sql
new file mode 100644
index 0000000..9d8c734
--- /dev/null
+++ b/db/migrations/20220426_add_index_for_filename.sql
@@ -0,0 +1,2 @@
+CREATE INDEX posts_filename ON posts USING btree(filename);
+ALTER TABLE app_users DROP COLUMN bio;
diff --git a/db/migrations/20220427_username_to_lower.sql b/db/migrations/20220427_username_to_lower.sql
new file mode 100644
index 0000000..d98a69d
--- /dev/null
+++ b/db/migrations/20220427_username_to_lower.sql
@@ -0,0 +1 @@
+UPDATE app_users SET name = LOWER(name) WHERE name != LOWER(name);
diff --git a/db/migrations/20220523_timestamp_with_tz.sql b/db/migrations/20220523_timestamp_with_tz.sql
new file mode 100644
index 0000000..f5044b3
--- /dev/null
+++ b/db/migrations/20220523_timestamp_with_tz.sql
@@ -0,0 +1,5 @@
+ALTER TABLE posts ALTER COLUMN updated_at TYPE timestamp WITH TIME ZONE USING updated_at AT TIME ZONE 'UTC';
+ALTER TABLE posts ALTER COLUMN publish_at TYPE timestamp WITH TIME ZONE USING publish_at AT TIME ZONE 'UTC';
+ALTER TABLE posts ALTER COLUMN created_at TYPE timestamp WITH TIME ZONE USING created_at AT TIME ZONE 'UTC';
+ALTER TABLE app_users ALTER COLUMN created_at TYPE timestamp WITH TIME ZONE USING created_at AT TIME ZONE 'UTC';
+ALTER TABLE public_keys ALTER COLUMN created_at TYPE timestamp WITH TIME ZONE USING created_at AT TIME ZONE 'UTC';
diff --git a/db/setup.sql b/db/setup.sql
new file mode 100644
index 0000000..71aa078
--- /dev/null
+++ b/db/setup.sql
@@ -0,0 +1 @@
+CREATE DATABASE "pastes" OWNER "postgres";
diff --git a/db/teardown.sql b/db/teardown.sql
new file mode 100644
index 0000000..3b69e40
--- /dev/null
+++ b/db/teardown.sql
@@ -0,0 +1,3 @@
+DROP TABLE posts CASCADE;
+DROP TABLE app_users CASCADE;
+DROP TABLE public_keys CASCADE;
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..4783ab4
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,9 @@
+version: "3.4"
+services:
+ db:
+ image: postgres
+ restart: always
+ ports:
+ - "5432:5432"
+ env_file:
+ - .env
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..7fab7b0
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,46 @@
+module git.sr.ht/~erock/pastes.sh
+
+go 1.18
+
+require (
+ git.sr.ht/~erock/wish v0.0.0-20220713141740-64595ee518ac
+ github.com/alecthomas/chroma v0.10.0
+ github.com/charmbracelet/wish v0.5.0
+ github.com/gliderlabs/ssh v0.3.4
+ github.com/yuin/goldmark v1.4.12
+ github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594
+ github.com/yuin/goldmark-meta v1.1.0
+ go.uber.org/zap v1.21.0
+)
+
+require (
+ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
+ github.com/atotto/clipboard v0.1.4 // indirect
+ github.com/caarlos0/sshmarshal v0.1.0 // indirect
+ github.com/charmbracelet/bubbles v0.12.0 // indirect
+ github.com/charmbracelet/bubbletea v0.22.0 // indirect
+ github.com/charmbracelet/keygen v0.3.0 // indirect
+ github.com/charmbracelet/lipgloss v0.5.0 // indirect
+ github.com/containerd/console v1.0.3 // indirect
+ github.com/dlclark/regexp2 v1.4.0 // indirect
+ github.com/kr/fs v0.1.0 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/lib/pq v1.10.6 // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/mattn/go-isatty v0.0.14 // indirect
+ github.com/mattn/go-runewidth v0.0.13 // indirect
+ github.com/mitchellh/go-homedir v1.1.0 // indirect
+ github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/reflow v0.3.0 // indirect
+ github.com/muesli/termenv v0.12.0 // indirect
+ github.com/pkg/sftp v1.13.5 // indirect
+ github.com/rivo/uniseg v0.2.0 // indirect
+ go.uber.org/atomic v1.9.0 // indirect
+ go.uber.org/multierr v1.8.0 // indirect
+ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d // indirect
+ golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d // indirect
+ golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b // indirect
+ golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..b65a6fd
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,160 @@
+git.sr.ht/~erock/wish v0.0.0-20220707194507-66e938674d95 h1:q3G01ELBZt3EZEOiYWfwdiqZWtp6PKYueRHT2eeOlSU=
+git.sr.ht/~erock/wish v0.0.0-20220707194507-66e938674d95/go.mod h1:QZKk7m9jc9iXah90daPGhQkSfNfxSVvpb6nfVeI+MM0=
+git.sr.ht/~erock/wish v0.0.0-20220713141740-64595ee518ac h1:d+q9VPi+kaZpYpZOoXPSEx2KtZ3Gcmkz+x/lpb/V6bU=
+git.sr.ht/~erock/wish v0.0.0-20220713141740-64595ee518ac/go.mod h1:QZKk7m9jc9iXah90daPGhQkSfNfxSVvpb6nfVeI+MM0=
+github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
+github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
+github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
+github.com/caarlos0/sshmarshal v0.1.0 h1:zTCZrDORFfWh526Tsb7vCm3+Yg/SfW/Ub8aQDeosk0I=
+github.com/caarlos0/sshmarshal v0.1.0/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQHfr9dlpDIyA=
+github.com/charmbracelet/bubbles v0.12.0 h1:fxb9U9yI60Hek3tcPmMTFya5NhvPrqpkpyMaNngFh7A=
+github.com/charmbracelet/bubbles v0.12.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=
+github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=
+github.com/charmbracelet/bubbletea v0.22.0 h1:E1BTNSE3iIrq0G0X6TjGAmrQ32cGCbFDPcIuImikrUc=
+github.com/charmbracelet/bubbletea v0.22.0/go.mod h1:aoVIwlNlr5wbCB26KhxfrqAn0bMp4YpJcoOelbxApjs=
+github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
+github.com/charmbracelet/keygen v0.3.0 h1:mXpsQcH7DDlST5TddmXNXjS0L7ECk4/kLQYyBcsan2Y=
+github.com/charmbracelet/keygen v0.3.0/go.mod h1:1ukgO8806O25lUZ5s0IrNur+RlwTBERlezdgW71F5rM=
+github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
+github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
+github.com/charmbracelet/wish v0.5.0 h1:FkkdNBFqrLABR1ciNrAL2KCxoyWfKhXnIGZw6GfAtPg=
+github.com/charmbracelet/wish v0.5.0/go.mod h1:5GAn5SrDSZ7cgKjnC+3kDmiIo7I6k4/AYiRzC4+tpCk=
+github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
+github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
+github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
+github.com/gliderlabs/ssh v0.3.4 h1:+AXBtim7MTKaLVPgvE+3mhewYRawNLTd+jEEz/wExZw=
+github.com/gliderlabs/ssh v0.3.4/go.mod h1:ZSS+CUoKHDrqVakTfTWUlKSr9MtMFkC4UvtQKD7O914=
+github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
+github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
+github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs=
+github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
+github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
+github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
+github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA=
+github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
+github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/cancelreader v0.2.1/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
+github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
+github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
+github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
+github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
+github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc=
+github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A=
+github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/sftp v1.13.5 h1:a3RLUqkyjYRtBTZJZ1VRrKbN3zhuPLlUc3sphVz81go=
+github.com/pkg/sftp v1.13.5/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/yuin/goldmark v1.4.5/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
+github.com/yuin/goldmark v1.4.12 h1:6hffw6vALvEDqJ19dOJvJKOoAOKe4NDaTqvd2sktGN0=
+github.com/yuin/goldmark v1.4.12/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 h1:yHfZyN55+5dp1wG7wDKv8HQ044moxkyGq12KFFMFDxg=
+github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594/go.mod h1:U9ihbh+1ZN7fR5Se3daSPoz1CGF9IYtSvWwVQtnzGHU=
+github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc=
+github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0=
+go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
+go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
+go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
+go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8=
+go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
+go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
+go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d h1:vtUKgx8dahOomfFzLREU8nSv25YHnTgLBn4rDnWZdU0=
+golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b h1:2n253B2r0pYSmEV+UNCQoPfU/FiaizQEK5Gu4Bq4JE8=
+golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM=
+golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/html/base.layout.tmpl b/html/base.layout.tmpl
new file mode 100644
index 0000000..9b4de13
--- /dev/null
+++ b/html/base.layout.tmpl
@@ -0,0 +1,18 @@
+{{define "base"}}
+
+
+
+
+
+ {{template "title" .}}
+
+
+
+
+ {{template "meta" .}}
+
+
+
+ {{template "body" .}}
+
+{{end}}
diff --git a/html/blog.page.tmpl b/html/blog.page.tmpl
new file mode 100644
index 0000000..5d97de7
--- /dev/null
+++ b/html/blog.page.tmpl
@@ -0,0 +1,44 @@
+{{template "base" .}}
+
+{{define "title"}}{{.PageTitle}}{{end}}
+
+{{define "meta"}}
+
+
+
+
+
+
+{{if .Header.Bio}} {{end}}
+
+
+
+
+
+
+
+
+{{if .Header.Bio}} {{end}}
+
+
+{{end}}
+
+{{define "body"}}
+
+
+
+ {{range .Posts}}
+
+
+
+ {{end}}
+
+
+{{template "footer" .}}
+{{end}}
diff --git a/html/footer.partial.tmpl b/html/footer.partial.tmpl
new file mode 100644
index 0000000..c9f4efa
--- /dev/null
+++ b/html/footer.partial.tmpl
@@ -0,0 +1,6 @@
+{{define "footer"}}
+
+{{end}}
diff --git a/html/help.page.tmpl b/html/help.page.tmpl
new file mode 100644
index 0000000..f791c16
--- /dev/null
+++ b/html/help.page.tmpl
@@ -0,0 +1,108 @@
+{{template "base" .}}
+
+{{define "title"}}help -- {{.Site.Domain}}{{end}}
+
+{{define "meta"}}
+
+{{end}}
+
+{{define "body"}}
+
+
+
+ I get a permission denied when trying to SSH
+
+ Unfortunately SHA-2 RSA keys are not currently supported.
+
+
+ Unfortunately, due to a shortcoming in Go’s x/crypto/ssh package, Soft Serve does
+ not currently support access via new SSH RSA keys: only the old SHA-1 ones will work.
+ Until we sort this out you’ll either need an SHA-1 RSA key or a key with another
+ algorithm, e.g. Ed25519. Not sure what type of keys you have? You can check with the
+ following:
+
+ $ find ~/.ssh/id_*.pub -exec ssh-keygen -l -f {} \;
+ If you’re curious about the inner workings of this problem have a look at:
+
+
+
+
+ Generating a new SSH key
+
+ Github reference
+
+ ssh-keygen -t ed25519 -C "your_email@example.com"
+
+ When you're prompted to "Enter a file in which to save the key," press Enter. This accepts the default file location.
+ At the prompt, type a secure passphrase.
+
+
+
+
+ How do I update a pastebin post?
+
+ Updating a post requires that you update the source document and then run the scp
+ command again. If the filename remains the same, then the post will be updated.
+
+
+
+
+ How do I delete a post?
+
+ Because scp
does not natively support deleting files, I didn't want to bake
+ that behavior into my ssh server.
+
+
+
+ However, if a user wants to delete a post they can delete the contents of the file and
+ then upload it to our server. If the file contains 0 bytes, we will remove the post.
+ For example, if you want to delete delete.txt
you could:
+
+
+
+cp /dev/null delete.txt
+scp ./delete.txt {{.Site.Domain}}:/
+
+
+ Alternatively, you can go to ssh {{.Site.Domain}}
and select "Manage posts."
+ Then you can highlight the post you want to delete and then press "X." It will ask for
+ confirmation before actually removing the post.
+
+
+
+
+ When I want to publish a new post, do I have to upload all posts everytime?
+
+ Nope! Just scp
the file you want to publish. For example, if you created
+ a new post called taco-tuesday.md
then you would publish it like this:
+
+ scp ./taco-tuesday.md {{.Site.Domain}}:
+
+
+
+ What is my pastebin URL?
+ https://{username}.{{.Site.Domain}}
+
+
+
+ Can I create multiple accounts?
+
+ Yes! You can either a) create a new keypair and use that for authentication
+ or b) use the same keypair and ssh into our CMS using our special username
+ ssh new@{{.Site.Domain}}
.
+
+
+ Please note that if you use the same keypair for multiple accounts, you will need to
+ always specify the user when logging into our CMS.
+
+
+
+{{template "marketing-footer" .}}
+{{end}}
diff --git a/html/marketing-footer.partial.tmpl b/html/marketing-footer.partial.tmpl
new file mode 100644
index 0000000..0ab59ee
--- /dev/null
+++ b/html/marketing-footer.partial.tmpl
@@ -0,0 +1,12 @@
+{{define "marketing-footer"}}
+
+{{end}}
diff --git a/html/marketing.page.tmpl b/html/marketing.page.tmpl
new file mode 100644
index 0000000..0647dbb
--- /dev/null
+++ b/html/marketing.page.tmpl
@@ -0,0 +1,99 @@
+{{template "base" .}}
+
+{{define "title"}}{{.Site.Domain}} -- a pastebin for hackers{{end}}
+
+{{define "meta"}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{end}}
+
+{{define "body"}}
+
+ {{.Site.Domain}}
+ a pastebin platform for hackers
+ discover some interesting pastebins
+
+
+
+
+
+ Create your account with Public-Key Cryptography
+ We don't want your email address.
+ To get started, simply ssh into our content management system:
+ ssh new@{{.Site.Domain}}
+
+ note: new
is a special username that will always send you to account
+ creation, even with multiple accounts associated with your key-pair.
+
+
+ note: getting permission denied?
read this
+
+
+ After that, just set a username and you're ready to start writing! When you SSH
+ again, use your username that you set in the CMS.
+
+
+
+
+ Publish your pastes with one command
+
+ When your post is ready to be published, copy the file to our server with a familiar
+ command:
+
+ scp ~/pastes/* {{.Site.Domain}}:/
+ We'll either create or update the posts for you.
+
+
+
+ Terminal workflow without installation
+
+ Since we are leveraging tools you already have on your computer
+ (ssh
and scp
), there is nothing to install.
+
+
+ This provides the convenience of a web app, but from inside your terminal!
+
+
+
+
+ Features
+
+ Bring your own editor
+ You control the source files
+ Terminal workflow with no installation
+ Public-key based authentication
+ No ads, zero tracking
+ No platform lock-in
+ No javascript
+ Minimalist design
+ 100% open source
+
+
+
+
+
+
+{{template "marketing-footer" .}}
+{{end}}
diff --git a/html/ops.page.tmpl b/html/ops.page.tmpl
new file mode 100644
index 0000000..435b5e9
--- /dev/null
+++ b/html/ops.page.tmpl
@@ -0,0 +1,124 @@
+{{template "base" .}}
+
+{{define "title"}}operations -- {{.Site.Domain}}{{end}}
+
+{{define "meta"}}
+
+{{end}}
+
+{{define "body"}}
+
+
+
+ Purpose
+
+ {{.Site.Domain}} exists to allow people to create and share their thoughts
+ without the need to set up their own server or be part of a platform
+ that shows ads or tracks its users.
+
+
+
+ Ethics
+ We are committed to:
+
+ No tracking of user or visitor behaviour.
+ Never sell any user or visitor data.
+ No ads — ever.
+
+
+
+ Code of Content Publication
+
+ Content in {{.Site.Domain}} pastes is unfiltered and unmonitored. Users are free to publish any
+ combination of words and pixels except for: content of animosity or disparagement of an
+ individual or a group on account of a group characteristic such as race, color, national
+ origin, sex, disability, religion, or sexual orientation, which will be taken down
+ immediately.
+
+
+ If one notices something along those lines in a paste post please let us know at
+ {{.Site.Email}} .
+
+
+
+ Liability
+
+ The user expressly understands and agrees that Eric Bower and Antonio Mika, the operator of this website
+ shall not be liable, in law or in equity, to them or to any third party for any direct,
+ indirect, incidental, lost profits, special, consequential, punitive or exemplary damages.
+
+
+
+ Account Terms
+
+
+
+ The user is responsible for all content posted and all actions performed with
+ their account.
+
+
+ We reserve the right to disable or delete a user's account for any reason at
+ any time. We have this clause because, statistically speaking, there will be
+ people trying to do something nefarious.
+
+
+
+
+
+ Service Availability
+
+ We provide the {{.Site.Domain}} service on an "as is" and "as available" basis. We do not offer
+ service-level agreements but do take uptime seriously.
+
+
+
+ Contact and Support
+
+ Email us at {{.Site.Email}}
+ with any questions.
+
+
+
+ Acknowledgments
+
+ {{.Site.Domain}} was inspired by Mataroa Blog
+ and Bear Blog .
+
+
+ {{.Site.Domain}} is built with many open source technologies.
+
+
+ In particular we would like to thank:
+
+
+
+
+{{template "marketing-footer" .}}
+{{end}}
diff --git a/html/post.page.tmpl b/html/post.page.tmpl
new file mode 100644
index 0000000..e8eee0c
--- /dev/null
+++ b/html/post.page.tmpl
@@ -0,0 +1,36 @@
+{{template "base" .}}
+
+{{define "title"}}{{.PageTitle}}{{end}}
+
+{{define "meta"}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{end}}
+
+{{define "body"}}
+
+
+
+ {{.Contents}}
+
+
+{{template "footer" .}}
+{{end}}
diff --git a/html/privacy.page.tmpl b/html/privacy.page.tmpl
new file mode 100644
index 0000000..f19d849
--- /dev/null
+++ b/html/privacy.page.tmpl
@@ -0,0 +1,52 @@
+{{template "base" .}}
+
+{{define "title"}}privacy -- {{.Site.Domain}}{{end}}
+
+{{define "meta"}}
+
+{{end}}
+
+{{define "body"}}
+
+
+
+ Account Data
+
+ In order to have a functional account at {{.Site.Domain}}, we need to store
+ your public key. That is the only piece of information we record for a user.
+
+
+ Because we use public-key cryptography, our security posture is a battle-tested
+ and proven technique for authentication.
+
+
+
+
+ Third parties
+
+ We have a strong commitment to never share any user data with any third-parties.
+
+
+
+
+
+
+ Cookies
+
+ We do not use any cookies, not even account authentication.
+
+
+
+{{template "marketing-footer" .}}
+{{end}}
diff --git a/html/read.page.tmpl b/html/read.page.tmpl
new file mode 100644
index 0000000..72a1871
--- /dev/null
+++ b/html/read.page.tmpl
@@ -0,0 +1,35 @@
+{{template "base" .}}
+
+{{define "title"}}discover -- {{.Site.Domain}}{{end}}
+
+{{define "meta"}}
+
+{{end}}
+
+{{define "body"}}
+
+ read
+ recent pastes
+
+
+
+
+ {{if .PrevPage}}
prev {{else}}
prev {{end}}
+ {{if .NextPage}}
next {{else}}
next {{end}}
+
+ {{range .Posts}}
+
+
+
+ {{end}}
+
+{{template "marketing-footer" .}}
+{{end}}
diff --git a/html/rss.page.tmpl b/html/rss.page.tmpl
new file mode 100644
index 0000000..062b5ed
--- /dev/null
+++ b/html/rss.page.tmpl
@@ -0,0 +1 @@
+{{.Contents}}
diff --git a/html/transparency.page.tmpl b/html/transparency.page.tmpl
new file mode 100644
index 0000000..42d8406
--- /dev/null
+++ b/html/transparency.page.tmpl
@@ -0,0 +1,57 @@
+{{template "base" .}}
+
+{{define "title"}}transparency -- {{.Site.Domain}}{{end}}
+
+{{define "meta"}}
+
+{{end}}
+
+{{define "body"}}
+
+
+
+ Analytics
+
+ Here are some interesting stats on usage.
+
+
+
+ Total users
+ {{.Analytics.TotalUsers}}
+
+
+
+ New users in the last month
+ {{.Analytics.UsersLastMonth}}
+
+
+
+ Total pastes
+ {{.Analytics.TotalPosts}}
+
+
+
+ New pastes in the last month
+ {{.Analytics.PostsLastMonth}}
+
+
+
+ Users with at least one paste
+ {{.Analytics.UsersWithPost}}
+
+
+
+
+ Service maintenance costs
+
+ Server $5.00/mo
+ Domain name $3.25/mo
+ Programmer $0.00/mo
+
+
+
+{{template "marketing-footer" .}}
+{{end}}
diff --git a/internal/api.go b/internal/api.go
new file mode 100644
index 0000000..109f272
--- /dev/null
+++ b/internal/api.go
@@ -0,0 +1,499 @@
+package internal
+
+import (
+ "fmt"
+ "html/template"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+
+ "git.sr.ht/~erock/wish/cms/db"
+ "git.sr.ht/~erock/wish/cms/db/postgres"
+)
+
+type PageData struct {
+ Site SitePageData
+}
+
+type PostItemData struct {
+ URL template.URL
+ BlogURL template.URL
+ Username string
+ Title string
+ Description string
+ PublishAtISO string
+ PublishAt string
+ UpdatedAtISO string
+ UpdatedTimeAgo string
+ Padding string
+}
+
+type BlogPageData struct {
+ Site SitePageData
+ PageTitle string
+ URL template.URL
+ RSSURL template.URL
+ Username string
+ Header *HeaderTxt
+ Posts []PostItemData
+}
+
+type ReadPageData struct {
+ Site SitePageData
+ NextPage string
+ PrevPage string
+ Posts []PostItemData
+}
+
+type PostPageData struct {
+ Site SitePageData
+ PageTitle string
+ URL template.URL
+ BlogURL template.URL
+ Title string
+ Description string
+ Username string
+ BlogName string
+ Contents template.HTML
+ PublishAtISO string
+ PublishAt string
+}
+
+type TransparencyPageData struct {
+ Site SitePageData
+ Analytics *db.Analytics
+}
+
+func renderTemplate(templates []string) (*template.Template, error) {
+ files := make([]string, len(templates))
+ copy(files, templates)
+ files = append(
+ files,
+ "./html/footer.partial.tmpl",
+ "./html/marketing-footer.partial.tmpl",
+ "./html/base.layout.tmpl",
+ )
+
+ ts, err := template.ParseFiles(files...)
+ if err != nil {
+ return nil, err
+ }
+ return ts, nil
+}
+
+func createPageHandler(fname string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ logger := GetLogger(r)
+ cfg := GetCfg(r)
+ ts, err := renderTemplate([]string{fname})
+
+ if err != nil {
+ logger.Error(err)
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ data := PageData{
+ Site: *cfg.GetSiteData(),
+ }
+ err = ts.Execute(w, data)
+ if err != nil {
+ logger.Error(err)
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ }
+}
+
+type Link struct {
+ URL string
+ Text string
+}
+
+type HeaderTxt struct {
+ Title string
+ Bio string
+ Nav []Link
+ HasLinks bool
+}
+
+type ReadmeTxt struct {
+ HasText bool
+ Contents template.HTML
+}
+
+func GetUsernameFromRequest(r *http.Request) string {
+ subdomain := GetSubdomain(r)
+ cfg := GetCfg(r)
+
+ if !cfg.IsSubdomains() || subdomain == "" {
+ return GetField(r, 0)
+ }
+ return subdomain
+}
+
+func blogHandler(w http.ResponseWriter, r *http.Request) {
+ username := GetUsernameFromRequest(r)
+ dbpool := GetDB(r)
+ logger := GetLogger(r)
+ cfg := GetCfg(r)
+
+ user, err := dbpool.FindUserForName(username)
+ if err != nil {
+ logger.Infof("blog not found: %s", username)
+ http.Error(w, "blog not found", http.StatusNotFound)
+ return
+ }
+ posts, err := dbpool.FindPostsForUser(user.ID)
+ if err != nil {
+ logger.Error(err)
+ http.Error(w, "could not fetch posts for blog", http.StatusInternalServerError)
+ return
+ }
+
+ ts, err := renderTemplate([]string{
+ "./html/blog.page.tmpl",
+ })
+
+ if err != nil {
+ logger.Error(err)
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ headerTxt := &HeaderTxt{
+ Title: GetBlogName(username),
+ Bio: "",
+ }
+
+ postCollection := make([]PostItemData, 0, len(posts))
+ for _, post := range posts {
+ p := PostItemData{
+ URL: template.URL(cfg.PostURL(post.Username, post.Filename)),
+ BlogURL: template.URL(cfg.BlogURL(post.Username)),
+ Title: FilenameToTitle(post.Filename, post.Title),
+ PublishAt: post.PublishAt.Format("02 Jan, 2006"),
+ PublishAtISO: post.PublishAt.Format(time.RFC3339),
+ UpdatedTimeAgo: TimeAgo(post.UpdatedAt),
+ UpdatedAtISO: post.UpdatedAt.Format(time.RFC3339),
+ }
+ postCollection = append(postCollection, p)
+ }
+
+ data := BlogPageData{
+ Site: *cfg.GetSiteData(),
+ PageTitle: headerTxt.Title,
+ URL: template.URL(cfg.BlogURL(username)),
+ RSSURL: template.URL(cfg.RssBlogURL(username)),
+ Header: headerTxt,
+ Username: username,
+ Posts: postCollection,
+ }
+
+ err = ts.Execute(w, data)
+ if err != nil {
+ logger.Error(err)
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
+
+func GetPostTitle(post *db.Post) string {
+ if post.Description == "" {
+ return post.Title
+ }
+
+ return fmt.Sprintf("%s: %s", post.Title, post.Description)
+}
+
+func GetBlogName(username string) string {
+ return fmt.Sprintf("%s's blog", username)
+}
+
+func postHandler(w http.ResponseWriter, r *http.Request) {
+ username := GetUsernameFromRequest(r)
+ subdomain := GetSubdomain(r)
+ cfg := GetCfg(r)
+
+ var filename string
+ if !cfg.IsSubdomains() || subdomain == "" {
+ filename, _ = url.PathUnescape(GetField(r, 1))
+ } else {
+ filename, _ = url.PathUnescape(GetField(r, 0))
+ }
+
+ dbpool := GetDB(r)
+ logger := GetLogger(r)
+
+ user, err := dbpool.FindUserForName(username)
+ if err != nil {
+ logger.Infof("blog not found: %s", username)
+ http.Error(w, "blog not found", http.StatusNotFound)
+ return
+ }
+
+ blogName := GetBlogName(username)
+
+ post, err := dbpool.FindPostWithFilename(filename, user.ID)
+ if err != nil {
+ logger.Infof("post not found %s/%s", username, filename)
+ http.Error(w, "post not found", http.StatusNotFound)
+ return
+ }
+
+ parsedText, err := ParseText(post.Filename, post.Text)
+ if err != nil {
+ logger.Error(err)
+ }
+
+ data := PostPageData{
+ Site: *cfg.GetSiteData(),
+ PageTitle: GetPostTitle(post),
+ URL: template.URL(cfg.PostURL(post.Username, post.Filename)),
+ BlogURL: template.URL(cfg.BlogURL(username)),
+ Description: post.Description,
+ Title: FilenameToTitle(post.Filename, post.Title),
+ PublishAt: post.PublishAt.Format("02 Jan, 2006"),
+ PublishAtISO: post.PublishAt.Format(time.RFC3339),
+ Username: username,
+ BlogName: blogName,
+ Contents: template.HTML(parsedText),
+ }
+
+ ts, err := renderTemplate([]string{
+ "./html/post.page.tmpl",
+ })
+
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+
+ err = ts.Execute(w, data)
+ if err != nil {
+ logger.Error(err)
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
+
+func postHandlerRaw(w http.ResponseWriter, r *http.Request) {
+ username := GetUsernameFromRequest(r)
+ subdomain := GetSubdomain(r)
+ cfg := GetCfg(r)
+
+ var filename string
+ if !cfg.IsSubdomains() || subdomain == "" {
+ filename, _ = url.PathUnescape(GetField(r, 1))
+ } else {
+ filename, _ = url.PathUnescape(GetField(r, 0))
+ }
+
+ dbpool := GetDB(r)
+ logger := GetLogger(r)
+
+ user, err := dbpool.FindUserForName(username)
+ if err != nil {
+ logger.Infof("blog not found: %s", username)
+ http.Error(w, "blog not found", http.StatusNotFound)
+ return
+ }
+
+ post, err := dbpool.FindPostWithFilename(filename, user.ID)
+ if err != nil {
+ logger.Infof("post not found %s/%s", username, filename)
+ http.Error(w, "post not found", http.StatusNotFound)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/plain")
+ w.Write([]byte(post.Text))
+}
+
+func transparencyHandler(w http.ResponseWriter, r *http.Request) {
+ dbpool := GetDB(r)
+ logger := GetLogger(r)
+ cfg := GetCfg(r)
+
+ analytics, err := dbpool.FindSiteAnalytics()
+ if err != nil {
+ logger.Error(err)
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ ts, err := template.ParseFiles(
+ "./html/transparency.page.tmpl",
+ "./html/footer.partial.tmpl",
+ "./html/marketing-footer.partial.tmpl",
+ "./html/base.layout.tmpl",
+ )
+
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+
+ data := TransparencyPageData{
+ Site: *cfg.GetSiteData(),
+ Analytics: analytics,
+ }
+ err = ts.Execute(w, data)
+ if err != nil {
+ logger.Error(err)
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
+
+func readHandler(w http.ResponseWriter, r *http.Request) {
+ dbpool := GetDB(r)
+ logger := GetLogger(r)
+ cfg := GetCfg(r)
+
+ page, _ := strconv.Atoi(r.URL.Query().Get("page"))
+ pager, err := dbpool.FindAllPosts(&db.Pager{Num: 30, Page: page})
+ if err != nil {
+ logger.Error(err)
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ ts, err := renderTemplate([]string{
+ "./html/read.page.tmpl",
+ })
+
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+
+ nextPage := ""
+ if page < pager.Total-1 {
+ nextPage = fmt.Sprintf("/read?page=%d", page+1)
+ }
+
+ prevPage := ""
+ if page > 0 {
+ prevPage = fmt.Sprintf("/read?page=%d", page-1)
+ }
+
+ data := ReadPageData{
+ Site: *cfg.GetSiteData(),
+ NextPage: nextPage,
+ PrevPage: prevPage,
+ }
+ for _, post := range pager.Data {
+ item := PostItemData{
+ URL: template.URL(cfg.PostURL(post.Username, post.Filename)),
+ BlogURL: template.URL(cfg.BlogURL(post.Username)),
+ Title: FilenameToTitle(post.Filename, post.Title),
+ Description: post.Description,
+ Username: post.Username,
+ PublishAt: post.PublishAt.Format("02 Jan, 2006"),
+ PublishAtISO: post.PublishAt.Format(time.RFC3339),
+ UpdatedTimeAgo: TimeAgo(post.UpdatedAt),
+ UpdatedAtISO: post.UpdatedAt.Format(time.RFC3339),
+ }
+ data.Posts = append(data.Posts, item)
+ }
+
+ err = ts.Execute(w, data)
+ if err != nil {
+ logger.Error(err)
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
+
+func serveFile(file string, contentType string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ logger := GetLogger(r)
+
+ contents, err := ioutil.ReadFile(fmt.Sprintf("./public/%s", file))
+ if err != nil {
+ logger.Error(err)
+ http.Error(w, "file not found", 404)
+ }
+ w.Header().Add("Content-Type", contentType)
+
+ _, err = w.Write(contents)
+ if err != nil {
+ logger.Error(err)
+ http.Error(w, "server error", 500)
+ }
+ }
+}
+
+func createStaticRoutes() []Route {
+ return []Route{
+ NewRoute("GET", "/main.css", serveFile("main.css", "text/css")),
+ NewRoute("GET", "/card.png", serveFile("card.png", "image/png")),
+ NewRoute("GET", "/favicon-16x16.png", serveFile("favicon-16x16.png", "image/png")),
+ NewRoute("GET", "/favicon-32x32.png", serveFile("favicon-32x32.png", "image/png")),
+ NewRoute("GET", "/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png")),
+ NewRoute("GET", "/favicon.ico", serveFile("favicon.ico", "image/x-icon")),
+ NewRoute("GET", "/robots.txt", serveFile("robots.txt", "text/plain")),
+ }
+}
+
+func createMainRoutes(staticRoutes []Route) []Route {
+ routes := []Route{
+ NewRoute("GET", "/", createPageHandler("./html/marketing.page.tmpl")),
+ NewRoute("GET", "/spec", createPageHandler("./html/spec.page.tmpl")),
+ NewRoute("GET", "/ops", createPageHandler("./html/ops.page.tmpl")),
+ NewRoute("GET", "/privacy", createPageHandler("./html/privacy.page.tmpl")),
+ NewRoute("GET", "/help", createPageHandler("./html/help.page.tmpl")),
+ NewRoute("GET", "/transparency", transparencyHandler),
+ NewRoute("GET", "/read", readHandler),
+ }
+
+ routes = append(
+ routes,
+ staticRoutes...,
+ )
+
+ routes = append(
+ routes,
+ NewRoute("GET", "/([^/]+)", blogHandler),
+ NewRoute("GET", "/([^/]+)/([^/]+)", postHandler),
+ NewRoute("GET", "/([^/]+)/([^/]+)/raw", postHandlerRaw),
+ )
+
+ return routes
+}
+
+func createSubdomainRoutes(staticRoutes []Route) []Route {
+ routes := []Route{
+ NewRoute("GET", "/", blogHandler),
+ }
+
+ routes = append(
+ routes,
+ staticRoutes...,
+ )
+
+ routes = append(
+ routes,
+ NewRoute("GET", "/([^/]+)", postHandler),
+ NewRoute("GET", "/([^/]+)/raw", postHandlerRaw),
+ )
+
+ return routes
+}
+
+func StartApiServer() {
+ cfg := NewConfigSite()
+ db := postgres.NewDB(&cfg.ConfigCms)
+ defer db.Close()
+ logger := cfg.Logger
+
+ staticRoutes := createStaticRoutes()
+ mainRoutes := createMainRoutes(staticRoutes)
+ subdomainRoutes := createSubdomainRoutes(staticRoutes)
+
+ handler := CreateServe(mainRoutes, subdomainRoutes, cfg, db, logger)
+ router := http.HandlerFunc(handler)
+
+ portStr := fmt.Sprintf(":%s", cfg.Port)
+ logger.Infof("Starting server on port %s", cfg.Port)
+ logger.Infof("Subdomains enabled: %t", cfg.SubdomainsEnabled)
+ logger.Infof("Domain: %s", cfg.Domain)
+ logger.Infof("Email: %s", cfg.Email)
+
+ logger.Fatal(http.ListenAndServe(portStr, router))
+}
diff --git a/internal/config.go b/internal/config.go
new file mode 100644
index 0000000..5b91dd7
--- /dev/null
+++ b/internal/config.go
@@ -0,0 +1,116 @@
+package internal
+
+import (
+ "fmt"
+ "html/template"
+ "log"
+ "net/url"
+
+ "git.sr.ht/~erock/wish/cms/config"
+ "go.uber.org/zap"
+)
+
+type SitePageData struct {
+ Domain template.URL
+ HomeURL template.URL
+ Email string
+}
+
+type ConfigSite struct {
+ config.ConfigCms
+ config.ConfigURL
+ SubdomainsEnabled bool
+}
+
+func NewConfigSite() *ConfigSite {
+ domain := GetEnv("PASTES_DOMAIN", "pastes.sh")
+ email := GetEnv("PASTES_EMAIL", "hello@pastes.sh")
+ subdomains := GetEnv("PASTES_SUBDOMAINS", "0")
+ port := GetEnv("PASTES_WEB_PORT", "3000")
+ dbURL := GetEnv("DATABASE_URL", "")
+ subdomainsEnabled := false
+ if subdomains == "1" {
+ subdomainsEnabled = true
+ }
+
+ intro := "To get started, enter a username.\n"
+ intro += "Then create a folder locally (e.g. ~/pastes).\n"
+ intro += "Then write your paste post (e.g. feature.patch).\n"
+ intro += "Finally, send your files to us:\n\n"
+ intro += fmt.Sprintf("scp ~/pastes/* %s:/", domain)
+
+ return &ConfigSite{
+ SubdomainsEnabled: subdomainsEnabled,
+ ConfigCms: config.ConfigCms{
+ Domain: domain,
+ Port: port,
+ Email: email,
+ DbURL: dbURL,
+ Description: "a pastebin for hackers.",
+ IntroText: intro,
+ Logger: CreateLogger(),
+ },
+ }
+}
+
+func (c *ConfigSite) GetSiteData() *SitePageData {
+ return &SitePageData{
+ Domain: template.URL(c.Domain),
+ HomeURL: template.URL(c.HomeURL()),
+ Email: c.Email,
+ }
+}
+
+func (c *ConfigSite) BlogURL(username string) string {
+ if c.IsSubdomains() {
+ return fmt.Sprintf("//%s.%s", username, c.Domain)
+ }
+
+ return fmt.Sprintf("/%s", username)
+}
+
+func (c *ConfigSite) PostURL(username, filename string) string {
+ fname := url.PathEscape(filename)
+ if c.IsSubdomains() {
+ return fmt.Sprintf("//%s.%s/%s", username, c.Domain, fname)
+ }
+
+ return fmt.Sprintf("/%s/%s", username, fname)
+}
+
+func (c *ConfigSite) IsSubdomains() bool {
+ return c.SubdomainsEnabled
+}
+
+func (c *ConfigSite) RssBlogURL(username string) string {
+ if c.IsSubdomains() {
+ return fmt.Sprintf("//%s.%s/rss", username, c.Domain)
+ }
+
+ return fmt.Sprintf("/%s/rss", username)
+}
+
+func (c *ConfigSite) HomeURL() string {
+ if c.IsSubdomains() {
+ return fmt.Sprintf("//%s", c.Domain)
+ }
+
+ return "/"
+}
+
+func (c *ConfigSite) ReadURL() string {
+ if c.IsSubdomains() {
+ return fmt.Sprintf("https://%s/read", c.Domain)
+ }
+
+ return "/read"
+}
+
+func CreateLogger() *zap.SugaredLogger {
+ logger, err := zap.NewProduction()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ return logger.Sugar()
+}
diff --git a/internal/db_handler.go b/internal/db_handler.go
new file mode 100644
index 0000000..aa7e924
--- /dev/null
+++ b/internal/db_handler.go
@@ -0,0 +1,119 @@
+package internal
+
+import (
+ "fmt"
+ "io"
+ "time"
+
+ "git.sr.ht/~erock/wish/cms/db"
+ "git.sr.ht/~erock/wish/cms/util"
+ "git.sr.ht/~erock/wish/send/utils"
+ "github.com/gliderlabs/ssh"
+)
+
+type Opener struct {
+ entry *utils.FileEntry
+}
+
+func (o *Opener) Open(name string) (io.Reader, error) {
+ return o.entry.Reader, nil
+}
+
+type DbHandler struct {
+ User *db.User
+ DBPool db.DB
+ Cfg *ConfigSite
+}
+
+func NewDbHandler(dbpool db.DB, cfg *ConfigSite) *DbHandler {
+ return &DbHandler{
+ DBPool: dbpool,
+ Cfg: cfg,
+ }
+}
+
+func (h *DbHandler) Validate(s ssh.Session) error {
+ var err error
+ key, err := util.KeyText(s)
+ if err != nil {
+ return fmt.Errorf("key not found")
+ }
+
+ user, err := h.DBPool.FindUserForKey(s.User(), key)
+ if err != nil {
+ return err
+ }
+
+ if user.Name == "" {
+ return fmt.Errorf("must have username set")
+ }
+
+ h.User = user
+ return nil
+}
+
+func (h *DbHandler) Write(s ssh.Session, entry *utils.FileEntry) (string, error) {
+ logger := h.Cfg.Logger
+ userID := h.User.ID
+ filename := entry.Name
+ title := filename
+ var err error
+ post, err := h.DBPool.FindPostWithFilename(filename, userID)
+ if err != nil {
+ logger.Debug("unable to load post, continuing:", err)
+ }
+
+ user, err := h.DBPool.FindUser(userID)
+ if err != nil {
+ return "", fmt.Errorf("error for %s: %v", filename, err)
+ }
+
+ var text string
+ if b, err := io.ReadAll(entry.Reader); err == nil {
+ text = string(b)
+ }
+
+ if !IsTextFile(text, entry.Filepath) {
+ logger.Errorf("WARNING: (%s) invalid file, the contents must be plain text, skipping", entry.Name)
+ return "", fmt.Errorf("WARNING: (%s) invalid file, the contents must be plain text, skipping", entry.Name)
+ }
+
+ // if the file is empty we remove it from our database
+ if len(text) == 0 {
+ // skip empty files from being added to db
+ if post == nil {
+ logger.Infof("(%s) is empty, skipping record", filename)
+ return "", nil
+ }
+
+ err := h.DBPool.RemovePosts([]string{post.ID})
+ logger.Infof("(%s) is empty, removing record", filename)
+ if err != nil {
+ logger.Errorf("error for %s: %v", filename, err)
+ return "", fmt.Errorf("error for %s: %v", filename, err)
+ }
+ } else if post == nil {
+ publishAt := time.Now()
+ logger.Infof("(%s) not found, adding record", filename)
+ _, err = h.DBPool.InsertPost(userID, filename, title, text, "", &publishAt)
+ if err != nil {
+ logger.Errorf("error for %s: %v", filename, err)
+ return "", fmt.Errorf("error for %s: %v", filename, err)
+ }
+ } else {
+ publishAt := post.PublishAt
+ if text == post.Text {
+ logger.Infof("(%s) found, but text is identical, skipping", filename)
+ return h.Cfg.PostURL(user.Name, filename), nil
+ }
+
+ logger.Infof("(%s) found, updating record", filename)
+ _, err = h.DBPool.UpdatePost(post.ID, title, text, "", publishAt)
+ if err != nil {
+ logger.Errorf("error for %s: %v", filename, err)
+ return "", fmt.Errorf("error for %s: %v", filename, err)
+ }
+ }
+
+ return h.Cfg.PostURL(user.Name, filename), nil
+}
diff --git a/internal/parser.go b/internal/parser.go
new file mode 100644
index 0000000..3daa8bf
--- /dev/null
+++ b/internal/parser.go
@@ -0,0 +1,32 @@
+package internal
+
+import (
+ "bytes"
+ "github.com/alecthomas/chroma/formatters/html"
+ "github.com/alecthomas/chroma/lexers"
+ "github.com/alecthomas/chroma/styles"
+)
+
+func ParseText(filename string, text string) (string, error) {
+ formatter := html.New(
+ html.WithLineNumbers(true),
+ html.LinkableLineNumbers(true, ""),
+ )
+ lexer := lexers.Match(filename)
+ if lexer == nil {
+ lexer = lexers.Analyse(text)
+ }
+ if lexer == nil {
+ lexer = lexers.Get("plaintext")
+ }
+ iterator, err := lexer.Tokenise(nil, text)
+ if err != nil {
+ return text, err
+ }
+ var buf bytes.Buffer
+ err = formatter.Format(&buf, styles.Dracula, iterator)
+ if err != nil {
+ return text, err
+ }
+ return buf.String(), nil
+}
diff --git a/internal/router.go b/internal/router.go
new file mode 100644
index 0000000..c13d951
--- /dev/null
+++ b/internal/router.go
@@ -0,0 +1,123 @@
+package internal
+
+import (
+ "context"
+ "net/http"
+ "regexp"
+ "strings"
+
+ "git.sr.ht/~erock/wish/cms/db"
+ "go.uber.org/zap"
+)
+
+type Route struct {
+ method string
+ regex *regexp.Regexp
+ handler http.HandlerFunc
+}
+
+func NewRoute(method, pattern string, handler http.HandlerFunc) Route {
+ return Route{
+ method,
+ regexp.MustCompile("^" + pattern + "$"),
+ handler,
+ }
+}
+
+type ServeFn func(http.ResponseWriter, *http.Request)
+
+func CreateServe(routes []Route, subdomainRoutes []Route, cfg *ConfigSite, dbpool db.DB, logger *zap.SugaredLogger) ServeFn {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var allow []string
+ curRoutes := routes
+ subdomain := GetRequestSubdomain(r)
+
+ if cfg.IsSubdomains() && subdomain != "" {
+ curRoutes = subdomainRoutes
+ }
+
+ for _, route := range curRoutes {
+ matches := route.regex.FindStringSubmatch(r.URL.Path)
+ if len(matches) > 0 {
+ if r.Method != route.method {
+ allow = append(allow, route.method)
+ continue
+ }
+ loggerCtx := context.WithValue(r.Context(), ctxLoggerKey{}, logger)
+ subdomainCtx := context.WithValue(loggerCtx, ctxSubdomainKey{}, subdomain)
+ dbCtx := context.WithValue(subdomainCtx, ctxDBKey{}, dbpool)
+ cfgCtx := context.WithValue(dbCtx, ctxCfg{}, cfg)
+ ctx := context.WithValue(cfgCtx, ctxKey{}, matches[1:])
+ route.handler(w, r.WithContext(ctx))
+ return
+ }
+ }
+ if len(allow) > 0 {
+ w.Header().Set("Allow", strings.Join(allow, ", "))
+ http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ http.NotFound(w, r)
+ }
+}
+
+type ctxDBKey struct{}
+type ctxKey struct{}
+type ctxLoggerKey struct{}
+type ctxSubdomainKey struct{}
+type ctxCfg struct{}
+
+func GetCfg(r *http.Request) *ConfigSite {
+ return r.Context().Value(ctxCfg{}).(*ConfigSite)
+}
+
+func GetLogger(r *http.Request) *zap.SugaredLogger {
+ return r.Context().Value(ctxLoggerKey{}).(*zap.SugaredLogger)
+}
+
+func GetDB(r *http.Request) db.DB {
+ return r.Context().Value(ctxDBKey{}).(db.DB)
+}
+
+func GetField(r *http.Request, index int) string {
+ fields := r.Context().Value(ctxKey{}).([]string)
+ return fields[index]
+}
+
+func GetSubdomain(r *http.Request) string {
+ return r.Context().Value(ctxSubdomainKey{}).(string)
+}
+
+// https://stackoverflow.com/a/66445657/1713216
+func GetRequestSubdomain(r *http.Request) string {
+ // The Host that the user queried.
+ host := r.Host
+ host = strings.TrimSpace(host)
+ // Figure out if a subdomain exists in the host given.
+ hostParts := strings.Split(host, ".")
+
+ lengthOfHostParts := len(hostParts)
+
+ // scenarios
+ // A. site.com -> length : 2
+ // B. www.site.com -> length : 3
+ // C. www.hello.site.com -> length : 4
+
+ if lengthOfHostParts == 4 {
+ // scenario C
+ return strings.Join([]string{hostParts[1]}, "")
+ }
+
+ // scenario B with a check
+ if lengthOfHostParts == 3 {
+ subdomain := strings.Join([]string{hostParts[0]}, "")
+
+ if subdomain == "www" {
+ return ""
+ } else {
+ return subdomain
+ }
+ }
+
+ return "" // scenario A
+}
diff --git a/internal/util.go b/internal/util.go
new file mode 100644
index 0000000..ef426dd
--- /dev/null
+++ b/internal/util.go
@@ -0,0 +1,107 @@
+package internal
+
+import (
+ "encoding/base64"
+ "fmt"
+ "math"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/gliderlabs/ssh"
+)
+
+var fnameRe = regexp.MustCompile(`[-_]+`)
+
+func FilenameToTitle(filename string, title string) string {
+ if filename != title {
+ return title
+ }
+
+ pre := fnameRe.ReplaceAllString(title, " ")
+ r := []rune(pre)
+ r[0] = unicode.ToUpper(r[0])
+ return string(r)
+}
+
+func SanitizeFileExt(fname string) string {
+ return strings.TrimSuffix(fname, filepath.Ext(fname))
+}
+
+func KeyText(s ssh.Session) (string, error) {
+ if s.PublicKey() == nil {
+ return "", fmt.Errorf("Session doesn't have public key")
+ }
+ kb := base64.StdEncoding.EncodeToString(s.PublicKey().Marshal())
+ return fmt.Sprintf("%s %s", s.PublicKey().Type(), kb), nil
+}
+
+func GetEnv(key string, defaultVal string) string {
+ if value, exists := os.LookupEnv(key); exists {
+ return value
+ }
+
+ return defaultVal
+}
+
+// IsText reports whether a significant prefix of s looks like correct UTF-8;
+// that is, if it is likely that s is human-readable text.
+func IsText(s string) bool {
+ const max = 1024 // at least utf8.UTFMax
+ if len(s) > max {
+ s = s[0:max]
+ }
+ for i, c := range s {
+ if i+utf8.UTFMax > len(s) {
+ // last char may be incomplete - ignore
+ break
+ }
+ if c == 0xFFFD || c < ' ' && c != '\n' && c != '\t' && c != '\f' {
+ // decoding error or control character - not a text file
+ return false
+ }
+ }
+ return true
+}
+
+// IsTextFile reports whether the file has a known extension indicating
+// a text file, or if a significant chunk of the specified file looks like
+// correct UTF-8; that is, if it is likely that the file contains human-
+// readable text.
+func IsTextFile(text string, filename string) bool {
+ num := math.Min(float64(len(text)), 1024)
+ return IsText(text[0:int(num)])
+}
+
+const solarYearSecs = 31556926
+
+func TimeAgo(t *time.Time) string {
+ d := time.Since(*t)
+ var metric string
+ var amount int
+ if d.Seconds() < 60 {
+ amount = int(d.Seconds())
+ metric = "second"
+ } else if d.Minutes() < 60 {
+ amount = int(d.Minutes())
+ metric = "minute"
+ } else if d.Hours() < 24 {
+ amount = int(d.Hours())
+ metric = "hour"
+ } else if d.Seconds() < solarYearSecs {
+ amount = int(d.Hours()) / 24
+ metric = "day"
+ } else {
+ amount = int(d.Seconds()) / solarYearSecs
+ metric = "year"
+ }
+ if amount == 1 {
+ return fmt.Sprintf("%d %s ago", amount, metric)
+ } else {
+ return fmt.Sprintf("%d %ss ago", amount, metric)
+ }
+}
diff --git a/production.yml b/production.yml
new file mode 100644
index 0000000..54264f4
--- /dev/null
+++ b/production.yml
@@ -0,0 +1,49 @@
+version: "3.7"
+
+services:
+ caddy:
+ image: neurosnap/pastes-caddy
+ restart: unless-stopped
+ env_file:
+ - .env.prod
+ volumes:
+ - ./Caddyfile:/etc/caddy/Caddyfile
+ - caddy_data:/data
+ - caddy_config:/config
+ ports:
+ - "443:443"
+ - "80:80"
+ links:
+ - web
+ db:
+ image: postgres
+ restart: unless-stopped
+ env_file:
+ - .env.prod
+ volumes:
+ - db_data:/var/lib/postgresql/data
+ web:
+ image: neurosnap/pastes-web
+ restart: unless-stopped
+ env_file:
+ - .env.prod
+ links:
+ - db
+ ssh:
+ image: neurosnap/pastes-ssh
+ restart: unless-stopped
+ ports:
+ - "22:2222"
+ env_file:
+ - .env.prod
+ links:
+ - db
+ volumes:
+ - ssh_data:/app/ssh_data
+
+volumes:
+ db_data:
+ caddy_data:
+ ssh_data:
+ caddy_config:
+ gemini_data:
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 0000000..6972571
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/public/card.png b/public/card.png
new file mode 100644
index 0000000..c807a0f
Binary files /dev/null and b/public/card.png differ
diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png
new file mode 100644
index 0000000..500a08e
Binary files /dev/null and b/public/favicon-16x16.png differ
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..1d6b78c
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/main.css b/public/main.css
new file mode 100644
index 0000000..38c6fd8
--- /dev/null
+++ b/public/main.css
@@ -0,0 +1,287 @@
+*, ::before, ::after {
+ box-sizing: border-box;
+}
+
+::-moz-focus-inner {
+ border-style: none;
+ padding: 0;
+}
+:-moz-focusring { outline: 1px dotted ButtonText; }
+:-moz-ui-invalid { box-shadow: none; }
+
+:root[data-theme="theme-dark"] {
+ --white: #f2f2f2;
+ --black: #252525;
+ --purple: #bd93f9;
+ --blue: #8be9fd;
+ --yellow: #ffff80;
+ --pink: #ff80bf;
+ --orange: #ffca80;
+ --green: #50fa7b;
+ --grey: #414558;
+ --greyer: #282a36;
+}
+
+html {
+ background-color: var(--greyer);
+ color: var(--white);
+ line-height: 1.5;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
+ "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji",
+ "Segoe UI Emoji", "Segoe UI Symbol";
+ -webkit-text-size-adjust: 100%;
+ -moz-tab-size: 4;
+ tab-size: 4;
+}
+
+body {
+ margin: 0 auto;
+ max-width: 42rem;
+}
+
+img {
+ max-width: 100%;
+ height: auto;
+}
+
+b, strong {
+ font-weight: bold;
+}
+
+code, kbd, samp, pre {
+ font-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
+ font-size: 1rem;
+ background-color: var(--black) !important;
+}
+
+code {
+ border-radius: 5px;
+}
+
+pre {
+ border-radius: 5px;
+ padding: 1rem;
+ overflow-x: auto;
+ margin: 0;
+}
+
+small {
+ font-size: 0.8rem;
+}
+
+summary {
+ display: list-item;
+}
+
+h1, h2, h3 {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ font-style: normal;
+ font-weight: inherit;
+ font-size: inherit;
+}
+
+hr {
+ color: inherit;
+ border: 0;
+ margin: 0;
+ height: 1px;
+ background: var(--grey);
+ margin: 2rem auto;
+ text-align: center;
+}
+
+a {
+ text-decoration: underline;
+ color: var(--blue);
+}
+
+a:hover, a:visited:hover {
+ color: var(--pink);
+}
+
+a:visited {
+ color: var(--purple);
+}
+
+a.link-grey {
+ text-decoration: underline;
+ color: var(--white);
+}
+
+a.link-grey:visited {
+ color: var(--white);
+}
+
+section {
+ margin-bottom: 2rem;
+}
+
+section:last-child {
+ margin-bottom: 0;
+}
+
+header {
+ margin: 1rem auto;
+}
+
+p {
+ margin: 1rem 0;
+}
+
+article {
+ overflow-wrap: break-word;
+}
+
+blockquote {
+ border-left: 5px solid var(--purple);
+ background-color: var(--grey);
+ padding: 0.5rem;
+ margin: 0;
+}
+
+ul, ol {
+ padding: 0 0 0 2rem;
+ list-style-position: outside;
+}
+
+ul[style*="list-style-type: none;"] {
+ padding: 0;
+}
+
+li {
+ margin: 0.5rem 0;
+}
+
+li > pre {
+ padding: 0;
+}
+
+footer {
+ text-align: center;
+ margin-bottom: 4rem;
+}
+
+dt {
+ font-weight: bold;
+}
+
+dd {
+ margin-left: 0;
+}
+
+dd:not(:last-child) {
+ margin-bottom: .5rem;
+}
+
+.md h1 {
+ font-size: 1.25rem;
+ line-height: 1.15;
+ font-weight: bold;
+ padding: 0.5rem 0;
+}
+
+.md h2 {
+ font-size: 1.125rem;
+ line-height: 1.15;
+ font-weight: bold;
+ padding: 0.5rem 0;
+}
+
+.md h3 {
+ font-weight: bold;
+}
+
+.md h4 {
+ font-size: 0.875rem;
+ font-weight: bold;
+}
+
+.post-date {
+ width: 130px;
+}
+
+.text-grey {
+ color: var(--grey);
+}
+
+.text-2xl {
+ font-size: 1.5rem;
+ line-height: 1.15;
+}
+
+.text-xl {
+ font-size: 1.25rem;
+ line-height: 1.15;
+}
+
+.text-lg {
+ font-size: 1.125rem;
+ line-height: 1.15;
+}
+
+.text-sm {
+ font-size: 0.875rem;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.font-bold {
+ font-weight: bold;
+}
+
+.font-italic {
+ font-style: italic;
+}
+
+.inline {
+ display: inline;
+}
+
+.flex {
+ display: flex;
+}
+
+.items-center {
+ align-items: center;
+}
+
+.m-0 {
+ margin: 0;
+}
+
+.my {
+ margin-top: 0.5rem;
+ margin-bottom: 0.5rem;
+}
+
+.mx {
+ margin-left: 0.5rem;
+ margin-right: 0.5rem;
+}
+
+.mx-2 {
+ margin-left: 1rem;
+ margin-right: 1rem;
+}
+
+.justify-between {
+ justify-content: space-between;
+}
+
+.flex-1 {
+ flex: 1;
+}
+
+@media only screen and (max-width: 600px) {
+ body {
+ padding: 1rem;
+ }
+
+ header {
+ margin: 0;
+ }
+}
diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 0000000..c2a49f4
--- /dev/null
+++ b/public/robots.txt
@@ -0,0 +1,2 @@
+User-agent: *
+Allow: /