[
  {
    "path": ".github/workflows/lint.yml",
    "content": "name: Lint\n\non:\n  push:\n    branches:\n    - main\n  pull_request:\n    branches:\n    - main\n\njobs:\n  build:\n    runs-on: ubuntu-20.04\n    steps:\n    - uses: actions/checkout@v2\n    - name: Set up Go\n      uses: actions/setup-go@v2\n      with:\n        go-version: 1.18\n    - name: Lint the codebase\n      uses: golangci/golangci-lint-action@v2\n      with:\n        version: latest\n        args: -E goimports -E godot\n"
  },
  {
    "path": ".gitignore",
    "content": "*.log\n*.swp\n.env\n.envrc\nbuild/*\n!build/.gitkeep\nssh_data/*\n!ssh_data/.gitkeep\ncaddy_data/*\n!caddy_data/.gitkeep\ncaddy_config/*\n!caddy_config/.gitkeep\n.env.prod\n*.bak\n"
  },
  {
    "path": "Caddyfile",
    "content": "*.lists.sh, lists.sh {\n\treverse_proxy web:3000\n\ttls webmaster@lists.sh\n\ttls {\n\t\tdns cloudflare {env.CF_API_TOKEN}\n\t}\n\tencode zstd gzip\n\n    header {\n        # disable FLoC tracking\n        Permissions-Policy interest-cohort=()\n\n        # enable HSTS\n        Strict-Transport-Security max-age=31536000;\n\n        # disable clients from sniffing the media type\n        X-Content-Type-Options nosniff\n\n        # clickjacking protection\n        X-Frame-Options DENY\n\n        # keep referrer data off of HTTP connections\n        Referrer-Policy no-referrer-when-downgrade\n\n        Content-Security-Policy \"default-src 'self'; img-src * 'unsafe-inline'\"\n\n        X-XSS-Protection \"1; mode=block\"\n    }\n}\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM golang:1.18.1-alpine3.15 AS builder\n\nRUN apk add --no-cache git\n\nWORKDIR /app\nCOPY . ./\n\nRUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ./build/ssh ./cmd/ssh\nRUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ./build/web ./cmd/web\nRUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ./build/gemini ./cmd/gemini\n\nFROM alpine:3.15 AS ssh\nWORKDIR /app\nCOPY --from=0 /app/build/ssh ./\nCMD [\"./ssh\"]\n\nFROM alpine:3.15 AS web\nWORKDIR /app\nCOPY --from=0 /app/build/web ./\nCOPY --from=0 /app/html ./html\nCOPY --from=0 /app/public ./public\nCMD [\"./web\"]\n\nFROM alpine:3.15 AS gemini\nWORKDIR /app\nCOPY --from=0 /app/build/gemini ./\nCOPY --from=0 /app/gmi ./gmi\nENV LISTS_SUBDOMAINS=0\nCMD [\"./gemini\"]\n"
  },
  {
    "path": "Dockerfile.caddy",
    "content": "FROM caddy:builder-alpine AS builder\n\nRUN xcaddy build \\\n    --with github.com/caddy-dns/cloudflare\n\nFROM caddy:alpine\n\nCOPY --from=builder /usr/bin/caddy /usr/bin/caddy\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2022 Eric Bower\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "Makefile",
    "content": "PGDATABASE?=\"lists\"\nPGHOST?=\"db\"\nPGUSER?=\"postgres\"\nPORT?=\"5432\"\nDB_CONTAINER?=listssh_db_1\nDOCKER_TAG?=$(shell git log --format=\"%H\" -n 1)\n\ntest:\n\tdocker run --rm -v $(shell pwd):/app -w /app golangci/golangci-lint:latest golangci-lint run -E goimports -E godot\n.PHONY: test\n\nbuild:\n\tgo build -o build/web ./cmd/web\n\tgo build -o build/ssh ./cmd/ssh\n\tgo build -o build/gemini ./cmd/gemini\n.PHONY: build\n\nformat:\n\tgo fmt ./...\n.PHONY: format\n\ncreate:\n\tdocker exec -i $(DB_CONTAINER) psql -U $(PGUSER) < ./db/setup.sql\n.PHONY: create\n\nteardown:\n\tdocker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/teardown.sql\n.PHONY: teardown\n\nmigrate:\n\tdocker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220310_init.sql\n\tdocker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220422_add_desc_to_user_and_post.sql\n\tdocker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220426_add_index_for_filename.sql\n\tdocker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220427_username_to_lower.sql\n\tdocker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220523_timestamp_with_tz.sql\n\tdocker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220721_analytics.sql\n\tdocker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220722_post_hidden.sql\n.PHONY: migrate\n\nlatest:\n\tdocker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220721_analytics.sql\n\tdocker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220722_post_hidden.sql\n.PHONY: latest\n\npsql:\n\tdocker exec -it $(DB_CONTAINER) psql -U $(PGUSER)\n.PHONY: psql\n\ndump:\n\tdocker exec -it $(DB_CONTAINER) pg_dump -U $(PGUSER) $(PGDATABASE) > ./backup.sql\n.PHONY: dump\n\nrestore:\n\tdocker cp ./backup.sql $(DB_CONTAINER):/backup.sql\n\tdocker exec -it $(DB_CONTAINER) /bin/bash\n\t# psql postgres -U postgres < /backup.sql\n.PHONY: restore\n\nbp-setup:\n\tdocker buildx ls | grep pico || docker buildx create --name pico\n\tdocker buildx use pico\n.PHONY: bp-setup\n\nbp-caddy: bp-setup\n\tdocker buildx build --push --platform linux/amd64,linux/arm64 -t neurosnap/lists-caddy:$(DOCKER_TAG) -f Dockerfile.caddy .\n.PHONY: bp-caddy\n\nbp-ssh: bp-setup\n\tdocker buildx build --push --platform linux/amd64,linux/arm64 -t neurosnap/lists-ssh:$(DOCKER_TAG) --target ssh .\n.PHONY: bp-ssh\n\nbp-web: bp-setup\n\tdocker buildx build --push --platform linux/amd64,linux/arm64 -t neurosnap/lists-web:$(DOCKER_TAG) --target web .\n.PHONY: bp-web\n\nbp-gemini: bp-setup\n\tdocker buildx build --push --platform linux/amd64,linux/arm64 -t neurosnap/lists-gemini:$(DOCKER_TAG) --target gemini .\n.PHONY: bp-gemini\n\nbp: bp-ssh bp-web bp-gemini bp-caddy\n.PHONY: bp\n\ndeploy:\n\tdocker system prune -f\n\tdocker-compose -f production.yml pull --ignore-pull-failures\n\tdocker-compose -f production.yml up --no-deps -d\n.PHONY: deploy\n"
  },
  {
    "path": "README.md",
    "content": "# lists.sh\n\nA microblog for lists.\n\n## comms\n\n- [website](https://pico.sh)\n- [irc #pico.sh](irc://irc.libera.chat/#pico.sh)\n- [mailing list](https://lists.sr.ht/~erock/pico.sh)\n- [ticket tracker](https://todo.sr.ht/~erock/pico.sh)\n- [email](mailto:hello@pico.sh)\n\n## setup\n\n- golang `v1.18`\n\nYou'll also need some environment variables\n\n```\nexport POSTGRES_PASSWORD=\"secret\"\nexport DATABASE_URL=\"postgresql://postgres:secret@db/lists?sslmode=disable\"\nexport LISTS_SSH_PORT=2222\nexport LISTS_WEB_PORT=3000\nexport LISTS_DOMAIN=\"lists.sh\"\nexport LISTS_EMAIL=\"support@lists.sh\"\nexport LISTS_PROTOCOL=\"http\"\n```\n\nI just use `direnv` which will load my `.env` file.\n\n## development\n\n### db\n\nI use `docker-compose` to standup a postgresql server.  If you already have a\nserver running you can skip this step.\n\nCopy example `.env`\n\n```bash\ncp .env.example .env\n```\n\nThen run docker compose.\n\n```bash\ndocker-compose up -d\n```\n\nThen create the database and migrate\n\n```bash\nmake create\nmake migrate\n```\n\n### build the apps\n\n```bash\nmake build\n```\n\n### run the apps\n\nThere are two apps: an ssh and web server.\n\n```bash\n./build/ssh\n```\n\nDefault port for ssh server is `2222`.\n\n```bash\n./build/web\n```\n\nDefault port for web server is `3000`.\n\n### subdomains\n\nSince we use subdomains for blogs, you'll need to update your `/etc/hosts` file\nto accommodate.\n\n```bash\n# /etc/hosts\n127.0.0.1 lists.test\n127.0.0.1 erock.lists.test\n```\n\nWildcards are not support in `/etc/hosts` so you'll have to add a subdomain for\neach blog in development. For this example you'll also want to change the domain \nenv var to `LISTS_DOMAIN=lists.test`.\n\n## deployment\n\nI use `docker-compose` for deployment.  First you need `.env.prod`. \n\n```bash\ncp .env.example .env.prod\n```\n\nThe `production.yml` file in this repo uses my docker hub images for deployment.\n\n```bash\ndocker-compose -f production.yml up -d\n```\n\nIf you want to deploy using your own domain then you'll need to edit the\n`Caddyfile` with your domain.\n"
  },
  {
    "path": "build/.gitkeep",
    "content": ""
  },
  {
    "path": "cmd/gemini/main.go",
    "content": "package main\n\nimport \"git.sr.ht/~erock/lists.sh/internal/gemini\"\n\nfunc main() {\n\tgemini.StartServer()\n}\n"
  },
  {
    "path": "cmd/ssh/main.go",
    "content": "package main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"git.sr.ht/~erock/lists.sh/internal\"\n\t\"git.sr.ht/~erock/wish/cms\"\n\t\"git.sr.ht/~erock/wish/cms/db/postgres\"\n\t\"git.sr.ht/~erock/wish/proxy\"\n\t\"git.sr.ht/~erock/wish/send/scp\"\n\t\"git.sr.ht/~erock/wish/send/sftp\"\n\t\"github.com/charmbracelet/wish\"\n\tbm \"github.com/charmbracelet/wish/bubbletea\"\n\tlm \"github.com/charmbracelet/wish/logging\"\n\t\"github.com/gliderlabs/ssh\"\n)\n\ntype SSHServer struct{}\n\nfunc (me *SSHServer) authHandler(ctx ssh.Context, key ssh.PublicKey) bool {\n\treturn true\n}\n\nfunc createRouter(handler *internal.DbHandler) proxy.Router {\n\treturn func(sh ssh.Handler, s ssh.Session) []wish.Middleware {\n\t\tcmd := s.Command()\n\t\tmdw := []wish.Middleware{}\n\n\t\tif len(cmd) == 0 {\n\t\t\tmdw = append(mdw,\n\t\t\t\tbm.Middleware(cms.Middleware(&handler.Cfg.ConfigCms, handler.Cfg)),\n\t\t\t\tlm.Middleware(),\n\t\t\t)\n\t\t} else if cmd[0] == \"scp\" {\n\t\t\tmdw = append(mdw, scp.Middleware(handler))\n\t\t}\n\n\t\treturn mdw\n\t}\n}\n\nfunc withProxy(handler *internal.DbHandler) ssh.Option {\n\treturn func(server *ssh.Server) error {\n\t\terr := sftp.SSHOption(handler)(server)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn proxy.WithProxy(createRouter(handler))(server)\n\t}\n}\n\nfunc main() {\n\thost := internal.GetEnv(\"PROSE_HOST\", \"0.0.0.0\")\n\tport := internal.GetEnv(\"PROSE_SSH_PORT\", \"2222\")\n\tcfg := internal.NewConfigSite()\n\tlogger := cfg.Logger\n\tdbh := postgres.NewDB(&cfg.ConfigCms)\n\tdefer dbh.Close()\n\thandler := internal.NewDbHandler(dbh, cfg)\n\n\tsshServer := &SSHServer{}\n\ts, err := wish.NewServer(\n\t\twish.WithAddress(fmt.Sprintf(\"%s:%s\", host, port)),\n\t\twish.WithHostKeyPath(\"ssh_data/term_info_ed25519\"),\n\t\twish.WithPublicKeyAuth(sshServer.authHandler),\n\t\twithProxy(handler),\n\t)\n\tif err != nil {\n\t\tlogger.Fatal(err)\n\t}\n\n\tdone := make(chan os.Signal, 1)\n\tsignal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)\n\tlogger.Infof(\"Starting SSH server on %s:%s\", host, port)\n\tgo func() {\n\t\tif err = s.ListenAndServe(); err != nil {\n\t\t\tlogger.Fatal(err)\n\t\t}\n\t}()\n\n\t<-done\n\tlogger.Info(\"Stopping SSH server\")\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer func() { cancel() }()\n\tif err := s.Shutdown(ctx); err != nil {\n\t\tlogger.Fatal(err)\n\t}\n}\n"
  },
  {
    "path": "cmd/web/main.go",
    "content": "package main\n\nimport \"git.sr.ht/~erock/lists.sh/internal\"\n\nfunc main() {\n\tinternal.StartApiServer()\n}\n"
  },
  {
    "path": "db/migrations/20220310_init.sql",
    "content": "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";\n\nCREATE TABLE IF NOT EXISTS app_users (\n  id uuid NOT NULL DEFAULT uuid_generate_v4(),\n  name character varying(50),\n  created_at timestamp without time zone NOT NULL DEFAULT NOW(),\n  CONSTRAINT unique_name UNIQUE (name),\n  CONSTRAINT app_user_pkey PRIMARY KEY (id)\n);\n\nCREATE TABLE IF NOT EXISTS public_keys (\n  id uuid NOT NULL DEFAULT uuid_generate_v4(),\n  user_id uuid NOT NULL,\n  public_key varchar(2048) NOT NULL,\n  created_at timestamp without time zone NOT NULL DEFAULT NOW(),\n  CONSTRAINT user_public_keys_pkey PRIMARY KEY (id),\n  CONSTRAINT unique_key_for_user UNIQUE (user_id, public_key),\n  CONSTRAINT fk_user_public_keys_owner\n    FOREIGN KEY(user_id)\n  REFERENCES app_users(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS posts (\n  id uuid NOT NULL DEFAULT uuid_generate_v4(),\n  user_id uuid NOT NULL,\n  title character varying(255) NOT NULL,\n  text text NOT NULL DEFAULT '',\n  publish_at timestamp without time zone NOT NULL DEFAULT NOW(),\n  created_at timestamp without time zone NOT NULL DEFAULT NOW(),\n  updated_at timestamp without time zone NOT NULL DEFAULT NOW(),\n  CONSTRAINT posts_pkey PRIMARY KEY (id),\n  CONSTRAINT unique_title_for_user UNIQUE (user_id, title),\n  CONSTRAINT fk_posts_app_users\n    FOREIGN KEY(user_id)\n  REFERENCES app_users(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n"
  },
  {
    "path": "db/migrations/20220422_add_desc_to_user_and_post.sql",
    "content": "ALTER TABLE app_users ADD COLUMN bio character varying(150) NOT NULL DEFAULT '';\nALTER TABLE posts ADD COLUMN description character varying(150) NOT NULL DEFAULT '';\nALTER TABLE posts ADD COLUMN filename character varying(255);\n\nUPDATE posts SET filename = title;\n\nALTER TABLE posts ADD CONSTRAINT unique_filename_for_user UNIQUE (user_id, filename);\nALTER TABLE posts DROP CONSTRAINT unique_title_for_user;\n"
  },
  {
    "path": "db/migrations/20220426_add_index_for_filename.sql",
    "content": "CREATE INDEX posts_filename ON posts USING btree(filename);\nALTER TABLE app_users DROP COLUMN bio;\n"
  },
  {
    "path": "db/migrations/20220427_username_to_lower.sql",
    "content": "UPDATE app_users SET name = LOWER(name) WHERE name != LOWER(name);\n"
  },
  {
    "path": "db/migrations/20220523_timestamp_with_tz.sql",
    "content": "ALTER TABLE posts ALTER COLUMN updated_at TYPE timestamp WITH TIME ZONE USING updated_at AT TIME ZONE 'UTC';\nALTER TABLE posts ALTER COLUMN publish_at TYPE timestamp WITH TIME ZONE USING publish_at AT TIME ZONE 'UTC';\nALTER TABLE posts ALTER COLUMN created_at TYPE timestamp WITH TIME ZONE USING created_at AT TIME ZONE 'UTC';\nALTER TABLE app_users ALTER COLUMN created_at TYPE timestamp WITH TIME ZONE USING created_at AT TIME ZONE 'UTC';\nALTER TABLE public_keys ALTER COLUMN created_at TYPE timestamp WITH TIME ZONE USING created_at AT TIME ZONE 'UTC';\n"
  },
  {
    "path": "db/migrations/20220721_analytics.sql",
    "content": "CREATE TABLE IF NOT EXISTS post_analytics (\n  id uuid NOT NULL DEFAULT uuid_generate_v4(),\n  post_id uuid NOT NULL,\n  views int DEFAULT 0,\n  updated_at timestamp without time zone NOT NULL DEFAULT NOW(),\n  CONSTRAINT analytics_pkey PRIMARY KEY (id),\n  CONSTRAINT fk_analytics_posts\n    FOREIGN KEY(post_id)\n  REFERENCES posts(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n"
  },
  {
    "path": "db/migrations/20220722_post_hidden.sql",
    "content": "ALTER TABLE posts ADD COLUMN hidden boolean NOT NULL DEFAULT FALSE;\nUPDATE posts SET hidden = TRUE WHERE filename LIKE E'\\\\_%';\n"
  },
  {
    "path": "db/setup.sql",
    "content": "CREATE DATABASE \"lists\" OWNER \"postgres\";\n"
  },
  {
    "path": "db/teardown.sql",
    "content": "DROP TABLE posts CASCADE;\nDROP TABLE app_users CASCADE;\nDROP TABLE public_keys CASCADE;\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "version: \"3.4\"\nservices:\n  db:\n    image: postgres\n    restart: always\n    ports:\n      - \"5433:5432\"\n    env_file:\n      - .env\n"
  },
  {
    "path": "gmi/base.layout.tmpl",
    "content": "{{define \"base\"}}\n{{template \"body\" .}}\n{{end}}\n"
  },
  {
    "path": "gmi/blog.page.tmpl",
    "content": "{{template \"base\" .}}\n{{define \"body\"}}\n# {{.Header.Title}}\n{{.Header.Bio}}\n{{range .Header.Nav}}\n{{if .IsURL}}=> {{.URL}} {{.Value}}{{end}}\n{{- end}}\n=> {{.RSSURL}} rss\n\n{{- if .Readme.HasItems}}\n\n---\n{{- template \"list\" .Readme -}}\n{{- end}}\n{{- range .Posts}}\n=> {{.URL}} {{.Title}} ({{.UpdatedTimeAgo}})\n{{- end}}\n{{- template \"footer\" . -}}\n{{end}}\n"
  },
  {
    "path": "gmi/footer.partial.tmpl",
    "content": "{{define \"footer\"}}\n---\n\n=> / published with {{.Site.Domain}}\n{{end}}\n"
  },
  {
    "path": "gmi/help.page.tmpl",
    "content": "{{template \"base\" .}}\n\n{{define \"body\"}}\n# Need help?\n\nHere are some common questions on using this platform that we would like to answer.\n\n## I get a permission denied when trying to SSH\n\nUnfortunately, 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:\n\n```\n$ find ~/.ssh/id_*.pub -exec ssh-keygen -l -f {} \\;\n```\n\nIf you’re curious about the inner workings of this problem have a look at:\n\n=> https://github.com/golang/go/issues/37278 golang/go#37278\n=> https://go-review.googlesource.com/c/crypto/+/220037 go-review\n=> https://github.com/golang/crypto/pull/197 golang/crypto#197\n\n## Generating a new SSH key\n\n=> 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\n\n```\nssh-keygen -t ed25519 -C \"your_email@example.com\"\n```\n\n* When you're prompted to \"Enter a file in which to save the key,\" press Enter. This accepts the default file location.\n* At the prompt, type a secure passphrase.\n\n## What should my blog folder look like?\n\nCurrently {{.Site.Domain}} only supports a flat folder structure.  Therefore, `scp -r` is not permitted.  We also only allow `.txt` files to be uploaded.\n\n=> https://github.com/neurosnap/lists-blog Here is the source to my blog on this platform\n\nBelow is an example of what your blog folder should look like:\n\n```\nblog/\n  first-post.txt\n  second-post.txt\n  third-post.txt\n```\n\nUnderscores and hyphens are permitted and will be automatically removed from the title of the list.\n\n## How do I update a list?\n\nUpdating 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.\n\n## How do I delete a list?\n\nBecause `scp` does not natively support deleting files, I didn't want to bake that behavior into my ssh server.\n\nHowever, 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:\n\n```\ncp /dev/null delete.txt\nscp ./delete.txt {{.Site.Domain}}:/\n```\n\nAlternatively, you can go to `ssh <username>@{{.Site.Domain}}` and select \"Manage posts.\" Then you can highlight the post you want to delete and then press \"X.\"  It will ask for confirmation before actually removing the list.\n\n## When I want to publish a new post, do I have to upload all posts everytime?\n\nNope!  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:\n\n```\nscp ./taco-tuesday.txt {{.Site.Domain}}:/\n```\n\n## How do I change my blog's name?\n\nAll you have to do is create a post titled `_header.txt` and add some information to the list.\n\n```\n=: title My new blog!\n=: description My blog description!\n=> https://xyz.com website\n=> https://twitter.com/xyz twitter\n```\n\n* `title` will change your blog name\n* `description` will add a blurb right under your blog name (and add meta descriptions)\n* The links will show up next to the `rss` link to your blog\n\n## How do I add an introduction to my blog?\n\nAll you have to do is create a post titled `_readme.txt` and add some information to the list.\n\n```\n=: list_type none\n# Hi my name is Bob!\nI like to sing. Dance. And I like to have fun fun fun!\n```\n\nWhatever is inside the `_readme` file will get rendered (as a list) right above your blog posts. Neat!\n\n## What is my blog URL?\n\n```\ngemini://{{.Site.Domain}}/{username}\n```\n\n## How can I automatically publish my post?\n\nThere is a github action that we built to make it easy to publish your blog automatically.\n\n=> https://github.com/marketplace/actions/scp-publish-action github marketplace\n=> https://github.com/neurosnap/lists-official-blog/blob/main/.github/workflows/publish.yml example workflow\n\nA user also created a systemd task to automatically publish new posts.\n\n=> https://github.com/neurosnap/lists.sh/discussions/24 Check out this github discussion for more details.\n\n## Can I create multiple accounts?\n\nYes!  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}}`.\nPlease note that if you use the same keypair for multiple accounts, you will need to always specify the user when logging into our CMS.\n{{template \"marketing-footer\" .}}\n{{end}}\n"
  },
  {
    "path": "gmi/list.partial.tmpl",
    "content": "{{define \"list\"}}\n{{range .Items}}\n{{- if .IsText}}\n{{- if .Value}}\n* {{.Value}}\n{{- end}}\n{{- else if .IsURL}}\n=> {{.URL}} {{.Value}}\n{{- else if .IsImg}}\n=> {{.URL}} {{.Value}}\n{{- else if .IsBlock}}\n> {{.Value}}\n{{- else if .IsHeaderOne}}\n\n## {{.Value}}\n{{- else if .IsHeaderTwo}}\n\n### {{.Value}}\n{{- else if .IsPre}}\n```\n{{.Value}}\n```\n{{- end}}\n{{- end}}\n{{end}}\n"
  },
  {
    "path": "gmi/marketing-footer.partial.tmpl",
    "content": "{{define \"marketing-footer\"}}\n---\n\nBuilt and maintained by pico.sh\n=> https://pico.sh\n\n=> / home\n=> /spec spec\n=> /ops ops\n=> /help help\n=> /rss rss\n=> https://github.com/neurosnap/lists.sh source\n{{end}}\n"
  },
  {
    "path": "gmi/marketing.page.tmpl",
    "content": "{{template \"base\" .}}\n\n{{define \"body\"}}\n# {{.Site.Domain}}\nA microblog for lists\n\n=> /read discover some interesting lists\n\n---\n\n## Examples\n\n=> /news official blog\n=> https://git.sr.ht/~erock/lists-official-blog blog source\n\n## Create your account\n\nWe don't want your email address.\n\nTo get started, simply ssh into our content management system:\n\n```\nssh new@{{.Site.Domain}}\n```\n\n=> /help#permission-denied note: getting permission denied?\n\nAfter 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.\n\n## You control the source files\n\nCreate lists using your favorite editor in plain text files.\n\n`~/blog/days-in-week.txt`\n\n```\nSunday\nMonday\nTuesday\nWednesday\nThursday\nFriday\nSaturday\n```\n\n## Publish your posts with one command\n\nWhen your post is ready to be published, copy the file to our server with a familiar command:\n\n```\nscp ~/blog/*.txt {{.Site.Domain}}\n```\n\nWe'll either create or update the lists for you.\n\n## Terminal workflow without installation\n\nSince 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!\n\n## Plain text format\n\nA simple specification that is flexible and with no frills.\n\n=> /spec specification\n\n## Features\n\n* Just lists\n* Looks great on any device\n* Bring your own editor\n* You control the source files\n* Terminal workflow with no installation\n* Public-key based authentication\n* No ads, zero tracking\n* No platform lock-in\n* No javascript\n* Subscriptions via RSS\n* Not a platform for todos\n* Minimalist design\n* 100% open source\n\n## Philosophy\n\nI 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.\n\nAnother 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!\n\nOther 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.\n\n## Roadmap\n\n* Feature complete?\n\n{{template \"marketing-footer\" .}}\n{{end}}\n"
  },
  {
    "path": "gmi/ops.page.tmpl",
    "content": "{{template \"base\" .}}\n\n{{define \"body\"}}\n# Operations\n\n=> /privacy privacy\n=> /transparency transparency\n\n## Purpose\n\n{{.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.\n\n## Ethics\n\nWe are committed to:\n\n* No tracking of user or visitor behaviour.\n* Never sell any user or visitor data.\n* No ads — ever.\n\n## Code of Content Publication\n\nContent 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.\n\nIf one notices something along those lines in a blog please let us know at {{.Site.Email}}.\n\n## Liability\n\nThe 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.\n\n## Account Terms\n\n* The user is responsible for all content posted and all actions performed with their account.\n* 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.\n\n## Service Availability\n\nWe 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.\n\n## Contact and Support\n\nEmail us at {{.Site.Email}} with any questions.\n\n## Acknowledgments\n\n{{.Site.Domain}} was inspired by Mataroa Blog[0] and Bear Blog[1].\n\n=> https://mataroa.blog [0]mataroa blog\n=> https://bearblog.dev [1]bearblog\n\n{{.Site.Domain}} is built with many open source technologies.\n\nIn particular we would like to thank:\n\n=> https://charm.sh The charm community\n=> https://go.dev The golang community\n=> https://www.postgresql.org The postgresql community\n=> https://github.com/caddyserver/caddy The caddy community\n{{template \"marketing-footer\" .}}\n{{end}}\n"
  },
  {
    "path": "gmi/post.page.tmpl",
    "content": "{{template \"base\" .}}\n\n{{define \"body\"}}\n# {{.Title}}\n{{.PublishAt}}\n{{if .Description}}{{.Description}}{{end}}\n=> {{.BlogURL}} on {{.BlogName}}\n\n---\n{{- template \"list\" . -}}\n{{- template \"footer\" . -}}\n{{end}}\n"
  },
  {
    "path": "gmi/privacy.page.tmpl",
    "content": "{{template \"base\" .}}\n\n{{define \"body\"}}\n# Privacy\n\nDetails on our privacy and security approach.\n\n## Account Data\n\nIn 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.\n\nBecause we use public-key cryptography, our security posture is a battle-tested and proven technique for authentication.\n\n## Third parties\n\nWe have a strong commitment to never share any user data with any third-parties.\n\n## Service Providers\n\nWe host our server on digital ocean [0]\n\n=> https://digitalocean.com [0]\n\n## Cookies\n\nWe do not use any cookies, not even account authentication.\n{{template \"marketing-footer\" .}}\n{{end}}\n"
  },
  {
    "path": "gmi/read.page.tmpl",
    "content": "{{template \"base\" .}}\n\n{{define \"body\"}}\n# read\nrecently updated lists\n\n{{if .NextPage}}=> {{.NextPage}} next{{end}}\n{{if .PrevPage}}=> {{.PrevPage}} prev{{end}}\n{{range .Posts}}\n=> {{.URL}} {{.UpdatedTimeAgo}}{{.Padding}} {{.Title}} ({{.Username}})\n{{- end}}\n{{template \"marketing-footer\" .}}\n{{end}}\n"
  },
  {
    "path": "gmi/rss.page.tmpl",
    "content": "{{template \"list\" .}}\n"
  },
  {
    "path": "gmi/spec.page.tmpl",
    "content": "{{template \"base\" .}}\n\n{{define \"body\"}}\n# Plain text list\nSpeculative specification\n\n## Overview\n\nVersion: 2022.05.02.dev\nStatus: Draft\nAuthor: Eric Bower\n\nThe 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.\n\nThe 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.\n\nFeedback on any part of this is extremely welcome, please email {{.Site.Email}}.\n\nThe source code for our parser can be found on github[0].\n\n=> https://github.com/neurosnap/lists.sh/blob/main/pkg/parser.go [0]github\n\nThe source code for an example list demonstrating all the features can be found on github[1].\n\n=> https://github.com/neurosnap/lists-official-blog/blob/main/spec-example.txt [1]lists-official-blog\n\n## Parameters\n\nAs 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.\n\n=> https://datatracker.ietf.org/doc/html/rfc2046#section-4.1 [2]rfc 2046\n\n## Line orientation\n\nAs 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.\n\n## File extensions\n\n{{.Site.Domain}} only supports the `.txt` file extension and will ignore all other file extensions.\n\n## List item\n\nList 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.\n\nEmpty lines will be completely removed and not rendered to the end user.\n\n## Hyperlinks\n\nHyperlinks are denoted by the prefix `=>`.  The following text should then be the hyperlink.\n\n```\n=> https://{{.Site.Domain}}\n```\n\nOptionally you can supply the hyperlink text immediately following the link.\n\n```\n=> https://{{.Site.Domain}} microblog for lists\n```\n\n## Images\n\nList items can be represented as images by prefixing the line with <code>=<</code>.\n\n```\n=< https://i.imgur.com/iXMNUN5.jpg\n```\n\nOptionally you can supply the image alt text immediately following the link.\n\n```\n=< https://i.imgur.com/iXMNUN5.jpg I use arch, btw\n```\n\n## Headers\n\nList 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.\n\n```\n# Header One\n## Header Two\n```\n\n## Blockquotes\n\nList items can be represented as blockquotes.\n\n```\n> This is a blockquote.\n```\n\n## Preformatted\n\nList 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 ```.\n\n```\n#!/usr/bin/env bash\n\nset -x\n\necho \"this is a preformatted list item!\n```\n\nYou must also close the preformatted text with another ``` on its own line. The next example with NOT work.\n\n## Variables\n\nVariables 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.\n\n```\n=: publish_at 2022-04-20\n```\n\nThese variables will not be rendered to the user inside the list.\n\n### List of available variables:\n\n* `title` (custom title not dependent on filename)\n* `description` (what is the purpose of this list?)\n* `publish_at` (format must be `YYYY-MM-DD`)\n* `list_type` (customize bullets; value gets sent directly to css property list-style-type[3])\n\n=> https://developer.mozilla.org/en-US/docs/Web/CSS/list-style-type [3]list-style-type\n{{template \"marketing-footer\" .}}\n{{end}}\n"
  },
  {
    "path": "gmi/transparency.page.tmpl",
    "content": "{{template \"base\" .}}\n\n{{define \"body\"}}\n# Transparency\n\n## Analytics\n\nHere are some interesting stats on usage.\n\nTotal users:\n{{.Analytics.TotalUsers}}\n\nNew users in the last month:\n{{.Analytics.UsersLastMonth}}\n\nTotal posts:\n{{.Analytics.TotalPosts}}\n\nNew posts in the last month:\n{{.Analytics.PostsLastMonth}}\n\nUsers with at least one post:\n{{.Analytics.UsersWithPost}}\n\nService maintenance costs:\n\n* Server $5.00/mo\n* Domain name $3.25/mo\n* Programmer $0.00/mo\n{{template \"marketing-footer\" .}}\n{{end}}\n"
  },
  {
    "path": "go.mod",
    "content": "module git.sr.ht/~erock/lists.sh\n\ngo 1.18\n\n// replace git.sr.ht/~erock/wish => /home/erock/pico/wish\n\nrequire (\n\tgit.sr.ht/~adnano/go-gemini v0.2.3\n\tgit.sr.ht/~aw/gorilla-feeds v1.1.4\n\tgit.sr.ht/~erock/wish v0.0.0-20220729004215-0881364c2120\n\tgithub.com/charmbracelet/wish v0.5.0\n\tgithub.com/gliderlabs/ssh v0.3.4\n\tgithub.com/gorilla/feeds v1.1.1\n\tgo.uber.org/zap v1.21.0\n\tgolang.org/x/exp v0.0.0-20220613132600-b0d781184e0d\n)\n\nrequire (\n\tgithub.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect\n\tgithub.com/atotto/clipboard v0.1.4 // indirect\n\tgithub.com/caarlos0/sshmarshal v0.1.0 // indirect\n\tgithub.com/charmbracelet/bubbles v0.12.0 // indirect\n\tgithub.com/charmbracelet/bubbletea v0.22.0 // indirect\n\tgithub.com/charmbracelet/keygen v0.3.0 // indirect\n\tgithub.com/charmbracelet/lipgloss v0.5.0 // indirect\n\tgithub.com/containerd/console v1.0.3 // indirect\n\tgithub.com/kr/fs v0.1.0 // indirect\n\tgithub.com/kr/text v0.2.0 // indirect\n\tgithub.com/lib/pq v1.10.6 // indirect\n\tgithub.com/lucasb-eyer/go-colorful v1.2.0 // indirect\n\tgithub.com/mattn/go-isatty v0.0.14 // indirect\n\tgithub.com/mattn/go-runewidth v0.0.13 // indirect\n\tgithub.com/mitchellh/go-homedir v1.1.0 // indirect\n\tgithub.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect\n\tgithub.com/muesli/cancelreader v0.2.2 // indirect\n\tgithub.com/muesli/reflow v0.3.0 // indirect\n\tgithub.com/muesli/termenv v0.12.0 // indirect\n\tgithub.com/pkg/sftp v1.13.5 // indirect\n\tgithub.com/rivo/uniseg v0.2.0 // indirect\n\tgo.uber.org/atomic v1.9.0 // indirect\n\tgo.uber.org/multierr v1.8.0 // indirect\n\tgolang.org/x/crypto v0.0.0-20220622213112-05595931fe9d // indirect\n\tgolang.org/x/net v0.0.0-20220624214902-1bab6f366d9e // indirect\n\tgolang.org/x/sys v0.0.0-20220702020025-31831981b65f // indirect\n\tgolang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect\n\tgolang.org/x/text v0.3.7 // indirect\n\tgopkg.in/yaml.v2 v2.4.0 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "git.sr.ht/~adnano/go-gemini v0.2.3 h1:oJ+Y0/mheZ4Vg0ABjtf5dlmvq1yoONStiaQvmWWkofc=\ngit.sr.ht/~adnano/go-gemini v0.2.3/go.mod h1:hQ75Y0i5jSFL+FQ7AzWVAYr5LQsaFC7v3ZviNyj46dY=\ngit.sr.ht/~aw/gorilla-feeds v1.1.4 h1:bL78pZ1DtHEhumHK0iWQi30uwEkWtetMfnyt9TFcdlc=\ngit.sr.ht/~aw/gorilla-feeds v1.1.4/go.mod h1:VLpbtNDEWoaJKU41Crj6r3ChvlqYvBm56c0O6IM457g=\ngit.sr.ht/~erock/wish v0.0.0-20220728012620-699415a43292 h1:KnP4IH79pVSf+yw8qe59KlzhOG9H+qbTMlXpFcDXopw=\ngit.sr.ht/~erock/wish v0.0.0-20220728012620-699415a43292/go.mod h1:QZKk7m9jc9iXah90daPGhQkSfNfxSVvpb6nfVeI+MM0=\ngit.sr.ht/~erock/wish v0.0.0-20220729004215-0881364c2120 h1:9O4PKFF8JGvK9g3aVHr2wgozHK0s6BaVISPRl8MAovs=\ngit.sr.ht/~erock/wish v0.0.0-20220729004215-0881364c2120/go.mod h1:QZKk7m9jc9iXah90daPGhQkSfNfxSVvpb6nfVeI+MM0=\ngithub.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=\ngithub.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=\ngithub.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=\ngithub.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=\ngithub.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=\ngithub.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=\ngithub.com/caarlos0/sshmarshal v0.1.0 h1:zTCZrDORFfWh526Tsb7vCm3+Yg/SfW/Ub8aQDeosk0I=\ngithub.com/caarlos0/sshmarshal v0.1.0/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQHfr9dlpDIyA=\ngithub.com/charmbracelet/bubbles v0.12.0 h1:fxb9U9yI60Hek3tcPmMTFya5NhvPrqpkpyMaNngFh7A=\ngithub.com/charmbracelet/bubbles v0.12.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=\ngithub.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=\ngithub.com/charmbracelet/bubbletea v0.22.0 h1:E1BTNSE3iIrq0G0X6TjGAmrQ32cGCbFDPcIuImikrUc=\ngithub.com/charmbracelet/bubbletea v0.22.0/go.mod h1:aoVIwlNlr5wbCB26KhxfrqAn0bMp4YpJcoOelbxApjs=\ngithub.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=\ngithub.com/charmbracelet/keygen v0.3.0 h1:mXpsQcH7DDlST5TddmXNXjS0L7ECk4/kLQYyBcsan2Y=\ngithub.com/charmbracelet/keygen v0.3.0/go.mod h1:1ukgO8806O25lUZ5s0IrNur+RlwTBERlezdgW71F5rM=\ngithub.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=\ngithub.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=\ngithub.com/charmbracelet/wish v0.5.0 h1:FkkdNBFqrLABR1ciNrAL2KCxoyWfKhXnIGZw6GfAtPg=\ngithub.com/charmbracelet/wish v0.5.0/go.mod h1:5GAn5SrDSZ7cgKjnC+3kDmiIo7I6k4/AYiRzC4+tpCk=\ngithub.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=\ngithub.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/gliderlabs/ssh v0.3.4 h1:+AXBtim7MTKaLVPgvE+3mhewYRawNLTd+jEEz/wExZw=\ngithub.com/gliderlabs/ssh v0.3.4/go.mod h1:ZSS+CUoKHDrqVakTfTWUlKSr9MtMFkC4UvtQKD7O914=\ngithub.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=\ngithub.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=\ngithub.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=\ngithub.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=\ngithub.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=\ngithub.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=\ngithub.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs=\ngithub.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=\ngithub.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=\ngithub.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=\ngithub.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=\ngithub.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=\ngithub.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=\ngithub.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=\ngithub.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=\ngithub.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=\ngithub.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=\ngithub.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=\ngithub.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=\ngithub.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=\ngithub.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA=\ngithub.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=\ngithub.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=\ngithub.com/muesli/cancelreader v0.2.1/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=\ngithub.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=\ngithub.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=\ngithub.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=\ngithub.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=\ngithub.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=\ngithub.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=\ngithub.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=\ngithub.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc=\ngithub.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A=\ngithub.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=\ngithub.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkg/sftp v1.13.5 h1:a3RLUqkyjYRtBTZJZ1VRrKbN3zhuPLlUc3sphVz81go=\ngithub.com/pkg/sftp v1.13.5/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=\ngithub.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=\ngo.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=\ngo.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=\ngo.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=\ngo.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=\ngo.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=\ngo.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=\ngo.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8=\ngo.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=\ngo.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=\ngo.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=\ngolang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY=\ngolang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=\ngolang.org/x/exp v0.0.0-20220613132600-b0d781184e0d h1:vtUKgx8dahOomfFzLREU8nSv25YHnTgLBn4rDnWZdU0=\ngolang.org/x/exp v0.0.0-20220613132600-b0d781184e0d/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA=\ngolang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=\ngolang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.0.0-20220624214902-1bab6f366d9e h1:TsQ7F31D3bUCLeqPT0u+yjp1guoArKaNKmCr22PYgTQ=\ngolang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220702020025-31831981b65f h1:xdsejrW/0Wf2diT5CPp3XmKUNbr7Xvw8kYilQ+6qjRY=\ngolang.org/x/sys v0.0.0-20220702020025-31831981b65f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM=\ngolang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=\ngopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=\ngopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "html/base.layout.tmpl",
    "content": "{{define \"base\"}}\n<!doctype html>\n<html lang=\"en\">\n    <head>\n        <meta charset='utf-8'>\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n        <title>{{template \"title\" .}}</title>\n\n        <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/favicon-16x16.png\">\n\n        <meta name=\"keywords\" content=\"blog, blogging, write, writing, lists\" />\n        {{template \"meta\" .}}\n\n        <link rel=\"stylesheet\" href=\"/main.css\" />\n    </head>\n    <body>{{template \"body\" .}}</body>\n</html>\n{{end}}\n"
  },
  {
    "path": "html/blog.page.tmpl",
    "content": "{{template \"base\" .}}\n\n{{define \"title\"}}{{.PageTitle}}{{end}}\n\n{{define \"meta\"}}\n<meta name=\"description\" content=\"{{if .Header.Bio}}{{.Header.Bio}}{{else}}{{.Header.Title}}{{end}}\" />\n\n<meta property=\"og:type\" content=\"website\">\n<meta property=\"og:site_name\" content=\"{{.Site.Domain}}\">\n<meta property=\"og:url\" content=\"{{.URL}}\">\n<meta property=\"og:title\" content=\"{{.Header.Title}}\">\n{{if .Header.Bio}}<meta property=\"og:description\" content=\"{{.Header.Bio}}\">{{end}}\n<meta property=\"og:image:width\" content=\"300\" />\n<meta property=\"og:image:height\" content=\"300\" />\n<meta itemprop=\"image\" content=\"https://{{.Site.Domain}}/card.png\" />\n<meta property=\"og:image\" content=\"https://{{.Site.Domain}}/card.png\" />\n\n<meta property=\"twitter:card\" content=\"summary\">\n<meta property=\"twitter:url\" content=\"{{.URL}}\">\n<meta property=\"twitter:title\" content=\"{{.Header.Title}}\">\n{{if .Header.Bio}}<meta property=\"twitter:description\" content=\"{{.Header.Bio}}\">{{end}}\n<meta name=\"twitter:image\" content=\"https://{{.Site.Domain}}/card.png\" />\n<meta name=\"twitter:image:src\" content=\"https://{{.Site.Domain}}/card.png\" />\n{{end}}\n\n{{define \"body\"}}\n<header class=\"text-center\">\n    <h1 class=\"text-2xl font-bold\">{{.Header.Title}}</h1>\n    {{if .Header.Bio}}<p class=\"text-lg\">{{.Header.Bio}}</p>{{end}}\n    <nav>\n        {{range .Header.Nav}}\n            {{if .IsURL}}\n            <a href=\"{{.URL}}\" class=\"text-lg\">{{.Value}}</a> |\n            {{end}}\n        {{end}}\n        <a href=\"{{.RSSURL}}\" class=\"text-lg\">rss</a>\n    </nav>\n    <hr />\n</header>\n<main>\n    {{if .Readme.HasItems}}\n    <section>\n        <article>\n            {{template \"list\" .Readme}}\n        </article>\n        <hr />\n    </section>\n    {{end}}\n\n    <section class=\"posts\">\n        {{range .Posts}}\n        <article>\n            <div class=\"flex items-center\">\n                <time datetime=\"{{.UpdatedAtISO}}\" class=\"font-italic text-sm post-date\">{{.UpdatedTimeAgo}}</time>\n                <h2 class=\"font-bold flex-1\"><a href=\"{{.URL}}\">{{.Title}}</a></h2>\n            </div>\n        </article>\n        {{end}}\n    </section>\n</main>\n{{template \"footer\" .}}\n{{end}}\n"
  },
  {
    "path": "html/footer.partial.tmpl",
    "content": "{{define \"footer\"}}\n<footer>\n    <hr />\n    published with <a href={{.Site.HomeURL}}>{{.Site.Domain}}</a>\n</footer>\n{{end}}\n"
  },
  {
    "path": "html/help.page.tmpl",
    "content": "{{template \"base\" .}}\n\n{{define \"title\"}}help -- {{.Site.Domain}}{{end}}\n\n{{define \"meta\"}}\n<meta name=\"description\" content=\"questions and answers\" />\n{{end}}\n\n{{define \"body\"}}\n<header>\n    <h1 class=\"text-2xl\">Need help?</h1>\n    <p>Here are some common questions on using this platform that we would like to answer.</p>\n</header>\n<main>\n    <section id=\"permission-denied\">\n        <h2 class=\"text-xl\">\n            <a href=\"#permission-denied\" rel=\"nofollow noopener\">#</a>\n            I get a permission denied when trying to SSH\n        </h2>\n        <p>\n            Unfortunately SHA-2 RSA keys are <strong>not</strong> currently supported.\n        </p>\n        <p>\n            Unfortunately, due to a shortcoming in Go’s x/crypto/ssh package, Soft Serve does\n            not currently support access via new SSH RSA keys: only the old SHA-1 ones will work.\n            Until we sort this out you’ll either need an SHA-1 RSA key or a key with another\n            algorithm, e.g. Ed25519. Not sure what type of keys you have? You can check with the\n            following:\n        </p>\n        <pre>$ find ~/.ssh/id_*.pub -exec ssh-keygen -l -f {} \\;</pre>\n        <p>If you’re curious about the inner workings of this problem have a look at:</p>\n        <ul>\n            <li><a href=\"https://github.com/golang/go/issues/37278\">golang/go#37278</a></li>\n            <li><a href=\"https://go-review.googlesource.com/c/crypto/+/220037\">go-review</a></li>\n            <li><a href=\"https://github.com/golang/crypto/pull/197\">golang/crypto#197</a></li>\n        </ul>\n    </section>\n\n    <section id=\"ssh-key\">\n        <h2 class=\"text-xl\">\n            <a href=\"#ssh-key\" rel=\"nofollow noopener\">#</a>\n            Generating a new SSH key\n        </h2>\n        <p>\n            <a href=\"https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent\">Github reference</a>\n        </p>\n        <pre>ssh-keygen -t ed25519 -C \"your_email@example.com\"</pre>\n        <ol>\n            <li>When you're prompted to \"Enter a file in which to save the key,\" press Enter. This accepts the default file location.</li>\n            <li>At the prompt, type a secure passphrase.</li>\n        </ol>\n    </section>\n\n    <section id=\"blog-structure\">\n        <h2 class=\"text-xl\">\n            <a href=\"#blog-structure\" rel=\"nofollow noopener\">#</a>\n            What should my blog folder look like?\n        </h2>\n        <p>\n            Currently {{.Site.Domain}} only supports a flat folder structure.  Therefore,\n            <code>scp -r</code> is not permitted.  We also only allow <code>.txt</code> files to be\n            uploaded.\n        </p>\n        <p>\n            <a href=\"https://github.com/neurosnap/lists-blog\">Here is the source to my blog on this platform</a>\n        </p>\n        <p>\n        Below is an example of what your blog folder should look like:\n        </p>\n            <pre>blog/\nfirst-post.txt\nsecond-post.txt\nthird-post.txt</pre>\n        </p>\n        <p>\n            Underscores and hyphens are permitted and will be automatically removed from the title of the list.\n        </p>\n    </section>\n\n    <section id=\"post-update\">\n        <h2 class=\"text-xl\">\n            <a href=\"#post-update\" rel=\"nofollow noopener\">#</a>\n            How do I update a list?\n        </h2>\n        <p>\n            Updating a list requires that you update the source document and then run the <code>scp</code>\n            command again.  If the filename remains the same, then the list will be updated.\n        </p>\n    </section>\n\n    <section id=\"post-delete\">\n        <h2 class=\"text-xl\">\n            <a href=\"#post-delete\" rel=\"nofollow noopener\">#</a>\n            How do I delete a list?\n        </h2>\n        <p>\n            Because <code>scp</code> does not natively support deleting files, I didn't want to bake\n            that behavior into my ssh server.\n        </p>\n\n        <p>\n            However, if a user wants to delete a post they can delete the contents of the file and\n            then upload it to our server.  If the file contains 0 bytes, we will remove the post.\n            For example, if you want to delete <code>delete.txt</code> you could:\n        </p>\n\n        <pre>\ncp /dev/null delete.txt\nscp ./delete.txt {{.Site.Domain}}:/</pre>\n\n        <p>\n            Alternatively, you can go to <code>ssh {{.Site.Domain}}</code> and select \"Manage posts.\"\n            Then you can highlight the post you want to delete and then press \"X.\"  It will ask for\n            confirmation before actually removing the list.\n        </p>\n    </section>\n\n    <section id=\"blog-upload-single-file\">\n        <h2 class=\"text-xl\">\n            <a href=\"#blog-upload-single-file\" rel=\"nofollow noopener\">#</a>\n            When I want to publish a new post, do I have to upload all posts everytime?\n        </h2>\n        <p>\n            Nope!  Just <code>scp</code> the file you want to publish.  For example, if you created\n            a new post called <code>taco-tuesday.txt</code> then you would publish it like this:\n        </p>\n        <pre>scp ./taco-tuesday.txt {{.Site.Domain}}:</pre>\n    </section>\n\n    <section id=\"blog-header\">\n        <h2 class=\"text-xl\">\n            <a href=\"#blog-header\" rel=\"nofollow noopener\">#</a>\n            How do I change my blog's name?\n        </h2>\n        <p>\n            All you have to do is create a post titled <code>_header.txt</code> and add some\n            information to the list.\n        </p>\n        <pre>=: title My new blog!\n=: description My blog description!\n=> https://xyz.com website\n=> https://twitter.com/xyz twitter</pre>\n        <ul>\n            <li><code>title</code> will change your blog name</li>\n            <li><code>description</code> will add a blurb right under your blog name (and add meta descriptions)</li>\n            <li>The links will show up next to the <code>rss</code> link to your blog\n        </ul>\n    </section>\n\n    <section id=\"blog-readme\">\n        <h2 class=\"text-xl\">\n            <a href=\"#blog-readme\" rel=\"nofollow noopener\">#</a>\n            How do I add an introduction to my blog?\n        </h2>\n        <p>\n            All you have to do is create a post titled <code>_readme.txt</code> and add some\n            information to the list.\n        </p>\n        <pre>=: list_type none\n# Hi my name is Bob!\nI like to sing. Dance. And I like to have fun fun fun!</pre>\n        <p>\n            Whatever is inside the <code>_readme</code> file will get rendered (as a list) right above your\n            blog posts. Neat!\n        </p>\n    </section>\n\n    <section id=\"blog-url\">\n        <h2 class=\"text-xl\">\n            <a href=\"#blog-url\" rel=\"nofollow noopener\">#</a>\n            What is my blog URL?\n        </h2>\n        <pre>https://{username}.{{.Site.Domain}}</pre>\n    </section>\n\n    <section id=\"continuous-deployment\">\n        <h2 class=\"text-xl\">\n            <a href=\"#continuous-deployment\" rel=\"nofollow noopener\">#</a>\n            How can I automatically publish my post?\n        </h2>\n        <p>\n            There is a github action that we built to make it easy to publish your blog automatically.\n        </p>\n        <ul>\n            <li>\n                <a href=\"https://github.com/marketplace/actions/scp-publish-action\">github marketplace</a>\n            </li>\n            <li>\n                <a href=\"https://github.com/neurosnap/lists-official-blog/blob/main/.github/workflows/publish.yml\">example workflow</a>\n            </li>\n        </ul>\n        <p>\n            A user also created a systemd task to automatically publish new posts.  <a href=\"https://github.com/neurosnap/lists.sh/discussions/24\">Check out this github discussion for more details.</a>\n        </p>\n    </section>\n\n    <section id=\"multiple-accounts\">\n        <h2 class=\"text-xl\">\n            <a href=\"#multiple-accounts\" rel=\"nofollow noopener\">#</a>\n            Can I create multiple accounts?\n        </h2>\n        <p>\n           Yes!  You can either a) create a new keypair and use that for authentication\n           or b) use the same keypair and ssh into our CMS using our special username\n           <code>ssh new@{{.Site.Domain}}</code>.\n        </p>\n        <p>\n            Please note that if you use the same keypair for multiple accounts, you will need to\n            always specify the user when logging into our CMS.\n        </p>\n    </section>\n</main>\n{{template \"marketing-footer\" .}}\n{{end}}\n"
  },
  {
    "path": "html/list.partial.tmpl",
    "content": "{{define \"list\"}}\n<ul style=\"list-style-type: {{.ListType}};\">\n    {{range .Items}}\n        {{if .IsText}}\n            {{if .Value}}\n            <li>{{.Value}}</li>\n            {{end}}\n        {{end}}\n\n        {{if .IsURL}}\n        <li><a href=\"{{.URL}}\">{{.Value}}</a></li>\n        {{end}}\n\n        {{if .IsImg}}\n        <li><img src=\"{{.URL}}\" alt=\"{{.Value}}\" /></li>\n        {{end}}\n\n        {{if .IsBlock}}\n        <li><blockquote>{{.Value}}</blockquote></li>\n        {{end}}\n\n        {{if .IsHeaderOne}}\n        </ul><h2 class=\"text-xl font-bold\">{{.Value}}</h2><ul style=\"list-style-type: {{$.ListType}};\">\n        {{end}}\n\n        {{if .IsHeaderTwo}}\n        </ul><h3 class=\"text-lg font-bold\">{{.Value}}</h3><ul style=\"list-style-type: {{$.ListType}};\">\n        {{end}}\n\n        {{if .IsPre}}\n        <li><pre>{{.Value}}</pre></li>\n        {{end}}\n    {{end}}\n</ul>\n{{end}}\n"
  },
  {
    "path": "html/marketing-footer.partial.tmpl",
    "content": "{{define \"marketing-footer\"}}\n<footer>\n    <hr />\n    <p class=\"font-italic\">Built and maintained by <a href=\"https://pico.sh\">pico.sh</a>.</p>\n    <div>\n        <a href=\"/\">home</a> |\n        <a href=\"/spec\">spec</a> |\n        <a href=\"/ops\">ops</a> |\n        <a href=\"/help\">help</a> |\n        <a href=\"/rss\">rss</a> |\n        <a href=\"https://git.sr.ht/~erock/lists.sh\">source</a>\n    </div>\n</footer>\n{{end}}\n"
  },
  {
    "path": "html/marketing.page.tmpl",
    "content": "{{template \"base\" .}}\n\n{{define \"title\"}}{{.Site.Domain}} -- a microblog for lists{{end}}\n\n{{define \"meta\"}}\n<meta name=\"description\" content=\"a microblog for lists\" />\n\n<meta property=\"og:type\" content=\"website\">\n<meta property=\"og:site_name\" content=\"{{.Site.Domain}}\">\n<meta property=\"og:url\" content=\"https://{{.Site.Domain}}\">\n<meta property=\"og:title\" content=\"{{.Site.Domain}}\">\n<meta property=\"og:description\" content=\"a microblog for lists\">\n\n<meta name=\"twitter:card\" content=\"summary\" />\n<meta property=\"twitter:url\" content=\"https://{{.Site.Domain}}\">\n<meta property=\"twitter:title\" content=\"{{.Site.Domain}}\">\n<meta property=\"twitter:description\" content=\"a microblog for lists\">\n<meta name=\"twitter:image\" content=\"https://{{.Site.Domain}}/card.png\" />\n<meta name=\"twitter:image:src\" content=\"https://{{.Site.Domain}}/card.png\" />\n\n<meta property=\"og:image:width\" content=\"300\" />\n<meta property=\"og:image:height\" content=\"300\" />\n<meta itemprop=\"image\" content=\"https://{{.Site.Domain}}/card.png\" />\n<meta property=\"og:image\" content=\"https://{{.Site.Domain}}/card.png\" />\n{{end}}\n\n{{define \"body\"}}\n<header class=\"text-center\">\n    <h1 class=\"text-2xl font-bold\">{{.Site.Domain}}</h1>\n    <p class=\"text-lg\">A microblog for lists</p>\n    <p class=\"text-lg\"><a href=\"/read\">discover</a> some interesting lists</p>\n    <hr />\n</header>\n\n<main>\n    <section>\n        <h2 class=\"text-lg font-bold\">Examples</h2>\n        <p>\n            <a href=\"//hey.{{.Site.Domain}}\">official blog</a> |\n            <a href=\"https://git.sr.ht/~erock/lists-official-blog\">blog source</a>\n        </p>\n    </section>\n\n    <section>\n        <h2 class=\"text-lg font-bold\">Create your account with Public-Key Cryptography</h2>\n        <p>We don't want your email address.</p>\n        <p>To get started, simply ssh into our content management system:</p>\n        <pre>ssh new@{{.Site.Domain}}</pre>\n        <div class=\"text-sm font-italic note\">\n            note: <code>new</code> is a special username that will always send you to account\n            creation.\n        </div>\n        <div class=\"text-sm font-italic note\">\n            note: getting permission denied? <a href=\"/help#permission-denied\">read this</a>\n        </div>\n        <p>\n            After that, just set a username and you're ready to start writing! When you SSH\n            again, use your username that you set in the CMS.\n        </p>\n    </section>\n\n    <section>\n        <h2 class=\"text-lg font-bold\">You control the source files</h2>\n        <p>Create lists using your favorite editor in plain text files.</p>\n        <code>~/blog/days-in-week.txt</code>\n        <pre>Sunday\nMonday\nTuesday\nWednesday\nThursday\nFriday\nSaturday</pre>\n    </section>\n\n    <section>\n        <h2 class=\"text-lg font-bold\">Publish your posts with one command</h2>\n        <p>\n            When your post is ready to be published, copy the file to our server with a familiar\n            command:\n        </p>\n        <pre>scp ~/blog/*.txt {{.Site.Domain}}:/</pre>\n        <p>We'll either create or update the lists for you.</p>\n    </section>\n\n    <section>\n        <h2 class=\"text-lg font-bold\">Terminal workflow without installation</h2>\n        <p>\n            Since we are leveraging tools you already have on your computer\n            (<code>ssh</code> and <code>scp</code>), there is nothing to install.\n        </p>\n        <p>\n            This provides the convenience of a web app, but from inside your terminal!\n        </p>\n    </section>\n\n    <section>\n        <h2 class=\"text-lg font-bold\">Plain text format</h2>\n        <p>A simple specification that is flexible and with no frills.</p>\n        <p><a href=\"/spec\">specification</a></p>\n    </section>\n\n    <section>\n        <h2 class=\"text-lg font-bold\">Features</h2>\n        <ul>\n            <li>Just lists</li>\n            <li>Looks great on any device</li>\n            <li>Bring your own editor</li>\n            <li>You control the source files</li>\n            <li>Terminal workflow with no installation</li>\n            <li>Public-key based authentication</li>\n            <li>No ads, zero browser-based tracking</li>\n            <li>No platform lock-in</li>\n            <li>No javascript</li>\n            <li>Subscriptions via RSS</li>\n            <li>Not a platform for todos</li>\n            <li>Minimalist design</li>\n            <li>100% open source</li>\n        </ul>\n    </section>\n\n    <section>\n        <h2 class=\"text-lg font-bold\">Philosophy</h2>\n        <p>\n            I love writing lists.  I think restricting writing to a set of lists can really\n            help improve clarity in thought.  The goal of this blogging platform is to make it\n            simple to use the tools you love to write and publish lists.  There is no installation,\n            signup is as easy as SSH'ing into our CMS, and publishing content is as easy as\n            copying files to our server.\n        </p>\n        <p>\n            Another goal of this microblog platform is to satisfy my own needs.  I like to\n            write and share lists with people because I find it's one of the best way to disseminate\n            knowledge.  Whether it's a list of links or a list of paragraphs, writing in lists is\n            very satisfying and I welcome you to explore it on this site!\n        </p>\n        <p>\n            Other blogging platforms support writing lists, but they don't\n            <span class=\"font-bold\">emphasize</span> them.  Writing lists is pretty popular\n            on Twitter, but discoverability is terrible.  Other blogging platforms focus on prose,\n            but there really is nothing out there catered specifically for lists ... until now.\n        </p>\n    </section>\n\n    <section>\n        <h2 class=\"text-lg font-bold\">Roadmap</h2>\n        <ol>\n            <li>Feature complete?</li>\n        </ol>\n    </section>\n</main>\n\n{{template \"marketing-footer\" .}}\n{{end}}\n"
  },
  {
    "path": "html/ops.page.tmpl",
    "content": "{{template \"base\" .}}\n\n{{define \"title\"}}operations -- {{.Site.Domain}}{{end}}\n\n{{define \"meta\"}}\n<meta name=\"description\" content=\"{{.Site.Domain}} operations\" />\n{{end}}\n\n{{define \"body\"}}\n<header>\n    <h1 class=\"text-2xl\">Operations</h1>\n    <ul>\n        <li><a href=\"/privacy\">privacy</a></li>\n        <li><a href=\"/transparency\">transparency</a></li>\n    </ul>\n</header>\n<main>\n    <section>\n        <h2 class=\"text-xl\">Purpose</h2>\n        <p>\n            {{.Site.Domain}} exists to allow people to create and share their lists\n            without the need to set up their own server or be part of a platform\n            that shows ads or tracks its users.\n        </p>\n    </section>\n    <section>\n        <h2 class=\"text-xl\">Ethics</h2>\n        <p>We are committed to:</p>\n        <ul>\n            <li>No browser-based tracking of visitor behavior.</li>\n            <li>No attempt to identify users.</li>\n            <li>Never sell any user or visitor data.</li>\n            <li>No ads — ever.</li>\n        </ul>\n    </section>\n    <section>\n        <h2 class=\"text-xl\">Code of Content Publication</h2>\n        <p>\n            Content in {{.Site.Domain}} blogs is unfiltered and unmonitored. Users are free to publish any\n            combination of words and pixels except for: content of animosity or disparagement of an\n            individual or a group on account of a group characteristic such as race, color, national\n            origin, sex, disability, religion, or sexual orientation, which will be taken down\n            immediately.\n        </p>\n        <p>\n            If one notices something along those lines in a blog please let us know at\n            <a href=\"mailto:{{.Site.Email}}\">{{.Site.Email}}</a>.\n        </p>\n    </section>\n    <section>\n        <h2 class=\"text-xl\">Liability</h2>\n        <p>\n            The user expressly understands and agrees that Eric Bower, the operator of this website\n            shall not be liable, in law or in equity, to them or to any third party for any direct,\n            indirect, incidental, lost profits, special, consequential, punitive or exemplary damages.\n        </p>\n    </section>\n    <section>\n        <h2 class=\"text-xl\">Analytics</h2>\n        <p>\n            We are committed to zero browser-based tracking or trying to identify visitors.  This\n            means we do not try to understand the user based on cookies or IP address.  We do not\n            store personally identifiable information.\n        </p>\n        <p>\n            However, in order to provide a better service, we do have some analytics on posts.\n            List of metrics we track for posts:\n        </p>\n        <ul>\n            <li>anonymous view counts</li>\n        </ul>\n        <p>\n            We might also inspect the headers of HTTP requests to determine some tertiary information\n            about the request.  For example we might inspect the <code>User-Agent</code> or\n            <code>Referer</code> to filter out requests from bots.\n        </p>\n    </section>\n    <section>\n        <h2 class=\"text-xl\">Account Terms</h2>\n        <p>\n            <ul>\n                <li>\n                    The user is responsible for all content posted and all actions performed with\n                    their account.\n                </li>\n                <li>\n                    We reserve the right to disable or delete a user's account for any reason at\n                    any time. We have this clause because, statistically speaking, there will be\n                    people trying to do something nefarious.\n                </li>\n            </ul>\n        </p>\n    </section>\n    <section>\n        <h2 class=\"text-xl\">Service Availability</h2>\n        <p>\n         We provide the {{.Site.Domain}} service on an \"as is\" and \"as available\" basis. We do not offer\n         service-level agreements but do take uptime seriously.\n        </p>\n    </section>\n    <section>\n        <h2 class=\"text-xl\">Contact and Support</h2>\n        <p>\n            Email us at <a href=\"mailto:support@{{.Site.Domain}}\">support@{{.Site.Domain}}</a>\n            with any questions.\n        </p>\n    </section>\n    <section>\n        <h2 class=\"text-xl\">Acknowledgments</h2>\n        <p>\n            {{.Site.Domain}} was inspired by <a href=\"https://mataroa.blog\">Mataroa Blog</a>\n            and <a href=\"https://bearblog.dev/\">Bear Blog</a>.\n        </p>\n        <p>\n            {{.Site.Domain}} is built with many open source technologies.\n        </p>\n        <p>\n            In particular we would like to thank:\n        </p>\n        <ul>\n            <li>\n                <span>The </span>\n                <a href=\"https://charm.sh\">charm.sh</a>\n                <span> community</span>\n            </li>\n            <li>\n                <span>The </span>\n                <a href=\"https://go.dev\">golang</a>\n                <span> community</span>\n            </li>\n            <li>\n                <span>The </span>\n                <a href=\"https://www.postgresql.org/\">postgresql</a>\n                <span> community</span>\n            </li>\n            <li>\n                <span>The </span>\n                <a href=\"https://github.com/caddyserver/caddy\">caddy</a>\n                <span> community</span>\n            </li>\n        </ul>\n    </section>\n</main>\n{{template \"marketing-footer\" .}}\n{{end}}\n"
  },
  {
    "path": "html/post.page.tmpl",
    "content": "{{template \"base\" .}}\n\n{{define \"title\"}}{{.PageTitle}}{{end}}\n\n{{define \"meta\"}}\n<meta name=\"description\" content=\"{{.Description}}\" />\n\n<meta property=\"og:type\" content=\"website\">\n<meta property=\"og:site_name\" content=\"{{.Site.Domain}}\">\n<meta property=\"og:url\" content=\"{{.URL}}\">\n<meta property=\"og:title\" content=\"{{.Title}}\">\n{{if .Description}}<meta property=\"og:description\" content=\"{{.Description}}\">{{end}}\n<meta property=\"og:image:width\" content=\"300\" />\n<meta property=\"og:image:height\" content=\"300\" />\n<meta itemprop=\"image\" content=\"https://{{.Site.Domain}}/card.png\" />\n<meta property=\"og:image\" content=\"https://{{.Site.Domain}}/card.png\" />\n\n<meta property=\"twitter:card\" content=\"summary\">\n<meta property=\"twitter:url\" content=\"{{.URL}}\">\n<meta property=\"twitter:title\" content=\"{{.Title}}\">\n{{if .Description}}<meta property=\"twitter:description\" content=\"{{.Description}}\">{{end}}\n<meta name=\"twitter:image\" content=\"https://{{.Site.Domain}}/card.png\" />\n<meta name=\"twitter:image:src\" content=\"https://{{.Site.Domain}}/card.png\" />\n{{end}}\n\n{{define \"body\"}}\n<header>\n    <h1 class=\"text-2xl font-bold\">{{.Title}}</h1>\n    <p class=\"font-bold m-0\">\n        <time datetime=\"{{.PublishAtISO}}\">{{.PublishAt}}</time>\n        <span> on </span>\n        <a href=\"{{.BlogURL}}\">{{.BlogName}}</a></p>\n    {{if .Description}}<div class=\"my font-italic\">{{.Description}}</div>{{end}}\n</header>\n<main>\n    <article>\n        {{template \"list\" .}}\n    </article>\n</main>\n{{template \"footer\" .}}\n{{end}}\n"
  },
  {
    "path": "html/privacy.page.tmpl",
    "content": "{{template \"base\" .}}\n\n{{define \"title\"}}privacy -- {{.Site.Domain}}{{end}}\n\n{{define \"meta\"}}\n<meta name=\"description\" content=\"{{.Site.Domain}} privacy policy\" />\n{{end}}\n\n{{define \"body\"}}\n<header>\n    <h1 class=\"text-2xl\">Privacy</h1>\n    <p>Details on our privacy and security approach.</p>\n</header>\n<main>\n    <section>\n        <h2 class=\"text-xl\">Account Data</h2>\n        <p>\n            In order to have a functional account at {{.Site.Domain}}, we need to store\n            your public key.  That is the only piece of information we record for a user.\n        </p>\n        <p>\n            Because we use public-key cryptography, our security posture is a battle-tested\n            and proven technique for authentication.\n        </p>\n    </section>\n\n    <section>\n        <h2 class=\"text-xl\">Third parties</h2>\n        <p>\n            We have a strong commitment to never share any user data with any third-parties.\n        </p>\n    </section>\n\n    <section>\n        <h2 class=\"text-xl\">Service Providers</h2>\n        <ul>\n            <li>\n                <span>We host our server on </span>\n                <a href=\"https://digitalocean.com\">digital ocean</a>\n            </li>\n        </ul>\n    </section>\n\n    <section>\n        <h2 class=\"text-xl\">Cookies</h2>\n        <p>\n            We do not use any cookies, not even account authentication.\n        </p>\n    </section>\n</main>\n{{template \"marketing-footer\" .}}\n{{end}}\n"
  },
  {
    "path": "html/read.page.tmpl",
    "content": "{{template \"base\" .}}\n\n{{define \"title\"}}discover lists -- {{.Site.Domain}}{{end}}\n\n{{define \"meta\"}}\n<meta name=\"description\" content=\"discover interesting lists\" />\n{{end}}\n\n{{define \"body\"}}\n<header class=\"text-center\">\n    <h1 class=\"text-2xl font-bold\">read</h1>\n    <p class=\"text-lg\">recently updated lists</p>\n    <hr />\n</header>\n<main>\n    <div class=\"my\">\n        {{if .PrevPage}}<a href=\"{{.PrevPage}}\">prev</a>{{else}}<span class=\"text-grey\">prev</span>{{end}}\n        {{if .NextPage}}<a href=\"{{.NextPage}}\">next</a>{{else}}<span class=\"text-grey\">next</span>{{end}}\n    </div>\n    {{range .Posts}}\n    <article>\n        <div class=\"flex items-center\">\n            <time datetime=\"{{.UpdatedAtISO}}\" class=\"font-italic text-sm post-date\">{{.UpdatedTimeAgo}}</time>\n            <div class=\"flex-1\">\n                <h2 class=\"inline\"><a href=\"{{.URL}}\">{{.Title}}</a></h2>\n                <address class=\"text-sm inline\">\n                    <a href=\"{{.BlogURL}}\" class=\"link-grey\">({{.Username}})</a>\n                </address>\n            </div>\n        </div>\n    </article>\n    {{end}}\n</main>\n{{template \"marketing-footer\" .}}\n{{end}}\n"
  },
  {
    "path": "html/rss.page.tmpl",
    "content": "{{template \"list\" .}}\n"
  },
  {
    "path": "html/spec.page.tmpl",
    "content": "{{template \"base\" .}}\n\n{{define \"title\"}}specification -- {{.Site.Domain}}{{end}}\n\n{{define \"meta\"}}\n<meta name=\"description\" content=\"a specification for lists\" />\n{{end}}\n\n{{define \"body\"}}\n<header>\n    <h1 class=\"text-2xl\">Plain text list</h1>\n    <h2 class=\"text-xl\">Speculative specification</h2>\n    <dl>\n        <dt>Version</dt>\n        <dd>2022.05.02.dev</dd>\n\n        <dt>Status</dt>\n        <dd>Draft</dd>\n\n        <dt>Author</dt>\n        <dd>Eric Bower</dd>\n    </dl>\n</header>\n<main>\n    <section id=\"overview\">\n        <p>\n            The goal of this specification is to understand how we render plain text lists.\n            The overall design of this format is to be easy to parse and render.\n        </p>\n\n        <p>\n            The format is line-oriented, and a satisfactory rendering can be achieved with a single\n            pass of a document, processing each line independently. As per gopher, links can only be\n            displayed one per line, encouraging neat, list-like structure.\n        </p>\n\n        <p>\n            Feedback on any part of this is extremely welcome, please email\n            <a href=\"mailto:{{.Site.Email}}\">{{.Site.Email}}</a>.\n        </p>\n\n        <p>\n            The source code for our parser can be found\n            <a href=\"https://github.com/neurosnap/lists.sh/blob/main/pkg/parser.go\">here</a>.\n        </p>\n\n        <p>\n            The source code for an example list demonstrating all the features can be found\n            <a href=\"https://github.com/neurosnap/lists-official-blog/blob/main/spec-example.txt\">here</a>.\n        </p>\n    </section>\n\n    <section id=\"parameters\">\n        <p>\n            As a subtype of the top-level media type \"text\", \"text/plain\" inherits the \"charset\"\n            parameter defined in <a href=\"https://datatracker.ietf.org/doc/html/rfc2046#section-4.1\">RFC 2046</a>.\n            The default value of \"charset\" is \"UTF-8\" for \"text\" content.\n        </p>\n    </section>\n\n    <section id=\"line-orientation\">\n        <p>\n            As mentioned, the text format is line-oriented. Each line of a document has a single\n            \"line type\". It is possible to unambiguously determine a line's type purely by\n            inspecting its first (3) characters. A line's type determines the manner in which it\n            should be presented to the user. Any details of presentation or rendering associated\n            with a particular line type are strictly limited in scope to that individual line.\n        </p>\n    </section>\n\n    <section id=\"file-extensions\">\n        <h2 class=\"text-xl\">File extension</h2>\n        <p>\n            {{.Site.Domain}} only supports the <code>.txt</code> file extension and will\n            ignore all other file extensions.\n        </p>\n    </section>\n\n    <section id=\"list-item\">\n        <h2 class=\"text-xl\">List item</h2>\n        <p>\n            List items are separated by newline characters <code>\\n</code>.\n            Each list item is on its own line.  A list item does not require any special formatting.\n            A list item can contain as much text as it wants.  We encourage soft wrapping for readability\n            in your editor of choice.  Hard wrapping is not permitted as it will create a new list item.\n        </p>\n        <p>\n            Empty lines will be completely removed and not rendered to the end user.\n        </p>\n    </section>\n\n    <section id=\"hyperlinks\">\n        <h2 class=\"text-xl\">Hyperlinks</h2>\n        <p>\n            Hyperlinks are denoted by the prefix <code>=></code>.  The following text should then be\n            the hyperlink.\n        </p>\n        <pre>=> https://{{.Site.Domain}}</pre>\n        <p>Optionally you can supply the hyperlink text immediately following the link.</p>\n        <pre>=> https://{{.Site.Domain}} microblog for lists</pre>\n    </section>\n\n    <section id=\"images\">\n        <h2 class=\"text-xl\">Images</h2>\n        <p>\n            List items can be represented as images by prefixing the line with <code>=<</code>.\n        </p>\n        <pre>=< https://i.imgur.com/iXMNUN5.jpg</pre>\n        <p>Optionally you can supply the image alt text immediately following the link.</p>\n        <pre>=< https://i.imgur.com/iXMNUN5.jpg I use arch, btw</pre>\n    </section>\n\n    <section id=\"headers\">\n        <h2 class=\"text-xl\">Headers</h2>\n        <p>\n            List items can be represented as headers.  We support two headers currently.  Headers\n            will end the previous list and then create a new one after it.  This allows a single\n            document to contain multiple lists.\n        </p>\n        <pre># Header One\n## Header Two</pre>\n    </section>\n\n    <section id=\"blockquotes\">\n        <h2 class=\"text-xl\">Blockquotes</h2>\n        <p>\n            List items can be represented as blockquotes.\n        </p>\n        <pre>> This is a blockquote.</pre>\n    </section>\n\n    <section id=\"preformatted\">\n        <h2 class=\"text-xl\">Preformatted</h2>\n        <p>\n            List items can be represented as preformatted text where newline characters are not\n            considered part of new list items.  They can be represented by prefixing the line with\n            <code>```</code>.\n        </p>\n        <pre>```\n#!/usr/bin/env bash\n\nset -x\n\necho \"this is a preformatted list item!\n```</pre>\n        <p>\n            You must also close the preformatted text with another <code>```</code> on its own line. The\n            next example with NOT work.\n        </p>\n        <pre>```\n#!/usr/bin/env bash\n\necho \"This will not render properly\"```</pre>\n    </section>\n\n    <section id=\"variables\">\n        <h2 class=\"text-xl\">Variables</h2>\n        <p>\n            Variables allow us to store metadata within our system.  Variables are list items with\n            key value pairs denoted by <code>=:</code> followed by the key, a whitespace character,\n            and then the value.\n        </p>\n        <pre>=: publish_at 2022-04-20</pre>\n        <p>These variables will not be rendered to the user inside the list.</p>\n        <h3 class=\"text-lg\">List of available variables:</h3>\n        <ul>\n            <li><code>title</code> (custom title not dependent on filename)</li>\n            <li><code>description</code> (what is the purpose of this list?)</li>\n            <li><code>publish_at</code> (format must be <code>YYYY-MM-DD</code>)</li>\n            <li>\n                <code>list_type</code> (customize bullets; value gets sent directly to css property\n                <a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/list-style-type\">list-style-type</a>)\n            </li>\n        </ul>\n    </section>\n</main>\n{{template \"marketing-footer\" .}}\n{{end}}\n"
  },
  {
    "path": "html/transparency.page.tmpl",
    "content": "{{template \"base\" .}}\n\n{{define \"title\"}}transparency -- {{.Site.Domain}}{{end}}\n\n{{define \"meta\"}}\n<meta name=\"description\" content=\"full transparency of analytics and cost at {{.Site.Domain}}\" />\n{{end}}\n\n{{define \"body\"}}\n<header>\n    <h1 class=\"text-2xl\">Transparency</h1>\n    <hr />\n</header>\n<main>\n    <section>\n        <h2 class=\"text-xl\">Analytics</h2>\n        <p>\n            Here are some interesting stats on usage.\n        </p>\n\n        <article>\n            <h2 class=\"text-lg\">Total users</h2>\n            <div>{{.Analytics.TotalUsers}}</div>\n        </article>\n\n        <article>\n            <h2 class=\"text-lg\">New users in the last month</h2>\n            <div>{{.Analytics.UsersLastMonth}}</div>\n        </article>\n\n        <article>\n            <h2 class=\"text-lg\">Total posts</h2>\n            <div>{{.Analytics.TotalPosts}}</div>\n        </article>\n\n        <article>\n            <h2 class=\"text-lg\">New posts in the last month</h2>\n            <div>{{.Analytics.PostsLastMonth}}</div>\n        </article>\n\n        <article>\n            <h2 class=\"text-lg\">Users with at least one post</h2>\n            <div>{{.Analytics.UsersWithPost}}</div>\n        </article>\n    </section>\n\n    <section>\n        <h2 class=\"text-xl\">Service maintenance costs</h2>\n        <ul>\n            <li>Server $5.00/mo</li>\n            <li>Domain name $3.25/mo</li>\n            <li>Programmer $0.00/mo</li>\n        </ul>\n    </section>\n</main>\n{{template \"marketing-footer\" .}}\n{{end}}\n"
  },
  {
    "path": "internal/api.go",
    "content": "package internal\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"git.sr.ht/~erock/lists.sh/pkg\"\n\t\"git.sr.ht/~erock/wish/cms/db\"\n\t\"git.sr.ht/~erock/wish/cms/db/postgres\"\n\t\"github.com/gorilla/feeds\"\n\t\"golang.org/x/exp/slices\"\n)\n\ntype PageData struct {\n\tSite SitePageData\n}\n\ntype PostItemData struct {\n\tURL            template.URL\n\tBlogURL        template.URL\n\tUsername       string\n\tTitle          string\n\tDescription    string\n\tPublishAtISO   string\n\tPublishAt      string\n\tUpdatedAtISO   string\n\tUpdatedTimeAgo string\n\tPadding        string\n}\n\ntype BlogPageData struct {\n\tSite      SitePageData\n\tPageTitle string\n\tURL       template.URL\n\tRSSURL    template.URL\n\tUsername  string\n\tReadme    *ReadmeTxt\n\tHeader    *HeaderTxt\n\tPosts     []PostItemData\n}\n\ntype ReadPageData struct {\n\tSite     SitePageData\n\tNextPage string\n\tPrevPage string\n\tPosts    []PostItemData\n}\n\ntype PostPageData struct {\n\tSite         SitePageData\n\tPageTitle    string\n\tURL          template.URL\n\tBlogURL      template.URL\n\tTitle        string\n\tDescription  string\n\tUsername     string\n\tBlogName     string\n\tListType     string\n\tItems        []*pkg.ListItem\n\tPublishAtISO string\n\tPublishAt    string\n}\n\ntype TransparencyPageData struct {\n\tSite      SitePageData\n\tAnalytics *db.Analytics\n}\n\nfunc isRequestTrackable(r *http.Request) bool {\n\treturn true\n}\n\nfunc renderTemplate(templates []string) (*template.Template, error) {\n\tfiles := make([]string, len(templates))\n\tcopy(files, templates)\n\tfiles = append(\n\t\tfiles,\n\t\t\"./html/footer.partial.tmpl\",\n\t\t\"./html/marketing-footer.partial.tmpl\",\n\t\t\"./html/base.layout.tmpl\",\n\t)\n\n\tts, err := template.ParseFiles(files...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn ts, nil\n}\n\nfunc createPageHandler(fname string) http.HandlerFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tlogger := GetLogger(r)\n\t\tcfg := GetCfg(r)\n\t\tts, err := renderTemplate([]string{fname})\n\n\t\tif err != nil {\n\t\t\tlogger.Error(err)\n\t\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\tdata := PageData{\n\t\t\tSite: *cfg.GetSiteData(),\n\t\t}\n\t\terr = ts.Execute(w, data)\n\t\tif err != nil {\n\t\t\tlogger.Error(err)\n\t\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\t}\n\t}\n}\n\ntype HeaderTxt struct {\n\tTitle    string\n\tBio      string\n\tNav      []*pkg.ListItem\n\tHasItems bool\n}\n\ntype ReadmeTxt struct {\n\tHasItems bool\n\tListType string\n\tItems    []*pkg.ListItem\n}\n\nfunc GetUsernameFromRequest(r *http.Request) string {\n\tsubdomain := GetSubdomain(r)\n\tcfg := GetCfg(r)\n\n\tif !cfg.IsSubdomains() || subdomain == \"\" {\n\t\treturn GetField(r, 0)\n\t}\n\treturn subdomain\n}\n\nfunc blogHandler(w http.ResponseWriter, r *http.Request) {\n\tusername := GetUsernameFromRequest(r)\n\tdbpool := GetDB(r)\n\tlogger := GetLogger(r)\n\tcfg := GetCfg(r)\n\n\tuser, err := dbpool.FindUserForName(username)\n\tif err != nil {\n\t\tlogger.Infof(\"blog not found: %s\", username)\n\t\thttp.Error(w, \"blog not found\", http.StatusNotFound)\n\t\treturn\n\t}\n\tposts, err := dbpool.FindUpdatedPostsForUser(user.ID, cfg.Space)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\thttp.Error(w, \"could not fetch posts for blog\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tts, err := renderTemplate([]string{\n\t\t\"./html/blog.page.tmpl\",\n\t\t\"./html/list.partial.tmpl\",\n\t})\n\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\theaderTxt := &HeaderTxt{\n\t\tTitle: GetBlogName(username),\n\t\tBio:   \"\",\n\t}\n\treadmeTxt := &ReadmeTxt{}\n\n\tpostCollection := make([]PostItemData, 0, len(posts))\n\tfor _, post := range posts {\n\t\tif post.Filename == \"_header\" {\n\t\t\tparsedText := pkg.ParseText(post.Text)\n\t\t\tif parsedText.MetaData.Title != \"\" {\n\t\t\t\theaderTxt.Title = parsedText.MetaData.Title\n\t\t\t}\n\n\t\t\tif parsedText.MetaData.Description != \"\" {\n\t\t\t\theaderTxt.Bio = parsedText.MetaData.Description\n\t\t\t}\n\n\t\t\theaderTxt.Nav = parsedText.Items\n\t\t\tif len(headerTxt.Nav) > 0 {\n\t\t\t\theaderTxt.HasItems = true\n\t\t\t}\n\t\t} else if post.Filename == \"_readme\" {\n\t\t\tparsedText := pkg.ParseText(post.Text)\n\t\t\treadmeTxt.Items = parsedText.Items\n\t\t\treadmeTxt.ListType = parsedText.MetaData.ListType\n\t\t\tif len(readmeTxt.Items) > 0 {\n\t\t\t\treadmeTxt.HasItems = true\n\t\t\t}\n\t\t} else {\n\t\t\tp := PostItemData{\n\t\t\t\tURL:            template.URL(cfg.PostURL(post.Username, post.Filename)),\n\t\t\t\tBlogURL:        template.URL(cfg.BlogURL(post.Username)),\n\t\t\t\tTitle:          FilenameToTitle(post.Filename, post.Title),\n\t\t\t\tPublishAt:      post.PublishAt.Format(\"02 Jan, 2006\"),\n\t\t\t\tPublishAtISO:   post.PublishAt.Format(time.RFC3339),\n\t\t\t\tUpdatedTimeAgo: TimeAgo(post.UpdatedAt),\n\t\t\t\tUpdatedAtISO:   post.UpdatedAt.Format(time.RFC3339),\n\t\t\t}\n\t\t\tpostCollection = append(postCollection, p)\n\t\t}\n\t}\n\n\tdata := BlogPageData{\n\t\tSite:      *cfg.GetSiteData(),\n\t\tPageTitle: headerTxt.Title,\n\t\tURL:       template.URL(cfg.BlogURL(username)),\n\t\tRSSURL:    template.URL(cfg.RssBlogURL(username)),\n\t\tReadme:    readmeTxt,\n\t\tHeader:    headerTxt,\n\t\tUsername:  username,\n\t\tPosts:     postCollection,\n\t}\n\n\terr = ts.Execute(w, data)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t}\n}\n\nfunc GetPostTitle(post *db.Post) string {\n\tif post.Description == \"\" {\n\t\treturn post.Title\n\t}\n\n\treturn fmt.Sprintf(\"%s: %s\", post.Title, post.Description)\n}\n\nfunc GetBlogName(username string) string {\n\treturn fmt.Sprintf(\"%s's lists\", username)\n}\n\nfunc postHandler(w http.ResponseWriter, r *http.Request) {\n\tusername := GetUsernameFromRequest(r)\n\tsubdomain := GetSubdomain(r)\n\tcfg := GetCfg(r)\n\n\tvar filename string\n\tif !cfg.IsSubdomains() || subdomain == \"\" {\n\t\tfilename, _ = url.PathUnescape(GetField(r, 1))\n\t} else {\n\t\tfilename, _ = url.PathUnescape(GetField(r, 0))\n\t}\n\n\tdbpool := GetDB(r)\n\tlogger := GetLogger(r)\n\n\tuser, err := dbpool.FindUserForName(username)\n\tif err != nil {\n\t\tlogger.Infof(\"blog not found: %s\", username)\n\t\thttp.Error(w, \"blog not found\", http.StatusNotFound)\n\t\treturn\n\t}\n\n\theader, _ := dbpool.FindPostWithFilename(\"_header\", user.ID, cfg.Space)\n\tblogName := GetBlogName(username)\n\tif header != nil {\n\t\theaderParsed := pkg.ParseText(header.Text)\n\t\tif headerParsed.MetaData.Title != \"\" {\n\t\t\tblogName = headerParsed.MetaData.Title\n\t\t}\n\t}\n\n\tvar data PostPageData\n\tpost, err := dbpool.FindPostWithFilename(filename, user.ID, cfg.Space)\n\tif err == nil {\n\t\tparsedText := pkg.ParseText(post.Text)\n\n\t\t// we need the blog name from the readme unfortunately\n\t\treadme, err := dbpool.FindPostWithFilename(\"_readme\", user.ID, cfg.Space)\n\t\tif err == nil {\n\t\t\treadmeParsed := pkg.ParseText(readme.Text)\n\t\t\tif readmeParsed.MetaData.Title != \"\" {\n\t\t\t\tblogName = readmeParsed.MetaData.Title\n\t\t\t}\n\t\t}\n\n\t\t// validate and fire off analytic event\n\t\tif isRequestTrackable(r) {\n\t\t\t_, err := dbpool.AddViewCount(post.ID)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Error(err)\n\t\t\t}\n\t\t}\n\n\t\tdata = PostPageData{\n\t\t\tSite:         *cfg.GetSiteData(),\n\t\t\tPageTitle:    GetPostTitle(post),\n\t\t\tURL:          template.URL(cfg.PostURL(post.Username, post.Filename)),\n\t\t\tBlogURL:      template.URL(cfg.BlogURL(username)),\n\t\t\tDescription:  post.Description,\n\t\t\tListType:     parsedText.MetaData.ListType,\n\t\t\tTitle:        FilenameToTitle(post.Filename, post.Title),\n\t\t\tPublishAt:    post.PublishAt.Format(\"02 Jan, 2006\"),\n\t\t\tPublishAtISO: post.PublishAt.Format(time.RFC3339),\n\t\t\tUsername:     username,\n\t\t\tBlogName:     blogName,\n\t\t\tItems:        parsedText.Items,\n\t\t}\n\t} else {\n\t\tlogger.Infof(\"post not found %s/%s\", username, filename)\n\t\tdata = PostPageData{\n\t\t\tSite:         *cfg.GetSiteData(),\n\t\t\tPageTitle:    \"Post not found\",\n\t\t\tDescription:  \"Post not found\",\n\t\t\tTitle:        \"Post not found\",\n\t\t\tListType:     \"none\",\n\t\t\tBlogURL:      template.URL(cfg.BlogURL(username)),\n\t\t\tPublishAt:    time.Now().Format(\"02 Jan, 2006\"),\n\t\t\tPublishAtISO: time.Now().Format(time.RFC3339),\n\t\t\tUsername:     username,\n\t\t\tBlogName:     blogName,\n\t\t\tItems: []*pkg.ListItem{\n\t\t\t\t{\n\t\t\t\t\tValue:  \"oops!  we can't seem to find this post.\",\n\t\t\t\t\tIsText: true,\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t}\n\n\tts, err := renderTemplate([]string{\n\t\t\"./html/post.page.tmpl\",\n\t\t\"./html/list.partial.tmpl\",\n\t})\n\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t}\n\n\terr = ts.Execute(w, data)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t}\n}\n\nfunc transparencyHandler(w http.ResponseWriter, r *http.Request) {\n\tdbpool := GetDB(r)\n\tlogger := GetLogger(r)\n\tcfg := GetCfg(r)\n\n\tanalytics, err := dbpool.FindSiteAnalytics(cfg.Space)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tts, err := template.ParseFiles(\n\t\t\"./html/transparency.page.tmpl\",\n\t\t\"./html/footer.partial.tmpl\",\n\t\t\"./html/marketing-footer.partial.tmpl\",\n\t\t\"./html/base.layout.tmpl\",\n\t)\n\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t}\n\n\tdata := TransparencyPageData{\n\t\tSite:      *cfg.GetSiteData(),\n\t\tAnalytics: analytics,\n\t}\n\terr = ts.Execute(w, data)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t}\n}\n\nfunc readHandler(w http.ResponseWriter, r *http.Request) {\n\tdbpool := GetDB(r)\n\tlogger := GetLogger(r)\n\tcfg := GetCfg(r)\n\n\tpage, _ := strconv.Atoi(r.URL.Query().Get(\"page\"))\n\tpager, err := dbpool.FindAllUpdatedPosts(&db.Pager{Num: 30, Page: page}, cfg.Space)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tts, err := renderTemplate([]string{\n\t\t\"./html/read.page.tmpl\",\n\t})\n\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t}\n\n\tnextPage := \"\"\n\tif page < pager.Total-1 {\n\t\tnextPage = fmt.Sprintf(\"/read?page=%d\", page+1)\n\t}\n\n\tprevPage := \"\"\n\tif page > 0 {\n\t\tprevPage = fmt.Sprintf(\"/read?page=%d\", page-1)\n\t}\n\n\tdata := ReadPageData{\n\t\tSite:     *cfg.GetSiteData(),\n\t\tNextPage: nextPage,\n\t\tPrevPage: prevPage,\n\t}\n\tfor _, post := range pager.Data {\n\t\titem := PostItemData{\n\t\t\tURL:            template.URL(cfg.PostURL(post.Username, post.Filename)),\n\t\t\tBlogURL:        template.URL(cfg.BlogURL(post.Username)),\n\t\t\tTitle:          FilenameToTitle(post.Filename, post.Title),\n\t\t\tDescription:    post.Description,\n\t\t\tUsername:       post.Username,\n\t\t\tPublishAt:      post.PublishAt.Format(\"02 Jan, 2006\"),\n\t\t\tPublishAtISO:   post.PublishAt.Format(time.RFC3339),\n\t\t\tUpdatedTimeAgo: TimeAgo(post.UpdatedAt),\n\t\t\tUpdatedAtISO:   post.UpdatedAt.Format(time.RFC3339),\n\t\t}\n\t\tdata.Posts = append(data.Posts, item)\n\t}\n\n\terr = ts.Execute(w, data)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t}\n}\n\nfunc rssBlogHandler(w http.ResponseWriter, r *http.Request) {\n\tusername := GetUsernameFromRequest(r)\n\tdbpool := GetDB(r)\n\tlogger := GetLogger(r)\n\tcfg := GetCfg(r)\n\n\tuser, err := dbpool.FindUserForName(username)\n\tif err != nil {\n\t\tlogger.Infof(\"rss feed not found: %s\", username)\n\t\thttp.Error(w, \"rss feed not found\", http.StatusNotFound)\n\t\treturn\n\t}\n\tposts, err := dbpool.FindUpdatedPostsForUser(user.ID, cfg.Space)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tts, err := template.ParseFiles(\"./html/rss.page.tmpl\", \"./html/list.partial.tmpl\")\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\theaderTxt := &HeaderTxt{\n\t\tTitle: GetBlogName(username),\n\t}\n\n\tfor _, post := range posts {\n\t\tif post.Filename == \"_header\" {\n\t\t\tparsedText := pkg.ParseText(post.Text)\n\t\t\tif parsedText.MetaData.Title != \"\" {\n\t\t\t\theaderTxt.Title = parsedText.MetaData.Title\n\t\t\t}\n\n\t\t\tif parsedText.MetaData.Description != \"\" {\n\t\t\t\theaderTxt.Bio = parsedText.MetaData.Description\n\t\t\t}\n\n\t\t\tbreak\n\t\t}\n\t}\n\n\tfeed := &feeds.Feed{\n\t\tTitle:       headerTxt.Title,\n\t\tLink:        &feeds.Link{Href: cfg.BlogURL(username)},\n\t\tDescription: headerTxt.Bio,\n\t\tAuthor:      &feeds.Author{Name: username},\n\t\tCreated:     time.Now(),\n\t}\n\n\tvar feedItems []*feeds.Item\n\tfor _, post := range posts {\n\t\tif slices.Contains(HiddenPosts, post.Filename) {\n\t\t\tcontinue\n\t\t}\n\n\t\tparsed := pkg.ParseText(post.Text)\n\t\tvar tpl bytes.Buffer\n\t\tdata := &PostPageData{\n\t\t\tListType: parsed.MetaData.ListType,\n\t\t\tItems:    parsed.Items,\n\t\t}\n\t\tif err := ts.Execute(&tpl, data); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\titem := &feeds.Item{\n\t\t\tId:      cfg.PostURL(post.Username, post.Filename),\n\t\t\tTitle:   FilenameToTitle(post.Filename, post.Title),\n\t\t\tLink:    &feeds.Link{Href: cfg.PostURL(post.Username, post.Filename)},\n\t\t\tContent: tpl.String(),\n\t\t\tCreated: *post.PublishAt,\n\t\t}\n\n\t\tif post.Description != \"\" {\n\t\t\titem.Description = post.Description\n\t\t}\n\n\t\tfeedItems = append(feedItems, item)\n\t}\n\tfeed.Items = feedItems\n\n\trss, err := feed.ToAtom()\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\thttp.Error(w, \"Could not generate atom rss feed\", http.StatusInternalServerError)\n\t}\n\n\tw.Header().Add(\"Content-Type\", \"application/atom+xml\")\n\t_, err = w.Write([]byte(rss))\n\tif err != nil {\n\t\tlogger.Error(err)\n\t}\n}\n\nfunc rssHandler(w http.ResponseWriter, r *http.Request) {\n\tdbpool := GetDB(r)\n\tlogger := GetLogger(r)\n\tcfg := GetCfg(r)\n\n\tpager, err := dbpool.FindAllPosts(&db.Pager{Num: 25, Page: 0}, cfg.Space)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tts, err := template.ParseFiles(\"./html/rss.page.tmpl\", \"./html/list.partial.tmpl\")\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tfeed := &feeds.Feed{\n\t\tTitle:       fmt.Sprintf(\"%s discovery feed\", cfg.Domain),\n\t\tLink:        &feeds.Link{Href: cfg.ReadURL()},\n\t\tDescription: fmt.Sprintf(\"%s latest posts\", cfg.Domain),\n\t\tAuthor:      &feeds.Author{Name: cfg.Domain},\n\t\tCreated:     time.Now(),\n\t}\n\n\tvar feedItems []*feeds.Item\n\tfor _, post := range pager.Data {\n\t\tparsed := pkg.ParseText(post.Text)\n\t\tvar tpl bytes.Buffer\n\t\tdata := &PostPageData{\n\t\t\tListType: parsed.MetaData.ListType,\n\t\t\tItems:    parsed.Items,\n\t\t}\n\t\tif err := ts.Execute(&tpl, data); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\titem := &feeds.Item{\n\t\t\tId:      cfg.PostURL(post.Username, post.Filename),\n\t\t\tTitle:   post.Title,\n\t\t\tLink:    &feeds.Link{Href: cfg.PostURL(post.Username, post.Filename)},\n\t\t\tContent: tpl.String(),\n\t\t\tCreated: *post.PublishAt,\n\t\t}\n\n\t\tif post.Description != \"\" {\n\t\t\titem.Description = post.Description\n\t\t}\n\n\t\tfeedItems = append(feedItems, item)\n\t}\n\tfeed.Items = feedItems\n\n\trss, err := feed.ToAtom()\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\thttp.Error(w, \"Could not generate atom rss feed\", http.StatusInternalServerError)\n\t}\n\n\tw.Header().Add(\"Content-Type\", \"application/atom+xml\")\n\t_, err = w.Write([]byte(rss))\n\tif err != nil {\n\t\tlogger.Error(err)\n\t}\n}\n\nfunc serveFile(file string, contentType string) http.HandlerFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tlogger := GetLogger(r)\n\n\t\tcontents, err := ioutil.ReadFile(fmt.Sprintf(\"./public/%s\", file))\n\t\tif err != nil {\n\t\t\tlogger.Error(err)\n\t\t\thttp.Error(w, \"file not found\", 404)\n\t\t}\n\n\t\tw.Header().Add(\"Content-Type\", contentType)\n\n\t\t_, err = w.Write(contents)\n\t\tif err != nil {\n\t\t\tlogger.Error(err)\n\t\t}\n\t}\n}\n\nfunc createStaticRoutes() []Route {\n\treturn []Route{\n\t\tNewRoute(\"GET\", \"/main.css\", serveFile(\"main.css\", \"text/css\")),\n\t\tNewRoute(\"GET\", \"/card.png\", serveFile(\"card.png\", \"image/png\")),\n\t\tNewRoute(\"GET\", \"/favicon-16x16.png\", serveFile(\"favicon-16x16.png\", \"image/png\")),\n\t\tNewRoute(\"GET\", \"/favicon-32x32.png\", serveFile(\"favicon-32x32.png\", \"image/png\")),\n\t\tNewRoute(\"GET\", \"/apple-touch-icon.png\", serveFile(\"apple-touch-icon.png\", \"image/png\")),\n\t\tNewRoute(\"GET\", \"/favicon.ico\", serveFile(\"favicon.ico\", \"image/x-icon\")),\n\t\tNewRoute(\"GET\", \"/robots.txt\", serveFile(\"robots.txt\", \"text/plain\")),\n\t}\n}\n\nfunc createMainRoutes(staticRoutes []Route) []Route {\n\troutes := []Route{\n\t\tNewRoute(\"GET\", \"/\", createPageHandler(\"./html/marketing.page.tmpl\")),\n\t\tNewRoute(\"GET\", \"/spec\", createPageHandler(\"./html/spec.page.tmpl\")),\n\t\tNewRoute(\"GET\", \"/ops\", createPageHandler(\"./html/ops.page.tmpl\")),\n\t\tNewRoute(\"GET\", \"/privacy\", createPageHandler(\"./html/privacy.page.tmpl\")),\n\t\tNewRoute(\"GET\", \"/help\", createPageHandler(\"./html/help.page.tmpl\")),\n\t\tNewRoute(\"GET\", \"/transparency\", transparencyHandler),\n\t\tNewRoute(\"GET\", \"/read\", readHandler),\n\t}\n\n\troutes = append(\n\t\troutes,\n\t\tstaticRoutes...,\n\t)\n\n\troutes = append(\n\t\troutes,\n\t\tNewRoute(\"GET\", \"/rss\", rssHandler),\n\t\tNewRoute(\"GET\", \"/rss.xml\", rssHandler),\n\t\tNewRoute(\"GET\", \"/atom.xml\", rssHandler),\n\t\tNewRoute(\"GET\", \"/feed.xml\", rssHandler),\n\n\t\tNewRoute(\"GET\", \"/([^/]+)\", blogHandler),\n\t\tNewRoute(\"GET\", \"/([^/]+)/rss\", rssBlogHandler),\n\t\tNewRoute(\"GET\", \"/([^/]+)/([^/]+)\", postHandler),\n\t)\n\n\treturn routes\n}\n\nfunc createSubdomainRoutes(staticRoutes []Route) []Route {\n\troutes := []Route{\n\t\tNewRoute(\"GET\", \"/\", blogHandler),\n\t\tNewRoute(\"GET\", \"/rss\", rssBlogHandler),\n\t}\n\n\troutes = append(\n\t\troutes,\n\t\tstaticRoutes...,\n\t)\n\n\troutes = append(\n\t\troutes,\n\t\tNewRoute(\"GET\", \"/([^/]+)\", postHandler),\n\t)\n\n\treturn routes\n}\n\nfunc StartApiServer() {\n\tcfg := NewConfigSite()\n\tdb := postgres.NewDB(&cfg.ConfigCms)\n\tdefer db.Close()\n\tlogger := cfg.Logger\n\n\tstaticRoutes := createStaticRoutes()\n\tmainRoutes := createMainRoutes(staticRoutes)\n\tsubdomainRoutes := createSubdomainRoutes(staticRoutes)\n\n\thandler := CreateServe(mainRoutes, subdomainRoutes, cfg, db, logger)\n\trouter := http.HandlerFunc(handler)\n\n\tportStr := fmt.Sprintf(\":%s\", cfg.Port)\n\tlogger.Infof(\"Starting server on port %s\", cfg.Port)\n\tlogger.Infof(\"Subdomains enabled: %t\", cfg.SubdomainsEnabled)\n\tlogger.Infof(\"Domain: %s\", cfg.Domain)\n\tlogger.Infof(\"Email: %s\", cfg.Email)\n\n\tlogger.Fatal(http.ListenAndServe(portStr, router))\n}\n"
  },
  {
    "path": "internal/config.go",
    "content": "package internal\n\nimport (\n\t\"fmt\"\n\t\"html/template\"\n\t\"log\"\n\t\"net/url\"\n\n\t\"git.sr.ht/~erock/wish/cms/config\"\n\t\"go.uber.org/zap\"\n)\n\ntype SitePageData struct {\n\tDomain  template.URL\n\tHomeURL template.URL\n\tEmail   string\n}\n\ntype ConfigSite struct {\n\tconfig.ConfigCms\n\tconfig.ConfigURL\n\tSubdomainsEnabled bool\n}\n\nfunc NewConfigSite() *ConfigSite {\n\tdomain := GetEnv(\"LISTS_DOMAIN\", \"lists.sh\")\n\temail := GetEnv(\"LISTS_EMAIL\", \"support@lists.sh\")\n\tsubdomains := GetEnv(\"LISTS_SUBDOMAINS\", \"0\")\n\tport := GetEnv(\"LISTS_WEB_PORT\", \"3000\")\n\tprotocol := GetEnv(\"LISTS_PROTOCOL\", \"https\")\n\tdbURL := GetEnv(\"DATABASE_URL\", \"\")\n\tsubdomainsEnabled := false\n\tif subdomains == \"1\" {\n\t\tsubdomainsEnabled = true\n\t}\n\n\tintro := \"To get started, enter a username.\\n\"\n\tintro += \"Then create a folder locally (e.g. ~/blog).\\n\"\n\tintro += \"Then write your lists in plain text files (e.g. hello-world.txt).\\n\"\n\tintro += \"Finally, send your list files to us:\\n\\n\"\n\tintro += fmt.Sprintf(\"scp ~/blog/*.txt %s:/\\n\\n\", domain)\n\n\treturn &ConfigSite{\n\t\tSubdomainsEnabled: subdomainsEnabled,\n\t\tConfigCms: config.ConfigCms{\n\t\t\tDomain:      domain,\n\t\t\tEmail:       email,\n\t\t\tPort:        port,\n\t\t\tProtocol:    protocol,\n\t\t\tDbURL:       dbURL,\n\t\t\tDescription: \"A microblog for your lists.\",\n\t\t\tIntroText:   intro,\n\t\t\tSpace:       \"lists\",\n\t\t\tLogger:      CreateLogger(),\n\t\t},\n\t}\n}\n\nfunc (c *ConfigSite) GetSiteData() *SitePageData {\n\treturn &SitePageData{\n\t\tDomain:  template.URL(c.Domain),\n\t\tHomeURL: template.URL(c.HomeURL()),\n\t\tEmail:   c.Email,\n\t}\n}\n\nfunc (c *ConfigSite) BlogURL(username string) string {\n\tif c.IsSubdomains() {\n\t\treturn fmt.Sprintf(\"%s://%s.%s\", c.Protocol, username, c.Domain)\n\t}\n\n\treturn fmt.Sprintf(\"/%s\", username)\n}\n\nfunc (c *ConfigSite) PostURL(username, filename string) string {\n\tfname := url.PathEscape(filename)\n\tif c.IsSubdomains() {\n\t\treturn fmt.Sprintf(\"%s://%s.%s/%s\", c.Protocol, username, c.Domain, fname)\n\t}\n\n\treturn fmt.Sprintf(\"/%s/%s\", username, fname)\n}\n\nfunc (c *ConfigSite) IsSubdomains() bool {\n\treturn c.SubdomainsEnabled\n}\n\nfunc (c *ConfigSite) RssBlogURL(username string) string {\n\tif c.IsSubdomains() {\n\t\treturn fmt.Sprintf(\"%s://%s.%s/rss\", c.Protocol, username, c.Domain)\n\t}\n\n\treturn fmt.Sprintf(\"/%s/rss\", username)\n}\n\nfunc (c *ConfigSite) HomeURL() string {\n\tif c.IsSubdomains() {\n\t\treturn fmt.Sprintf(\"%s://%s\", c.Protocol, c.Domain)\n\t}\n\n\treturn \"/\"\n}\n\nfunc (c *ConfigSite) ReadURL() string {\n\tif c.IsSubdomains() {\n\t\treturn fmt.Sprintf(\"%s://%s/read\", c.Protocol, c.Domain)\n\t}\n\n\treturn \"/read\"\n}\n\nfunc CreateLogger() *zap.SugaredLogger {\n\tlogger, err := zap.NewProduction()\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\treturn logger.Sugar()\n}\n"
  },
  {
    "path": "internal/db_handler.go",
    "content": "package internal\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"time\"\n\n\t\"git.sr.ht/~erock/lists.sh/pkg\"\n\t\"git.sr.ht/~erock/wish/cms/db\"\n\t\"git.sr.ht/~erock/wish/cms/util\"\n\tsendutils \"git.sr.ht/~erock/wish/send/utils\"\n\t\"github.com/gliderlabs/ssh\"\n\t\"golang.org/x/exp/slices\"\n)\n\nvar HiddenPosts = []string{\"_readme\", \"_header\"}\n\ntype Opener struct {\n\tentry *sendutils.FileEntry\n}\n\nfunc (o *Opener) Open(name string) (io.Reader, error) {\n\treturn o.entry.Reader, nil\n}\n\ntype DbHandler struct {\n\tUser   *db.User\n\tDBPool db.DB\n\tCfg    *ConfigSite\n}\n\nfunc NewDbHandler(dbpool db.DB, cfg *ConfigSite) *DbHandler {\n\treturn &DbHandler{\n\t\tDBPool: dbpool,\n\t\tCfg:    cfg,\n\t}\n}\n\nfunc (h *DbHandler) Validate(s ssh.Session) error {\n\tvar err error\n\tkey, err := util.KeyText(s)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"key not found\")\n\t}\n\n\tuser, err := h.DBPool.FindUserForKey(s.User(), key)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif user.Name == \"\" {\n\t\treturn fmt.Errorf(\"must have username set\")\n\t}\n\n\th.User = user\n\treturn nil\n}\n\nfunc (h *DbHandler) Write(s ssh.Session, entry *sendutils.FileEntry) (string, error) {\n\tlogger := h.Cfg.Logger\n\tuserID := h.User.ID\n\tfilename := SanitizeFileExt(entry.Name)\n\ttitle := filename\n\n\tpost, err := h.DBPool.FindPostWithFilename(filename, userID, h.Cfg.Space)\n\tif err != nil {\n\t\tlogger.Debug(\"unable to load post, continuing:\", err)\n\t}\n\n\tuser, err := h.DBPool.FindUser(userID)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error for %s: %v\", filename, err)\n\t}\n\n\tvar text string\n\tif b, err := io.ReadAll(entry.Reader); err == nil {\n\t\ttext = string(b)\n\t}\n\n\tif !IsTextFile(text, entry.Filepath) {\n\t\treturn \"\", fmt.Errorf(\"WARNING: (%s) invalid file, format must be '.txt' and the contents must be plain text, skipping\", entry.Name)\n\t}\n\n\tparsedText := pkg.ParseText(text)\n\tif parsedText.MetaData.Title != \"\" {\n\t\ttitle = parsedText.MetaData.Title\n\t}\n\tdescription := parsedText.MetaData.Description\n\n\t// if the file is empty we remove it from our database\n\tif len(text) == 0 {\n\t\t// skip empty files from being added to db\n\t\tif post == nil {\n\t\t\tlogger.Infof(\"(%s) is empty, skipping record\", filename)\n\t\t\treturn \"\", nil\n\t\t}\n\n\t\terr := h.DBPool.RemovePosts([]string{post.ID})\n\t\tlogger.Infof(\"(%s) is empty, removing record\", filename)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"error for %s: %v\", filename, err)\n\t\t}\n\t} else if post == nil {\n\t\tpublishAt := time.Now()\n\t\tif parsedText.MetaData.PublishAt != nil {\n\t\t\tpublishAt = *parsedText.MetaData.PublishAt\n\t\t}\n\t\thidden := slices.Contains(HiddenPosts, filename)\n\n\t\tlogger.Infof(\"(%s) not found, adding record\", filename)\n\t\t_, err = h.DBPool.InsertPost(userID, filename, title, text, description, &publishAt, hidden, h.Cfg.Space)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"error for %s: %v\", filename, err)\n\t\t}\n\t} else {\n\t\tpublishAt := post.PublishAt\n\t\tif parsedText.MetaData.PublishAt != nil {\n\t\t\tpublishAt = parsedText.MetaData.PublishAt\n\t\t}\n\t\tif text == post.Text {\n\t\t\tlogger.Infof(\"(%s) found, but text is identical, skipping\", filename)\n\t\t\treturn h.Cfg.PostURL(user.Name, filename), nil\n\t\t}\n\n\t\tlogger.Infof(\"(%s) found, updating record\", filename)\n\t\t_, err = h.DBPool.UpdatePost(post.ID, title, text, description, publishAt)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"error for %s: %v\", filename, err)\n\t\t}\n\t}\n\n\treturn h.Cfg.PostURL(user.Name, filename), nil\n}\n"
  },
  {
    "path": "internal/gemini/gemini.go",
    "content": "package gemini\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\thtml \"html/template\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/signal\"\n\t\"strconv\"\n\t\"strings\"\n\t\"text/template\"\n\t\"time\"\n\n\t\"git.sr.ht/~adnano/go-gemini\"\n\t\"git.sr.ht/~adnano/go-gemini/certificate\"\n\tfeeds \"git.sr.ht/~aw/gorilla-feeds\"\n\t\"git.sr.ht/~erock/lists.sh/internal\"\n\t\"git.sr.ht/~erock/lists.sh/pkg\"\n\t\"git.sr.ht/~erock/wish/cms/db\"\n\t\"git.sr.ht/~erock/wish/cms/db/postgres\"\n\t\"golang.org/x/exp/slices\"\n)\n\nfunc renderTemplate(templates []string) (*template.Template, error) {\n\tfiles := make([]string, len(templates))\n\tcopy(files, templates)\n\tfiles = append(\n\t\tfiles,\n\t\t\"./gmi/footer.partial.tmpl\",\n\t\t\"./gmi/marketing-footer.partial.tmpl\",\n\t\t\"./gmi/base.layout.tmpl\",\n\t)\n\n\tts, err := template.ParseFiles(files...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn ts, nil\n}\n\nfunc createPageHandler(fname string) gemini.HandlerFunc {\n\treturn func(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {\n\t\tlogger := GetLogger(ctx)\n\t\tcfg := GetCfg(ctx)\n\t\tts, err := renderTemplate([]string{fname})\n\n\t\tif err != nil {\n\t\t\tlogger.Error(err)\n\t\t\tw.WriteHeader(gemini.StatusTemporaryFailure, \"Internal Service Error\")\n\t\t\treturn\n\t\t}\n\n\t\tdata := internal.PageData{\n\t\t\tSite: *cfg.GetSiteData(),\n\t\t}\n\t\terr = ts.Execute(w, data)\n\t\tif err != nil {\n\t\t\tlogger.Error(err)\n\t\t\tw.WriteHeader(gemini.StatusTemporaryFailure, \"Internal Service Error\")\n\t\t}\n\t}\n}\n\nfunc blogHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {\n\tusername := GetField(ctx, 0)\n\tdbpool := GetDB(ctx)\n\tlogger := GetLogger(ctx)\n\tcfg := GetCfg(ctx)\n\n\tuser, err := dbpool.FindUserForName(username)\n\tif err != nil {\n\t\tlogger.Infof(\"blog not found: %s\", username)\n\t\tw.WriteHeader(gemini.StatusNotFound, \"blog not found\")\n\t\treturn\n\t}\n\tposts, err := dbpool.FindUpdatedPostsForUser(user.ID, cfg.Space)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\tw.WriteHeader(gemini.StatusTemporaryFailure, \"could not fetch posts for blog\")\n\t\treturn\n\t}\n\n\tts, err := renderTemplate([]string{\n\t\t\"./gmi/blog.page.tmpl\",\n\t\t\"./gmi/list.partial.tmpl\",\n\t})\n\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\tw.WriteHeader(gemini.StatusTemporaryFailure, err.Error())\n\t\treturn\n\t}\n\n\theaderTxt := &internal.HeaderTxt{\n\t\tTitle: internal.GetBlogName(username),\n\t\tBio:   \"\",\n\t}\n\treadmeTxt := &internal.ReadmeTxt{}\n\n\tpostCollection := make([]internal.PostItemData, 0, len(posts))\n\tfor _, post := range posts {\n\t\tif post.Filename == \"_header\" {\n\t\t\tparsedText := pkg.ParseText(post.Text)\n\t\t\tif parsedText.MetaData.Title != \"\" {\n\t\t\t\theaderTxt.Title = parsedText.MetaData.Title\n\t\t\t}\n\n\t\t\tif parsedText.MetaData.Description != \"\" {\n\t\t\t\theaderTxt.Bio = parsedText.MetaData.Description\n\t\t\t}\n\n\t\t\theaderTxt.Nav = parsedText.Items\n\t\t\tif len(headerTxt.Nav) > 0 {\n\t\t\t\theaderTxt.HasItems = true\n\t\t\t}\n\t\t} else if post.Filename == \"_readme\" {\n\t\t\tparsedText := pkg.ParseText(post.Text)\n\t\t\treadmeTxt.Items = parsedText.Items\n\t\t\treadmeTxt.ListType = parsedText.MetaData.ListType\n\t\t\tif len(readmeTxt.Items) > 0 {\n\t\t\t\treadmeTxt.HasItems = true\n\t\t\t}\n\t\t} else {\n\t\t\tp := internal.PostItemData{\n\t\t\t\tURL:            html.URL(cfg.PostURL(post.Username, post.Filename)),\n\t\t\t\tBlogURL:        html.URL(cfg.BlogURL(post.Username)),\n\t\t\t\tTitle:          internal.FilenameToTitle(post.Filename, post.Title),\n\t\t\t\tPublishAt:      post.PublishAt.Format(\"02 Jan, 2006\"),\n\t\t\t\tPublishAtISO:   post.PublishAt.Format(time.RFC3339),\n\t\t\t\tUpdatedTimeAgo: internal.TimeAgo(post.UpdatedAt),\n\t\t\t\tUpdatedAtISO:   post.UpdatedAt.Format(time.RFC3339),\n\t\t\t}\n\t\t\tpostCollection = append(postCollection, p)\n\t\t}\n\t}\n\n\tdata := internal.BlogPageData{\n\t\tSite:      *cfg.GetSiteData(),\n\t\tPageTitle: headerTxt.Title,\n\t\tURL:       html.URL(cfg.BlogURL(username)),\n\t\tRSSURL:    html.URL(cfg.RssBlogURL(username)),\n\t\tReadme:    readmeTxt,\n\t\tHeader:    headerTxt,\n\t\tUsername:  username,\n\t\tPosts:     postCollection,\n\t}\n\n\terr = ts.Execute(w, data)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\tw.WriteHeader(gemini.StatusTemporaryFailure, err.Error())\n\t}\n}\n\nfunc readHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {\n\tdbpool := GetDB(ctx)\n\tlogger := GetLogger(ctx)\n\tcfg := GetCfg(ctx)\n\n\tpage, _ := strconv.Atoi(r.URL.Query().Get(\"page\"))\n\tpager, err := dbpool.FindAllUpdatedPosts(&db.Pager{Num: 30, Page: page}, cfg.Space)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\tw.WriteHeader(gemini.StatusTemporaryFailure, err.Error())\n\t\treturn\n\t}\n\n\tts, err := renderTemplate([]string{\n\t\t\"./gmi/read.page.tmpl\",\n\t})\n\n\tif err != nil {\n\t\tw.WriteHeader(gemini.StatusTemporaryFailure, err.Error())\n\t\treturn\n\t}\n\n\tnextPage := \"\"\n\tif page < pager.Total-1 {\n\t\tnextPage = fmt.Sprintf(\"/read?page=%d\", page+1)\n\t}\n\n\tprevPage := \"\"\n\tif page > 0 {\n\t\tprevPage = fmt.Sprintf(\"/read?page=%d\", page-1)\n\t}\n\n\tdata := internal.ReadPageData{\n\t\tSite:     *cfg.GetSiteData(),\n\t\tNextPage: nextPage,\n\t\tPrevPage: prevPage,\n\t}\n\n\tlongest := 0\n\tfor _, post := range pager.Data {\n\t\tsize := len(internal.TimeAgo(post.UpdatedAt))\n\t\tif size > longest {\n\t\t\tlongest = size\n\t\t}\n\t}\n\n\tfor _, post := range pager.Data {\n\t\titem := internal.PostItemData{\n\t\t\tURL:            html.URL(cfg.PostURL(post.Username, post.Filename)),\n\t\t\tBlogURL:        html.URL(cfg.BlogURL(post.Username)),\n\t\t\tTitle:          internal.FilenameToTitle(post.Filename, post.Title),\n\t\t\tDescription:    post.Description,\n\t\t\tUsername:       post.Username,\n\t\t\tPublishAt:      post.PublishAt.Format(\"02 Jan, 2006\"),\n\t\t\tPublishAtISO:   post.PublishAt.Format(time.RFC3339),\n\t\t\tUpdatedTimeAgo: internal.TimeAgo(post.UpdatedAt),\n\t\t\tUpdatedAtISO:   post.UpdatedAt.Format(time.RFC3339),\n\t\t}\n\n\t\titem.Padding = strings.Repeat(\" \", longest-len(item.UpdatedTimeAgo))\n\t\tdata.Posts = append(data.Posts, item)\n\t}\n\n\terr = ts.Execute(w, data)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\tw.WriteHeader(gemini.StatusTemporaryFailure, err.Error())\n\t}\n}\n\nfunc postHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {\n\tusername := GetField(ctx, 0)\n\tfilename, _ := url.PathUnescape(GetField(ctx, 1))\n\n\tdbpool := GetDB(ctx)\n\tlogger := GetLogger(ctx)\n\tcfg := GetCfg(ctx)\n\n\tuser, err := dbpool.FindUserForName(username)\n\tif err != nil {\n\t\tlogger.Infof(\"blog not found: %s\", username)\n\t\tw.WriteHeader(gemini.StatusNotFound, \"blog not found\")\n\t\treturn\n\t}\n\n\theader, _ := dbpool.FindPostWithFilename(\"_header\", user.ID, cfg.Space)\n\tblogName := internal.GetBlogName(username)\n\tif header != nil {\n\t\theaderParsed := pkg.ParseText(header.Text)\n\t\tif headerParsed.MetaData.Title != \"\" {\n\t\t\tblogName = headerParsed.MetaData.Title\n\t\t}\n\t}\n\n\tpost, err := dbpool.FindPostWithFilename(filename, user.ID, cfg.Space)\n\tif err != nil {\n\t\tlogger.Infof(\"post not found %s/%s\", username, filename)\n\t\tw.WriteHeader(gemini.StatusNotFound, \"post not found\")\n\t\treturn\n\t}\n\n\tparsedText := pkg.ParseText(post.Text)\n\n\t// we need the blog name from the readme unfortunately\n\treadme, err := dbpool.FindPostWithFilename(\"_readme\", user.ID, cfg.Space)\n\tif err == nil {\n\t\treadmeParsed := pkg.ParseText(readme.Text)\n\t\tif readmeParsed.MetaData.Title != \"\" {\n\t\t\tblogName = readmeParsed.MetaData.Title\n\t\t}\n\t}\n\n\t_, err = dbpool.AddViewCount(post.ID)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t}\n\n\tdata := internal.PostPageData{\n\t\tSite:         *cfg.GetSiteData(),\n\t\tPageTitle:    internal.GetPostTitle(post),\n\t\tURL:          html.URL(cfg.PostURL(post.Username, post.Filename)),\n\t\tBlogURL:      html.URL(cfg.BlogURL(username)),\n\t\tDescription:  post.Description,\n\t\tListType:     parsedText.MetaData.ListType,\n\t\tTitle:        internal.FilenameToTitle(post.Filename, post.Title),\n\t\tPublishAt:    post.PublishAt.Format(\"02 Jan, 2006\"),\n\t\tPublishAtISO: post.PublishAt.Format(time.RFC3339),\n\t\tUsername:     username,\n\t\tBlogName:     blogName,\n\t\tItems:        parsedText.Items,\n\t}\n\n\tts, err := renderTemplate([]string{\n\t\t\"./gmi/post.page.tmpl\",\n\t\t\"./gmi/list.partial.tmpl\",\n\t})\n\n\tif err != nil {\n\t\tw.WriteHeader(gemini.StatusTemporaryFailure, err.Error())\n\t\treturn\n\t}\n\n\terr = ts.Execute(w, data)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\tw.WriteHeader(gemini.StatusTemporaryFailure, err.Error())\n\t}\n}\n\nfunc transparencyHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {\n\tdbpool := GetDB(ctx)\n\tlogger := GetLogger(ctx)\n\tcfg := GetCfg(ctx)\n\n\tanalytics, err := dbpool.FindSiteAnalytics(cfg.Space)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\tw.WriteHeader(gemini.StatusTemporaryFailure, err.Error())\n\t\treturn\n\t}\n\n\tts, err := template.ParseFiles(\n\t\t\"./gmi/transparency.page.tmpl\",\n\t\t\"./gmi/footer.partial.tmpl\",\n\t\t\"./gmi/marketing-footer.partial.tmpl\",\n\t\t\"./gmi/base.layout.tmpl\",\n\t)\n\n\tif err != nil {\n\t\tw.WriteHeader(gemini.StatusTemporaryFailure, err.Error())\n\t\treturn\n\t}\n\n\tdata := internal.TransparencyPageData{\n\t\tSite:      *cfg.GetSiteData(),\n\t\tAnalytics: analytics,\n\t}\n\terr = ts.Execute(w, data)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\tw.WriteHeader(gemini.StatusTemporaryFailure, err.Error())\n\t}\n}\n\nfunc rssBlogHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {\n\tusername := GetField(ctx, 0)\n\tdbpool := GetDB(ctx)\n\tlogger := GetLogger(ctx)\n\tcfg := GetCfg(ctx)\n\n\tuser, err := dbpool.FindUserForName(username)\n\tif err != nil {\n\t\tlogger.Infof(\"rss feed not found: %s\", username)\n\t\tw.WriteHeader(gemini.StatusNotFound, \"rss feed not found\")\n\t\treturn\n\t}\n\tposts, err := dbpool.FindUpdatedPostsForUser(user.ID, cfg.Space)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\tw.WriteHeader(gemini.StatusTemporaryFailure, err.Error())\n\t\treturn\n\t}\n\n\tts, err := template.ParseFiles(\"./gmi/rss.page.tmpl\", \"./gmi/list.partial.tmpl\")\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\tw.WriteHeader(gemini.StatusTemporaryFailure, err.Error())\n\t\treturn\n\t}\n\n\theaderTxt := &internal.HeaderTxt{\n\t\tTitle: internal.GetBlogName(username),\n\t}\n\n\tfor _, post := range posts {\n\t\tif post.Filename == \"_header\" {\n\t\t\tparsedText := pkg.ParseText(post.Text)\n\t\t\tif parsedText.MetaData.Title != \"\" {\n\t\t\t\theaderTxt.Title = parsedText.MetaData.Title\n\t\t\t}\n\n\t\t\tif parsedText.MetaData.Description != \"\" {\n\t\t\t\theaderTxt.Bio = parsedText.MetaData.Description\n\t\t\t}\n\n\t\t\tbreak\n\t\t}\n\t}\n\n\tfeed := &feeds.Feed{\n\t\tTitle:       headerTxt.Title,\n\t\tLink:        &feeds.Link{Href: cfg.BlogURL(username)},\n\t\tDescription: headerTxt.Bio,\n\t\tAuthor:      &feeds.Author{Name: username},\n\t\tCreated:     time.Now(),\n\t}\n\n\tvar feedItems []*feeds.Item\n\tfor _, post := range posts {\n\t\tif slices.Contains(internal.HiddenPosts, post.Filename) {\n\t\t\tcontinue\n\t\t}\n\t\tparsed := pkg.ParseText(post.Text)\n\t\tvar tpl bytes.Buffer\n\t\tdata := &internal.PostPageData{\n\t\t\tListType: parsed.MetaData.ListType,\n\t\t\tItems:    parsed.Items,\n\t\t}\n\t\tif err := ts.Execute(&tpl, data); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\titem := &feeds.Item{\n\t\t\tId:      cfg.PostURL(post.Username, post.Filename),\n\t\t\tTitle:   internal.FilenameToTitle(post.Filename, post.Title),\n\t\t\tLink:    &feeds.Link{Href: cfg.PostURL(post.Username, post.Filename)},\n\t\t\tContent: tpl.String(),\n\t\t\tCreated: *post.PublishAt,\n\t\t}\n\n\t\tif post.Description != \"\" {\n\t\t\titem.Description = post.Description\n\t\t}\n\n\t\tfeedItems = append(feedItems, item)\n\t}\n\tfeed.Items = feedItems\n\n\trss, err := feed.ToAtom()\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\tw.WriteHeader(gemini.StatusTemporaryFailure, \"Could not generate atom rss feed\")\n\t\treturn\n\t}\n\n\t// w.Header().Add(\"Content-Type\", \"application/atom+xml\")\n\t_, err = w.Write([]byte(rss))\n\tif err != nil {\n\t\tlogger.Error(err)\n\t}\n}\n\nfunc rssHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {\n\tdbpool := GetDB(ctx)\n\tlogger := GetLogger(ctx)\n\tcfg := GetCfg(ctx)\n\n\tpager, err := dbpool.FindAllPosts(&db.Pager{Num: 25, Page: 0}, cfg.Space)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\tw.WriteHeader(gemini.StatusTemporaryFailure, err.Error())\n\t\treturn\n\t}\n\n\tts, err := template.ParseFiles(\"./gmi/rss.page.tmpl\", \"./gmi/list.partial.tmpl\")\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\tw.WriteHeader(gemini.StatusTemporaryFailure, err.Error())\n\t\treturn\n\t}\n\n\tfeed := &feeds.Feed{\n\t\tTitle:       fmt.Sprintf(\"%s discovery feed\", cfg.Domain),\n\t\tLink:        &feeds.Link{Href: cfg.ReadURL()},\n\t\tDescription: fmt.Sprintf(\"%s latest posts\", cfg.Domain),\n\t\tAuthor:      &feeds.Author{Name: cfg.Domain},\n\t\tCreated:     time.Now(),\n\t}\n\n\tvar feedItems []*feeds.Item\n\tfor _, post := range pager.Data {\n\t\tparsed := pkg.ParseText(post.Text)\n\t\tvar tpl bytes.Buffer\n\t\tdata := &internal.PostPageData{\n\t\t\tListType: parsed.MetaData.ListType,\n\t\t\tItems:    parsed.Items,\n\t\t}\n\t\tif err := ts.Execute(&tpl, data); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\titem := &feeds.Item{\n\t\t\tId:      cfg.PostURL(post.Username, post.Filename),\n\t\t\tTitle:   post.Title,\n\t\t\tLink:    &feeds.Link{Href: cfg.PostURL(post.Username, post.Filename)},\n\t\t\tContent: tpl.String(),\n\t\t\tCreated: *post.PublishAt,\n\t\t}\n\n\t\tif post.Description != \"\" {\n\t\t\titem.Description = post.Description\n\t\t}\n\n\t\tfeedItems = append(feedItems, item)\n\t}\n\tfeed.Items = feedItems\n\n\trss, err := feed.ToAtom()\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\tw.WriteHeader(gemini.StatusTemporaryFailure, \"Could not generate atom rss feed\")\n\t}\n\n\t// w.Header().Add(\"Content-Type\", \"application/atom+xml\")\n\t_, err = w.Write([]byte(rss))\n\tif err != nil {\n\t\tlogger.Error(err)\n\t}\n}\n\nfunc StartServer() {\n\tcfg := internal.NewConfigSite()\n\tdb := postgres.NewDB(&cfg.ConfigCms)\n\tlogger := cfg.Logger\n\n\tcertificates := &certificate.Store{}\n\tcertificates.Register(\"localhost\")\n\tcertificates.Register(cfg.Domain)\n\tcertificates.Register(fmt.Sprintf(\"*.%s\", cfg.Domain))\n\tif err := certificates.Load(\"/var/lib/gemini/certs\"); err != nil {\n\t\tlogger.Fatal(err)\n\t}\n\n\troutes := []Route{\n\t\tNewRoute(\"/\", createPageHandler(\"./gmi/marketing.page.tmpl\")),\n\t\tNewRoute(\"/spec\", createPageHandler(\"./gmi/spec.page.tmpl\")),\n\t\tNewRoute(\"/help\", createPageHandler(\"./gmi/help.page.tmpl\")),\n\t\tNewRoute(\"/ops\", createPageHandler(\"./gmi/ops.page.tmpl\")),\n\t\tNewRoute(\"/privacy\", createPageHandler(\"./gmi/privacy.page.tmpl\")),\n\t\tNewRoute(\"/transparency\", transparencyHandler),\n\t\tNewRoute(\"/read\", readHandler),\n\t\tNewRoute(\"/rss\", rssHandler),\n\t\tNewRoute(\"/([^/]+)\", blogHandler),\n\t\tNewRoute(\"/([^/]+)/rss\", rssBlogHandler),\n\t\tNewRoute(\"/([^/]+)/([^/]+)\", postHandler),\n\t}\n\thandler := CreateServe(routes, cfg, db, logger)\n\trouter := gemini.HandlerFunc(handler)\n\n\tserver := &gemini.Server{\n\t\tAddr:           \"0.0.0.0:1965\",\n\t\tHandler:        gemini.LoggingMiddleware(router),\n\t\tReadTimeout:    30 * time.Second,\n\t\tWriteTimeout:   1 * time.Minute,\n\t\tGetCertificate: certificates.Get,\n\t}\n\n\t// Listen for interrupt signal\n\tc := make(chan os.Signal, 1)\n\tsignal.Notify(c, os.Interrupt)\n\n\terrch := make(chan error)\n\tgo func() {\n\t\tlogger.Info(\"Starting server\")\n\t\tctx := context.Background()\n\t\terrch <- server.ListenAndServe(ctx)\n\t}()\n\n\tselect {\n\tcase err := <-errch:\n\t\tlogger.Fatal(err)\n\tcase <-c:\n\t\t// Shutdown the server\n\t\tlogger.Info(\"Shutting down...\")\n\t\tdb.Close()\n\t\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tdefer cancel()\n\t\terr := server.Shutdown(ctx)\n\t\tif err != nil {\n\t\t\tlogger.Fatal(err)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/gemini/router.go",
    "content": "package gemini\n\nimport (\n\t\"context\"\n\t\"regexp\"\n\n\t\"git.sr.ht/~adnano/go-gemini\"\n\t\"git.sr.ht/~erock/lists.sh/internal\"\n\t\"git.sr.ht/~erock/wish/cms/db\"\n\t\"go.uber.org/zap\"\n)\n\ntype ctxKey struct{}\ntype ctxDBKey struct{}\ntype ctxLoggerKey struct{}\ntype ctxCfgKey struct{}\n\nfunc GetLogger(ctx context.Context) *zap.SugaredLogger {\n\treturn ctx.Value(ctxLoggerKey{}).(*zap.SugaredLogger)\n}\n\nfunc GetCfg(ctx context.Context) *internal.ConfigSite {\n\treturn ctx.Value(ctxCfgKey{}).(*internal.ConfigSite)\n}\n\nfunc GetDB(ctx context.Context) db.DB {\n\treturn ctx.Value(ctxDBKey{}).(db.DB)\n}\n\nfunc GetField(ctx context.Context, index int) string {\n\tfields := ctx.Value(ctxKey{}).([]string)\n\treturn fields[index]\n}\n\ntype Route struct {\n\tregex   *regexp.Regexp\n\thandler gemini.HandlerFunc\n}\n\nfunc NewRoute(pattern string, handler gemini.HandlerFunc) Route {\n\treturn Route{\n\t\tregexp.MustCompile(\"^\" + pattern + \"$\"),\n\t\thandler,\n\t}\n}\n\ntype ServeFn func(context.Context, gemini.ResponseWriter, *gemini.Request)\n\nfunc CreateServe(routes []Route, cfg *internal.ConfigSite, dbpool db.DB, logger *zap.SugaredLogger) ServeFn {\n\treturn func(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {\n\t\tcurRoutes := routes\n\n\t\tfor _, route := range curRoutes {\n\t\t\tmatches := route.regex.FindStringSubmatch(r.URL.Path)\n\t\t\tif len(matches) > 0 {\n\t\t\t\tctx = context.WithValue(ctx, ctxLoggerKey{}, logger)\n\t\t\t\tctx = context.WithValue(ctx, ctxDBKey{}, dbpool)\n\t\t\t\tctx = context.WithValue(ctx, ctxCfgKey{}, cfg)\n\t\t\t\tctx = context.WithValue(ctx, ctxKey{}, matches[1:])\n\t\t\t\troute.handler(ctx, w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tw.WriteHeader(gemini.StatusTemporaryFailure, \"Internal Service Error\")\n\t}\n}\n"
  },
  {
    "path": "internal/router.go",
    "content": "package internal\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"git.sr.ht/~erock/wish/cms/db\"\n\t\"go.uber.org/zap\"\n)\n\ntype Route struct {\n\tmethod  string\n\tregex   *regexp.Regexp\n\thandler http.HandlerFunc\n}\n\nfunc NewRoute(method, pattern string, handler http.HandlerFunc) Route {\n\treturn Route{\n\t\tmethod,\n\t\tregexp.MustCompile(\"^\" + pattern + \"$\"),\n\t\thandler,\n\t}\n}\n\ntype ServeFn func(http.ResponseWriter, *http.Request)\n\nfunc CreateServe(routes []Route, subdomainRoutes []Route, cfg *ConfigSite, dbpool db.DB, logger *zap.SugaredLogger) ServeFn {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tvar allow []string\n\t\tcurRoutes := routes\n\n\t\thostDomain := strings.ToLower(strings.Split(r.Host, \":\")[0])\n\t\tappDomain := strings.ToLower(strings.Split(cfg.ConfigCms.Domain, \":\")[0])\n\n\t\tsubdomain := \"\"\n\t\tif hostDomain != appDomain && strings.Contains(hostDomain, appDomain) {\n\t\t\tsubdomain = strings.TrimSuffix(hostDomain, fmt.Sprintf(\".%s\", appDomain))\n\t\t}\n\n\t\tif cfg.IsSubdomains() && subdomain != \"\" {\n\t\t\tcurRoutes = subdomainRoutes\n\t\t}\n\n\t\tfor _, route := range curRoutes {\n\t\t\tmatches := route.regex.FindStringSubmatch(r.URL.Path)\n\t\t\tif len(matches) > 0 {\n\t\t\t\tif r.Method != route.method {\n\t\t\t\t\tallow = append(allow, route.method)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tloggerCtx := context.WithValue(r.Context(), ctxLoggerKey{}, logger)\n\t\t\t\tsubdomainCtx := context.WithValue(loggerCtx, ctxSubdomainKey{}, subdomain)\n\t\t\t\tdbCtx := context.WithValue(subdomainCtx, ctxDBKey{}, dbpool)\n\t\t\t\tcfgCtx := context.WithValue(dbCtx, ctxCfg{}, cfg)\n\t\t\t\tctx := context.WithValue(cfgCtx, ctxKey{}, matches[1:])\n\t\t\t\troute.handler(w, r.WithContext(ctx))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tif len(allow) > 0 {\n\t\t\tw.Header().Set(\"Allow\", strings.Join(allow, \", \"))\n\t\t\thttp.Error(w, \"405 method not allowed\", http.StatusMethodNotAllowed)\n\t\t\treturn\n\t\t}\n\t\thttp.NotFound(w, r)\n\t}\n}\n\ntype ctxDBKey struct{}\ntype ctxKey struct{}\ntype ctxLoggerKey struct{}\ntype ctxSubdomainKey struct{}\ntype ctxCfg struct{}\n\nfunc GetCfg(r *http.Request) *ConfigSite {\n\treturn r.Context().Value(ctxCfg{}).(*ConfigSite)\n}\n\nfunc GetLogger(r *http.Request) *zap.SugaredLogger {\n\treturn r.Context().Value(ctxLoggerKey{}).(*zap.SugaredLogger)\n}\n\nfunc GetDB(r *http.Request) db.DB {\n\treturn r.Context().Value(ctxDBKey{}).(db.DB)\n}\n\nfunc GetField(r *http.Request, index int) string {\n\tfields := r.Context().Value(ctxKey{}).([]string)\n\treturn fields[index]\n}\n\nfunc GetSubdomain(r *http.Request) string {\n\treturn r.Context().Value(ctxSubdomainKey{}).(string)\n}\n"
  },
  {
    "path": "internal/util.go",
    "content": "package internal\n\nimport (\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"math\"\n\t\"os\"\n\tpathpkg \"path\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\t\"unicode\"\n\t\"unicode/utf8\"\n\n\t\"github.com/gliderlabs/ssh\"\n\t\"golang.org/x/exp/slices\"\n)\n\nvar fnameRe = regexp.MustCompile(`[-_]+`)\n\nfunc FilenameToTitle(filename string, title string) string {\n\tif filename != title {\n\t\treturn title\n\t}\n\n\tpre := fnameRe.ReplaceAllString(title, \" \")\n\tr := []rune(pre)\n\tr[0] = unicode.ToUpper(r[0])\n\treturn string(r)\n}\n\nfunc SanitizeFileExt(fname string) string {\n\treturn strings.TrimSuffix(fname, filepath.Ext(fname))\n}\n\nfunc KeyText(s ssh.Session) (string, error) {\n\tif s.PublicKey() == nil {\n\t\treturn \"\", fmt.Errorf(\"Session doesn't have public key\")\n\t}\n\tkb := base64.StdEncoding.EncodeToString(s.PublicKey().Marshal())\n\treturn fmt.Sprintf(\"%s %s\", s.PublicKey().Type(), kb), nil\n}\n\nfunc GetEnv(key string, defaultVal string) string {\n\tif value, exists := os.LookupEnv(key); exists {\n\t\treturn value\n\t}\n\n\treturn defaultVal\n}\n\n// IsText reports whether a significant prefix of s looks like correct UTF-8;\n// that is, if it is likely that s is human-readable text.\nfunc IsText(s string) bool {\n\tconst max = 1024 // at least utf8.UTFMax\n\tif len(s) > max {\n\t\ts = s[0:max]\n\t}\n\tfor i, c := range s {\n\t\tif i+utf8.UTFMax > len(s) {\n\t\t\t// last char may be incomplete - ignore\n\t\t\tbreak\n\t\t}\n\t\tif c == 0xFFFD || c < ' ' && c != '\\n' && c != '\\t' && c != '\\f' && c != '\\r' {\n\t\t\t// decoding error or control character - not a text file\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nvar allowedExtensions = []string{\".txt\"}\n\n// IsTextFile reports whether the file has a known extension indicating\n// a text file, or if a significant chunk of the specified file looks like\n// correct UTF-8; that is, if it is likely that the file contains human-\n// readable text.\nfunc IsTextFile(text string, filename string) bool {\n\text := pathpkg.Ext(filename)\n\tif !slices.Contains(allowedExtensions, ext) {\n\t\treturn false\n\t}\n\n\tnum := math.Min(float64(len(text)), 1024)\n\treturn IsText(text[0:int(num)])\n}\n\nconst solarYearSecs = 31556926\n\nfunc TimeAgo(t *time.Time) string {\n\td := time.Since(*t)\n\tvar metric string\n\tvar amount int\n\tif d.Seconds() < 60 {\n\t\tamount = int(d.Seconds())\n\t\tmetric = \"second\"\n\t} else if d.Minutes() < 60 {\n\t\tamount = int(d.Minutes())\n\t\tmetric = \"minute\"\n\t} else if d.Hours() < 24 {\n\t\tamount = int(d.Hours())\n\t\tmetric = \"hour\"\n\t} else if d.Seconds() < solarYearSecs {\n\t\tamount = int(d.Hours()) / 24\n\t\tmetric = \"day\"\n\t} else {\n\t\tamount = int(d.Seconds()) / solarYearSecs\n\t\tmetric = \"year\"\n\t}\n\tif amount == 1 {\n\t\treturn fmt.Sprintf(\"%d %s ago\", amount, metric)\n\t} else {\n\t\treturn fmt.Sprintf(\"%d %ss ago\", amount, metric)\n\t}\n}\n"
  },
  {
    "path": "pkg/parser.go",
    "content": "package pkg\n\nimport (\n\t\"fmt\"\n\t\"html/template\"\n\t\"strings\"\n\t\"time\"\n)\n\ntype ParsedText struct {\n\tItems    []*ListItem\n\tMetaData *MetaData\n}\n\ntype ListItem struct {\n\tValue       string\n\tURL         template.URL\n\tVariable    string\n\tIsURL       bool\n\tIsBlock     bool\n\tIsText      bool\n\tIsHeaderOne bool\n\tIsHeaderTwo bool\n\tIsImg       bool\n\tIsPre       bool\n}\n\ntype MetaData struct {\n\tPublishAt   *time.Time\n\tTitle       string\n\tDescription string\n\tListType    string // https://developer.mozilla.org/en-US/docs/Web/CSS/list-style-type\n}\n\nvar urlToken = \"=>\"\nvar blockToken = \">\"\nvar varToken = \"=:\"\nvar imgToken = \"=<\"\nvar headerOneToken = \"#\"\nvar headerTwoToken = \"##\"\nvar preToken = \"```\"\n\ntype SplitToken struct {\n\tKey   string\n\tValue string\n}\n\nfunc TextToSplitToken(text string) *SplitToken {\n\ttxt := strings.Trim(text, \" \")\n\ttoken := &SplitToken{}\n\tword := \"\"\n\tfor i, c := range txt {\n\t\tif c == ' ' {\n\t\t\ttoken.Key = strings.Trim(word, \" \")\n\t\t\ttoken.Value = strings.Trim(txt[i:], \" \")\n\t\t\tbreak\n\t\t} else {\n\t\t\tword += string(c)\n\t\t}\n\t}\n\n\tif token.Key == \"\" {\n\t\ttoken.Key = strings.Trim(text, \" \")\n\t\ttoken.Value = strings.Trim(text, \" \")\n\t}\n\n\treturn token\n}\n\nfunc SplitByNewline(text string) []string {\n\treturn strings.Split(strings.ReplaceAll(text, \"\\r\\n\", \"\\n\"), \"\\n\")\n}\n\nfunc PublishAtDate(date string) (*time.Time, error) {\n\tt, err := time.Parse(\"2006-01-02\", date)\n\treturn &t, err\n}\n\nfunc TokenToMetaField(meta *MetaData, token *SplitToken) {\n\tif token.Key == \"publish_at\" {\n\t\tpublishAt, err := PublishAtDate(token.Value)\n\t\tif err == nil {\n\t\t\tmeta.PublishAt = publishAt\n\t\t}\n\t} else if token.Key == \"title\" {\n\t\tmeta.Title = token.Value\n\t} else if token.Key == \"description\" {\n\t\tmeta.Description = token.Value\n\t} else if token.Key == \"list_type\" {\n\t\tmeta.ListType = token.Value\n\t}\n}\n\nfunc KeyAsValue(token *SplitToken) string {\n\tif token.Value == \"\" {\n\t\treturn token.Key\n\t}\n\treturn token.Value\n}\n\nfunc ParseText(text string) *ParsedText {\n\ttextItems := SplitByNewline(text)\n\titems := []*ListItem{}\n\tmeta := &MetaData{\n\t\tListType: \"disc\",\n\t}\n\tpre := false\n\tskip := false\n\tvar prevItem *ListItem\n\n\tfor _, t := range textItems {\n\t\tskip = false\n\n\t\tif len(items) > 0 {\n\t\t\tprevItem = items[len(items)-1]\n\t\t}\n\n\t\tli := &ListItem{\n\t\t\tValue: strings.Trim(t, \" \"),\n\t\t}\n\n\t\tif strings.HasPrefix(li.Value, preToken) {\n\t\t\tpre = !pre\n\t\t\tif pre {\n\t\t\t\tnextValue := strings.Replace(li.Value, preToken, \"\", 1)\n\t\t\t\tli.IsPre = true\n\t\t\t\tli.Value = nextValue\n\t\t\t} else {\n\t\t\t\tskip = true\n\t\t\t}\n\t\t} else if pre {\n\t\t\tnextValue := strings.Replace(li.Value, preToken, \"\", 1)\n\t\t\tprevItem.Value = fmt.Sprintf(\"%s\\n%s\", prevItem.Value, nextValue)\n\t\t\tskip = true\n\t\t} else if strings.HasPrefix(li.Value, urlToken) {\n\t\t\tli.IsURL = true\n\t\t\tsplit := TextToSplitToken(strings.Replace(li.Value, urlToken, \"\", 1))\n\t\t\tli.URL = template.URL(split.Key)\n\t\t\tli.Value = KeyAsValue(split)\n\t\t} else if strings.HasPrefix(li.Value, blockToken) {\n\t\t\tli.IsBlock = true\n\t\t\tli.Value = strings.Replace(li.Value, blockToken, \"\", 1)\n\t\t} else if strings.HasPrefix(li.Value, imgToken) {\n\t\t\tli.IsImg = true\n\t\t\tsplit := TextToSplitToken(strings.Replace(li.Value, imgToken, \"\", 1))\n\t\t\tli.URL = template.URL(split.Key)\n\t\t\tli.Value = KeyAsValue(split)\n\t\t} else if strings.HasPrefix(li.Value, varToken) {\n\t\t\tsplit := TextToSplitToken(strings.Replace(li.Value, varToken, \"\", 1))\n\t\t\tTokenToMetaField(meta, split)\n\t\t\tcontinue\n\t\t} else if strings.HasPrefix(li.Value, headerTwoToken) {\n\t\t\tli.IsHeaderTwo = true\n\t\t\tli.Value = strings.Replace(li.Value, headerTwoToken, \"\", 1)\n\t\t} else if strings.HasPrefix(li.Value, headerOneToken) {\n\t\t\tli.IsHeaderOne = true\n\t\t\tli.Value = strings.Replace(li.Value, headerOneToken, \"\", 1)\n\t\t} else {\n\t\t\tli.IsText = true\n\t\t}\n\n\t\tif li.IsText && li.Value == \"\" {\n\t\t\tskip = true\n\t\t}\n\n\t\tif !skip {\n\t\t\titems = append(items, li)\n\t\t}\n\t}\n\n\treturn &ParsedText{\n\t\tItems:    items,\n\t\tMetaData: meta,\n\t}\n}\n"
  },
  {
    "path": "production.yml",
    "content": "version: \"3.7\"\n\nservices:\n  caddy:\n    image: neurosnap/lists-caddy\n    restart: unless-stopped\n    env_file:\n      - .env.prod\n    volumes:\n      - ./Caddyfile:/etc/caddy/Caddyfile\n      - caddy_data:/data\n      - caddy_config:/config\n    ports:\n      - \"443:443\"\n      - \"80:80\"\n    links:\n      - web\n  db:\n    image: postgres\n    restart: unless-stopped\n    env_file:\n      - .env.prod\n    volumes:\n      - db_data:/var/lib/postgresql/data\n  web:\n    image: neurosnap/lists-web\n    restart: unless-stopped\n    env_file:\n      - .env.prod\n    links:\n      - db\n  gemini:\n    image: neurosnap/lists-gemini\n    restart: unless-stopped\n    environment:\n      - LISTS_SUBDOMAINS=0\n    env_file:\n      - .env.prod\n    ports:\n      - \"1965:1965\"\n    links:\n      - db\n    volumes:\n      - gemini_data:/var/lib/gemini/certs\n  ssh:\n    image: neurosnap/lists-ssh\n    restart: unless-stopped\n    ports:\n      - \"22:2222\"\n    env_file:\n      - .env.prod\n    links:\n      - db\n    volumes:\n      - ssh_data:/app/ssh_data\n\nvolumes:\n  db_data:\n  caddy_data:\n  ssh_data:\n  caddy_config:\n  gemini_data:\n"
  },
  {
    "path": "public/main.css",
    "content": "*, ::before, ::after {\n  box-sizing: border-box;\n}\n\n::-moz-focus-inner {\n\tborder-style: none;\n\tpadding: 0;\n}\n:-moz-focusring { outline: 1px dotted ButtonText; }\n:-moz-ui-invalid { box-shadow: none; }\n\n@media (prefers-color-scheme: light) {\n  :root {\n    --white: #6a737d;\n    --code: rgba(255, 229, 100, 0.2);\n    --pre: #f6f8fa;\n    --bg-color: #fff;\n    --text-color: #24292f;\n    --link-color: #005cc5;\n    --visited: #6f42c1;\n    --blockquote: #785840;\n    --blockquote-bg: #fff;\n    --hover: #d73a49;\n    --grey: #ccc;\n  }\n}\n\n@media (prefers-color-scheme: dark) {\n  :root {\n    --white: #f2f2f2;\n    --code: #252525;\n    --pre: #252525;\n    --bg-color: #282a36;\n    --text-color: #f2f2f2;\n    --link-color: #8be9fd;\n    --visited: #bd93f9;\n    --blockquote: #bd93f9;\n    --blockquote-bg: #414558;\n    --hover: #ff80bf;\n    --grey: #414558;\n  }\n}\n\nhtml {\n  background-color: var(--bg-color);\n  color: var(--text-color);\n  line-height: 1.5;\n  font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Oxygen, Ubuntu, Cantarell,\n    \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\", Arial, sans-serif, \"Apple Color Emoji\",\n    \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n\t-webkit-text-size-adjust: 100%;\n\t-moz-tab-size: 4;\n\ttab-size: 4;\n}\n\nbody {\n  margin: 0 auto;\n  max-width: 35rem;\n}\n\nimg {\n  max-width: 100%;\n  height: auto;\n}\n\nb, strong {\n  font-weight: bold;\n}\n\ncode, kbd, samp, pre {\n\tfont-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;\n\tfont-size: 0.8rem;\n}\n\ncode, kbd, samp {\n  background-color: var(--code);\n}\n\npre > code {\n  background-color: inherit;\n  padding: 0;\n}\n\ncode {\n  border-radius: 0.3rem;\n  padding: .15rem .2rem .05rem;\n}\n\npre {\n  border-radius: 5px;\n  padding: 1rem;\n  overflow-x: auto;\n  margin: 0;\n  background-color: var(--pre) !important;\n}\n\nsmall {\n  font-size: 0.8rem;\n}\n\nsummary {\n  display: list-item;\n}\n\nh1, h2, h3 {\n  margin: 0;\n\tpadding: 0;\n\tborder: 0;\n  font-style: normal;\n  font-weight: inherit;\n  font-size: inherit;\n}\n\nhr {\n  color: inherit;\n  border: 0;\n  margin: 0;\n  height: 1px;\n  background: var(--grey);\n  margin: 2rem auto;\n  text-align: center;\n}\n\na {\n  text-decoration: underline;\n  color: var(--link-color);\n}\n\na:hover, a:visited:hover {\n  color: var(--hover);\n}\n\na:visited {\n  color: var(--visited);\n}\n\na.link-grey {\n  text-decoration: underline;\n  color: var(--white);\n}\n\na.link-grey:visited {\n  color: var(--white);\n}\n\nsection {\n  margin-bottom: 2rem;\n}\n\nsection:last-child {\n  margin-bottom: 0;\n}\n\nheader {\n  margin: 1rem auto;\n}\n\np {\n  margin: 1rem 0;\n}\n\narticle {\n  overflow-wrap: break-word;\n}\n\nblockquote {\n  border-left: 5px solid var(--blockquote);\n  background-color: var(--blockquote-bg);\n  padding: 0.5rem;\n  margin: 0.5rem 0;\n}\n\nul, ol {\n  padding: 0 0 0 2rem;\n  list-style-position: outside;\n}\n\nul[style*=\"list-style-type: none;\"] {\n  padding: 0;\n}\n\nli {\n  margin: 0.5rem 0;\n}\n\nli > pre {\n  padding: 0;\n}\n\nfooter {\n  text-align: center;\n  margin-bottom: 4rem;\n}\n\ndt {\n  font-weight: bold;\n}\n\ndd {\n  margin-left: 0;\n}\n\ndd:not(:last-child) {\n  margin-bottom: .5rem;\n}\n\n.post-date {\n  width: 130px;\n}\n\n.text-grey {\n  color: var(--grey);\n}\n\n.text-2xl {\n  font-size: 1.5rem;\n  line-height: 1.15;\n}\n\n.text-xl {\n  font-size: 1.25rem;\n  line-height: 1.15;\n}\n\n.text-lg {\n  font-size: 1.125rem;\n  line-height: 1.15;\n}\n\n.text-sm {\n  font-size: 0.875rem;\n}\n\n.text-center {\n  text-align: center;\n}\n\n.font-bold {\n  font-weight: bold;\n}\n\n.font-italic {\n  font-style: italic;\n}\n\n.inline {\n  display: inline;\n}\n\n.flex {\n  display: flex;\n}\n\n.items-center {\n  align-items: center;\n}\n\n.m-0 {\n  margin: 0;\n}\n\n.my {\n  margin-top: 0.5rem;\n  margin-bottom: 0.5rem;\n}\n\n.mx {\n  margin-left: 0.5rem;\n  margin-right: 0.5rem;\n}\n\n.mx-2 {\n  margin-left: 1rem;\n  margin-right: 1rem;\n}\n\n.justify-between {\n  justify-content: space-between;\n}\n\n.flex-1 {\n  flex: 1;\n}\n\n@media only screen and (max-width: 600px) {\n  body {\n    padding: 1rem;\n  }\n\n  header {\n    margin: 0;\n  }\n}\n"
  },
  {
    "path": "public/robots.txt",
    "content": "User-agent: *\nAllow: /\n"
  }
]