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

{{.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 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: /