Repository: neurosnap/lists.sh
Branch: main
Commit: bc7f4fd43f42
Files: 63
Total size: 134.4 KB
Directory structure:
gitextract_evvli2ae/
├── .github/
│ └── workflows/
│ └── lint.yml
├── .gitignore
├── Caddyfile
├── Dockerfile
├── Dockerfile.caddy
├── LICENSE
├── Makefile
├── README.md
├── build/
│ └── .gitkeep
├── cmd/
│ ├── gemini/
│ │ └── main.go
│ ├── ssh/
│ │ └── main.go
│ └── web/
│ └── main.go
├── db/
│ ├── migrations/
│ │ ├── 20220310_init.sql
│ │ ├── 20220422_add_desc_to_user_and_post.sql
│ │ ├── 20220426_add_index_for_filename.sql
│ │ ├── 20220427_username_to_lower.sql
│ │ ├── 20220523_timestamp_with_tz.sql
│ │ ├── 20220721_analytics.sql
│ │ └── 20220722_post_hidden.sql
│ ├── setup.sql
│ └── teardown.sql
├── docker-compose.yml
├── gmi/
│ ├── base.layout.tmpl
│ ├── blog.page.tmpl
│ ├── footer.partial.tmpl
│ ├── help.page.tmpl
│ ├── list.partial.tmpl
│ ├── marketing-footer.partial.tmpl
│ ├── marketing.page.tmpl
│ ├── ops.page.tmpl
│ ├── post.page.tmpl
│ ├── privacy.page.tmpl
│ ├── read.page.tmpl
│ ├── rss.page.tmpl
│ ├── spec.page.tmpl
│ └── transparency.page.tmpl
├── go.mod
├── go.sum
├── html/
│ ├── base.layout.tmpl
│ ├── blog.page.tmpl
│ ├── footer.partial.tmpl
│ ├── help.page.tmpl
│ ├── list.partial.tmpl
│ ├── marketing-footer.partial.tmpl
│ ├── marketing.page.tmpl
│ ├── ops.page.tmpl
│ ├── post.page.tmpl
│ ├── privacy.page.tmpl
│ ├── read.page.tmpl
│ ├── rss.page.tmpl
│ ├── spec.page.tmpl
│ └── transparency.page.tmpl
├── internal/
│ ├── api.go
│ ├── config.go
│ ├── db_handler.go
│ ├── gemini/
│ │ ├── gemini.go
│ │ └── router.go
│ ├── router.go
│ └── util.go
├── pkg/
│ └── parser.go
├── production.yml
└── public/
├── main.css
└── robots.txt
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/workflows/lint.yml
================================================
name: Lint
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.18
- name: Lint the codebase
uses: golangci/golangci-lint-action@v2
with:
version: latest
args: -E goimports -E godot
================================================
FILE: .gitignore
================================================
*.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
================================================
FILE: Caddyfile
================================================
*.lists.sh, lists.sh {
reverse_proxy web:3000
tls webmaster@lists.sh
tls {
dns cloudflare {env.CF_API_TOKEN}
}
encode zstd gzip
header {
# disable FLoC tracking
Permissions-Policy interest-cohort=()
# enable HSTS
Strict-Transport-Security max-age=31536000;
# disable clients from sniffing the media type
X-Content-Type-Options nosniff
# clickjacking protection
X-Frame-Options DENY
# keep referrer data off of HTTP connections
Referrer-Policy no-referrer-when-downgrade
Content-Security-Policy "default-src 'self'; img-src * 'unsafe-inline'"
X-XSS-Protection "1; mode=block"
}
}
================================================
FILE: Dockerfile
================================================
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
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ./build/gemini ./cmd/gemini
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"]
FROM alpine:3.15 AS gemini
WORKDIR /app
COPY --from=0 /app/build/gemini ./
COPY --from=0 /app/gmi ./gmi
ENV LISTS_SUBDOMAINS=0
CMD ["./gemini"]
================================================
FILE: Dockerfile.caddy
================================================
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
================================================
FILE: LICENSE
================================================
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.
================================================
FILE: Makefile
================================================
PGDATABASE?="lists"
PGHOST?="db"
PGUSER?="postgres"
PORT?="5432"
DB_CONTAINER?=listssh_db_1
DOCKER_TAG?=$(shell git log --format="%H" -n 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
go build -o build/gemini ./cmd/gemini
.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
docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220721_analytics.sql
docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220722_post_hidden.sql
.PHONY: migrate
latest:
docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220721_analytics.sql
docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220722_post_hidden.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-setup:
docker buildx ls | grep pico || docker buildx create --name pico
docker buildx use pico
.PHONY: bp-setup
bp-caddy: bp-setup
docker buildx build --push --platform linux/amd64,linux/arm64 -t neurosnap/lists-caddy:$(DOCKER_TAG) -f Dockerfile.caddy .
.PHONY: bp-caddy
bp-ssh: bp-setup
docker buildx build --push --platform linux/amd64,linux/arm64 -t neurosnap/lists-ssh:$(DOCKER_TAG) --target ssh .
.PHONY: bp-ssh
bp-web: bp-setup
docker buildx build --push --platform linux/amd64,linux/arm64 -t neurosnap/lists-web:$(DOCKER_TAG) --target web .
.PHONY: bp-web
bp-gemini: bp-setup
docker buildx build --push --platform linux/amd64,linux/arm64 -t neurosnap/lists-gemini:$(DOCKER_TAG) --target gemini .
.PHONY: bp-gemini
bp: bp-ssh bp-web bp-gemini 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
================================================
FILE: README.md
================================================
# lists.sh
A microblog for lists.
## 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/lists?sslmode=disable"
export LISTS_SSH_PORT=2222
export LISTS_WEB_PORT=3000
export LISTS_DOMAIN="lists.sh"
export LISTS_EMAIL="support@lists.sh"
export LISTS_PROTOCOL="http"
```
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 lists.test
127.0.0.1 erock.lists.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 `LISTS_DOMAIN=lists.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.
================================================
FILE: build/.gitkeep
================================================
================================================
FILE: cmd/gemini/main.go
================================================
package main
import "git.sr.ht/~erock/lists.sh/internal/gemini"
func main() {
gemini.StartServer()
}
================================================
FILE: cmd/ssh/main.go
================================================
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"git.sr.ht/~erock/lists.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("PROSE_HOST", "0.0.0.0")
port := internal.GetEnv("PROSE_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)
}
}
================================================
FILE: cmd/web/main.go
================================================
package main
import "git.sr.ht/~erock/lists.sh/internal"
func main() {
internal.StartApiServer()
}
================================================
FILE: db/migrations/20220310_init.sql
================================================
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
);
================================================
FILE: db/migrations/20220422_add_desc_to_user_and_post.sql
================================================
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;
================================================
FILE: db/migrations/20220426_add_index_for_filename.sql
================================================
CREATE INDEX posts_filename ON posts USING btree(filename);
ALTER TABLE app_users DROP COLUMN bio;
================================================
FILE: db/migrations/20220427_username_to_lower.sql
================================================
UPDATE app_users SET name = LOWER(name) WHERE name != LOWER(name);
================================================
FILE: db/migrations/20220523_timestamp_with_tz.sql
================================================
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';
================================================
FILE: db/migrations/20220721_analytics.sql
================================================
CREATE TABLE IF NOT EXISTS post_analytics (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
post_id uuid NOT NULL,
views int DEFAULT 0,
updated_at timestamp without time zone NOT NULL DEFAULT NOW(),
CONSTRAINT analytics_pkey PRIMARY KEY (id),
CONSTRAINT fk_analytics_posts
FOREIGN KEY(post_id)
REFERENCES posts(id)
ON DELETE CASCADE
ON UPDATE CASCADE
);
================================================
FILE: db/migrations/20220722_post_hidden.sql
================================================
ALTER TABLE posts ADD COLUMN hidden boolean NOT NULL DEFAULT FALSE;
UPDATE posts SET hidden = TRUE WHERE filename LIKE E'\\_%';
================================================
FILE: db/setup.sql
================================================
CREATE DATABASE "lists" OWNER "postgres";
================================================
FILE: db/teardown.sql
================================================
DROP TABLE posts CASCADE;
DROP TABLE app_users CASCADE;
DROP TABLE public_keys CASCADE;
================================================
FILE: docker-compose.yml
================================================
version: "3.4"
services:
db:
image: postgres
restart: always
ports:
- "5433:5432"
env_file:
- .env
================================================
FILE: gmi/base.layout.tmpl
================================================
{{define "base"}}
{{template "body" .}}
{{end}}
================================================
FILE: gmi/blog.page.tmpl
================================================
{{template "base" .}}
{{define "body"}}
# {{.Header.Title}}
{{.Header.Bio}}
{{range .Header.Nav}}
{{if .IsURL}}=> {{.URL}} {{.Value}}{{end}}
{{- end}}
=> {{.RSSURL}} rss
{{- if .Readme.HasItems}}
---
{{- template "list" .Readme -}}
{{- end}}
{{- range .Posts}}
=> {{.URL}} {{.Title}} ({{.UpdatedTimeAgo}})
{{- end}}
{{- template "footer" . -}}
{{end}}
================================================
FILE: gmi/footer.partial.tmpl
================================================
{{define "footer"}}
---
=> / published with {{.Site.Domain}}
{{end}}
================================================
FILE: gmi/help.page.tmpl
================================================
{{template "base" .}}
{{define "body"}}
# Need help?
Here are some common questions on using this platform that we would like to answer.
## I get a permission denied when trying to SSH
Unfortunately, due to a shortcoming in Go’s x/crypto/ssh package, Soft Serve does not currently support access via new SSH RSA keys: only the old SHA-1 ones will work. Until we sort this out you’ll either need an SHA-1 RSA key or a key with another algorithm, e.g. Ed25519. Not sure what type of keys you have? You can check with the following:
```
$ find ~/.ssh/id_*.pub -exec ssh-keygen -l -f {} \;
```
If you’re curious about the inner workings of this problem have a look at:
=> https://github.com/golang/go/issues/37278 golang/go#37278
=> https://go-review.googlesource.com/c/crypto/+/220037 go-review
=> https://github.com/golang/crypto/pull/197 golang/crypto#197
## Generating a new SSH key
=> 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
```
ssh-keygen -t ed25519 -C "your_email@example.com"
```
* When you're prompted to "Enter a file in which to save the key," press Enter. This accepts the default file location.
* At the prompt, type a secure passphrase.
## What should my blog folder look like?
Currently {{.Site.Domain}} only supports a flat folder structure. Therefore, `scp -r` is not permitted. We also only allow `.txt` files to be uploaded.
=> https://github.com/neurosnap/lists-blog Here is the source to my blog on this platform
Below is an example of what your blog folder should look like:
```
blog/
first-post.txt
second-post.txt
third-post.txt
```
Underscores and hyphens are permitted and will be automatically removed from the title of the list.
## How do I update a list?
Updating a list requires that you update the source document and then run the `scp` command again. If the filename remains the same, then the list will be updated.
## How do I delete a list?
Because `scp` does not natively support deleting files, I didn't want to bake that behavior into my ssh server.
However, if a user wants to delete a post they can delete the contents of the file and then upload it to our server. If the file contains 0 bytes, we will remove the post. For example, if you want to delete `delete.txt` you could:
```
cp /dev/null delete.txt
scp ./delete.txt {{.Site.Domain}}:/
```
Alternatively, you can go to `ssh <username>@{{.Site.Domain}}` and select "Manage posts." Then you can highlight the post you want to delete and then press "X." It will ask for confirmation before actually removing the list.
## When I want to publish a new post, do I have to upload all posts everytime?
Nope! Just `scp` the file you want to publish. For example, if you created a new post called `taco-tuesday.txt` then you would publish it like this:
```
scp ./taco-tuesday.txt {{.Site.Domain}}:/
```
## How do I change my blog's name?
All you have to do is create a post titled `_header.txt` and add some information to the list.
```
=: title My new blog!
=: description My blog description!
=> https://xyz.com website
=> https://twitter.com/xyz twitter
```
* `title` will change your blog name
* `description` will add a blurb right under your blog name (and add meta descriptions)
* The links will show up next to the `rss` link to your blog
## How do I add an introduction to my blog?
All you have to do is create a post titled `_readme.txt` and add some information to the list.
```
=: list_type none
# Hi my name is Bob!
I like to sing. Dance. And I like to have fun fun fun!
```
Whatever is inside the `_readme` file will get rendered (as a list) right above your blog posts. Neat!
## What is my blog URL?
```
gemini://{{.Site.Domain}}/{username}
```
## How can I automatically publish my post?
There is a github action that we built to make it easy to publish your blog automatically.
=> https://github.com/marketplace/actions/scp-publish-action github marketplace
=> https://github.com/neurosnap/lists-official-blog/blob/main/.github/workflows/publish.yml example workflow
A user also created a systemd task to automatically publish new posts.
=> https://github.com/neurosnap/lists.sh/discussions/24 Check out this github discussion for more details.
## Can I create multiple accounts?
Yes! You can either a) create a new keypair and use that for authentication or b) use the same keypair and ssh into our CMS using our special username `ssh new@{{.Site.Domain}}`.
Please note that if you use the same keypair for multiple accounts, you will need to always specify the user when logging into our CMS.
{{template "marketing-footer" .}}
{{end}}
================================================
FILE: gmi/list.partial.tmpl
================================================
{{define "list"}}
{{range .Items}}
{{- if .IsText}}
{{- if .Value}}
* {{.Value}}
{{- end}}
{{- else if .IsURL}}
=> {{.URL}} {{.Value}}
{{- else if .IsImg}}
=> {{.URL}} {{.Value}}
{{- else if .IsBlock}}
> {{.Value}}
{{- else if .IsHeaderOne}}
## {{.Value}}
{{- else if .IsHeaderTwo}}
### {{.Value}}
{{- else if .IsPre}}
```
{{.Value}}
```
{{- end}}
{{- end}}
{{end}}
================================================
FILE: gmi/marketing-footer.partial.tmpl
================================================
{{define "marketing-footer"}}
---
Built and maintained by pico.sh
=> https://pico.sh
=> / home
=> /spec spec
=> /ops ops
=> /help help
=> /rss rss
=> https://github.com/neurosnap/lists.sh source
{{end}}
================================================
FILE: gmi/marketing.page.tmpl
================================================
{{template "base" .}}
{{define "body"}}
# {{.Site.Domain}}
A microblog for lists
=> /read discover some interesting lists
---
## Examples
=> /news official blog
=> https://git.sr.ht/~erock/lists-official-blog blog source
## Create your account
We don't want your email address.
To get started, simply ssh into our content management system:
```
ssh new@{{.Site.Domain}}
```
=> /help#permission-denied note: getting permission denied?
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.
## You control the source files
Create lists using your favorite editor in plain text files.
`~/blog/days-in-week.txt`
```
Sunday
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday
```
## Publish your posts with one command
When your post is ready to be published, copy the file to our server with a familiar command:
```
scp ~/blog/*.txt {{.Site.Domain}}
```
We'll either create or update the lists for you.
## Terminal workflow without installation
Since we are leveraging tools you already have on your computer (`ssh` and `scp`), there is nothing to install. This provides the convenience of a web app, but from inside your terminal!
## Plain text format
A simple specification that is flexible and with no frills.
=> /spec specification
## Features
* Just lists
* Looks great on any device
* Bring your own editor
* You control the source files
* Terminal workflow with no installation
* Public-key based authentication
* No ads, zero tracking
* No platform lock-in
* No javascript
* Subscriptions via RSS
* Not a platform for todos
* Minimalist design
* 100% open source
## Philosophy
I love writing lists. I think restricting writing to a set of lists can really help improve clarity in thought. The goal of this blogging platform is to make it simple to use the tools you love to write and publish lists. There is no installation, signup is as easy as SSH'ing into our CMS, and publishing content is as easy as copying files to our server.
Another goal of this microblog platform is to satisfy my own needs. I like to write and share lists with people because I find it's one of the best way to disseminate knowledge. Whether it's a list of links or a list of paragraphs, writing in lists is very satisfying and I welcome you to explore it on this site!
Other blogging platforms support writing lists, but they don't emphasize them. Writing lists is pretty popular on Twitter, but discoverability is terrible. Other blogging platforms focus on prose, but there really is nothing out there catered specifically for lists ... until now.
## Roadmap
* Feature complete?
{{template "marketing-footer" .}}
{{end}}
================================================
FILE: gmi/ops.page.tmpl
================================================
{{template "base" .}}
{{define "body"}}
# Operations
=> /privacy privacy
=> /transparency transparency
## Purpose
{{.Site.Domain}} exists to allow people to create and share their lists without the need to set up their own server or be part of a platform that shows ads or tracks its users.
## Ethics
We are committed to:
* No tracking of user or visitor behaviour.
* Never sell any user or visitor data.
* No ads — ever.
## Code of Content Publication
Content in {{.Site.Domain}} blogs is unfiltered and unmonitored. Users are free to publish any combination of words and pixels except for: content of animosity or disparagement of an individual or a group on account of a group characteristic such as race, color, national origin, sex, disability, religion, or sexual orientation, which will be taken down immediately.
If one notices something along those lines in a blog please let us know at {{.Site.Email}}.
## Liability
The user expressly understands and agrees that Eric Bower, the operator of this website shall not be liable, in law or in equity, to them or to any third party for any direct, indirect, incidental, lost profits, special, consequential, punitive or exemplary damages.
## Account Terms
* The user is responsible for all content posted and all actions performed with their account.
* We reserve the right to disable or delete a user's account for any reason at any time. We have this clause because, statistically speaking, there will be people trying to do something nefarious.
## Service Availability
We provide the {{.Site.Domain}} service on an "as is" and "as available" basis. We do not offer service-level agreements but do take uptime seriously.
## Contact and Support
Email us at {{.Site.Email}} with any questions.
## Acknowledgments
{{.Site.Domain}} was inspired by Mataroa Blog[0] and Bear Blog[1].
=> https://mataroa.blog [0]mataroa blog
=> https://bearblog.dev [1]bearblog
{{.Site.Domain}} is built with many open source technologies.
In particular we would like to thank:
=> https://charm.sh The charm community
=> https://go.dev The golang community
=> https://www.postgresql.org The postgresql community
=> https://github.com/caddyserver/caddy The caddy community
{{template "marketing-footer" .}}
{{end}}
================================================
FILE: gmi/post.page.tmpl
================================================
{{template "base" .}}
{{define "body"}}
# {{.Title}}
{{.PublishAt}}
{{if .Description}}{{.Description}}{{end}}
=> {{.BlogURL}} on {{.BlogName}}
---
{{- template "list" . -}}
{{- template "footer" . -}}
{{end}}
================================================
FILE: gmi/privacy.page.tmpl
================================================
{{template "base" .}}
{{define "body"}}
# Privacy
Details on our privacy and security approach.
## Account Data
In order to have a functional account at {{.Site.Domain}}, we need to store your public key. That is the only piece of information we record for a user.
Because we use public-key cryptography, our security posture is a battle-tested and proven technique for authentication.
## Third parties
We have a strong commitment to never share any user data with any third-parties.
## Service Providers
We host our server on digital ocean [0]
=> https://digitalocean.com [0]
## Cookies
We do not use any cookies, not even account authentication.
{{template "marketing-footer" .}}
{{end}}
================================================
FILE: gmi/read.page.tmpl
================================================
{{template "base" .}}
{{define "body"}}
# read
recently updated lists
{{if .NextPage}}=> {{.NextPage}} next{{end}}
{{if .PrevPage}}=> {{.PrevPage}} prev{{end}}
{{range .Posts}}
=> {{.URL}} {{.UpdatedTimeAgo}}{{.Padding}} {{.Title}} ({{.Username}})
{{- end}}
{{template "marketing-footer" .}}
{{end}}
================================================
FILE: gmi/rss.page.tmpl
================================================
{{template "list" .}}
================================================
FILE: gmi/spec.page.tmpl
================================================
{{template "base" .}}
{{define "body"}}
# Plain text list
Speculative specification
## Overview
Version: 2022.05.02.dev
Status: Draft
Author: Eric Bower
The goal of this specification is to understand how we render plain text lists. The overall design of this format is to be easy to parse and render.
The format is line-oriented, and a satisfactory rendering can be achieved with a single pass of a document, processing each line independently. As per gopher, links can only be displayed one per line, encouraging neat, list-like structure.
Feedback on any part of this is extremely welcome, please email {{.Site.Email}}.
The source code for our parser can be found on github[0].
=> https://github.com/neurosnap/lists.sh/blob/main/pkg/parser.go [0]github
The source code for an example list demonstrating all the features can be found on github[1].
=> https://github.com/neurosnap/lists-official-blog/blob/main/spec-example.txt [1]lists-official-blog
## Parameters
As a subtype of the top-level media type "text", "text/plain" inherits the "charset" parameter defined in RFC 2046[2]. The default value of "charset" is "UTF-8" for "text" content.
=> https://datatracker.ietf.org/doc/html/rfc2046#section-4.1 [2]rfc 2046
## Line orientation
As mentioned, the text format is line-oriented. Each line of a document has a single "line type". It is possible to unambiguously determine a line's type purely by inspecting its first (3) characters. A line's type determines the manner in which it should be presented to the user. Any details of presentation or rendering associated with a particular line type are strictly limited in scope to that individual line.
## File extensions
{{.Site.Domain}} only supports the `.txt` file extension and will ignore all other file extensions.
## List item
List items are separated by newline characters `\n`. Each list item is on its own line. A list item does not require any special formatting. A list item can contain as much text as it wants. We encourage soft wrapping for readability in your editor of choice. Hard wrapping is not permitted as it will create a new list item.
Empty lines will be completely removed and not rendered to the end user.
## Hyperlinks
Hyperlinks are denoted by the prefix `=>`. The following text should then be the hyperlink.
```
=> https://{{.Site.Domain}}
```
Optionally you can supply the hyperlink text immediately following the link.
```
=> https://{{.Site.Domain}} microblog for lists
```
## Images
List items can be represented as images by prefixing the line with <code>=<</code>.
```
=< https://i.imgur.com/iXMNUN5.jpg
```
Optionally you can supply the image alt text immediately following the link.
```
=< https://i.imgur.com/iXMNUN5.jpg I use arch, btw
```
## Headers
List items can be represented as headers. We support two headers currently. Headers will end the previous list and then create a new one after it. This allows a single document to contain multiple lists.
```
# Header One
## Header Two
```
## Blockquotes
List items can be represented as blockquotes.
```
> This is a blockquote.
```
## Preformatted
List items can be represented as preformatted text where newline characters are not considered part of new list items. They can be represented by prefixing the line with ```.
```
#!/usr/bin/env bash
set -x
echo "this is a preformatted list item!
```
You must also close the preformatted text with another ``` on its own line. The next example with NOT work.
## Variables
Variables allow us to store metadata within our system. Variables are list items with key value pairs denoted by `=:` followed by the key, a whitespace character, and then the value.
```
=: publish_at 2022-04-20
```
These variables will not be rendered to the user inside the list.
### List of available variables:
* `title` (custom title not dependent on filename)
* `description` (what is the purpose of this list?)
* `publish_at` (format must be `YYYY-MM-DD`)
* `list_type` (customize bullets; value gets sent directly to css property list-style-type[3])
=> https://developer.mozilla.org/en-US/docs/Web/CSS/list-style-type [3]list-style-type
{{template "marketing-footer" .}}
{{end}}
================================================
FILE: gmi/transparency.page.tmpl
================================================
{{template "base" .}}
{{define "body"}}
# Transparency
## Analytics
Here are some interesting stats on usage.
Total users:
{{.Analytics.TotalUsers}}
New users in the last month:
{{.Analytics.UsersLastMonth}}
Total posts:
{{.Analytics.TotalPosts}}
New posts in the last month:
{{.Analytics.PostsLastMonth}}
Users with at least one post:
{{.Analytics.UsersWithPost}}
Service maintenance costs:
* Server $5.00/mo
* Domain name $3.25/mo
* Programmer $0.00/mo
{{template "marketing-footer" .}}
{{end}}
================================================
FILE: go.mod
================================================
module git.sr.ht/~erock/lists.sh
go 1.18
// replace git.sr.ht/~erock/wish => /home/erock/pico/wish
require (
git.sr.ht/~adnano/go-gemini v0.2.3
git.sr.ht/~aw/gorilla-feeds v1.1.4
git.sr.ht/~erock/wish v0.0.0-20220729004215-0881364c2120
github.com/charmbracelet/wish v0.5.0
github.com/gliderlabs/ssh v0.3.4
github.com/gorilla/feeds v1.1.1
go.uber.org/zap v1.21.0
golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d
)
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/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/net v0.0.0-20220624214902-1bab6f366d9e // indirect
golang.org/x/sys v0.0.0-20220702020025-31831981b65f // indirect
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect
golang.org/x/text v0.3.7 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
================================================
FILE: go.sum
================================================
git.sr.ht/~adnano/go-gemini v0.2.3 h1:oJ+Y0/mheZ4Vg0ABjtf5dlmvq1yoONStiaQvmWWkofc=
git.sr.ht/~adnano/go-gemini v0.2.3/go.mod h1:hQ75Y0i5jSFL+FQ7AzWVAYr5LQsaFC7v3ZviNyj46dY=
git.sr.ht/~aw/gorilla-feeds v1.1.4 h1:bL78pZ1DtHEhumHK0iWQi30uwEkWtetMfnyt9TFcdlc=
git.sr.ht/~aw/gorilla-feeds v1.1.4/go.mod h1:VLpbtNDEWoaJKU41Crj6r3ChvlqYvBm56c0O6IM457g=
git.sr.ht/~erock/wish v0.0.0-20220728012620-699415a43292 h1:KnP4IH79pVSf+yw8qe59KlzhOG9H+qbTMlXpFcDXopw=
git.sr.ht/~erock/wish v0.0.0-20220728012620-699415a43292/go.mod h1:QZKk7m9jc9iXah90daPGhQkSfNfxSVvpb6nfVeI+MM0=
git.sr.ht/~erock/wish v0.0.0-20220729004215-0881364c2120 h1:9O4PKFF8JGvK9g3aVHr2wgozHK0s6BaVISPRl8MAovs=
git.sr.ht/~erock/wish v0.0.0-20220729004215-0881364c2120/go.mod h1:QZKk7m9jc9iXah90daPGhQkSfNfxSVvpb6nfVeI+MM0=
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/gliderlabs/ssh v0.3.4 h1:+AXBtim7MTKaLVPgvE+3mhewYRawNLTd+jEEz/wExZw=
github.com/gliderlabs/ssh v0.3.4/go.mod h1:ZSS+CUoKHDrqVakTfTWUlKSr9MtMFkC4UvtQKD7O914=
github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
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=
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-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
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/net v0.0.0-20220624214902-1bab6f366d9e h1:TsQ7F31D3bUCLeqPT0u+yjp1guoArKaNKmCr22PYgTQ=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
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-20220702020025-31831981b65f h1:xdsejrW/0Wf2diT5CPp3XmKUNbr7Xvw8kYilQ+6qjRY=
golang.org/x/sys v0.0.0-20220702020025-31831981b65f/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/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
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/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=
================================================
FILE: html/base.layout.tmpl
================================================
{{define "base"}}
<!doctype html>
<html lang="en">
<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="blog, blogging, write, writing, lists" />
{{template "meta" .}}
<link rel="stylesheet" href="/main.css" />
</head>
<body>{{template "body" .}}</body>
</html>
{{end}}
================================================
FILE: html/blog.page.tmpl
================================================
{{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>
{{if .Header.Bio}}<p class="text-lg">{{.Header.Bio}}</p>{{end}}
<nav>
{{range .Header.Nav}}
{{if .IsURL}}
<a href="{{.URL}}" class="text-lg">{{.Value}}</a> |
{{end}}
{{end}}
<a href="{{.RSSURL}}" class="text-lg">rss</a>
</nav>
<hr />
</header>
<main>
{{if .Readme.HasItems}}
<section>
<article>
{{template "list" .Readme}}
</article>
<hr />
</section>
{{end}}
<section class="posts">
{{range .Posts}}
<article>
<div class="flex items-center">
<time datetime="{{.UpdatedAtISO}}" class="font-italic text-sm post-date">{{.UpdatedTimeAgo}}</time>
<h2 class="font-bold flex-1"><a href="{{.URL}}">{{.Title}}</a></h2>
</div>
</article>
{{end}}
</section>
</main>
{{template "footer" .}}
{{end}}
================================================
FILE: html/footer.partial.tmpl
================================================
{{define "footer"}}
<footer>
<hr />
published with <a href={{.Site.HomeURL}}>{{.Site.Domain}}</a>
</footer>
{{end}}
================================================
FILE: html/help.page.tmpl
================================================
{{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">
<a href="#permission-denied" rel="nofollow noopener">#</a>
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">
<a href="#ssh-key" rel="nofollow noopener">#</a>
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="blog-structure">
<h2 class="text-xl">
<a href="#blog-structure" rel="nofollow noopener">#</a>
What should my blog folder look like?
</h2>
<p>
Currently {{.Site.Domain}} only supports a flat folder structure. Therefore,
<code>scp -r</code> is not permitted. We also only allow <code>.txt</code> files to be
uploaded.
</p>
<p>
<a href="https://github.com/neurosnap/lists-blog">Here is the source to my blog on this platform</a>
</p>
<p>
Below is an example of what your blog folder should look like:
</p>
<pre>blog/
first-post.txt
second-post.txt
third-post.txt</pre>
</p>
<p>
Underscores and hyphens are permitted and will be automatically removed from the title of the list.
</p>
</section>
<section id="post-update">
<h2 class="text-xl">
<a href="#post-update" rel="nofollow noopener">#</a>
How do I update a list?
</h2>
<p>
Updating a list requires that you update the source document and then run the <code>scp</code>
command again. If the filename remains the same, then the list will be updated.
</p>
</section>
<section id="post-delete">
<h2 class="text-xl">
<a href="#post-delete" rel="nofollow noopener">#</a>
How do I delete a list?
</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 list.
</p>
</section>
<section id="blog-upload-single-file">
<h2 class="text-xl">
<a href="#blog-upload-single-file" rel="nofollow noopener">#</a>
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.txt</code> then you would publish it like this:
</p>
<pre>scp ./taco-tuesday.txt {{.Site.Domain}}:</pre>
</section>
<section id="blog-header">
<h2 class="text-xl">
<a href="#blog-header" rel="nofollow noopener">#</a>
How do I change my blog's name?
</h2>
<p>
All you have to do is create a post titled <code>_header.txt</code> and add some
information to the list.
</p>
<pre>=: title My new blog!
=: description My blog description!
=> https://xyz.com website
=> https://twitter.com/xyz twitter</pre>
<ul>
<li><code>title</code> will change your blog name</li>
<li><code>description</code> will add a blurb right under your blog name (and add meta descriptions)</li>
<li>The links will show up next to the <code>rss</code> link to your blog
</ul>
</section>
<section id="blog-readme">
<h2 class="text-xl">
<a href="#blog-readme" rel="nofollow noopener">#</a>
How do I add an introduction to my blog?
</h2>
<p>
All you have to do is create a post titled <code>_readme.txt</code> and add some
information to the list.
</p>
<pre>=: list_type none
# Hi my name is Bob!
I like to sing. Dance. And I like to have fun fun fun!</pre>
<p>
Whatever is inside the <code>_readme</code> file will get rendered (as a list) right above your
blog posts. Neat!
</p>
</section>
<section id="blog-url">
<h2 class="text-xl">
<a href="#blog-url" rel="nofollow noopener">#</a>
What is my blog URL?
</h2>
<pre>https://{username}.{{.Site.Domain}}</pre>
</section>
<section id="continuous-deployment">
<h2 class="text-xl">
<a href="#continuous-deployment" rel="nofollow noopener">#</a>
How can I automatically publish my post?
</h2>
<p>
There is a github action that we built to make it easy to publish your blog automatically.
</p>
<ul>
<li>
<a href="https://github.com/marketplace/actions/scp-publish-action">github marketplace</a>
</li>
<li>
<a href="https://github.com/neurosnap/lists-official-blog/blob/main/.github/workflows/publish.yml">example workflow</a>
</li>
</ul>
<p>
A user also created a systemd task to automatically publish new posts. <a href="https://github.com/neurosnap/lists.sh/discussions/24">Check out this github discussion for more details.</a>
</p>
</section>
<section id="multiple-accounts">
<h2 class="text-xl">
<a href="#multiple-accounts" rel="nofollow noopener">#</a>
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}}
================================================
FILE: html/list.partial.tmpl
================================================
{{define "list"}}
<ul style="list-style-type: {{.ListType}};">
{{range .Items}}
{{if .IsText}}
{{if .Value}}
<li>{{.Value}}</li>
{{end}}
{{end}}
{{if .IsURL}}
<li><a href="{{.URL}}">{{.Value}}</a></li>
{{end}}
{{if .IsImg}}
<li><img src="{{.URL}}" alt="{{.Value}}" /></li>
{{end}}
{{if .IsBlock}}
<li><blockquote>{{.Value}}</blockquote></li>
{{end}}
{{if .IsHeaderOne}}
</ul><h2 class="text-xl font-bold">{{.Value}}</h2><ul style="list-style-type: {{$.ListType}};">
{{end}}
{{if .IsHeaderTwo}}
</ul><h3 class="text-lg font-bold">{{.Value}}</h3><ul style="list-style-type: {{$.ListType}};">
{{end}}
{{if .IsPre}}
<li><pre>{{.Value}}</pre></li>
{{end}}
{{end}}
</ul>
{{end}}
================================================
FILE: html/marketing-footer.partial.tmpl
================================================
{{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="/spec">spec</a> |
<a href="/ops">ops</a> |
<a href="/help">help</a> |
<a href="/rss">rss</a> |
<a href="https://git.sr.ht/~erock/lists.sh">source</a>
</div>
</footer>
{{end}}
================================================
FILE: html/marketing.page.tmpl
================================================
{{template "base" .}}
{{define "title"}}{{.Site.Domain}} -- a microblog for lists{{end}}
{{define "meta"}}
<meta name="description" content="a microblog for lists" />
<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 microblog for lists">
<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 microblog for lists">
<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 microblog for lists</p>
<p class="text-lg"><a href="/read">discover</a> some interesting lists</p>
<hr />
</header>
<main>
<section>
<h2 class="text-lg font-bold">Examples</h2>
<p>
<a href="//hey.{{.Site.Domain}}">official blog</a> |
<a href="https://git.sr.ht/~erock/lists-official-blog">blog source</a>
</p>
</section>
<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.
</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">You control the source files</h2>
<p>Create lists using your favorite editor in plain text files.</p>
<code>~/blog/days-in-week.txt</code>
<pre>Sunday
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday</pre>
</section>
<section>
<h2 class="text-lg font-bold">Publish your posts 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 ~/blog/*.txt {{.Site.Domain}}:/</pre>
<p>We'll either create or update the lists 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">Plain text format</h2>
<p>A simple specification that is flexible and with no frills.</p>
<p><a href="/spec">specification</a></p>
</section>
<section>
<h2 class="text-lg font-bold">Features</h2>
<ul>
<li>Just lists</li>
<li>Looks great on any device</li>
<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 browser-based tracking</li>
<li>No platform lock-in</li>
<li>No javascript</li>
<li>Subscriptions via RSS</li>
<li>Not a platform for todos</li>
<li>Minimalist design</li>
<li>100% open source</li>
</ul>
</section>
<section>
<h2 class="text-lg font-bold">Philosophy</h2>
<p>
I love writing lists. I think restricting writing to a set of lists can really
help improve clarity in thought. The goal of this blogging platform is to make it
simple to use the tools you love to write and publish lists. There is no installation,
signup is as easy as SSH'ing into our CMS, and publishing content is as easy as
copying files to our server.
</p>
<p>
Another goal of this microblog platform is to satisfy my own needs. I like to
write and share lists with people because I find it's one of the best way to disseminate
knowledge. Whether it's a list of links or a list of paragraphs, writing in lists is
very satisfying and I welcome you to explore it on this site!
</p>
<p>
Other blogging platforms support writing lists, but they don't
<span class="font-bold">emphasize</span> them. Writing lists is pretty popular
on Twitter, but discoverability is terrible. Other blogging platforms focus on prose,
but there really is nothing out there catered specifically for lists ... until now.
</p>
</section>
<section>
<h2 class="text-lg font-bold">Roadmap</h2>
<ol>
<li>Feature complete?</li>
</ol>
</section>
</main>
{{template "marketing-footer" .}}
{{end}}
================================================
FILE: html/ops.page.tmpl
================================================
{{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 lists
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 browser-based tracking of visitor behavior.</li>
<li>No attempt to identify users.</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}} blogs 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 blog 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, 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">Analytics</h2>
<p>
We are committed to zero browser-based tracking or trying to identify visitors. This
means we do not try to understand the user based on cookies or IP address. We do not
store personally identifiable information.
</p>
<p>
However, in order to provide a better service, we do have some analytics on posts.
List of metrics we track for posts:
</p>
<ul>
<li>anonymous view counts</li>
</ul>
<p>
We might also inspect the headers of HTTP requests to determine some tertiary information
about the request. For example we might inspect the <code>User-Agent</code> or
<code>Referer</code> to filter out requests from bots.
</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:support@{{.Site.Domain}}">support@{{.Site.Domain}}</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}}
================================================
FILE: html/post.page.tmpl
================================================
{{template "base" .}}
{{define "title"}}{{.PageTitle}}{{end}}
{{define "meta"}}
<meta name="description" content="{{.Description}}" />
<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}}">
{{if .Description}}<meta property="og:description" content="{{.Description}}">{{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="{{.Title}}">
{{if .Description}}<meta property="twitter:description" content="{{.Description}}">{{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>
<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>
{{if .Description}}<div class="my font-italic">{{.Description}}</div>{{end}}
</header>
<main>
<article>
{{template "list" .}}
</article>
</main>
{{template "footer" .}}
{{end}}
================================================
FILE: html/privacy.page.tmpl
================================================
{{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}}
================================================
FILE: html/read.page.tmpl
================================================
{{template "base" .}}
{{define "title"}}discover lists -- {{.Site.Domain}}{{end}}
{{define "meta"}}
<meta name="description" content="discover interesting lists" />
{{end}}
{{define "body"}}
<header class="text-center">
<h1 class="text-2xl font-bold">read</h1>
<p class="text-lg">recently updated lists</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="{{.UpdatedAtISO}}" class="font-italic text-sm post-date">{{.UpdatedTimeAgo}}</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}}
================================================
FILE: html/rss.page.tmpl
================================================
{{template "list" .}}
================================================
FILE: html/spec.page.tmpl
================================================
{{template "base" .}}
{{define "title"}}specification -- {{.Site.Domain}}{{end}}
{{define "meta"}}
<meta name="description" content="a specification for lists" />
{{end}}
{{define "body"}}
<header>
<h1 class="text-2xl">Plain text list</h1>
<h2 class="text-xl">Speculative specification</h2>
<dl>
<dt>Version</dt>
<dd>2022.05.02.dev</dd>
<dt>Status</dt>
<dd>Draft</dd>
<dt>Author</dt>
<dd>Eric Bower</dd>
</dl>
</header>
<main>
<section id="overview">
<p>
The goal of this specification is to understand how we render plain text lists.
The overall design of this format is to be easy to parse and render.
</p>
<p>
The format is line-oriented, and a satisfactory rendering can be achieved with a single
pass of a document, processing each line independently. As per gopher, links can only be
displayed one per line, encouraging neat, list-like structure.
</p>
<p>
Feedback on any part of this is extremely welcome, please email
<a href="mailto:{{.Site.Email}}">{{.Site.Email}}</a>.
</p>
<p>
The source code for our parser can be found
<a href="https://github.com/neurosnap/lists.sh/blob/main/pkg/parser.go">here</a>.
</p>
<p>
The source code for an example list demonstrating all the features can be found
<a href="https://github.com/neurosnap/lists-official-blog/blob/main/spec-example.txt">here</a>.
</p>
</section>
<section id="parameters">
<p>
As a subtype of the top-level media type "text", "text/plain" inherits the "charset"
parameter defined in <a href="https://datatracker.ietf.org/doc/html/rfc2046#section-4.1">RFC 2046</a>.
The default value of "charset" is "UTF-8" for "text" content.
</p>
</section>
<section id="line-orientation">
<p>
As mentioned, the text format is line-oriented. Each line of a document has a single
"line type". It is possible to unambiguously determine a line's type purely by
inspecting its first (3) characters. A line's type determines the manner in which it
should be presented to the user. Any details of presentation or rendering associated
with a particular line type are strictly limited in scope to that individual line.
</p>
</section>
<section id="file-extensions">
<h2 class="text-xl">File extension</h2>
<p>
{{.Site.Domain}} only supports the <code>.txt</code> file extension and will
ignore all other file extensions.
</p>
</section>
<section id="list-item">
<h2 class="text-xl">List item</h2>
<p>
List items are separated by newline characters <code>\n</code>.
Each list item is on its own line. A list item does not require any special formatting.
A list item can contain as much text as it wants. We encourage soft wrapping for readability
in your editor of choice. Hard wrapping is not permitted as it will create a new list item.
</p>
<p>
Empty lines will be completely removed and not rendered to the end user.
</p>
</section>
<section id="hyperlinks">
<h2 class="text-xl">Hyperlinks</h2>
<p>
Hyperlinks are denoted by the prefix <code>=></code>. The following text should then be
the hyperlink.
</p>
<pre>=> https://{{.Site.Domain}}</pre>
<p>Optionally you can supply the hyperlink text immediately following the link.</p>
<pre>=> https://{{.Site.Domain}} microblog for lists</pre>
</section>
<section id="images">
<h2 class="text-xl">Images</h2>
<p>
List items can be represented as images by prefixing the line with <code>=<</code>.
</p>
<pre>=< https://i.imgur.com/iXMNUN5.jpg</pre>
<p>Optionally you can supply the image alt text immediately following the link.</p>
<pre>=< https://i.imgur.com/iXMNUN5.jpg I use arch, btw</pre>
</section>
<section id="headers">
<h2 class="text-xl">Headers</h2>
<p>
List items can be represented as headers. We support two headers currently. Headers
will end the previous list and then create a new one after it. This allows a single
document to contain multiple lists.
</p>
<pre># Header One
## Header Two</pre>
</section>
<section id="blockquotes">
<h2 class="text-xl">Blockquotes</h2>
<p>
List items can be represented as blockquotes.
</p>
<pre>> This is a blockquote.</pre>
</section>
<section id="preformatted">
<h2 class="text-xl">Preformatted</h2>
<p>
List items can be represented as preformatted text where newline characters are not
considered part of new list items. They can be represented by prefixing the line with
<code>```</code>.
</p>
<pre>```
#!/usr/bin/env bash
set -x
echo "this is a preformatted list item!
```</pre>
<p>
You must also close the preformatted text with another <code>```</code> on its own line. The
next example with NOT work.
</p>
<pre>```
#!/usr/bin/env bash
echo "This will not render properly"```</pre>
</section>
<section id="variables">
<h2 class="text-xl">Variables</h2>
<p>
Variables allow us to store metadata within our system. Variables are list items with
key value pairs denoted by <code>=:</code> followed by the key, a whitespace character,
and then the value.
</p>
<pre>=: publish_at 2022-04-20</pre>
<p>These variables will not be rendered to the user inside the list.</p>
<h3 class="text-lg">List of available variables:</h3>
<ul>
<li><code>title</code> (custom title not dependent on filename)</li>
<li><code>description</code> (what is the purpose of this list?)</li>
<li><code>publish_at</code> (format must be <code>YYYY-MM-DD</code>)</li>
<li>
<code>list_type</code> (customize bullets; value gets sent directly to css property
<a href="https://developer.mozilla.org/en-US/docs/Web/CSS/list-style-type">list-style-type</a>)
</li>
</ul>
</section>
</main>
{{template "marketing-footer" .}}
{{end}}
================================================
FILE: html/transparency.page.tmpl
================================================
{{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 posts</h2>
<div>{{.Analytics.TotalPosts}}</div>
</article>
<article>
<h2 class="text-lg">New posts in the last month</h2>
<div>{{.Analytics.PostsLastMonth}}</div>
</article>
<article>
<h2 class="text-lg">Users with at least one post</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}}
================================================
FILE: internal/api.go
================================================
package internal
import (
"bytes"
"fmt"
"html/template"
"io/ioutil"
"net/http"
"net/url"
"strconv"
"time"
"git.sr.ht/~erock/lists.sh/pkg"
"git.sr.ht/~erock/wish/cms/db"
"git.sr.ht/~erock/wish/cms/db/postgres"
"github.com/gorilla/feeds"
"golang.org/x/exp/slices"
)
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
Readme *ReadmeTxt
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
ListType string
Items []*pkg.ListItem
PublishAtISO string
PublishAt string
}
type TransparencyPageData struct {
Site SitePageData
Analytics *db.Analytics
}
func isRequestTrackable(r *http.Request) bool {
return true
}
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 HeaderTxt struct {
Title string
Bio string
Nav []*pkg.ListItem
HasItems bool
}
type ReadmeTxt struct {
HasItems bool
ListType string
Items []*pkg.ListItem
}
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.FindUpdatedPostsForUser(user.ID, cfg.Space)
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",
"./html/list.partial.tmpl",
})
if err != nil {
logger.Error(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
headerTxt := &HeaderTxt{
Title: GetBlogName(username),
Bio: "",
}
readmeTxt := &ReadmeTxt{}
postCollection := make([]PostItemData, 0, len(posts))
for _, post := range posts {
if post.Filename == "_header" {
parsedText := pkg.ParseText(post.Text)
if parsedText.MetaData.Title != "" {
headerTxt.Title = parsedText.MetaData.Title
}
if parsedText.MetaData.Description != "" {
headerTxt.Bio = parsedText.MetaData.Description
}
headerTxt.Nav = parsedText.Items
if len(headerTxt.Nav) > 0 {
headerTxt.HasItems = true
}
} else if post.Filename == "_readme" {
parsedText := pkg.ParseText(post.Text)
readmeTxt.Items = parsedText.Items
readmeTxt.ListType = parsedText.MetaData.ListType
if len(readmeTxt.Items) > 0 {
readmeTxt.HasItems = true
}
} else {
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)),
Readme: readmeTxt,
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 lists", 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
}
header, _ := dbpool.FindPostWithFilename("_header", user.ID, cfg.Space)
blogName := GetBlogName(username)
if header != nil {
headerParsed := pkg.ParseText(header.Text)
if headerParsed.MetaData.Title != "" {
blogName = headerParsed.MetaData.Title
}
}
var data PostPageData
post, err := dbpool.FindPostWithFilename(filename, user.ID, cfg.Space)
if err == nil {
parsedText := pkg.ParseText(post.Text)
// we need the blog name from the readme unfortunately
readme, err := dbpool.FindPostWithFilename("_readme", user.ID, cfg.Space)
if err == nil {
readmeParsed := pkg.ParseText(readme.Text)
if readmeParsed.MetaData.Title != "" {
blogName = readmeParsed.MetaData.Title
}
}
// validate and fire off analytic event
if isRequestTrackable(r) {
_, err := dbpool.AddViewCount(post.ID)
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,
ListType: parsedText.MetaData.ListType,
Title: FilenameToTitle(post.Filename, post.Title),
PublishAt: post.PublishAt.Format("02 Jan, 2006"),
PublishAtISO: post.PublishAt.Format(time.RFC3339),
Username: username,
BlogName: blogName,
Items: parsedText.Items,
}
} else {
logger.Infof("post not found %s/%s", username, filename)
data = PostPageData{
Site: *cfg.GetSiteData(),
PageTitle: "Post not found",
Description: "Post not found",
Title: "Post not found",
ListType: "none",
BlogURL: template.URL(cfg.BlogURL(username)),
PublishAt: time.Now().Format("02 Jan, 2006"),
PublishAtISO: time.Now().Format(time.RFC3339),
Username: username,
BlogName: blogName,
Items: []*pkg.ListItem{
{
Value: "oops! we can't seem to find this post.",
IsText: true,
},
},
}
}
ts, err := renderTemplate([]string{
"./html/post.page.tmpl",
"./html/list.partial.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 transparencyHandler(w http.ResponseWriter, r *http.Request) {
dbpool := GetDB(r)
logger := GetLogger(r)
cfg := GetCfg(r)
analytics, err := dbpool.FindSiteAnalytics(cfg.Space)
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.FindAllUpdatedPosts(&db.Pager{Num: 30, Page: page}, cfg.Space)
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 rssBlogHandler(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("rss feed not found: %s", username)
http.Error(w, "rss feed not found", http.StatusNotFound)
return
}
posts, err := dbpool.FindUpdatedPostsForUser(user.ID, cfg.Space)
if err != nil {
logger.Error(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
ts, err := template.ParseFiles("./html/rss.page.tmpl", "./html/list.partial.tmpl")
if err != nil {
logger.Error(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
headerTxt := &HeaderTxt{
Title: GetBlogName(username),
}
for _, post := range posts {
if post.Filename == "_header" {
parsedText := pkg.ParseText(post.Text)
if parsedText.MetaData.Title != "" {
headerTxt.Title = parsedText.MetaData.Title
}
if parsedText.MetaData.Description != "" {
headerTxt.Bio = parsedText.MetaData.Description
}
break
}
}
feed := &feeds.Feed{
Title: headerTxt.Title,
Link: &feeds.Link{Href: cfg.BlogURL(username)},
Description: headerTxt.Bio,
Author: &feeds.Author{Name: username},
Created: time.Now(),
}
var feedItems []*feeds.Item
for _, post := range posts {
if slices.Contains(HiddenPosts, post.Filename) {
continue
}
parsed := pkg.ParseText(post.Text)
var tpl bytes.Buffer
data := &PostPageData{
ListType: parsed.MetaData.ListType,
Items: parsed.Items,
}
if err := ts.Execute(&tpl, data); err != nil {
continue
}
item := &feeds.Item{
Id: cfg.PostURL(post.Username, post.Filename),
Title: FilenameToTitle(post.Filename, post.Title),
Link: &feeds.Link{Href: cfg.PostURL(post.Username, post.Filename)},
Content: tpl.String(),
Created: *post.PublishAt,
}
if post.Description != "" {
item.Description = post.Description
}
feedItems = append(feedItems, item)
}
feed.Items = feedItems
rss, err := feed.ToAtom()
if err != nil {
logger.Error(err)
http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
}
w.Header().Add("Content-Type", "application/atom+xml")
_, err = w.Write([]byte(rss))
if err != nil {
logger.Error(err)
}
}
func rssHandler(w http.ResponseWriter, r *http.Request) {
dbpool := GetDB(r)
logger := GetLogger(r)
cfg := GetCfg(r)
pager, err := dbpool.FindAllPosts(&db.Pager{Num: 25, Page: 0}, cfg.Space)
if err != nil {
logger.Error(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
ts, err := template.ParseFiles("./html/rss.page.tmpl", "./html/list.partial.tmpl")
if err != nil {
logger.Error(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
feed := &feeds.Feed{
Title: fmt.Sprintf("%s discovery feed", cfg.Domain),
Link: &feeds.Link{Href: cfg.ReadURL()},
Description: fmt.Sprintf("%s latest posts", cfg.Domain),
Author: &feeds.Author{Name: cfg.Domain},
Created: time.Now(),
}
var feedItems []*feeds.Item
for _, post := range pager.Data {
parsed := pkg.ParseText(post.Text)
var tpl bytes.Buffer
data := &PostPageData{
ListType: parsed.MetaData.ListType,
Items: parsed.Items,
}
if err := ts.Execute(&tpl, data); err != nil {
continue
}
item := &feeds.Item{
Id: cfg.PostURL(post.Username, post.Filename),
Title: post.Title,
Link: &feeds.Link{Href: cfg.PostURL(post.Username, post.Filename)},
Content: tpl.String(),
Created: *post.PublishAt,
}
if post.Description != "" {
item.Description = post.Description
}
feedItems = append(feedItems, item)
}
feed.Items = feedItems
rss, err := feed.ToAtom()
if err != nil {
logger.Error(err)
http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
}
w.Header().Add("Content-Type", "application/atom+xml")
_, err = w.Write([]byte(rss))
if err != nil {
logger.Error(err)
}
}
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)
}
}
}
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", "/rss", rssHandler),
NewRoute("GET", "/rss.xml", rssHandler),
NewRoute("GET", "/atom.xml", rssHandler),
NewRoute("GET", "/feed.xml", rssHandler),
NewRoute("GET", "/([^/]+)", blogHandler),
NewRoute("GET", "/([^/]+)/rss", rssBlogHandler),
NewRoute("GET", "/([^/]+)/([^/]+)", postHandler),
)
return routes
}
func createSubdomainRoutes(staticRoutes []Route) []Route {
routes := []Route{
NewRoute("GET", "/", blogHandler),
NewRoute("GET", "/rss", rssBlogHandler),
}
routes = append(
routes,
staticRoutes...,
)
routes = append(
routes,
NewRoute("GET", "/([^/]+)", postHandler),
)
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))
}
================================================
FILE: internal/config.go
================================================
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("LISTS_DOMAIN", "lists.sh")
email := GetEnv("LISTS_EMAIL", "support@lists.sh")
subdomains := GetEnv("LISTS_SUBDOMAINS", "0")
port := GetEnv("LISTS_WEB_PORT", "3000")
protocol := GetEnv("LISTS_PROTOCOL", "https")
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. ~/blog).\n"
intro += "Then write your lists in plain text files (e.g. hello-world.txt).\n"
intro += "Finally, send your list files to us:\n\n"
intro += fmt.Sprintf("scp ~/blog/*.txt %s:/\n\n", domain)
return &ConfigSite{
SubdomainsEnabled: subdomainsEnabled,
ConfigCms: config.ConfigCms{
Domain: domain,
Email: email,
Port: port,
Protocol: protocol,
DbURL: dbURL,
Description: "A microblog for your lists.",
IntroText: intro,
Space: "lists",
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.%s", c.Protocol, 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/%s", c.Protocol, 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.%s/rss", c.Protocol, username, c.Domain)
}
return fmt.Sprintf("/%s/rss", username)
}
func (c *ConfigSite) HomeURL() string {
if c.IsSubdomains() {
return fmt.Sprintf("%s://%s", c.Protocol, c.Domain)
}
return "/"
}
func (c *ConfigSite) ReadURL() string {
if c.IsSubdomains() {
return fmt.Sprintf("%s://%s/read", c.Protocol, c.Domain)
}
return "/read"
}
func CreateLogger() *zap.SugaredLogger {
logger, err := zap.NewProduction()
if err != nil {
log.Fatal(err)
}
return logger.Sugar()
}
================================================
FILE: internal/db_handler.go
================================================
package internal
import (
"fmt"
"io"
"time"
"git.sr.ht/~erock/lists.sh/pkg"
"git.sr.ht/~erock/wish/cms/db"
"git.sr.ht/~erock/wish/cms/util"
sendutils "git.sr.ht/~erock/wish/send/utils"
"github.com/gliderlabs/ssh"
"golang.org/x/exp/slices"
)
var HiddenPosts = []string{"_readme", "_header"}
type Opener struct {
entry *sendutils.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 *sendutils.FileEntry) (string, error) {
logger := h.Cfg.Logger
userID := h.User.ID
filename := SanitizeFileExt(entry.Name)
title := filename
post, err := h.DBPool.FindPostWithFilename(filename, userID, h.Cfg.Space)
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) {
return "", fmt.Errorf("WARNING: (%s) invalid file, format must be '.txt' and the contents must be plain text, skipping", entry.Name)
}
parsedText := pkg.ParseText(text)
if parsedText.MetaData.Title != "" {
title = parsedText.MetaData.Title
}
description := parsedText.MetaData.Description
// 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 {
return "", fmt.Errorf("error for %s: %v", filename, err)
}
} else if post == nil {
publishAt := time.Now()
if parsedText.MetaData.PublishAt != nil {
publishAt = *parsedText.MetaData.PublishAt
}
hidden := slices.Contains(HiddenPosts, filename)
logger.Infof("(%s) not found, adding record", filename)
_, err = h.DBPool.InsertPost(userID, filename, title, text, description, &publishAt, hidden, h.Cfg.Space)
if err != nil {
return "", fmt.Errorf("error for %s: %v", filename, err)
}
} else {
publishAt := post.PublishAt
if parsedText.MetaData.PublishAt != nil {
publishAt = parsedText.MetaData.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, description, publishAt)
if err != nil {
return "", fmt.Errorf("error for %s: %v", filename, err)
}
}
return h.Cfg.PostURL(user.Name, filename), nil
}
================================================
FILE: internal/gemini/gemini.go
================================================
package gemini
import (
"bytes"
"context"
"fmt"
html "html/template"
"net/url"
"os"
"os/signal"
"strconv"
"strings"
"text/template"
"time"
"git.sr.ht/~adnano/go-gemini"
"git.sr.ht/~adnano/go-gemini/certificate"
feeds "git.sr.ht/~aw/gorilla-feeds"
"git.sr.ht/~erock/lists.sh/internal"
"git.sr.ht/~erock/lists.sh/pkg"
"git.sr.ht/~erock/wish/cms/db"
"git.sr.ht/~erock/wish/cms/db/postgres"
"golang.org/x/exp/slices"
)
func renderTemplate(templates []string) (*template.Template, error) {
files := make([]string, len(templates))
copy(files, templates)
files = append(
files,
"./gmi/footer.partial.tmpl",
"./gmi/marketing-footer.partial.tmpl",
"./gmi/base.layout.tmpl",
)
ts, err := template.ParseFiles(files...)
if err != nil {
return nil, err
}
return ts, nil
}
func createPageHandler(fname string) gemini.HandlerFunc {
return func(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
logger := GetLogger(ctx)
cfg := GetCfg(ctx)
ts, err := renderTemplate([]string{fname})
if err != nil {
logger.Error(err)
w.WriteHeader(gemini.StatusTemporaryFailure, "Internal Service Error")
return
}
data := internal.PageData{
Site: *cfg.GetSiteData(),
}
err = ts.Execute(w, data)
if err != nil {
logger.Error(err)
w.WriteHeader(gemini.StatusTemporaryFailure, "Internal Service Error")
}
}
}
func blogHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
username := GetField(ctx, 0)
dbpool := GetDB(ctx)
logger := GetLogger(ctx)
cfg := GetCfg(ctx)
user, err := dbpool.FindUserForName(username)
if err != nil {
logger.Infof("blog not found: %s", username)
w.WriteHeader(gemini.StatusNotFound, "blog not found")
return
}
posts, err := dbpool.FindUpdatedPostsForUser(user.ID, cfg.Space)
if err != nil {
logger.Error(err)
w.WriteHeader(gemini.StatusTemporaryFailure, "could not fetch posts for blog")
return
}
ts, err := renderTemplate([]string{
"./gmi/blog.page.tmpl",
"./gmi/list.partial.tmpl",
})
if err != nil {
logger.Error(err)
w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
return
}
headerTxt := &internal.HeaderTxt{
Title: internal.GetBlogName(username),
Bio: "",
}
readmeTxt := &internal.ReadmeTxt{}
postCollection := make([]internal.PostItemData, 0, len(posts))
for _, post := range posts {
if post.Filename == "_header" {
parsedText := pkg.ParseText(post.Text)
if parsedText.MetaData.Title != "" {
headerTxt.Title = parsedText.MetaData.Title
}
if parsedText.MetaData.Description != "" {
headerTxt.Bio = parsedText.MetaData.Description
}
headerTxt.Nav = parsedText.Items
if len(headerTxt.Nav) > 0 {
headerTxt.HasItems = true
}
} else if post.Filename == "_readme" {
parsedText := pkg.ParseText(post.Text)
readmeTxt.Items = parsedText.Items
readmeTxt.ListType = parsedText.MetaData.ListType
if len(readmeTxt.Items) > 0 {
readmeTxt.HasItems = true
}
} else {
p := internal.PostItemData{
URL: html.URL(cfg.PostURL(post.Username, post.Filename)),
BlogURL: html.URL(cfg.BlogURL(post.Username)),
Title: internal.FilenameToTitle(post.Filename, post.Title),
PublishAt: post.PublishAt.Format("02 Jan, 2006"),
PublishAtISO: post.PublishAt.Format(time.RFC3339),
UpdatedTimeAgo: internal.TimeAgo(post.UpdatedAt),
UpdatedAtISO: post.UpdatedAt.Format(time.RFC3339),
}
postCollection = append(postCollection, p)
}
}
data := internal.BlogPageData{
Site: *cfg.GetSiteData(),
PageTitle: headerTxt.Title,
URL: html.URL(cfg.BlogURL(username)),
RSSURL: html.URL(cfg.RssBlogURL(username)),
Readme: readmeTxt,
Header: headerTxt,
Username: username,
Posts: postCollection,
}
err = ts.Execute(w, data)
if err != nil {
logger.Error(err)
w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
}
}
func readHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
dbpool := GetDB(ctx)
logger := GetLogger(ctx)
cfg := GetCfg(ctx)
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
pager, err := dbpool.FindAllUpdatedPosts(&db.Pager{Num: 30, Page: page}, cfg.Space)
if err != nil {
logger.Error(err)
w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
return
}
ts, err := renderTemplate([]string{
"./gmi/read.page.tmpl",
})
if err != nil {
w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
return
}
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 := internal.ReadPageData{
Site: *cfg.GetSiteData(),
NextPage: nextPage,
PrevPage: prevPage,
}
longest := 0
for _, post := range pager.Data {
size := len(internal.TimeAgo(post.UpdatedAt))
if size > longest {
longest = size
}
}
for _, post := range pager.Data {
item := internal.PostItemData{
URL: html.URL(cfg.PostURL(post.Username, post.Filename)),
BlogURL: html.URL(cfg.BlogURL(post.Username)),
Title: internal.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: internal.TimeAgo(post.UpdatedAt),
UpdatedAtISO: post.UpdatedAt.Format(time.RFC3339),
}
item.Padding = strings.Repeat(" ", longest-len(item.UpdatedTimeAgo))
data.Posts = append(data.Posts, item)
}
err = ts.Execute(w, data)
if err != nil {
logger.Error(err)
w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
}
}
func postHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
username := GetField(ctx, 0)
filename, _ := url.PathUnescape(GetField(ctx, 1))
dbpool := GetDB(ctx)
logger := GetLogger(ctx)
cfg := GetCfg(ctx)
user, err := dbpool.FindUserForName(username)
if err != nil {
logger.Infof("blog not found: %s", username)
w.WriteHeader(gemini.StatusNotFound, "blog not found")
return
}
header, _ := dbpool.FindPostWithFilename("_header", user.ID, cfg.Space)
blogName := internal.GetBlogName(username)
if header != nil {
headerParsed := pkg.ParseText(header.Text)
if headerParsed.MetaData.Title != "" {
blogName = headerParsed.MetaData.Title
}
}
post, err := dbpool.FindPostWithFilename(filename, user.ID, cfg.Space)
if err != nil {
logger.Infof("post not found %s/%s", username, filename)
w.WriteHeader(gemini.StatusNotFound, "post not found")
return
}
parsedText := pkg.ParseText(post.Text)
// we need the blog name from the readme unfortunately
readme, err := dbpool.FindPostWithFilename("_readme", user.ID, cfg.Space)
if err == nil {
readmeParsed := pkg.ParseText(readme.Text)
if readmeParsed.MetaData.Title != "" {
blogName = readmeParsed.MetaData.Title
}
}
_, err = dbpool.AddViewCount(post.ID)
if err != nil {
logger.Error(err)
}
data := internal.PostPageData{
Site: *cfg.GetSiteData(),
PageTitle: internal.GetPostTitle(post),
URL: html.URL(cfg.PostURL(post.Username, post.Filename)),
BlogURL: html.URL(cfg.BlogURL(username)),
Description: post.Description,
ListType: parsedText.MetaData.ListType,
Title: internal.FilenameToTitle(post.Filename, post.Title),
PublishAt: post.PublishAt.Format("02 Jan, 2006"),
PublishAtISO: post.PublishAt.Format(time.RFC3339),
Username: username,
BlogName: blogName,
Items: parsedText.Items,
}
ts, err := renderTemplate([]string{
"./gmi/post.page.tmpl",
"./gmi/list.partial.tmpl",
})
if err != nil {
w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
return
}
err = ts.Execute(w, data)
if err != nil {
logger.Error(err)
w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
}
}
func transparencyHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
dbpool := GetDB(ctx)
logger := GetLogger(ctx)
cfg := GetCfg(ctx)
analytics, err := dbpool.FindSiteAnalytics(cfg.Space)
if err != nil {
logger.Error(err)
w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
return
}
ts, err := template.ParseFiles(
"./gmi/transparency.page.tmpl",
"./gmi/footer.partial.tmpl",
"./gmi/marketing-footer.partial.tmpl",
"./gmi/base.layout.tmpl",
)
if err != nil {
w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
return
}
data := internal.TransparencyPageData{
Site: *cfg.GetSiteData(),
Analytics: analytics,
}
err = ts.Execute(w, data)
if err != nil {
logger.Error(err)
w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
}
}
func rssBlogHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
username := GetField(ctx, 0)
dbpool := GetDB(ctx)
logger := GetLogger(ctx)
cfg := GetCfg(ctx)
user, err := dbpool.FindUserForName(username)
if err != nil {
logger.Infof("rss feed not found: %s", username)
w.WriteHeader(gemini.StatusNotFound, "rss feed not found")
return
}
posts, err := dbpool.FindUpdatedPostsForUser(user.ID, cfg.Space)
if err != nil {
logger.Error(err)
w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
return
}
ts, err := template.ParseFiles("./gmi/rss.page.tmpl", "./gmi/list.partial.tmpl")
if err != nil {
logger.Error(err)
w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
return
}
headerTxt := &internal.HeaderTxt{
Title: internal.GetBlogName(username),
}
for _, post := range posts {
if post.Filename == "_header" {
parsedText := pkg.ParseText(post.Text)
if parsedText.MetaData.Title != "" {
headerTxt.Title = parsedText.MetaData.Title
}
if parsedText.MetaData.Description != "" {
headerTxt.Bio = parsedText.MetaData.Description
}
break
}
}
feed := &feeds.Feed{
Title: headerTxt.Title,
Link: &feeds.Link{Href: cfg.BlogURL(username)},
Description: headerTxt.Bio,
Author: &feeds.Author{Name: username},
Created: time.Now(),
}
var feedItems []*feeds.Item
for _, post := range posts {
if slices.Contains(internal.HiddenPosts, post.Filename) {
continue
}
parsed := pkg.ParseText(post.Text)
var tpl bytes.Buffer
data := &internal.PostPageData{
ListType: parsed.MetaData.ListType,
Items: parsed.Items,
}
if err := ts.Execute(&tpl, data); err != nil {
continue
}
item := &feeds.Item{
Id: cfg.PostURL(post.Username, post.Filename),
Title: internal.FilenameToTitle(post.Filename, post.Title),
Link: &feeds.Link{Href: cfg.PostURL(post.Username, post.Filename)},
Content: tpl.String(),
Created: *post.PublishAt,
}
if post.Description != "" {
item.Description = post.Description
}
feedItems = append(feedItems, item)
}
feed.Items = feedItems
rss, err := feed.ToAtom()
if err != nil {
logger.Error(err)
w.WriteHeader(gemini.StatusTemporaryFailure, "Could not generate atom rss feed")
return
}
// w.Header().Add("Content-Type", "application/atom+xml")
_, err = w.Write([]byte(rss))
if err != nil {
logger.Error(err)
}
}
func rssHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
dbpool := GetDB(ctx)
logger := GetLogger(ctx)
cfg := GetCfg(ctx)
pager, err := dbpool.FindAllPosts(&db.Pager{Num: 25, Page: 0}, cfg.Space)
if err != nil {
logger.Error(err)
w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
return
}
ts, err := template.ParseFiles("./gmi/rss.page.tmpl", "./gmi/list.partial.tmpl")
if err != nil {
logger.Error(err)
w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
return
}
feed := &feeds.Feed{
Title: fmt.Sprintf("%s discovery feed", cfg.Domain),
Link: &feeds.Link{Href: cfg.ReadURL()},
Description: fmt.Sprintf("%s latest posts", cfg.Domain),
Author: &feeds.Author{Name: cfg.Domain},
Created: time.Now(),
}
var feedItems []*feeds.Item
for _, post := range pager.Data {
parsed := pkg.ParseText(post.Text)
var tpl bytes.Buffer
data := &internal.PostPageData{
ListType: parsed.MetaData.ListType,
Items: parsed.Items,
}
if err := ts.Execute(&tpl, data); err != nil {
continue
}
item := &feeds.Item{
Id: cfg.PostURL(post.Username, post.Filename),
Title: post.Title,
Link: &feeds.Link{Href: cfg.PostURL(post.Username, post.Filename)},
Content: tpl.String(),
Created: *post.PublishAt,
}
if post.Description != "" {
item.Description = post.Description
}
feedItems = append(feedItems, item)
}
feed.Items = feedItems
rss, err := feed.ToAtom()
if err != nil {
logger.Error(err)
w.WriteHeader(gemini.StatusTemporaryFailure, "Could not generate atom rss feed")
}
// w.Header().Add("Content-Type", "application/atom+xml")
_, err = w.Write([]byte(rss))
if err != nil {
logger.Error(err)
}
}
func StartServer() {
cfg := internal.NewConfigSite()
db := postgres.NewDB(&cfg.ConfigCms)
logger := cfg.Logger
certificates := &certificate.Store{}
certificates.Register("localhost")
certificates.Register(cfg.Domain)
certificates.Register(fmt.Sprintf("*.%s", cfg.Domain))
if err := certificates.Load("/var/lib/gemini/certs"); err != nil {
logger.Fatal(err)
}
routes := []Route{
NewRoute("/", createPageHandler("./gmi/marketing.page.tmpl")),
NewRoute("/spec", createPageHandler("./gmi/spec.page.tmpl")),
NewRoute("/help", createPageHandler("./gmi/help.page.tmpl")),
NewRoute("/ops", createPageHandler("./gmi/ops.page.tmpl")),
NewRoute("/privacy", createPageHandler("./gmi/privacy.page.tmpl")),
NewRoute("/transparency", transparencyHandler),
NewRoute("/read", readHandler),
NewRoute("/rss", rssHandler),
NewRoute("/([^/]+)", blogHandler),
NewRoute("/([^/]+)/rss", rssBlogHandler),
NewRoute("/([^/]+)/([^/]+)", postHandler),
}
handler := CreateServe(routes, cfg, db, logger)
router := gemini.HandlerFunc(handler)
server := &gemini.Server{
Addr: "0.0.0.0:1965",
Handler: gemini.LoggingMiddleware(router),
ReadTimeout: 30 * time.Second,
WriteTimeout: 1 * time.Minute,
GetCertificate: certificates.Get,
}
// Listen for interrupt signal
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
errch := make(chan error)
go func() {
logger.Info("Starting server")
ctx := context.Background()
errch <- server.ListenAndServe(ctx)
}()
select {
case err := <-errch:
logger.Fatal(err)
case <-c:
// Shutdown the server
logger.Info("Shutting down...")
db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := server.Shutdown(ctx)
if err != nil {
logger.Fatal(err)
}
}
}
================================================
FILE: internal/gemini/router.go
================================================
package gemini
import (
"context"
"regexp"
"git.sr.ht/~adnano/go-gemini"
"git.sr.ht/~erock/lists.sh/internal"
"git.sr.ht/~erock/wish/cms/db"
"go.uber.org/zap"
)
type ctxKey struct{}
type ctxDBKey struct{}
type ctxLoggerKey struct{}
type ctxCfgKey struct{}
func GetLogger(ctx context.Context) *zap.SugaredLogger {
return ctx.Value(ctxLoggerKey{}).(*zap.SugaredLogger)
}
func GetCfg(ctx context.Context) *internal.ConfigSite {
return ctx.Value(ctxCfgKey{}).(*internal.ConfigSite)
}
func GetDB(ctx context.Context) db.DB {
return ctx.Value(ctxDBKey{}).(db.DB)
}
func GetField(ctx context.Context, index int) string {
fields := ctx.Value(ctxKey{}).([]string)
return fields[index]
}
type Route struct {
regex *regexp.Regexp
handler gemini.HandlerFunc
}
func NewRoute(pattern string, handler gemini.HandlerFunc) Route {
return Route{
regexp.MustCompile("^" + pattern + "$"),
handler,
}
}
type ServeFn func(context.Context, gemini.ResponseWriter, *gemini.Request)
func CreateServe(routes []Route, cfg *internal.ConfigSite, dbpool db.DB, logger *zap.SugaredLogger) ServeFn {
return func(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
curRoutes := routes
for _, route := range curRoutes {
matches := route.regex.FindStringSubmatch(r.URL.Path)
if len(matches) > 0 {
ctx = context.WithValue(ctx, ctxLoggerKey{}, logger)
ctx = context.WithValue(ctx, ctxDBKey{}, dbpool)
ctx = context.WithValue(ctx, ctxCfgKey{}, cfg)
ctx = context.WithValue(ctx, ctxKey{}, matches[1:])
route.handler(ctx, w, r)
return
}
}
w.WriteHeader(gemini.StatusTemporaryFailure, "Internal Service Error")
}
}
================================================
FILE: internal/router.go
================================================
package internal
import (
"context"
"fmt"
"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
hostDomain := strings.ToLower(strings.Split(r.Host, ":")[0])
appDomain := strings.ToLower(strings.Split(cfg.ConfigCms.Domain, ":")[0])
subdomain := ""
if hostDomain != appDomain && strings.Contains(hostDomain, appDomain) {
subdomain = strings.TrimSuffix(hostDomain, fmt.Sprintf(".%s", appDomain))
}
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)
}
================================================
FILE: internal/util.go
================================================
package internal
import (
"encoding/base64"
"fmt"
"math"
"os"
pathpkg "path"
"path/filepath"
"regexp"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/gliderlabs/ssh"
"golang.org/x/exp/slices"
)
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' && c != '\r' {
// decoding error or control character - not a text file
return false
}
}
return true
}
var allowedExtensions = []string{".txt"}
// 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 {
ext := pathpkg.Ext(filename)
if !slices.Contains(allowedExtensions, ext) {
return false
}
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)
}
}
================================================
FILE: pkg/parser.go
================================================
package pkg
import (
"fmt"
"html/template"
"strings"
"time"
)
type ParsedText struct {
Items []*ListItem
MetaData *MetaData
}
type ListItem struct {
Value string
URL template.URL
Variable string
IsURL bool
IsBlock bool
IsText bool
IsHeaderOne bool
IsHeaderTwo bool
IsImg bool
IsPre bool
}
type MetaData struct {
PublishAt *time.Time
Title string
Description string
ListType string // https://developer.mozilla.org/en-US/docs/Web/CSS/list-style-type
}
var urlToken = "=>"
var blockToken = ">"
var varToken = "=:"
var imgToken = "=<"
var headerOneToken = "#"
var headerTwoToken = "##"
var preToken = "```"
type SplitToken struct {
Key string
Value string
}
func TextToSplitToken(text string) *SplitToken {
txt := strings.Trim(text, " ")
token := &SplitToken{}
word := ""
for i, c := range txt {
if c == ' ' {
token.Key = strings.Trim(word, " ")
token.Value = strings.Trim(txt[i:], " ")
break
} else {
word += string(c)
}
}
if token.Key == "" {
token.Key = strings.Trim(text, " ")
token.Value = strings.Trim(text, " ")
}
return token
}
func SplitByNewline(text string) []string {
return strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
}
func PublishAtDate(date string) (*time.Time, error) {
t, err := time.Parse("2006-01-02", date)
return &t, err
}
func TokenToMetaField(meta *MetaData, token *SplitToken) {
if token.Key == "publish_at" {
publishAt, err := PublishAtDate(token.Value)
if err == nil {
meta.PublishAt = publishAt
}
} else if token.Key == "title" {
meta.Title = token.Value
} else if token.Key == "description" {
meta.Description = token.Value
} else if token.Key == "list_type" {
meta.ListType = token.Value
}
}
func KeyAsValue(token *SplitToken) string {
if token.Value == "" {
return token.Key
}
return token.Value
}
func ParseText(text string) *ParsedText {
textItems := SplitByNewline(text)
items := []*ListItem{}
meta := &MetaData{
ListType: "disc",
}
pre := false
skip := false
var prevItem *ListItem
for _, t := range textItems {
skip = false
if len(items) > 0 {
prevItem = items[len(items)-1]
}
li := &ListItem{
Value: strings.Trim(t, " "),
}
if strings.HasPrefix(li.Value, preToken) {
pre = !pre
if pre {
nextValue := strings.Replace(li.Value, preToken, "", 1)
li.IsPre = true
li.Value = nextValue
} else {
skip = true
}
} else if pre {
nextValue := strings.Replace(li.Value, preToken, "", 1)
prevItem.Value = fmt.Sprintf("%s\n%s", prevItem.Value, nextValue)
skip = true
} else if strings.HasPrefix(li.Value, urlToken) {
li.IsURL = true
split := TextToSplitToken(strings.Replace(li.Value, urlToken, "", 1))
li.URL = template.URL(split.Key)
li.Value = KeyAsValue(split)
} else if strings.HasPrefix(li.Value, blockToken) {
li.IsBlock = true
li.Value = strings.Replace(li.Value, blockToken, "", 1)
} else if strings.HasPrefix(li.Value, imgToken) {
li.IsImg = true
split := TextToSplitToken(strings.Replace(li.Value, imgToken, "", 1))
li.URL = template.URL(split.Key)
li.Value = KeyAsValue(split)
} else if strings.HasPrefix(li.Value, varToken) {
split := TextToSplitToken(strings.Replace(li.Value, varToken, "", 1))
TokenToMetaField(meta, split)
continue
} else if strings.HasPrefix(li.Value, headerTwoToken) {
li.IsHeaderTwo = true
li.Value = strings.Replace(li.Value, headerTwoToken, "", 1)
} else if strings.HasPrefix(li.Value, headerOneToken) {
li.IsHeaderOne = true
li.Value = strings.Replace(li.Value, headerOneToken, "", 1)
} else {
li.IsText = true
}
if li.IsText && li.Value == "" {
skip = true
}
if !skip {
items = append(items, li)
}
}
return &ParsedText{
Items: items,
MetaData: meta,
}
}
================================================
FILE: production.yml
================================================
version: "3.7"
services:
caddy:
image: neurosnap/lists-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/lists-web
restart: unless-stopped
env_file:
- .env.prod
links:
- db
gemini:
image: neurosnap/lists-gemini
restart: unless-stopped
environment:
- LISTS_SUBDOMAINS=0
env_file:
- .env.prod
ports:
- "1965:1965"
links:
- db
volumes:
- gemini_data:/var/lib/gemini/certs
ssh:
image: neurosnap/lists-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:
================================================
FILE: public/main.css
================================================
*, ::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; }
@media (prefers-color-scheme: light) {
:root {
--white: #6a737d;
--code: rgba(255, 229, 100, 0.2);
--pre: #f6f8fa;
--bg-color: #fff;
--text-color: #24292f;
--link-color: #005cc5;
--visited: #6f42c1;
--blockquote: #785840;
--blockquote-bg: #fff;
--hover: #d73a49;
--grey: #ccc;
}
}
@media (prefers-color-scheme: dark) {
:root {
--white: #f2f2f2;
--code: #252525;
--pre: #252525;
--bg-color: #282a36;
--text-color: #f2f2f2;
--link-color: #8be9fd;
--visited: #bd93f9;
--blockquote: #bd93f9;
--blockquote-bg: #414558;
--hover: #ff80bf;
--grey: #414558;
}
}
html {
background-color: var(--bg-color);
color: var(--text-color);
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: 35rem;
}
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: 0.8rem;
}
code, kbd, samp {
background-color: var(--code);
}
pre > code {
background-color: inherit;
padding: 0;
}
code {
border-radius: 0.3rem;
padding: .15rem .2rem .05rem;
}
pre {
border-radius: 5px;
padding: 1rem;
overflow-x: auto;
margin: 0;
background-color: var(--pre) !important;
}
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(--link-color);
}
a:hover, a:visited:hover {
color: var(--hover);
}
a:visited {
color: var(--visited);
}
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(--blockquote);
background-color: var(--blockquote-bg);
padding: 0.5rem;
margin: 0.5rem 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;
}
.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;
}
}
================================================
FILE: public/robots.txt
================================================
User-agent: *
Allow: /
gitextract_evvli2ae/
├── .github/
│ └── workflows/
│ └── lint.yml
├── .gitignore
├── Caddyfile
├── Dockerfile
├── Dockerfile.caddy
├── LICENSE
├── Makefile
├── README.md
├── build/
│ └── .gitkeep
├── cmd/
│ ├── gemini/
│ │ └── main.go
│ ├── ssh/
│ │ └── main.go
│ └── web/
│ └── main.go
├── db/
│ ├── migrations/
│ │ ├── 20220310_init.sql
│ │ ├── 20220422_add_desc_to_user_and_post.sql
│ │ ├── 20220426_add_index_for_filename.sql
│ │ ├── 20220427_username_to_lower.sql
│ │ ├── 20220523_timestamp_with_tz.sql
│ │ ├── 20220721_analytics.sql
│ │ └── 20220722_post_hidden.sql
│ ├── setup.sql
│ └── teardown.sql
├── docker-compose.yml
├── gmi/
│ ├── base.layout.tmpl
│ ├── blog.page.tmpl
│ ├── footer.partial.tmpl
│ ├── help.page.tmpl
│ ├── list.partial.tmpl
│ ├── marketing-footer.partial.tmpl
│ ├── marketing.page.tmpl
│ ├── ops.page.tmpl
│ ├── post.page.tmpl
│ ├── privacy.page.tmpl
│ ├── read.page.tmpl
│ ├── rss.page.tmpl
│ ├── spec.page.tmpl
│ └── transparency.page.tmpl
├── go.mod
├── go.sum
├── html/
│ ├── base.layout.tmpl
│ ├── blog.page.tmpl
│ ├── footer.partial.tmpl
│ ├── help.page.tmpl
│ ├── list.partial.tmpl
│ ├── marketing-footer.partial.tmpl
│ ├── marketing.page.tmpl
│ ├── ops.page.tmpl
│ ├── post.page.tmpl
│ ├── privacy.page.tmpl
│ ├── read.page.tmpl
│ ├── rss.page.tmpl
│ ├── spec.page.tmpl
│ └── transparency.page.tmpl
├── internal/
│ ├── api.go
│ ├── config.go
│ ├── db_handler.go
│ ├── gemini/
│ │ ├── gemini.go
│ │ └── router.go
│ ├── router.go
│ └── util.go
├── pkg/
│ └── parser.go
├── production.yml
└── public/
├── main.css
└── robots.txt
SYMBOL INDEX (107 symbols across 14 files)
FILE: cmd/gemini/main.go
function main (line 5) | func main() {
FILE: cmd/ssh/main.go
type SSHServer (line 23) | type SSHServer struct
method authHandler (line 25) | func (me *SSHServer) authHandler(ctx ssh.Context, key ssh.PublicKey) b...
function createRouter (line 29) | func createRouter(handler *internal.DbHandler) proxy.Router {
function withProxy (line 47) | func withProxy(handler *internal.DbHandler) ssh.Option {
function main (line 58) | func main() {
FILE: cmd/web/main.go
function main (line 5) | func main() {
FILE: db/migrations/20220310_init.sql
type app_users (line 3) | CREATE TABLE IF NOT EXISTS app_users (
type public_keys (line 11) | CREATE TABLE IF NOT EXISTS public_keys (
type posts (line 25) | CREATE TABLE IF NOT EXISTS posts (
FILE: db/migrations/20220426_add_index_for_filename.sql
type posts_filename (line 1) | CREATE INDEX posts_filename ON posts USING btree(filename)
FILE: db/migrations/20220721_analytics.sql
type post_analytics (line 1) | CREATE TABLE IF NOT EXISTS post_analytics (
FILE: internal/api.go
type PageData (line 20) | type PageData struct
type PostItemData (line 24) | type PostItemData struct
type BlogPageData (line 37) | type BlogPageData struct
type ReadPageData (line 48) | type ReadPageData struct
type PostPageData (line 55) | type PostPageData struct
type TransparencyPageData (line 70) | type TransparencyPageData struct
function isRequestTrackable (line 75) | func isRequestTrackable(r *http.Request) bool {
function renderTemplate (line 79) | func renderTemplate(templates []string) (*template.Template, error) {
function createPageHandler (line 96) | func createPageHandler(fname string) http.HandlerFunc {
type HeaderTxt (line 119) | type HeaderTxt struct
type ReadmeTxt (line 126) | type ReadmeTxt struct
function GetUsernameFromRequest (line 132) | func GetUsernameFromRequest(r *http.Request) string {
function blogHandler (line 142) | func blogHandler(w http.ResponseWriter, r *http.Request) {
function GetPostTitle (line 233) | func GetPostTitle(post *db.Post) string {
function GetBlogName (line 241) | func GetBlogName(username string) string {
function postHandler (line 245) | func postHandler(w http.ResponseWriter, r *http.Request) {
function transparencyHandler (line 350) | func transparencyHandler(w http.ResponseWriter, r *http.Request) {
function readHandler (line 384) | func readHandler(w http.ResponseWriter, r *http.Request) {
function rssBlogHandler (line 442) | func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
function rssHandler (line 540) | func rssHandler(w http.ResponseWriter, r *http.Request) {
function serveFile (line 608) | func serveFile(file string, contentType string) http.HandlerFunc {
function createStaticRoutes (line 627) | func createStaticRoutes() []Route {
function createMainRoutes (line 639) | func createMainRoutes(staticRoutes []Route) []Route {
function createSubdomainRoutes (line 670) | func createSubdomainRoutes(staticRoutes []Route) []Route {
function StartApiServer (line 689) | func StartApiServer() {
FILE: internal/config.go
type SitePageData (line 13) | type SitePageData struct
type ConfigSite (line 19) | type ConfigSite struct
method GetSiteData (line 59) | func (c *ConfigSite) GetSiteData() *SitePageData {
method BlogURL (line 67) | func (c *ConfigSite) BlogURL(username string) string {
method PostURL (line 75) | func (c *ConfigSite) PostURL(username, filename string) string {
method IsSubdomains (line 84) | func (c *ConfigSite) IsSubdomains() bool {
method RssBlogURL (line 88) | func (c *ConfigSite) RssBlogURL(username string) string {
method HomeURL (line 96) | func (c *ConfigSite) HomeURL() string {
method ReadURL (line 104) | func (c *ConfigSite) ReadURL() string {
function NewConfigSite (line 25) | func NewConfigSite() *ConfigSite {
function CreateLogger (line 112) | func CreateLogger() *zap.SugaredLogger {
FILE: internal/db_handler.go
type Opener (line 18) | type Opener struct
method Open (line 22) | func (o *Opener) Open(name string) (io.Reader, error) {
type DbHandler (line 26) | type DbHandler struct
method Validate (line 39) | func (h *DbHandler) Validate(s ssh.Session) error {
method Write (line 59) | func (h *DbHandler) Write(s ssh.Session, entry *sendutils.FileEntry) (...
function NewDbHandler (line 32) | func NewDbHandler(dbpool db.DB, cfg *ConfigSite) *DbHandler {
FILE: internal/gemini/gemini.go
function renderTemplate (line 26) | func renderTemplate(templates []string) (*template.Template, error) {
function createPageHandler (line 43) | func createPageHandler(fname string) gemini.HandlerFunc {
function blogHandler (line 66) | func blogHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini...
function readHandler (line 157) | func readHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini...
function postHandler (line 227) | func postHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini...
function transparencyHandler (line 306) | func transparencyHandler(ctx context.Context, w gemini.ResponseWriter, r...
function rssBlogHandler (line 341) | func rssBlogHandler(ctx context.Context, w gemini.ResponseWriter, r *gem...
function rssHandler (line 439) | func rssHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini....
function StartServer (line 507) | func StartServer() {
FILE: internal/gemini/router.go
type ctxKey (line 13) | type ctxKey struct
type ctxDBKey (line 14) | type ctxDBKey struct
type ctxLoggerKey (line 15) | type ctxLoggerKey struct
type ctxCfgKey (line 16) | type ctxCfgKey struct
function GetLogger (line 18) | func GetLogger(ctx context.Context) *zap.SugaredLogger {
function GetCfg (line 22) | func GetCfg(ctx context.Context) *internal.ConfigSite {
function GetDB (line 26) | func GetDB(ctx context.Context) db.DB {
function GetField (line 30) | func GetField(ctx context.Context, index int) string {
type Route (line 35) | type Route struct
function NewRoute (line 40) | func NewRoute(pattern string, handler gemini.HandlerFunc) Route {
type ServeFn (line 47) | type ServeFn
function CreateServe (line 49) | func CreateServe(routes []Route, cfg *internal.ConfigSite, dbpool db.DB,...
FILE: internal/router.go
type Route (line 14) | type Route struct
function NewRoute (line 20) | func NewRoute(method, pattern string, handler http.HandlerFunc) Route {
type ServeFn (line 28) | type ServeFn
function CreateServe (line 30) | func CreateServe(routes []Route, subdomainRoutes []Route, cfg *ConfigSit...
type ctxDBKey (line 72) | type ctxDBKey struct
type ctxKey (line 73) | type ctxKey struct
type ctxLoggerKey (line 74) | type ctxLoggerKey struct
type ctxSubdomainKey (line 75) | type ctxSubdomainKey struct
type ctxCfg (line 76) | type ctxCfg struct
function GetCfg (line 78) | func GetCfg(r *http.Request) *ConfigSite {
function GetLogger (line 82) | func GetLogger(r *http.Request) *zap.SugaredLogger {
function GetDB (line 86) | func GetDB(r *http.Request) db.DB {
function GetField (line 90) | func GetField(r *http.Request, index int) string {
function GetSubdomain (line 95) | func GetSubdomain(r *http.Request) string {
FILE: internal/util.go
function FilenameToTitle (line 22) | func FilenameToTitle(filename string, title string) string {
function SanitizeFileExt (line 33) | func SanitizeFileExt(fname string) string {
function KeyText (line 37) | func KeyText(s ssh.Session) (string, error) {
function GetEnv (line 45) | func GetEnv(key string, defaultVal string) string {
function IsText (line 55) | func IsText(s string) bool {
function IsTextFile (line 79) | func IsTextFile(text string, filename string) bool {
constant solarYearSecs (line 89) | solarYearSecs = 31556926
function TimeAgo (line 91) | func TimeAgo(t *time.Time) string {
FILE: pkg/parser.go
type ParsedText (line 10) | type ParsedText struct
type ListItem (line 15) | type ListItem struct
type MetaData (line 28) | type MetaData struct
type SplitToken (line 43) | type SplitToken struct
function TextToSplitToken (line 48) | func TextToSplitToken(text string) *SplitToken {
function SplitByNewline (line 70) | func SplitByNewline(text string) []string {
function PublishAtDate (line 74) | func PublishAtDate(date string) (*time.Time, error) {
function TokenToMetaField (line 79) | func TokenToMetaField(meta *MetaData, token *SplitToken) {
function KeyAsValue (line 94) | func KeyAsValue(token *SplitToken) string {
function ParseText (line 101) | func ParseText(text string) *ParsedText {
Condensed preview — 63 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (150K chars).
[
{
"path": ".github/workflows/lint.yml",
"chars": 413,
"preview": "name: Lint\n\non:\n push:\n branches:\n - main\n pull_request:\n branches:\n - main\n\njobs:\n build:\n runs-on: u"
},
{
"path": ".gitignore",
"chars": 166,
"preview": "*.log\n*.swp\n.env\n.envrc\nbuild/*\n!build/.gitkeep\nssh_data/*\n!ssh_data/.gitkeep\ncaddy_data/*\n!caddy_data/.gitkeep\ncaddy_co"
},
{
"path": "Caddyfile",
"chars": 697,
"preview": "*.lists.sh, lists.sh {\n\treverse_proxy web:3000\n\ttls webmaster@lists.sh\n\ttls {\n\t\tdns cloudflare {env.CF_API_TOKEN}\n\t}\n\ten"
},
{
"path": "Dockerfile",
"chars": 734,
"preview": "FROM golang:1.18.1-alpine3.15 AS builder\n\nRUN apk add --no-cache git\n\nWORKDIR /app\nCOPY . ./\n\nRUN CGO_ENABLED=0 GOOS=lin"
},
{
"path": "Dockerfile.caddy",
"chars": 170,
"preview": "FROM caddy:builder-alpine AS builder\n\nRUN xcaddy build \\\n --with github.com/caddy-dns/cloudflare\n\nFROM caddy:alpine\n\n"
},
{
"path": "LICENSE",
"chars": 1067,
"preview": "MIT License\n\nCopyright (c) 2022 Eric Bower\n\nPermission is hereby granted, free of charge, to any person obtaining a copy"
},
{
"path": "Makefile",
"chars": 2995,
"preview": "PGDATABASE?=\"lists\"\nPGHOST?=\"db\"\nPGUSER?=\"postgres\"\nPORT?=\"5432\"\nDB_CONTAINER?=listssh_db_1\nDOCKER_TAG?=$(shell git log "
},
{
"path": "README.md",
"chars": 1979,
"preview": "# lists.sh\n\nA microblog for lists.\n\n## comms\n\n- [website](https://pico.sh)\n- [irc #pico.sh](irc://irc.libera.chat/#pico."
},
{
"path": "build/.gitkeep",
"chars": 0,
"preview": ""
},
{
"path": "cmd/gemini/main.go",
"chars": 104,
"preview": "package main\n\nimport \"git.sr.ht/~erock/lists.sh/internal/gemini\"\n\nfunc main() {\n\tgemini.StartServer()\n}\n"
},
{
"path": "cmd/ssh/main.go",
"chars": 2225,
"preview": "package main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"git.sr.ht/~erock/lists.sh/internal\"\n\t\""
},
{
"path": "cmd/web/main.go",
"chars": 102,
"preview": "package main\n\nimport \"git.sr.ht/~erock/lists.sh/internal\"\n\nfunc main() {\n\tinternal.StartApiServer()\n}\n"
},
{
"path": "db/migrations/20220310_init.sql",
"chars": 1385,
"preview": "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";\n\nCREATE TABLE IF NOT EXISTS app_users (\n id uuid NOT NULL DEFAULT uuid_gene"
},
{
"path": "db/migrations/20220422_add_desc_to_user_and_post.sql",
"chars": 408,
"preview": "ALTER TABLE app_users ADD COLUMN bio character varying(150) NOT NULL DEFAULT '';\nALTER TABLE posts ADD COLUMN descriptio"
},
{
"path": "db/migrations/20220426_add_index_for_filename.sql",
"chars": 99,
"preview": "CREATE INDEX posts_filename ON posts USING btree(filename);\nALTER TABLE app_users DROP COLUMN bio;\n"
},
{
"path": "db/migrations/20220427_username_to_lower.sql",
"chars": 67,
"preview": "UPDATE app_users SET name = LOWER(name) WHERE name != LOWER(name);\n"
},
{
"path": "db/migrations/20220523_timestamp_with_tz.sql",
"chars": 555,
"preview": "ALTER TABLE posts ALTER COLUMN updated_at TYPE timestamp WITH TIME ZONE USING updated_at AT TIME ZONE 'UTC';\nALTER TABLE"
},
{
"path": "db/migrations/20220721_analytics.sql",
"chars": 373,
"preview": "CREATE TABLE IF NOT EXISTS post_analytics (\n id uuid NOT NULL DEFAULT uuid_generate_v4(),\n post_id uuid NOT NULL,\n vi"
},
{
"path": "db/migrations/20220722_post_hidden.sql",
"chars": 128,
"preview": "ALTER TABLE posts ADD COLUMN hidden boolean NOT NULL DEFAULT FALSE;\nUPDATE posts SET hidden = TRUE WHERE filename LIKE E"
},
{
"path": "db/setup.sql",
"chars": 42,
"preview": "CREATE DATABASE \"lists\" OWNER \"postgres\";\n"
},
{
"path": "db/teardown.sql",
"chars": 88,
"preview": "DROP TABLE posts CASCADE;\nDROP TABLE app_users CASCADE;\nDROP TABLE public_keys CASCADE;\n"
},
{
"path": "docker-compose.yml",
"chars": 129,
"preview": "version: \"3.4\"\nservices:\n db:\n image: postgres\n restart: always\n ports:\n - \"5433:5432\"\n env_file:\n "
},
{
"path": "gmi/base.layout.tmpl",
"chars": 48,
"preview": "{{define \"base\"}}\n{{template \"body\" .}}\n{{end}}\n"
},
{
"path": "gmi/blog.page.tmpl",
"chars": 354,
"preview": "{{template \"base\" .}}\n{{define \"body\"}}\n# {{.Header.Title}}\n{{.Header.Bio}}\n{{range .Header.Nav}}\n{{if .IsURL}}=> {{.URL"
},
{
"path": "gmi/footer.partial.tmpl",
"chars": 70,
"preview": "{{define \"footer\"}}\n---\n\n=> / published with {{.Site.Domain}}\n{{end}}\n"
},
{
"path": "gmi/help.page.tmpl",
"chars": 4699,
"preview": "{{template \"base\" .}}\n\n{{define \"body\"}}\n# Need help?\n\nHere are some common questions on using this platform that we wou"
},
{
"path": "gmi/list.partial.tmpl",
"chars": 368,
"preview": "{{define \"list\"}}\n{{range .Items}}\n{{- if .IsText}}\n{{- if .Value}}\n* {{.Value}}\n{{- end}}\n{{- else if .IsURL}}\n=> {{.UR"
},
{
"path": "gmi/marketing-footer.partial.tmpl",
"chars": 205,
"preview": "{{define \"marketing-footer\"}}\n---\n\nBuilt and maintained by pico.sh\n=> https://pico.sh\n\n=> / home\n=> /spec spec\n=> /ops o"
},
{
"path": "gmi/marketing.page.tmpl",
"chars": 2720,
"preview": "{{template \"base\" .}}\n\n{{define \"body\"}}\n# {{.Site.Domain}}\nA microblog for lists\n\n=> /read discover some interesting li"
},
{
"path": "gmi/ops.page.tmpl",
"chars": 2270,
"preview": "{{template \"base\" .}}\n\n{{define \"body\"}}\n# Operations\n\n=> /privacy privacy\n=> /transparency transparency\n\n## Purpose\n\n{{"
},
{
"path": "gmi/post.page.tmpl",
"chars": 212,
"preview": "{{template \"base\" .}}\n\n{{define \"body\"}}\n# {{.Title}}\n{{.PublishAt}}\n{{if .Description}}{{.Description}}{{end}}\n=> {{.Bl"
},
{
"path": "gmi/privacy.page.tmpl",
"chars": 703,
"preview": "{{template \"base\" .}}\n\n{{define \"body\"}}\n# Privacy\n\nDetails on our privacy and security approach.\n\n## Account Data\n\nIn o"
},
{
"path": "gmi/read.page.tmpl",
"chars": 302,
"preview": "{{template \"base\" .}}\n\n{{define \"body\"}}\n# read\nrecently updated lists\n\n{{if .NextPage}}=> {{.NextPage}} next{{end}}\n{{i"
},
{
"path": "gmi/rss.page.tmpl",
"chars": 22,
"preview": "{{template \"list\" .}}\n"
},
{
"path": "gmi/spec.page.tmpl",
"chars": 4211,
"preview": "{{template \"base\" .}}\n\n{{define \"body\"}}\n# Plain text list\nSpeculative specification\n\n## Overview\n\nVersion: 2022.05.02.d"
},
{
"path": "gmi/transparency.page.tmpl",
"chars": 507,
"preview": "{{template \"base\" .}}\n\n{{define \"body\"}}\n# Transparency\n\n## Analytics\n\nHere are some interesting stats on usage.\n\nTotal "
},
{
"path": "go.mod",
"chars": 1919,
"preview": "module git.sr.ht/~erock/lists.sh\n\ngo 1.18\n\n// replace git.sr.ht/~erock/wish => /home/erock/pico/wish\n\nrequire (\n\tgit.sr."
},
{
"path": "go.sum",
"chars": 14941,
"preview": "git.sr.ht/~adnano/go-gemini v0.2.3 h1:oJ+Y0/mheZ4Vg0ABjtf5dlmvq1yoONStiaQvmWWkofc=\ngit.sr.ht/~adnano/go-gemini v0.2.3/go"
},
{
"path": "html/base.layout.tmpl",
"chars": 533,
"preview": "{{define \"base\"}}\n<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset='utf-8'>\n <meta name=\"viewpor"
},
{
"path": "html/blog.page.tmpl",
"chars": 2141,
"preview": "{{template \"base\" .}}\n\n{{define \"title\"}}{{.PageTitle}}{{end}}\n\n{{define \"meta\"}}\n<meta name=\"description\" content=\"{{if"
},
{
"path": "html/footer.partial.tmpl",
"chars": 124,
"preview": "{{define \"footer\"}}\n<footer>\n <hr />\n published with <a href={{.Site.HomeURL}}>{{.Site.Domain}}</a>\n</footer>\n{{en"
},
{
"path": "html/help.page.tmpl",
"chars": 8451,
"preview": "{{template \"base\" .}}\n\n{{define \"title\"}}help -- {{.Site.Domain}}{{end}}\n\n{{define \"meta\"}}\n<meta name=\"description\" con"
},
{
"path": "html/list.partial.tmpl",
"chars": 883,
"preview": "{{define \"list\"}}\n<ul style=\"list-style-type: {{.ListType}};\">\n {{range .Items}}\n {{if .IsText}}\n {"
},
{
"path": "html/marketing-footer.partial.tmpl",
"chars": 413,
"preview": "{{define \"marketing-footer\"}}\n<footer>\n <hr />\n <p class=\"font-italic\">Built and maintained by <a href=\"https://pi"
},
{
"path": "html/marketing.page.tmpl",
"chars": 5905,
"preview": "{{template \"base\" .}}\n\n{{define \"title\"}}{{.Site.Domain}} -- a microblog for lists{{end}}\n\n{{define \"meta\"}}\n<meta name="
},
{
"path": "html/ops.page.tmpl",
"chars": 5346,
"preview": "{{template \"base\" .}}\n\n{{define \"title\"}}operations -- {{.Site.Domain}}{{end}}\n\n{{define \"meta\"}}\n<meta name=\"descriptio"
},
{
"path": "html/post.page.tmpl",
"chars": 1513,
"preview": "{{template \"base\" .}}\n\n{{define \"title\"}}{{.PageTitle}}{{end}}\n\n{{define \"meta\"}}\n<meta name=\"description\" content=\"{{.D"
},
{
"path": "html/privacy.page.tmpl",
"chars": 1438,
"preview": "{{template \"base\" .}}\n\n{{define \"title\"}}privacy -- {{.Site.Domain}}{{end}}\n\n{{define \"meta\"}}\n<meta name=\"description\" "
},
{
"path": "html/read.page.tmpl",
"chars": 1154,
"preview": "{{template \"base\" .}}\n\n{{define \"title\"}}discover lists -- {{.Site.Domain}}{{end}}\n\n{{define \"meta\"}}\n<meta name=\"descri"
},
{
"path": "html/rss.page.tmpl",
"chars": 22,
"preview": "{{template \"list\" .}}\n"
},
{
"path": "html/spec.page.tmpl",
"chars": 6672,
"preview": "{{template \"base\" .}}\n\n{{define \"title\"}}specification -- {{.Site.Domain}}{{end}}\n\n{{define \"meta\"}}\n<meta name=\"descrip"
},
{
"path": "html/transparency.page.tmpl",
"chars": 1482,
"preview": "{{template \"base\" .}}\n\n{{define \"title\"}}transparency -- {{.Site.Domain}}{{end}}\n\n{{define \"meta\"}}\n<meta name=\"descript"
},
{
"path": "internal/api.go",
"chars": 17625,
"preview": "package internal\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"gi"
},
{
"path": "internal/config.go",
"chars": 2653,
"preview": "package internal\n\nimport (\n\t\"fmt\"\n\t\"html/template\"\n\t\"log\"\n\t\"net/url\"\n\n\t\"git.sr.ht/~erock/wish/cms/config\"\n\t\"go.uber.org/"
},
{
"path": "internal/db_handler.go",
"chars": 3296,
"preview": "package internal\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"time\"\n\n\t\"git.sr.ht/~erock/lists.sh/pkg\"\n\t\"git.sr.ht/~erock/wish/cms/db\"\n\t\"git."
},
{
"path": "internal/gemini/gemini.go",
"chars": 14783,
"preview": "package gemini\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\thtml \"html/template\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/signal\"\n\t\"strconv\"\n\t\"str"
},
{
"path": "internal/gemini/router.go",
"chars": 1664,
"preview": "package gemini\n\nimport (\n\t\"context\"\n\t\"regexp\"\n\n\t\"git.sr.ht/~adnano/go-gemini\"\n\t\"git.sr.ht/~erock/lists.sh/internal\"\n\t\"gi"
},
{
"path": "internal/router.go",
"chars": 2497,
"preview": "package internal\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"git.sr.ht/~erock/wish/cms/db\"\n\t\"go.uber"
},
{
"path": "internal/util.go",
"chars": 2671,
"preview": "package internal\n\nimport (\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"math\"\n\t\"os\"\n\tpathpkg \"path\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\""
},
{
"path": "pkg/parser.go",
"chars": 3852,
"preview": "package pkg\n\nimport (\n\t\"fmt\"\n\t\"html/template\"\n\t\"strings\"\n\t\"time\"\n)\n\ntype ParsedText struct {\n\tItems []*ListItem\n\tMeta"
},
{
"path": "production.yml",
"chars": 1091,
"preview": "version: \"3.7\"\n\nservices:\n caddy:\n image: neurosnap/lists-caddy\n restart: unless-stopped\n env_file:\n - .e"
},
{
"path": "public/main.css",
"chars": 3947,
"preview": "*, ::before, ::after {\n box-sizing: border-box;\n}\n\n::-moz-focus-inner {\n\tborder-style: none;\n\tpadding: 0;\n}\n:-moz-focus"
},
{
"path": "public/robots.txt",
"chars": 23,
"preview": "User-agent: *\nAllow: /\n"
}
]
About this extraction
This page contains the full source code of the neurosnap/lists.sh GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 63 files (134.4 KB), approximately 43.3k tokens, and a symbol index with 107 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.