From 8d86bdd33d0dadf59e51dbb7419e379d0786be5c Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Wed, 13 Jul 2022 13:30:27 -0400 Subject: [PATCH] initial release --- .gitignore | 14 + Caddyfile | 8 + Dockerfile | 21 + Dockerfile.caddy | 8 + LICENSE | 21 + Makefile | 76 +++ README.md | 108 ++++ build/.gitkeep | 0 cmd/ssh/main.go | 94 ++++ cmd/web/main.go | 7 + db/migrations/20220310_init.sql | 40 ++ .../20220422_add_desc_to_user_and_post.sql | 8 + .../20220426_add_index_for_filename.sql | 2 + db/migrations/20220427_username_to_lower.sql | 1 + db/migrations/20220523_timestamp_with_tz.sql | 5 + db/setup.sql | 1 + db/teardown.sql | 3 + docker-compose.yml | 9 + go.mod | 46 ++ go.sum | 160 ++++++ html/base.layout.tmpl | 18 + html/blog.page.tmpl | 44 ++ html/footer.partial.tmpl | 6 + html/help.page.tmpl | 108 ++++ html/marketing-footer.partial.tmpl | 12 + html/marketing.page.tmpl | 99 ++++ html/ops.page.tmpl | 124 +++++ html/post.page.tmpl | 36 ++ html/privacy.page.tmpl | 52 ++ html/read.page.tmpl | 35 ++ html/rss.page.tmpl | 1 + html/transparency.page.tmpl | 57 ++ internal/api.go | 499 ++++++++++++++++++ internal/config.go | 116 ++++ internal/db_handler.go | 119 +++++ internal/parser.go | 32 ++ internal/router.go | 123 +++++ internal/util.go | 107 ++++ production.yml | 49 ++ public/apple-touch-icon.png | Bin 0 -> 2816 bytes public/card.png | Bin 0 -> 8220 bytes public/favicon-16x16.png | Bin 0 -> 738 bytes public/favicon.ico | Bin 0 -> 15086 bytes public/main.css | 287 ++++++++++ public/robots.txt | 2 + 45 files changed, 2558 insertions(+) create mode 100644 .gitignore create mode 100644 Caddyfile create mode 100644 Dockerfile create mode 100644 Dockerfile.caddy create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 build/.gitkeep create mode 100644 cmd/ssh/main.go create mode 100644 cmd/web/main.go create mode 100644 db/migrations/20220310_init.sql create mode 100644 db/migrations/20220422_add_desc_to_user_and_post.sql create mode 100644 db/migrations/20220426_add_index_for_filename.sql create mode 100644 db/migrations/20220427_username_to_lower.sql create mode 100644 db/migrations/20220523_timestamp_with_tz.sql create mode 100644 db/setup.sql create mode 100644 db/teardown.sql create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 html/base.layout.tmpl create mode 100644 html/blog.page.tmpl create mode 100644 html/footer.partial.tmpl create mode 100644 html/help.page.tmpl create mode 100644 html/marketing-footer.partial.tmpl create mode 100644 html/marketing.page.tmpl create mode 100644 html/ops.page.tmpl create mode 100644 html/post.page.tmpl create mode 100644 html/privacy.page.tmpl create mode 100644 html/read.page.tmpl create mode 100644 html/rss.page.tmpl create mode 100644 html/transparency.page.tmpl create mode 100644 internal/api.go create mode 100644 internal/config.go create mode 100644 internal/db_handler.go create mode 100644 internal/parser.go create mode 100644 internal/router.go create mode 100644 internal/util.go create mode 100644 production.yml create mode 100644 public/apple-touch-icon.png create mode 100644 public/card.png create mode 100644 public/favicon-16x16.png create mode 100644 public/favicon.ico create mode 100644 public/main.css create mode 100644 public/robots.txt 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"}} +
+

{{.Header.Title}}

