initial release

This commit is contained in:
Eric Bower 2022-07-13 13:30:27 -04:00
commit 8d86bdd33d
No known key found for this signature in database
GPG Key ID: 7A51A47D76D3FD02
45 changed files with 2558 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@ -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

8
Caddyfile Normal file
View File

@ -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
}

21
Dockerfile Normal file
View File

@ -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"]

8
Dockerfile.caddy Normal file
View File

@ -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

21
LICENSE Normal file
View File

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

76
Makefile Normal file
View File

@ -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

108
README.md Normal file
View File

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

0
build/.gitkeep Normal file
View File

94
cmd/ssh/main.go Normal file
View File

@ -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)
}
}

7
cmd/web/main.go Normal file
View File

@ -0,0 +1,7 @@
package main
import "git.sr.ht/~erock/pastes.sh/internal"
func main() {
internal.StartApiServer()
}

View File

@ -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
);

View File

@ -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;

View File

@ -0,0 +1,2 @@
CREATE INDEX posts_filename ON posts USING btree(filename);
ALTER TABLE app_users DROP COLUMN bio;

View File

@ -0,0 +1 @@
UPDATE app_users SET name = LOWER(name) WHERE name != LOWER(name);

View File

@ -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';

1
db/setup.sql Normal file
View File

@ -0,0 +1 @@
CREATE DATABASE "pastes" OWNER "postgres";

3
db/teardown.sql Normal file
View File

@ -0,0 +1,3 @@
DROP TABLE posts CASCADE;
DROP TABLE app_users CASCADE;
DROP TABLE public_keys CASCADE;

9
docker-compose.yml Normal file
View File

@ -0,0 +1,9 @@
version: "3.4"
services:
db:
image: postgres
restart: always
ports:
- "5432:5432"
env_file:
- .env

46
go.mod Normal file
View File

@ -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
)

160
go.sum Normal file
View File

@ -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=

18
html/base.layout.tmpl Normal file
View File

@ -0,0 +1,18 @@
{{define "base"}}
<!doctype html>
<html lang="en" data-theme="theme-dark">
<head>
<meta charset='utf-8'>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{template "title" .}}</title>
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<meta name="keywords" content="pastebin, paste, copy" />
{{template "meta" .}}
<link rel="stylesheet" href="/main.css" />
</head>
<body>{{template "body" .}}</body>
</html>
{{end}}

44
html/blog.page.tmpl Normal file
View File

@ -0,0 +1,44 @@
{{template "base" .}}
{{define "title"}}{{.PageTitle}}{{end}}
{{define "meta"}}
<meta name="description" content="{{if .Header.Bio}}{{.Header.Bio}}{{else}}{{.Header.Title}}{{end}}" />
<meta property="og:type" content="website">
<meta property="og:site_name" content="{{.Site.Domain}}">
<meta property="og:url" content="{{.URL}}">
<meta property="og:title" content="{{.Header.Title}}">
{{if .Header.Bio}}<meta property="og:description" content="{{.Header.Bio}}">{{end}}
<meta property="og:image:width" content="300" />
<meta property="og:image:height" content="300" />
<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
<meta property="twitter:card" content="summary">
<meta property="twitter:url" content="{{.URL}}">
<meta property="twitter:title" content="{{.Header.Title}}">
{{if .Header.Bio}}<meta property="twitter:description" content="{{.Header.Bio}}">{{end}}
<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
{{end}}
{{define "body"}}
<header class="text-center">
<h1 class="text-2xl font-bold">{{.Header.Title}}</h1>
<hr />
</header>
<main>
<section class="posts">
{{range .Posts}}
<article>
<div class="flex items-center">
<time datetime="{{.PublishAtISO}}" class="font-italic text-sm post-date">{{.PublishAt}}</time>
<h2 class="font-bold flex-1"><a href="{{.URL}}">{{.Title}}</a></h2>
</div>
</article>
{{end}}
</section>
</main>
{{template "footer" .}}
{{end}}

