initial release
This commit is contained in:
commit
8d86bdd33d
45 changed files with 2558 additions and 0 deletions
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal 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
8
Caddyfile
Normal 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
21
Dockerfile
Normal 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
8
Dockerfile.caddy
Normal 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
21
LICENSE
Normal 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
76
Makefile
Normal 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
108
README.md
Normal 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
0
build/.gitkeep
Normal file
94
cmd/ssh/main.go
Normal file
94
cmd/ssh/main.go
Normal 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
7
cmd/web/main.go
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "git.sr.ht/~erock/pastes.sh/internal"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
internal.StartApiServer()
|
||||||
|
}
|
40
db/migrations/20220310_init.sql
Normal file
40
db/migrations/20220310_init.sql
Normal 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
|
||||||
|
);
|
8
db/migrations/20220422_add_desc_to_user_and_post.sql
Normal file
8
db/migrations/20220422_add_desc_to_user_and_post.sql
Normal 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;
|
2
db/migrations/20220426_add_index_for_filename.sql
Normal file
2
db/migrations/20220426_add_index_for_filename.sql
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
CREATE INDEX posts_filename ON posts USING btree(filename);
|
||||||
|
ALTER TABLE app_users DROP COLUMN bio;
|
1
db/migrations/20220427_username_to_lower.sql
Normal file
1
db/migrations/20220427_username_to_lower.sql
Normal file
|
@ -0,0 +1 @@
|
||||||
|
UPDATE app_users SET name = LOWER(name) WHERE name != LOWER(name);
|
5
db/migrations/20220523_timestamp_with_tz.sql
Normal file
5
db/migrations/20220523_timestamp_with_tz.sql
Normal 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
1
db/setup.sql
Normal file
|
@ -0,0 +1 @@
|
||||||
|
CREATE DATABASE "pastes" OWNER "postgres";
|
3
db/teardown.sql
Normal file
3
db/teardown.sql
Normal 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
9
docker-compose.yml
Normal 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
46
go.mod
Normal 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
160
go.sum
Normal 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
18
html/base.layout.tmpl
Normal 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
44
html/blog.page.tmpl
Normal 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
6
html/footer.partial.tmpl
Normal 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
108
html/help.page.tmpl
Normal 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 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:
|
||||||
|
</p>
|
||||||
|
<pre>$ find ~/.ssh/id_*.pub -exec ssh-keygen -l -f {} \;</pre>
|
||||||
|
<p>If you’re 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}}
|
12
html/marketing-footer.partial.tmpl
Normal file
12
html/marketing-footer.partial.tmpl
Normal 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
99
html/marketing.page.tmpl
Normal 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
124
html/ops.page.tmpl
Normal 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
36
html/post.page.tmpl
Normal 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
52
html/privacy.page.tmpl
Normal 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
35
html/read.page.tmpl
Normal 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
1
html/rss.page.tmpl
Normal file
|
@ -0,0 +1 @@
|
||||||
|
{{.Contents}}
|
57
html/transparency.page.tmpl
Normal file
57
html/transparency.page.tmpl
Normal 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
499
internal/api.go
Normal 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
116
internal/config.go
Normal 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
119
internal/db_handler.go
Normal 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
32
internal/parser.go
Normal 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
123
internal/router.go
Normal 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
107
internal/util.go
Normal 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
49
production.yml
Normal 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
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
BIN
public/card.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8 KiB |
BIN
public/favicon-16x16.png
Normal file
BIN
public/favicon-16x16.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 738 B |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
287
public/main.css
Normal file
287
public/main.css
Normal 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
2
public/robots.txt
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
Loading…
Reference in a new issue