+
+
+
+
+ {{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"}} +
+

Need help?

+

Here are some common questions on using this platform that we would like to answer.

+
+
+
+

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"
+
    +
  1. When you're prompted to "Enter a file in which to save the key," press Enter. This accepts the default file location.
  2. +
  3. At the prompt, type a secure passphrase.
  4. +
+
+ +
+

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
  • +
+
+ +
+

Roadmap

+
    +
  1. idk
  2. +
+
+
+ +{{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"}} +
+

Operations

+ +
+
+
+

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"}} +
+

{{.Title}}

+

+ + on + {{.BlogName}}

+
+
+
+ {{.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"}} +
+

Privacy

+

Details on our privacy and security approach.

+
+
+
+

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. +

+
+ +
+

Service Providers

+ +
+ +
+

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"}} +
+

Transparency

+
+
+
+
+

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 0000000000000000000000000000000000000000..6972571f4ca3c2f1c7539b0f3cef35e019b5a7c2 GIT binary patch literal 2816 zcmeHIc{J2*8~$M|SrVgWvM)1YB9mo=dS#u(*oMa1GFiq@h_Q?%LLp=jWmhN)i4fUI zS@V`H6>1obt+HfG^Uiy|bH49X3JRBiZbh-_qO1OQJ{g?^EEId^$C48alr!ju6ZIt~DKIIifg01$)( zfQ1VHpnn$tApUtxwkH9AyVwSAZ^n7hlCk~I`6mPapBZ4Y*%DXd(3~=zrV^~|IAtpW zg&|KG|DmZYxu1vYfR00G#)GE8&l|t@HfM)h%CjO|^$!ZkPzt~QS}nUGFLyq#|80ra z5i!Hmp|z2_=tG`$ovMl4 zf0iFaVtxzT=i*EyEPLaoSQibxbdQ1Cv#sa5v;~r_q>csB$G@6reOk1<^+A7{g3pdk)f^#mD6NsbYum3!yTP^$U_>KFJmmfT0>#^=>(ds=fSf!i$H`B{ zTAJ}QK@bp^RMGeTG63M+#F`q}hfXf%?Q{!rCM>t<{3%plQ}9PYb)V?3(eC< zw(`TLu6~OwHpjxm6HOJskxp%3?xK3kibhLz`A%g7@pk9guvU#Q3uHqr_rU|fGW<|o-D#)dg}W+d9_ zxI?v#-jQK&gMJ&X4LdD6!bbQ081i62#)ANEzu{B)vX7slX@Hrb?(f4LlSMhYQARgf zdQid&LR!4H#G}979jQoD6)hSZq7iMLarNvn`BGhJfZIhvK|(1A|3+{If_h4l4${O_vMyQhuBn8QTWTp#h(Z4l- z2F4FMhn<(|!|BS7&D43<#mrrrE7kF2u8FDnj4L2qoY<>JlS?}Yw={X>V5ovhoiYTS z5urOKH6UDRRF@WjrfKIQEC(vM&V3r_%%(pYcg)`f>hWX-9npcLp zCF?Cc=kc$9UgUdw;TW>|HZk~lkqggKL~N;+!VYx(gE0OvY4MWG*b92R&eD|IX37WB zm!4uA&r;>Z75uu7t%=C4@p}WXc#YgU=d(${F|t{@Vhw@*m5eX=1<@jGYpxF+>NP;ZolgxosaDj15u)j zF&bJw?`#Or&M1#_2^Si%Fwe}CEh)(nd8A#7UOV_Uy z#FvK0$sg}+XSj;CY5$afP8s>=K7Se*xQaw`?o8jiY04?57K?vrg79!M+D1SBY|F3fQB#E z8ON=Y;Z(>6#eo1+IOYChN(_#Fp}5=$;~)(x6ml%f6zmI0_2RnI-Kn8c+R`?w(Gny% z=Te}b*S4c^t)ggH6-I>duzbKC?}8zUjx zxR7(k^mkEAbiNNa=~`M$BB*|-dU#C5)l#|+@s-cXgrrA#{A}VbmuiynSbBS+!L^Yr6S_vah$^86?BFrK61IU+tCb#1GD` zBD&rT$blNdssdOlxwL2XU!OFVhcpre+e%8xl=r8|6A~FozIvfa0s(^q!LIW^8NvSo|H=-@>H6?hdjlf>IMLz zd5l$Ifh$B6l|JkK0qh(B;87Af<|YJI0$XHA@POq_qqV!-d6a7H<@+Z_UUv_4OLz14 dM`Ma`$AATh7-*MS_ujR>cis2>`y`Iss!}_0H_%L77{>m>b*aWBpy17vVh`#){Q@v>ozj#G5|nX zG!@aD3;)3+V*hEGh+ zID!N#dAq`H1dl4wP3ow2k+W9mNR2wI$bF^tx}O2h+lEv4ws1YVspH6`^q^?M4gIx6 z*y%ZCU6eY<(DCJN@06s+@oddWe?REF_YyJOwV70NQr|0fb)LHRYAw|xw7AiHm;}%p z$aU340=P%gat$ExzyM%&<2n_f_q`d(d*<69z;n_dGJy5#Qb6-93P!-U|81v!nB?NK zopbV&9oj1->HCn*2^Z*+9|`0C8S>Ck=^j4L$<$LZaL12845p0L&c9yHWD?&0q>`RLjKv66rCV>Jy3Jb zz&D$7#Fuj!NL(p*o?An?YSDuJ*&Wdq6ya&=HAUup)UEnwgvC*)x9gS&_{y{a|E_B1 ztjcP^D88xvG)ZK3vW&877h_r{r-&GFGh~xgJjQ=kxf=6F@HY3e?QUGqnra7wNvt)#%5&- zg|yiUp%h7sA1lKj85|o$+3dn38X!I7bFYsB$3B#-IpDy=p z-!nwS&cM&R3rF6j)@$s#oDSmaNt_60ft8=$j(ztmy6)h_^~o>LY~L4*Ql34u=Qgk( zHC%k*KC;3;!MNh#xqINe%Wy;s&=yBE!fIwt(R?>Y+;$d0?2*B5j){jKXZF+99H{i` zJaw65VcC!Q^LP18ucDz5OOE(u%9&5NQdV0d> zkpo7F5Uu;Tc9tqhC?9Yv7!=OCetCXL;Ho)m&*;2T)oYRNcT7l!ntcgH$dAIr;M9PsAvo);c&b%%0D97~C`m2iCU<2grs)4yoANO$ny`wNJ9$(WtV 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 literal 0 HcmV?d00001 diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..500a08e656f8f1f7cd66e3b5647645485d1bb2ef GIT binary patch literal 738 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJOS+@4BLl<6e(pbstU$g(vPY0F z14ES>14Ba#1H&(%P{RubhEf9thF1v;3|2E37{m+a>x_PW40}i zKyFhlag8WRNi0dVN-jzTQVd20Mn<{@Cb~w(AqFN^28LEf=Gq1ZRt5(1R_^kVZK-vOn3#2VDwp-xvvs>GsUFq0XT2_b7scu`|#&tVN*03EG5H^lCN$6z-!(p;}DKVi2PW+Yt)V$O$QI0E(bx#X+f z+u>?R>?u00Hz+TtRXD!mdi@2GH`p?71FD=rqvCK{P6+97G(Hs11x`WK`9a0~U{6;Xdb+Vd^auXejWqW&}S z@lA{L|4iI@5%o{N#~UruuQAI>QS{3eANNC;`JufB!=_J0)n3s*3;pT~!|DiQx1V?y z`_NyhgVqbe=m=}`0`Yho;Ag|vS@1jWkA#G|viKYvOjpeXtGw0vGMNM|Z%(a7NhJKJCzX)??p- zo$w>5AJRJQ2)qMN!;LV_XMCT&(AEj3!c3S0>c4wnqR%*~ue1fy7D!tlZGrGDP%Lf6 zgii~ZVo~-R+rJdc_W+>Rs*)v;XSDK+stpRO={iYgl08hGY+1CX5s~v)!gy+ z5b-a;PTxHQ`D`}t6Z|waucQ2WPqC%-Zy0cG1IlX7x*eLWXQT2PnutG-*uLLwzK_qz zE*dDe09^0&Ms=<}_9Z@@t^vv|1YNh)_ECR-g})xh`d>)d1D@mS9&mc+LAldl#B==3 z_;czLR1WmKkJ{$eUeCcnm$tWMD6hWG*ER4hzGhnAE-B@;o~ypr)*)=FwZxS!F>A|U zcM+&h4;x!{T5}8i%@npvU=M`RpniL9ZM~Y^$H!Hm=dtU%K0WWZz&Xvvs#hLg8Vl$h z#n(_f_OflG>vbF44NmuZy?8M`wmWr%iv0w4V^9_DW@^+O3*mtARL0d@G|r@^1<;g*aYg^ z?6a>r)mUqOJs%uf0h*sx+tmJko8$kRKa!`|A0Nk7zBGQlrd9)adT*ooX>Giq{S^WB z=VH6x(f*+T`)+J^IM{1Gzqr<3`{Y02VE-Xy0{eaPHNJeafe*c(TND)A=5bv8q2{*x zK<83@v)=c<3%VAM+b2+FJg7ZyhNW;hc<(o@<|t`-O;qcm#uPHf 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: /