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 @{{.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 =<. ``` =< 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"}} {{template "title" .}} {{template "meta" .}} {{template "body" .}} {{end}} ================================================ FILE: html/blog.page.tmpl ================================================ {{template "base" .}} {{define "title"}}{{.PageTitle}}{{end}} {{define "meta"}} {{if .Header.Bio}}{{end}} {{if .Header.Bio}}{{end}} {{end}} {{define "body"}}

{{.Header.Title}}

{{if .Header.Bio}}

{{.Header.Bio}}

{{end}}
{{if .Readme.HasItems}}
{{template "list" .Readme}}

{{end}}
{{range .Posts}} {{end}}
{{template "footer" .}} {{end}} ================================================ FILE: html/footer.partial.tmpl ================================================ {{define "footer"}} {{end}} ================================================ FILE: html/help.page.tmpl ================================================ {{template "base" .}} {{define "title"}}help -- {{.Site.Domain}}{{end}} {{define "meta"}} {{end}} {{define "body"}}

Need help?

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

# I get a permission denied when trying to SSH

Unfortunately SHA-2 RSA keys are not currently supported.

Unfortunately, due to a shortcoming in Go’s x/crypto/ssh package, Soft Serve does not currently support access via new SSH RSA keys: only the old SHA-1 ones will work. Until we sort this out you’ll either need an SHA-1 RSA key or a key with another algorithm, e.g. Ed25519. Not sure what type of keys you have? You can check with the following:

$ find ~/.ssh/id_*.pub -exec ssh-keygen -l -f {} \;

If you’re curious about the inner workings of this problem have a look at:

# Generating a new SSH key

Github reference

ssh-keygen -t ed25519 -C "your_email@example.com"
  1. When you're prompted to "Enter a file in which to save the key," press Enter. This accepts the default file location.
  2. 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.

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 {{.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?

https://{username}.{{.Site.Domain}}

# How can I automatically publish my post?

There is a github action that we built to make it easy to publish your blog automatically.

A user also created a systemd task to automatically publish new posts. 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: html/list.partial.tmpl ================================================ {{define "list"}}

{{.Value}}

{{.Value}}

{{end}} ================================================ FILE: html/marketing-footer.partial.tmpl ================================================ {{define "marketing-footer"}} {{end}} ================================================ FILE: html/marketing.page.tmpl ================================================ {{template "base" .}} {{define "title"}}{{.Site.Domain}} -- a microblog for lists{{end}} {{define "meta"}} {{end}} {{define "body"}}

{{.Site.Domain}}

A microblog for lists

discover some interesting lists


Examples

official blog | blog source

Create your account with Public-Key Cryptography

We don't want your email address.

To get started, simply ssh into our content management system:

ssh new@{{.Site.Domain}}
note: new is a special username that will always send you to account creation.
note: getting permission denied? read this

After that, just set a username and you're ready to start writing! When you SSH again, use your username that you set in the CMS.

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.

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 browser-based 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

  1. Feature complete?
{{template "marketing-footer" .}} {{end}} ================================================ FILE: html/ops.page.tmpl ================================================ {{template "base" .}} {{define "title"}}operations -- {{.Site.Domain}}{{end}} {{define "meta"}} {{end}} {{define "body"}}

Operations

Purpose

{{.Site.Domain}} exists to allow people to create and share their 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 browser-based tracking of visitor behavior.
  • No attempt to identify users.
  • 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.

Analytics

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.

However, in order to provide a better service, we do have some analytics on posts. List of metrics we track for posts:

  • anonymous view counts

We might also inspect the headers of HTTP requests to determine some tertiary information about the request. For example we might inspect the User-Agent or Referer to filter out requests from bots.

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 support@{{.Site.Domain}} with any questions.

Acknowledgments

{{.Site.Domain}} was inspired by Mataroa Blog and Bear Blog.

{{.Site.Domain}} is built with many open source technologies.

In particular we would like to thank:

{{template "marketing-footer" .}} {{end}} ================================================ FILE: html/post.page.tmpl ================================================ {{template "base" .}} {{define "title"}}{{.PageTitle}}{{end}} {{define "meta"}} {{if .Description}}{{end}} {{if .Description}}{{end}} {{end}} {{define "body"}}

{{.Title}}

on {{.BlogName}}

{{if .Description}}
{{.Description}}
{{end}}
{{template "list" .}}
{{template "footer" .}} {{end}} ================================================ FILE: html/privacy.page.tmpl ================================================ {{template "base" .}} {{define "title"}}privacy -- {{.Site.Domain}}{{end}} {{define "meta"}} {{end}} {{define "body"}}

Privacy

Details on our privacy and security approach.

Account Data

In order to have a functional account at {{.Site.Domain}}, we need to store your public key. That is the only piece of information we record for a user.

Because we use public-key cryptography, our security posture is a battle-tested and proven technique for authentication.

Third parties

We have a strong commitment to never share any user data with any third-parties.

Service Providers

Cookies

We do not use any cookies, not even account authentication.

{{template "marketing-footer" .}} {{end}} ================================================ FILE: html/read.page.tmpl ================================================ {{template "base" .}} {{define "title"}}discover lists -- {{.Site.Domain}}{{end}} {{define "meta"}} {{end}} {{define "body"}}

read

recently updated lists


{{if .PrevPage}}prev{{else}}prev{{end}} {{if .NextPage}}next{{else}}next{{end}}
{{range .Posts}} {{end}}
{{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"}} {{end}} {{define "body"}}

Plain text list

Speculative specification

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

The source code for an example list demonstrating all the features can be found here.

As a subtype of the top-level media type "text", "text/plain" inherits the "charset" parameter defined in RFC 2046. The default value of "charset" is "UTF-8" for "text" content.

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 extension

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

Images

List items can be represented as images by prefixing the line with =<.

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

```
#!/usr/bin/env bash

echo "This will not render properly"```

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)
{{template "marketing-footer" .}} {{end}} ================================================ FILE: html/transparency.page.tmpl ================================================ {{template "base" .}} {{define "title"}}transparency -- {{.Site.Domain}}{{end}} {{define "meta"}} {{end}} {{define "body"}}

Transparency


Analytics

Here are some interesting stats on usage.

Total users

{{.Analytics.TotalUsers}}

New users in the last month

{{.Analytics.UsersLastMonth}}

Total 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: 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: /