6
html/footer.partial.tmpl Normal file
View File

@ -0,0 +1,6 @@
{{define "footer"}}
<footer>
<hr />
published with <a href={{.Site.HomeURL}}>{{.Site.Domain}}</a>
</footer>
{{end}}

108
html/help.page.tmpl Normal file
View File

@ -0,0 +1,108 @@
{{template "base" .}}
{{define "title"}}help -- {{.Site.Domain}}{{end}}
{{define "meta"}}
<meta name="description" content="questions and answers" />
{{end}}
{{define "body"}}
<header>
<h1 class="text-2xl">Need help?</h1>
<p>Here are some common questions on using this platform that we would like to answer.</p>
</header>
<main>
<section id="permission-denied">
<h2 class="text-xl">I get a permission denied when trying to SSH</h2>
<p>
Unfortunately SHA-2 RSA keys are <strong>not</strong> currently supported.
</p>
<p>
Unfortunately, due to a shortcoming in Gos 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 youll 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:
</p>
<pre>$ find ~/.ssh/id_*.pub -exec ssh-keygen -l -f {} \;</pre>
<p>If youre curious about the inner workings of this problem have a look at:</p>
<ul>
<li><a href="https://github.com/golang/go/issues/37278">golang/go#37278</a></li>
<li><a href="https://go-review.googlesource.com/c/crypto/+/220037">go-review</a></li>
<li><a href="https://github.com/golang/crypto/pull/197">golang/crypto#197</a></li>
</ul>
</section>
<section id="ssh-key">
<h2 class="text-xl">Generating a new SSH key</h2>
<p>
<a href="https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent">Github reference</a>
</p>
<pre>ssh-keygen -t ed25519 -C "your_email@example.com"</pre>
<ol>
<li>When you're prompted to "Enter a file in which to save the key," press Enter. This accepts the default file location.</li>
<li>At the prompt, type a secure passphrase.</li>
</ol>
</section>
<section id="post-update">
<h2 class="text-xl">How do I update a pastebin post?</h2>
<p>
Updating a post requires that you update the source document and then run the <code>scp</code>
command again. If the filename remains the same, then the post will be updated.
</p>
</section>
<section id="post-delete">
<h2 class="text-xl">How do I delete a post?</h2>
<p>
Because <code>scp</code> does not natively support deleting files, I didn't want to bake
that behavior into my ssh server.
</p>
<p>
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 <code>delete.txt</code> you could:
</p>
<pre>
cp /dev/null delete.txt
scp ./delete.txt {{.Site.Domain}}:/</pre>
<p>
Alternatively, you can go to <code>ssh {{.Site.Domain}}</code> 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.
</p>
</section>
<section id="post-upload-single-file">
<h2 class="text-xl">When I want to publish a new post, do I have to upload all posts everytime?</h2>
<p>
Nope! Just <code>scp</code> the file you want to publish. For example, if you created
a new post called <code>taco-tuesday.md</code> then you would publish it like this:
</p>
<pre>scp ./taco-tuesday.md {{.Site.Domain}}:</pre>
</section>
<section id="pastebin-url">
<h2 class="text-xl">What is my pastebin URL?</h2>
<pre>https://{username}.{{.Site.Domain}}</pre>
</section>
<section id="multiple-accounts">
<h2 class="text-xl">Can I create multiple accounts?</h2>
<p>
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
<code>ssh new@{{.Site.Domain}}</code>.
</p>
<p>
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.
</p>
</section>
</main>
{{template "marketing-footer" .}}
{{end}}

View File

@ -0,0 +1,12 @@
{{define "marketing-footer"}}
<footer>
<hr />
<p class="font-italic">Built and maintained by <a href="https://pico.sh">pico.sh</a>.</p>
<div>
<a href="/">home</a> |
<a href="/ops">ops</a> |
<a href="/help">help</a> |
<a href="https://git.sr.ht/~erock/pastes.sh">source</a>
</div>
</footer>
{{end}}

99
html/marketing.page.tmpl Normal file
View File

@ -0,0 +1,99 @@
{{template "base" .}}
{{define "title"}}{{.Site.Domain}} -- a pastebin for hackers{{end}}
{{define "meta"}}
<meta name="description" content="a pastebin for hackers" />
<meta property="og:type" content="website">
<meta property="og:site_name" content="{{.Site.Domain}}">
<meta property="og:url" content="https://{{.Site.Domain}}">
<meta property="og:title" content="{{.Site.Domain}}">
<meta property="og:description" content="a pastebin for hackers">
<meta name="twitter:card" content="summary" />
<meta property="twitter:url" content="https://{{.Site.Domain}}">
<meta property="twitter:title" content="{{.Site.Domain}}">
<meta property="twitter:description" content="a pastebin platform for hackers">
<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
<meta property="og:image:width" content="300" />
<meta property="og:image:height" content="300" />
<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
{{end}}
{{define "body"}}
<header class="text-center">
<h1 class="text-2xl font-bold">{{.Site.Domain}}</h1>
<p class="text-lg">a pastebin platform for hackers</p>
<p class="text-lg"><a href="/read">discover</a> some interesting pastebins</p>
<hr />
</header>
<main>
<section>
<h2 class="text-lg font-bold">Create your account with Public-Key Cryptography</h2>
<p>We don't want your email address.</p>
<p>To get started, simply ssh into our content management system:</p>
<pre>ssh new@{{.Site.Domain}}</pre>
<div class="text-sm font-italic note">
note: <code>new</code> is a special username that will always send you to account
creation, even with multiple accounts associated with your key-pair.
</div>
<div class="text-sm font-italic note">
note: getting permission denied? <a href="/help#permission-denied">read this</a>
</div>
<p>
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.
</p>
</section>
<section>
<h2 class="text-lg font-bold">Publish your pastes with one command</h2>
<p>
When your post is ready to be published, copy the file to our server with a familiar
command:
</p>
<pre>scp ~/pastes/* {{.Site.Domain}}:/</pre>
<p>We'll either create or update the posts for you.</p>
</section>
<section>
<h2 class="text-lg font-bold">Terminal workflow without installation</h2>
<p>
Since we are leveraging tools you already have on your computer
(<code>ssh</code> and <code>scp</code>), there is nothing to install.
</p>
<p>
This provides the convenience of a web app, but from inside your terminal!
</p>
</section>
<section>
<h2 class="text-lg font-bold">Features</h2>
<ul>
<li>Bring your own editor</li>
<li>You control the source files</li>
<li>Terminal workflow with no installation</li>
<li>Public-key based authentication</li>
<li>No ads, zero tracking</li>
<li>No platform lock-in</li>
<li>No javascript</li>
<li>Minimalist design</li>
<li>100% open source</li>
</ul>
</section>
<section>
<h2 class="text-lg font-bold">Roadmap</h2>
<ol>
<li>idk</li>
</ol>
</section>
</main>
{{template "marketing-footer" .}}
{{end}}

124
html/ops.page.tmpl Normal file
View File

@ -0,0 +1,124 @@
{{template "base" .}}
{{define "title"}}operations -- {{.Site.Domain}}{{end}}
{{define "meta"}}
<meta name="description" content="{{.Site.Domain}} operations" />
{{end}}
{{define "body"}}
<header>
<h1 class="text-2xl">Operations</h1>
<ul>
<li><a href="/privacy">privacy</a></li>
<li><a href="/transparency">transparency</a></li>
</ul>
</header>
<main>
<section>
<h2 class="text-xl">Purpose</h2>
<p>
{{.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.
</p>
</section>
<section>
<h2 class="text-xl">Ethics</h2>
<p>We are committed to:</p>
<ul>
<li>No tracking of user or visitor behaviour.</li>
<li>Never sell any user or visitor data.</li>
<li>No ads — ever.</li>
</ul>
</section>
<section>
<h2 class="text-xl">Code of Content Publication</h2>
<p>
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.
</p>
<p>
If one notices something along those lines in a paste post please let us know at
<a href="mailto:{{.Site.Email}}">{{.Site.Email}}</a>.
</p>
</section>
<section>
<h2 class="text-xl">Liability</h2>
<p>
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.
</p>
</section>
<section>
<h2 class="text-xl">Account Terms</h2>
<p>
<ul>
<li>
The user is responsible for all content posted and all actions performed with
their account.
</li>
<li>
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.
</li>
</ul>
</p>
</section>
<section>
<h2 class="text-xl">Service Availability</h2>
<p>
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.
</p>
</section>
<section>
<h2 class="text-xl">Contact and Support</h2>
<p>
Email us at <a href="mailto:{{.Site.Email}}">{{.Site.Email}}</a>
with any questions.
</p>
</section>
<section>
<h2 class="text-xl">Acknowledgments</h2>
<p>
{{.Site.Domain}} was inspired by <a href="https://mataroa.blog">Mataroa Blog</a>
and <a href="https://bearblog.dev/">Bear Blog</a>.
</p>
<p>
{{.Site.Domain}} is built with many open source technologies.
</p>
<p>
In particular we would like to thank:
</p>
<ul>
<li>
<span>The </span>
<a href="https://charm.sh">charm.sh</a>
<span> community</span>
</li>
<li>
<span>The </span>
<a href="https://go.dev">golang</a>
<span> community</span>
</li>
<li>
<span>The </span>
<a href="https://www.postgresql.org/">postgresql</a>
<span> community</span>
</li>
<li>
<span>The </span>
<a href="https://github.com/caddyserver/caddy">caddy</a>
<span> community</span>
</li>
</ul>
</section>
</main>
{{template "marketing-footer" .}}
{{end}}

36
html/post.page.tmpl Normal file
View File

@ -0,0 +1,36 @@
{{template "base" .}}
{{define "title"}}{{.PageTitle}}{{end}}
{{define "meta"}}
<meta property="og:type" content="website">
<meta property="og:site_name" content="{{.Site.Domain}}">
<meta property="og:url" content="{{.URL}}">
<meta property="og:title" content="{{.Title}}">
<meta property="og:image:width" content="300" />
<meta property="og:image:height" content="300" />
<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
<meta property="twitter:card" content="summary">
<meta property="twitter:url" content="{{.URL}}">
<meta property="twitter:title" content="{{.Title}}">
<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
{{end}}
{{define "body"}}
<header>
<h1 class="text-2xl font-bold">{{.Title}}</h1>
<p class="font-bold m-0">
<time datetime="{{.PublishAtISO}}">{{.PublishAt}}</time>
<span> on </span>
<a href="{{.BlogURL}}">{{.BlogName}}</a></p>
</header>
<main>
<article>
{{.Contents}}
</article>
</main>
{{template "footer" .}}
{{end}}

52
html/privacy.page.tmpl Normal file
View File

@ -0,0 +1,52 @@
{{template "base" .}}
{{define "title"}}privacy -- {{.Site.Domain}}{{end}}
{{define "meta"}}
<meta name="description" content="{{.Site.Domain}} privacy policy" />
{{end}}
{{define "body"}}
<header>
<h1 class="text-2xl">Privacy</h1>
<p>Details on our privacy and security approach.</p>
</header>
<main>
<section>
<h2 class="text-xl">Account Data</h2>
<p>
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.
</p>
<p>
Because we use public-key cryptography, our security posture is a battle-tested
and proven technique for authentication.
</p>
</section>
<section>
<h2 class="text-xl">Third parties</h2>
<p>
We have a strong commitment to never share any user data with any third-parties.
</p>
</section>
<section>
<h2 class="text-xl">Service Providers</h2>
<ul>
<li>
<span>We host our server on </span>
<a href="https://digitalocean.com">digital ocean</a>
</li>
</ul>
</section>
<section>
<h2 class="text-xl">Cookies</h2>
<p>
We do not use any cookies, not even account authentication.
</p>
</section>
</main>
{{template "marketing-footer" .}}
{{end}}

35
html/read.page.tmpl Normal file
View File

@ -0,0 +1,35 @@
{{template "base" .}}
{{define "title"}}discover -- {{.Site.Domain}}{{end}}
{{define "meta"}}
<meta name="description" content="discover interesting posts" />
{{end}}
{{define "body"}}
<header class="text-center">
<h1 class="text-2xl font-bold">read</h1>
<p class="text-lg">recent pastes</p>
<hr />
</header>
<main>
<div class="my">
{{if .PrevPage}}<a href="{{.PrevPage}}">prev</a>{{else}}<span class="text-grey">prev</span>{{end}}
{{if .NextPage}}<a href="{{.NextPage}}">next</a>{{else}}<span class="text-grey">next</span>{{end}}
</div>
{{range .Posts}}
<article>
<div class="flex items-center">
<time datetime="{{.PublishAtISO}}" class="font-italic text-sm post-date">{{.PublishAt}}</time>
<div class="flex-1">
<h2 class="inline"><a href="{{.URL}}">{{.Title}}</a></h2>
<address class="text-sm inline">
<a href="{{.BlogURL}}" class="link-grey">({{.Username}})</a>
</address>
</div>
</div>
</article>
{{end}}
</main>
{{template "marketing-footer" .}}
{{end}}

1
html/rss.page.tmpl Normal file
View File

@ -0,0 +1 @@
{{.Contents}}

View File

@ -0,0 +1,57 @@
{{template "base" .}}
{{define "title"}}transparency -- {{.Site.Domain}}{{end}}
{{define "meta"}}
<meta name="description" content="full transparency of analytics and cost at {{.Site.Domain}}" />
{{end}}
{{define "body"}}
<header>
<h1 class="text-2xl">Transparency</h1>
<hr />
</header>
<main>
<section>
<h2 class="text-xl">Analytics</h2>
<p>
Here are some interesting stats on usage.
</p>
<article>
<h2 class="text-lg">Total users</h2>
<div>{{.Analytics.TotalUsers}}</div>
</article>
<article>
<h2 class="text-lg">New users in the last month</h2>
<div>{{.Analytics.UsersLastMonth}}</div>
</article>
<article>
<h2 class="text-lg">Total pastes</h2>
<div>{{.Analytics.TotalPosts}}</div>
</article>
<article>
<h2 class="text-lg">New pastes in the last month</h2>
<div>{{.Analytics.PostsLastMonth}}</div>
</article>
<article>
<h2 class="text-lg">Users with at least one paste</h2>
<div>{{.Analytics.UsersWithPost}}</div>
</article>
</section>
<section>
<h2 class="text-xl">Service maintenance costs</h2>
<ul>
<li>Server $5.00/mo</li>
<li>Domain name $3.25/mo</li>
<li>Programmer $0.00/mo</li>
</ul>
</section>
</main>
{{template "marketing-footer" .}}
{{end}}

499
internal/api.go Normal file
View File

@ -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))
}

116
internal/config.go Normal file
View File

@ -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()
}

119
internal/db_handler.go Normal file
View File

@ -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
}

32
internal/parser.go Normal file
View File

@ -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
}

123
internal/router.go Normal file
View File

@ -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
}

107
internal/util.go Normal file
View File

@ -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)
}
}

49
production.yml Normal file
View File

@ -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:

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/card.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 B

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

287
public/main.css Normal file
View File

@ -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;
}
}

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Allow: /