repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

commit
9b5a1b5
parent
4b30ba2
author
Eric Bower
date
2022-08-11 02:49:01 +0000 UTC
feat: imgs.sh

This is the initial release for the imgs.sh premium image hosting for
hackers.
49 files changed,  +3339, -260
M .gitignore
+2, -0
1@@ -9,3 +9,5 @@ build/*
2 data/*
3 !data/.gitkeep
4 ssh_data
5+.storage
6+__debug_bin
M Makefile
+7, -3
 1@@ -11,6 +11,7 @@ css:
 2 	cp ./smol.css lists/public/main.css
 3 	cp ./smol.css prose/public/main.css
 4 	cp ./smol.css pastes/public/main.css
 5+	cp ./smol.css imgs/public/main.css
 6 
 7 	cp ./syntax.css pastes/public/syntax.css
 8 	cp ./syntax.css prose/public/syntax.css
 9@@ -34,7 +35,7 @@ bp-%: bp-setup
10 	$(DOCKER_BUILDX_BUILD) --build-arg "APP=$*" -t "neurosnap/$*-web:$(DOCKER_TAG)" --target release-web .
11 .PHONY: bp-%
12 
13-bp-all: bp-prose bp-lists bp-pastes
14+bp-all: bp-prose bp-lists bp-pastes bp-imgs
15 .PHONY: bp-all
16 
17 build-%:
18@@ -42,7 +43,7 @@ build-%:
19 	go build -o "build/$*-ssh" "./cmd/$*/ssh"
20 .PHONY: build-%
21 
22-build: build-prose build-lists build-pastes
23+build: build-prose build-lists build-pastes build-imgs
24 .PHONY: build
25 
26 format:
27@@ -68,10 +69,13 @@ migrate:
28 	docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20220727_post_change_post_contraints.sql
29 	docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20220730_post_change_filename_to_slug.sql
30 	docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20220801_add_post_tags.sql
31+	docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20220811_add_data_to_post.sql
32+	docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20220811_add_feature.sql
33 .PHONY: migrate
34 
35 latest:
36-	docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20220801_add_post_tags.sql
37+	docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20220811_add_data_to_post.sql
38+	docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20220811_add_feature.sql
39 .PHONY: latest
40 
41 psql:
A cmd/imgs/ssh/main.go
+105, -0
  1@@ -0,0 +1,105 @@
  2+package main
  3+
  4+import (
  5+	"context"
  6+	"fmt"
  7+	"os"
  8+	"os/signal"
  9+	"syscall"
 10+	"time"
 11+
 12+	"git.sr.ht/~erock/pico/db/postgres"
 13+	"git.sr.ht/~erock/pico/imgs"
 14+	"git.sr.ht/~erock/pico/imgs/upload"
 15+	"git.sr.ht/~erock/pico/shared"
 16+	"git.sr.ht/~erock/pico/wish/cms"
 17+	"git.sr.ht/~erock/pico/wish/pipe"
 18+	"git.sr.ht/~erock/pico/wish/proxy"
 19+	"git.sr.ht/~erock/pico/wish/send/scp"
 20+	"git.sr.ht/~erock/pico/wish/send/sftp"
 21+	"git.sr.ht/~erock/pico/wish/send/utils"
 22+	"github.com/charmbracelet/promwish"
 23+	"github.com/charmbracelet/wish"
 24+	bm "github.com/charmbracelet/wish/bubbletea"
 25+	lm "github.com/charmbracelet/wish/logging"
 26+	"github.com/gliderlabs/ssh"
 27+)
 28+
 29+type SSHServer struct{}
 30+
 31+func (me *SSHServer) authHandler(ctx ssh.Context, key ssh.PublicKey) bool {
 32+	return true
 33+}
 34+
 35+func createRouter(cfg *shared.ConfigSite, handler utils.CopyFromClientHandler) proxy.Router {
 36+	return func(sh ssh.Handler, s ssh.Session) []wish.Middleware {
 37+		cmd := s.Command()
 38+		mdw := []wish.Middleware{}
 39+
 40+		if len(cmd) > 0 && cmd[0] == "scp" {
 41+			mdw = append(mdw, scp.Middleware(handler))
 42+		} else {
 43+			mdw = append(mdw,
 44+				pipe.Middleware(handler, ""),
 45+				bm.Middleware(cms.Middleware(&cfg.ConfigCms, cfg)),
 46+				lm.Middleware(),
 47+			)
 48+		}
 49+
 50+		return mdw
 51+	}
 52+}
 53+
 54+func withProxy(cfg *shared.ConfigSite, handler utils.CopyFromClientHandler, otherMiddleware ...wish.Middleware) ssh.Option {
 55+	return func(server *ssh.Server) error {
 56+		err := sftp.SSHOption(handler)(server)
 57+		if err != nil {
 58+			return err
 59+		}
 60+
 61+		return proxy.WithProxy(createRouter(cfg, handler), otherMiddleware...)(server)
 62+	}
 63+}
 64+
 65+func main() {
 66+	host := shared.GetEnv("IMGS_HOST", "0.0.0.0")
 67+	port := shared.GetEnv("IMGS_SSH_PORT", "2222")
 68+	promPort := shared.GetEnv("IMGS_PROM_PORT", "9222")
 69+	cfg := imgs.NewConfigSite()
 70+	logger := cfg.Logger
 71+	dbh := postgres.NewDB(&cfg.ConfigCms)
 72+	defer dbh.Close()
 73+	handler := upload.NewUploadImgHandler(dbh, cfg, imgs.NewStorageFS(cfg.StorageDir))
 74+
 75+	sshServer := &SSHServer{}
 76+	s, err := wish.NewServer(
 77+		wish.WithAddress(fmt.Sprintf("%s:%s", host, port)),
 78+		wish.WithHostKeyPath("ssh_data/term_info_ed25519"),
 79+		wish.WithPublicKeyAuth(sshServer.authHandler),
 80+		withProxy(
 81+			cfg,
 82+			handler,
 83+			promwish.Middleware(fmt.Sprintf("%s:%s", host, promPort), "pastes-ssh"),
 84+		),
 85+	)
 86+	if err != nil {
 87+		logger.Fatal(err)
 88+	}
 89+
 90+	done := make(chan os.Signal, 1)
 91+	signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
 92+	logger.Infof("Starting SSH server on %s:%s", host, port)
 93+	go func() {
 94+		if err = s.ListenAndServe(); err != nil {
 95+			logger.Fatal(err)
 96+		}
 97+	}()
 98+
 99+	<-done
100+	logger.Info("Stopping SSH server")
101+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
102+	defer func() { cancel() }()
103+	if err := s.Shutdown(ctx); err != nil {
104+		logger.Fatal(err)
105+	}
106+}
A cmd/imgs/web/main.go
+7, -0
1@@ -0,0 +1,7 @@
2+package main
3+
4+import "git.sr.ht/~erock/pico/imgs"
5+
6+func main() {
7+	imgs.StartApiServer()
8+}
M cmd/lists/ssh/main.go
+1, -0
1@@ -74,6 +74,7 @@ func main() {
2 
3 	hooks := &lists.ListHooks{
4 		Cfg: cfg,
5+		Db:  dbh,
6 	}
7 	handler := filehandlers.NewScpPostHandler(dbh, cfg, hooks)
8 
M cmd/pastes/ssh/main.go
+1, -0
1@@ -73,6 +73,7 @@ func main() {
2 	defer dbh.Close()
3 	hooks := &pastes.FileHooks{
4 		Cfg: cfg,
5+		Db:  dbh,
6 	}
7 	handler := filehandlers.NewScpPostHandler(dbh, cfg, hooks)
8 
M cmd/prose/ssh/main.go
+1, -0
1@@ -73,6 +73,7 @@ func main() {
2 	defer dbh.Close()
3 	hooks := &prose.MarkdownHooks{
4 		Cfg: cfg,
5+		Db:  dbh,
6 	}
7 	handler := filehandlers.NewScpPostHandler(dbh, cfg, hooks)
8 
M db/db.go
+35, -2
 1@@ -1,6 +1,8 @@
 2 package db
 3 
 4 import (
 5+	"database/sql/driver"
 6+	"encoding/json"
 7 	"errors"
 8 	"regexp"
 9 	"time"
10@@ -24,6 +26,27 @@ type User struct {
11 	CreatedAt *time.Time `json:"created_at"`
12 }
13 
14+type PostData struct {
15+	ImgPath string `json:"img_path"`
16+}
17+
18+// Make the Attrs struct implement the driver.Valuer interface. This method
19+// simply returns the JSON-encoded representation of the struct.
20+func (p PostData) Value() (driver.Value, error) {
21+	return json.Marshal(p)
22+}
23+
24+// Make the Attrs struct implement the sql.Scanner interface. This method
25+// simply decodes a JSON-encoded value into the struct fields.
26+func (p *PostData) Scan(value interface{}) error {
27+	b, ok := value.([]byte)
28+	if !ok {
29+		return errors.New("type assertion to []byte failed")
30+	}
31+
32+	return json.Unmarshal(b, &p)
33+}
34+
35 type Post struct {
36 	ID          string     `json:"id"`
37 	UserID      string     `json:"user_id"`
38@@ -40,6 +63,11 @@ type Post struct {
39 	Views       int        `json:"views"`
40 	Space       string     `json:"space"`
41 	Score       string     `json:"score"`
42+	Shasum      string     `json:"shasum"`
43+	FileSize    int        `json:"file_size"`
44+	MimeType    string     `json:"mime_type"`
45+	Data        PostData   `json:"data"`
46+	Tags        []string   `json:"tags"`
47 }
48 
49 type Paginate[T any] struct {
50@@ -115,16 +143,21 @@ type DB interface {
51 	FindPostWithSlug(slug string, userID string, space string) (*Post, error)
52 	FindAllPosts(pager *Pager, space string) (*Paginate[*Post], error)
53 	FindAllUpdatedPosts(pager *Pager, space string) (*Paginate[*Post], error)
54-	InsertPost(userID string, filename string, slug string, title string, text string, description string, publishAt *time.Time, hidden bool, space string) (*Post, error)
55-	UpdatePost(postID string, slug string, title string, text string, description string, publishAt *time.Time) (*Post, error)
56+	// FindPostsWithTagsForUser(userID, space string) ([]*Post, error)
57+	InsertPost(post *Post) (*Post, error)
58+	UpdatePost(post *Post) (*Post, error)
59 	RemovePosts(postIDs []string) error
60 
61 	ReplaceTagsForPost(tags []string, postID string) error
62 	FindUserPostsByTag(tag, userID, space string) ([]*Post, error)
63 	FindPostsByTag(tag, space string) ([]*Post, error)
64 	FindPopularTags() ([]string, error)
65+	FindTagsForPost(postID string) ([]string, error)
66 
67 	AddViewCount(postID string) (int, error)
68 
69+	HasFeatureForUser(userID string, feature string) bool
70+	FindTotalSizeForUser(userID string) (int, error)
71+
72 	Close() error
73 }
M db/postgres/storage.go
+332, -186
  1@@ -18,50 +18,74 @@ import (
  2 
  3 var PAGER_SIZE = 15
  4 
  5-const (
  6-	sqlSelectPublicKey         = `SELECT id, user_id, public_key, created_at FROM public_keys WHERE public_key = $1`
  7-	sqlSelectPublicKeys        = `SELECT id, user_id, public_key, created_at FROM public_keys WHERE user_id = $1`
  8-	sqlSelectUser              = `SELECT id, name, created_at FROM app_users WHERE id = $1`
  9-	sqlSelectUserForName       = `SELECT id, name, created_at FROM app_users WHERE name = $1`
 10-	sqlSelectUserForNameAndKey = `SELECT app_users.id, app_users.name, app_users.created_at, public_keys.id as pk_id, public_keys.public_key, public_keys.created_at as pk_created_at FROM app_users LEFT OUTER JOIN public_keys ON public_keys.user_id = app_users.id WHERE app_users.name = $1 AND public_keys.public_key = $2`
 11-	sqlSelectUsers             = `SELECT id, name, created_at FROM app_users ORDER BY name ASC`
 12+var SelectPost = `
 13+	posts.id, user_id, app_users.name, filename, slug, title, text, description,
 14+	posts.created_at, publish_at, posts.updated_at, hidden, file_size, mime_type, shasum, data`
 15 
 16-	sqlSelectTotalUsers          = `SELECT count(id) FROM app_users`
 17-	sqlSelectUsersAfterDate      = `SELECT count(id) FROM app_users WHERE created_at >= $1`
 18-	sqlSelectTotalPosts          = `SELECT count(id) FROM posts WHERE cur_space = $1`
 19-	sqlSelectTotalPostsAfterDate = `SELECT count(id) FROM posts WHERE created_at >= $1 AND cur_space = $2`
 20-	sqlSelectUsersWithPost       = `SELECT count(app_users.id) FROM app_users WHERE EXISTS (SELECT 1 FROM posts WHERE user_id = app_users.id AND cur_space = $1);`
 21+var (
 22+	sqlSelectPosts = fmt.Sprintf(`
 23+	SELECT %s
 24+	FROM posts
 25+	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id`, SelectPost)
 26+
 27+	sqlSelectPostsBeforeDate = fmt.Sprintf(`
 28+	SELECT %s
 29+	FROM posts
 30+	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
 31+	WHERE publish_at::date <= $1 AND cur_space = $2`, SelectPost)
 32 
 33-	sqlSelectPosts               = `SELECT id, user_id, filename, slug, title, text, description, created_at, publish_at, updated_at, hidden FROM posts`
 34-	sqlSelectPostsBeforeDate     = `SELECT posts.id, user_id, filename, slug, title, text, description, publish_at, app_users.name as username, posts.updated_at FROM posts LEFT OUTER JOIN app_users ON app_users.id = posts.user_id WHERE publish_at::date <= $1 AND cur_space = $2`
 35-	sqlSelectPostWithFilename    = `SELECT posts.id, user_id, filename, slug, title, text, description, publish_at, app_users.name as username, posts.updated_at FROM posts LEFT OUTER JOIN app_users ON app_users.id = posts.user_id WHERE filename = $1 AND user_id = $2 AND cur_space = $3`
 36-	sqlSelectPostWithSlug        = `SELECT posts.id, user_id, filename, slug, title, text, description, publish_at, app_users.name as username, posts.updated_at FROM posts LEFT OUTER JOIN app_users ON app_users.id = posts.user_id WHERE slug = $1 AND user_id = $2 AND cur_space = $3`
 37-	sqlSelectPost                = `SELECT posts.id, user_id, filename, slug, title, text, description, publish_at, app_users.name as username, posts.updated_at FROM posts LEFT OUTER JOIN app_users ON app_users.id = posts.user_id WHERE posts.id = $1`
 38-	sqlSelectUpdatedPostsForUser = `SELECT posts.id, user_id, filename, slug, title, text, description, publish_at, app_users.name as username, posts.updated_at FROM posts LEFT OUTER JOIN app_users ON app_users.id = posts.user_id WHERE user_id = $1 AND publish_at::date <= CURRENT_DATE AND cur_space = $2 ORDER BY updated_at DESC`
 39-	sqlSelectAllUpdatedPosts     = `SELECT posts.id, user_id, filename, slug, title, text, description, publish_at, app_users.name as username, posts.updated_at, 0 as score FROM posts LEFT OUTER JOIN app_users ON app_users.id = posts.user_id WHERE hidden = FALSE AND publish_at::date <= CURRENT_DATE AND cur_space = $3 ORDER BY updated_at DESC LIMIT $1 OFFSET $2`
 40-	sqlSelectPostCount           = `SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`
 41-	sqlSelectPostsForUser        = `
 42-	SELECT posts.id, user_id, filename, slug, title, text, description, publish_at,
 43-		app_users.name as username, posts.updated_at
 44+	sqlSelectPostWithFilename = fmt.Sprintf(`
 45+	SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
 46 	FROM posts
 47 	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
 48+	LEFT OUTER JOIN post_tags ON post_tags.post_id = posts.id
 49+	WHERE filename = $1 AND user_id = $2 AND cur_space = $3
 50+	GROUP BY %s`, SelectPost, SelectPost)
 51+
 52+	sqlSelectPostWithSlug = fmt.Sprintf(`
 53+	SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
 54+	FROM posts
 55+	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
 56+	LEFT OUTER JOIN post_tags ON post_tags.post_id = posts.id
 57+	WHERE slug = $1 AND user_id = $2 AND cur_space = $3
 58+	GROUP BY %s`, SelectPost, SelectPost)
 59+
 60+	sqlSelectPost = fmt.Sprintf(`
 61+	SELECT %s
 62+	FROM posts
 63+	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
 64+	WHERE posts.id = $1`, SelectPost)
 65+
 66+	sqlSelectUpdatedPostsForUser = fmt.Sprintf(`
 67+	SELECT %s
 68+	FROM posts
 69+	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
 70+	WHERE user_id = $1 AND publish_at::date <= CURRENT_DATE AND cur_space = $2
 71+	ORDER BY posts.updated_at DESC`, SelectPost)
 72+
 73+	sqlSelectPostsForUser = fmt.Sprintf(`
 74+	SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
 75+	FROM posts
 76+	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
 77+	LEFT OUTER JOIN post_tags ON post_tags.post_id = posts.id
 78 	WHERE
 79 		user_id = $1 AND
 80 		publish_at::date <= CURRENT_DATE AND
 81 		cur_space = $2
 82-	ORDER BY publish_at DESC`
 83-	sqlSelectAllPostsForUser = `
 84-	SELECT posts.id, user_id, filename, slug, title, text, description, publish_at,
 85-		app_users.name as username, posts.updated_at
 86+	GROUP BY %s
 87+	ORDER BY publish_at DESC, slug DESC`, SelectPost, SelectPost)
 88+
 89+	sqlSelectAllPostsForUser = fmt.Sprintf(`
 90+	SELECT %s
 91 	FROM posts
 92 	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
 93 	WHERE
 94 		user_id = $1 AND
 95 		cur_space = $2
 96-	ORDER BY publish_at DESC`
 97-	sqlSelectPostsByTag = `
 98-	SELECT posts.id, user_id, filename, slug, title, text, description, publish_at,
 99-		app_users.name as username, posts.updated_at
100+	ORDER BY publish_at DESC`, SelectPost)
101+
102+	sqlSelectPostsByTag = fmt.Sprintf(`
103+	SELECT %s
104 	FROM posts
105 	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
106 	LEFT OUTER JOIN post_tags ON post_tags.post_id = posts.id
107@@ -69,11 +93,10 @@ const (
108 		post_tags.name = '$1' AND
109 		publish_at::date <= CURRENT_DATE AND
110 		cur_space = $2
111-	ORDER BY publish_at DESC`
112-	sqlSelectUserPostsByTag = `
113-	SELECT
114-		posts.id, user_id, filename, slug, title, text, description, publish_at,
115-		app_users.name as username, posts.updated_at
116+	ORDER BY publish_at DESC`, SelectPost)
117+
118+	sqlSelectUserPostsByTag = fmt.Sprintf(`
119+	SELECT %s
120 	FROM posts
121 	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
122 	LEFT OUTER JOIN post_tags ON post_tags.post_id = posts.id
123@@ -82,7 +105,57 @@ const (
124 		(post_tags.name = $2 OR hidden = true) AND
125 		publish_at::date <= CURRENT_DATE AND
126 		cur_space = $3
127-	ORDER BY publish_at DESC`
128+	ORDER BY publish_at DESC`, SelectPost)
129+
130+	/* sqlSelectUserPostsWithTags = fmt.Sprintf(`
131+	SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
132+	FROM posts
133+	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
134+	LEFT OUTER JOIN post_tags ON post_tags.post_id = posts.id
135+	WHERE
136+		user_id = $1 AND
137+		publish_at::date <= CURRENT_DATE AND
138+		cur_space = $2
139+	GROUP BY %s
140+	ORDER BY publish_at DESC`, SelectPost, SelectPost) */
141+)
142+
143+const (
144+	sqlSelectPublicKey         = `SELECT id, user_id, public_key, created_at FROM public_keys WHERE public_key = $1`
145+	sqlSelectPublicKeys        = `SELECT id, user_id, public_key, created_at FROM public_keys WHERE user_id = $1`
146+	sqlSelectUser              = `SELECT id, name, created_at FROM app_users WHERE id = $1`
147+	sqlSelectUserForName       = `SELECT id, name, created_at FROM app_users WHERE name = $1`
148+	sqlSelectUserForNameAndKey = `SELECT app_users.id, app_users.name, app_users.created_at, public_keys.id as pk_id, public_keys.public_key, public_keys.created_at as pk_created_at FROM app_users LEFT OUTER JOIN public_keys ON public_keys.user_id = app_users.id WHERE app_users.name = $1 AND public_keys.public_key = $2`
149+	sqlSelectUsers             = `SELECT id, name, created_at FROM app_users ORDER BY name ASC`
150+
151+	sqlSelectTotalUsers          = `SELECT count(id) FROM app_users`
152+	sqlSelectUsersAfterDate      = `SELECT count(id) FROM app_users WHERE created_at >= $1`
153+	sqlSelectTotalPosts          = `SELECT count(id) FROM posts WHERE cur_space = $1`
154+	sqlSelectTotalPostsAfterDate = `SELECT count(id) FROM posts WHERE created_at >= $1 AND cur_space = $2`
155+	sqlSelectUsersWithPost       = `SELECT count(app_users.id) FROM app_users WHERE EXISTS (SELECT 1 FROM posts WHERE user_id = app_users.id AND cur_space = $1);`
156+
157+	sqlSelectFeatureForUser = `SELECT id FROM feature_flags WHERE user_id = $1 AND name = $2`
158+	sqlSelectSizeForUser    = `SELECT sum(file_size) FROM posts WHERE user_id = $1`
159+
160+	sqlSelectPostCount       = `SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`
161+	sqlSelectAllUpdatedPosts = `
162+	SELECT
163+		posts.id,
164+		user_id,
165+		filename,
166+		slug,
167+		title,
168+		text,
169+		description,
170+		publish_at,
171+		app_users.name as username,
172+		posts.updated_at,
173+		0 AS "score"
174+	FROM posts
175+	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
176+	WHERE hidden = FALSE AND publish_at::date <= CURRENT_DATE AND cur_space = $3
177+	ORDER BY updated_at DESC
178+	LIMIT $1 OFFSET $2`
179 	sqlSelectPostsByRank = `
180 	SELECT
181 		posts.id,
182@@ -112,13 +185,23 @@ const (
183 	LIMIT $1 OFFSET $2`
184 
185 	sqlSelectPopularTags = `SELECT name, count(post_id) as tally FROM post_tags GROUP_BY name, post_id ORDER BY tally DESC LIMIT 10`
186+	sqlSelectTagsForPost = `SELECT name FROM post_tags WHERE post_id=$1`
187 
188 	sqlInsertPublicKey = `INSERT INTO public_keys (user_id, public_key) VALUES ($1, $2)`
189-	sqlInsertPost      = `INSERT INTO posts (user_id, filename, slug, title, text, description, publish_at, hidden, cur_space) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`
190-	sqlInsertUser      = `INSERT INTO app_users DEFAULT VALUES returning id`
191-	sqlInsertTag       = `INSERT INTO post_tags (post_id, name) VALUES($1, $2) RETURNING id;`
192-
193-	sqlUpdatePost     = `UPDATE posts SET slug = $1, title = $2, text = $3, description = $4, updated_at = $5, publish_at = $6 WHERE id = $7`
194+	sqlInsertPost      = `
195+	INSERT INTO posts
196+		(user_id, filename, slug, title, text, description, publish_at, hidden, cur_space,
197+		file_size, mime_type, shasum, data)
198+	VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
199+	RETURNING id`
200+	sqlInsertUser = `INSERT INTO app_users DEFAULT VALUES returning id`
201+	sqlInsertTag  = `INSERT INTO post_tags (post_id, name) VALUES($1, $2) RETURNING id;`
202+
203+	sqlUpdatePost = `
204+	UPDATE posts
205+	SET slug = $1, title = $2, text = $3, description = $4, updated_at = $5, publish_at = $6,
206+		file_size = $7, shasum = $8, data = $9
207+	WHERE id = $10`
208 	sqlUpdateUserName = `UPDATE app_users SET name = $1 WHERE id = $2`
209 	sqlIncrementViews = `UPDATE posts SET views = views + 1 WHERE id = $1 RETURNING views`
210 
211@@ -133,6 +216,74 @@ type PsqlDB struct {
212 	Db     *sql.DB
213 }
214 
215+type RowScanner interface {
216+	Scan(dest ...any) error
217+}
218+
219+func CreatePostFromRow(r RowScanner) (*db.Post, error) {
220+	post := &db.Post{}
221+	err := r.Scan(
222+		&post.ID,
223+		&post.UserID,
224+		&post.Username,
225+		&post.Filename,
226+		&post.Slug,
227+		&post.Title,
228+		&post.Text,
229+		&post.Description,
230+		&post.CreatedAt,
231+		&post.PublishAt,
232+		&post.UpdatedAt,
233+		&post.Hidden,
234+		&post.FileSize,
235+		&post.MimeType,
236+		&post.Shasum,
237+		&post.Data,
238+	)
239+	if err != nil {
240+		return nil, err
241+	}
242+	return post, nil
243+}
244+
245+func CreatePostWithTagsFromRow(r RowScanner) (*db.Post, error) {
246+	post := &db.Post{}
247+	tagStr := ""
248+	err := r.Scan(
249+		&post.ID,
250+		&post.UserID,
251+		&post.Username,
252+		&post.Filename,
253+		&post.Slug,
254+		&post.Title,
255+		&post.Text,
256+		&post.Description,
257+		&post.CreatedAt,
258+		&post.PublishAt,
259+		&post.UpdatedAt,
260+		&post.Hidden,
261+		&post.FileSize,
262+		&post.MimeType,
263+		&post.Shasum,
264+		&post.Data,
265+		&tagStr,
266+	)
267+	if err != nil {
268+		return nil, err
269+	}
270+
271+	tags := strings.Split(tagStr, ",")
272+	for _, tag := range tags {
273+		tg := strings.TrimSpace(tag)
274+		if tg == "" {
275+			continue
276+		}
277+		post.Tags = append(post.Tags, tg)
278+	}
279+
280+	return post, nil
281+}
282+
283 func NewDB(cfg *config.ConfigCms) *PsqlDB {
284 	databaseUrl := cfg.DbURL
285 	var err error
286@@ -280,21 +431,9 @@ func (me *PsqlDB) FindPostsBeforeDate(date *time.Time, space string) ([]*db.Post
287 		return posts, err
288 	}
289 	for rs.Next() {
290-		post := &db.Post{}
291-		err := rs.Scan(
292-			&post.ID,
293-			&post.UserID,
294-			&post.Filename,
295-			&post.Slug,
296-			&post.Title,
297-			&post.Text,
298-			&post.Description,
299-			&post.PublishAt,
300-			&post.Username,
301-			&post.UpdatedAt,
302-		)
303+		post, err := CreatePostFromRow(rs)
304 		if err != nil {
305-			return posts, err
306+			return nil, err
307 		}
308 
309 		posts = append(posts, post)
310@@ -399,20 +538,8 @@ func (me *PsqlDB) SetUserName(userID string, name string) error {
311 }
312 
313 func (me *PsqlDB) FindPostWithFilename(filename string, persona_id string, space string) (*db.Post, error) {
314-	post := &db.Post{}
315 	r := me.Db.QueryRow(sqlSelectPostWithFilename, filename, persona_id, space)
316-	err := r.Scan(
317-		&post.ID,
318-		&post.UserID,
319-		&post.Filename,
320-		&post.Slug,
321-		&post.Title,
322-		&post.Text,
323-		&post.Description,
324-		&post.PublishAt,
325-		&post.Username,
326-		&post.UpdatedAt,
327-	)
328+	post, err := CreatePostWithTagsFromRow(r)
329 	if err != nil {
330 		return nil, err
331 	}
332@@ -421,20 +548,8 @@ func (me *PsqlDB) FindPostWithFilename(filename string, persona_id string, space
333 }
334 
335 func (me *PsqlDB) FindPostWithSlug(slug string, user_id string, space string) (*db.Post, error) {
336-	post := &db.Post{}
337 	r := me.Db.QueryRow(sqlSelectPostWithSlug, slug, user_id, space)
338-	err := r.Scan(
339-		&post.ID,
340-		&post.UserID,
341-		&post.Filename,
342-		&post.Slug,
343-		&post.Title,
344-		&post.Text,
345-		&post.Description,
346-		&post.PublishAt,
347-		&post.Username,
348-		&post.UpdatedAt,
349-	)
350+	post, err := CreatePostWithTagsFromRow(r)
351 	if err != nil {
352 		return nil, err
353 	}
354@@ -443,20 +558,8 @@ func (me *PsqlDB) FindPostWithSlug(slug string, user_id string, space string) (*
355 }
356 
357 func (me *PsqlDB) FindPost(postID string) (*db.Post, error) {
358-	post := &db.Post{}
359 	r := me.Db.QueryRow(sqlSelectPost, postID)
360-	err := r.Scan(
361-		&post.ID,
362-		&post.UserID,
363-		&post.Filename,
364-		&post.Slug,
365-		&post.Title,
366-		&post.Text,
367-		&post.Description,
368-		&post.PublishAt,
369-		&post.Username,
370-		&post.UpdatedAt,
371-	)
372+	post, err := CreatePostFromRow(r)
373 	if err != nil {
374 		return nil, err
375 	}
376@@ -521,9 +624,24 @@ func (me *PsqlDB) FindAllUpdatedPosts(page *db.Pager, space string) (*db.Paginat
377 	return me.postPager(rs, page.Num, space)
378 }
379 
380-func (me *PsqlDB) InsertPost(userID, filename, slug, title, text, description string, publishAt *time.Time, hidden bool, space string) (*db.Post, error) {
381+func (me *PsqlDB) InsertPost(post *db.Post) (*db.Post, error) {
382 	var id string
383-	err := me.Db.QueryRow(sqlInsertPost, userID, filename, slug, title, text, description, publishAt, hidden, space).Scan(&id)
384+	err := me.Db.QueryRow(
385+		sqlInsertPost,
386+		post.UserID,
387+		post.Filename,
388+		post.Slug,
389+		post.Title,
390+		post.Text,
391+		post.Description,
392+		post.PublishAt,
393+		post.Hidden,
394+		post.Space,
395+		post.FileSize,
396+		post.MimeType,
397+		post.Shasum,
398+		post.Data,
399+	).Scan(&id)
400 	if err != nil {
401 		return nil, err
402 	}
403@@ -531,13 +649,25 @@ func (me *PsqlDB) InsertPost(userID, filename, slug, title, text, description st
404 	return me.FindPost(id)
405 }
406 
407-func (me *PsqlDB) UpdatePost(postID, slug, title, text, description string, publishAt *time.Time) (*db.Post, error) {
408-	_, err := me.Db.Exec(sqlUpdatePost, slug, title, text, description, time.Now(), publishAt, postID)
409+func (me *PsqlDB) UpdatePost(post *db.Post) (*db.Post, error) {
410+	_, err := me.Db.Exec(
411+		sqlUpdatePost,
412+		post.Slug,
413+		post.Title,
414+		post.Text,
415+		post.Description,
416+		time.Now(),
417+		post.PublishAt,
418+		post.FileSize,
419+		post.Shasum,
420+		post.Data,
421+		post.ID,
422+	)
423 	if err != nil {
424 		return nil, err
425 	}
426 
427-	return me.FindPost(postID)
428+	return me.FindPost(post.ID)
429 }
430 
431 func (me *PsqlDB) RemovePosts(postIDs []string) error {
432@@ -553,21 +683,9 @@ func (me *PsqlDB) FindPostsForUser(userID string, space string) ([]*db.Post, err
433 		return posts, err
434 	}
435 	for rs.Next() {
436-		post := &db.Post{}
437-		err := rs.Scan(
438-			&post.ID,
439-			&post.UserID,
440-			&post.Filename,
441-			&post.Slug,
442-			&post.Title,
443-			&post.Text,
444-			&post.Description,
445-			&post.PublishAt,
446-			&post.Username,
447-			&post.UpdatedAt,
448-		)
449+		post, err := CreatePostWithTagsFromRow(rs)
450 		if err != nil {
451-			return posts, err
452+			return nil, err
453 		}
454 
455 		posts = append(posts, post)
456@@ -585,21 +703,9 @@ func (me *PsqlDB) FindAllPostsForUser(userID string, space string) ([]*db.Post,
457 		return posts, err
458 	}
459 	for rs.Next() {
460-		post := &db.Post{}
461-		err := rs.Scan(
462-			&post.ID,
463-			&post.UserID,
464-			&post.Filename,
465-			&post.Slug,
466-			&post.Title,
467-			&post.Text,
468-			&post.Description,
469-			&post.PublishAt,
470-			&post.Username,
471-			&post.UpdatedAt,
472-		)
473+		post, err := CreatePostFromRow(rs)
474 		if err != nil {
475-			return posts, err
476+			return nil, err
477 		}
478 
479 		posts = append(posts, post)
480@@ -617,22 +723,9 @@ func (me *PsqlDB) FindPosts() ([]*db.Post, error) {
481 		return posts, err
482 	}
483 	for rs.Next() {
484-		post := &db.Post{}
485-		err := rs.Scan(
486-			&post.ID,
487-			&post.UserID,
488-			&post.Filename,
489-			&post.Slug,
490-			&post.Title,
491-			&post.Text,
492-			&post.Description,
493-			&post.CreatedAt,
494-			&post.PublishAt,
495-			&post.UpdatedAt,
496-			&post.Hidden,
497-		)
498+		post, err := CreatePostFromRow(rs)
499 		if err != nil {
500-			return posts, err
501+			return nil, err
502 		}
503 
504 		posts = append(posts, post)
505@@ -650,21 +743,9 @@ func (me *PsqlDB) FindUpdatedPostsForUser(userID string, space string) ([]*db.Po
506 		return posts, err
507 	}
508 	for rs.Next() {
509-		post := &db.Post{}
510-		err := rs.Scan(
511-			&post.ID,
512-			&post.UserID,
513-			&post.Filename,
514-			&post.Slug,
515-			&post.Title,
516-			&post.Text,
517-			&post.Description,
518-			&post.PublishAt,
519-			&post.Username,
520-			&post.UpdatedAt,
521-		)
522+		post, err := CreatePostFromRow(rs)
523 		if err != nil {
524-			return posts, err
525+			return nil, err
526 		}
527 
528 		posts = append(posts, post)
529@@ -764,21 +845,9 @@ func (me *PsqlDB) FindUserPostsByTag(tag, userID, space string) ([]*db.Post, err
530 		return posts, err
531 	}
532 	for rs.Next() {
533-		post := &db.Post{}
534-		err := rs.Scan(
535-			&post.ID,
536-			&post.UserID,
537-			&post.Filename,
538-			&post.Slug,
539-			&post.Title,
540-			&post.Text,
541-			&post.Description,
542-			&post.PublishAt,
543-			&post.Username,
544-			&post.UpdatedAt,
545-		)
546+		post, err := CreatePostFromRow(rs)
547 		if err != nil {
548-			return posts, err
549+			return nil, err
550 		}
551 
552 		posts = append(posts, post)
553@@ -796,21 +865,9 @@ func (me *PsqlDB) FindPostsByTag(tag, space string) ([]*db.Post, error) {
554 		return posts, err
555 	}
556 	for rs.Next() {
557-		post := &db.Post{}
558-		err := rs.Scan(
559-			&post.ID,
560-			&post.UserID,
561-			&post.Filename,
562-			&post.Slug,
563-			&post.Title,
564-			&post.Text,
565-			&post.Description,
566-			&post.PublishAt,
567-			&post.Username,
568-			&post.UpdatedAt,
569-		)
570+		post, err := CreatePostFromRow(rs)
571 		if err != nil {
572-			return posts, err
573+			return nil, err
574 		}
575 
576 		posts = append(posts, post)
577@@ -829,15 +886,104 @@ func (me *PsqlDB) FindPopularTags() ([]string, error) {
578 	}
579 	for rs.Next() {
580 		name := ""
581-		err := rs.Scan(name)
582+		err := rs.Scan(&name)
583+		if err != nil {
584+			return tags, err
585+		}
586+
587+		tags = append(tags, name)
588+	}
589+	if rs.Err() != nil {
590+		return tags, rs.Err()
591+	}
592+	return tags, nil
593+}
594+
595+func (me *PsqlDB) FindTagsForPost(postID string) ([]string, error) {
596+	tags := make([]string, 0)
597+	rs, err := me.Db.Query(sqlSelectTagsForPost, postID)
598+	if err != nil {
599+		return tags, err
600+	}
601+
602+	for rs.Next() {
603+		name := ""
604+		err := rs.Scan(&name)
605 		if err != nil {
606 			return tags, err
607 		}
608 
609 		tags = append(tags, name)
610 	}
611+
612 	if rs.Err() != nil {
613 		return tags, rs.Err()
614 	}
615+
616 	return tags, nil
617 }
618+
619+/* func (me *PsqlDB) FindPostsWithTagsForUser(userID, space string) ([]*db.Post, error) {
620+	var posts []*db.Post
621+	rs, err := me.Db.Query(sqlSelectUserPostsWithTags, userID, space)
622+	if err != nil {
623+		return posts, err
624+	}
625+	for rs.Next() {
626+		tagStr := ""
627+		post := &db.Post{}
628+		err := rs.Scan(
629+			&post.ID,
630+			&post.UserID,
631+			&post.Username,
632+			&post.Filename,
633+			&post.Slug,
634+			&post.Title,
635+			&post.Text,
636+			&post.Description,
637+			&post.CreatedAt,
638+			&post.PublishAt,
639+			&post.UpdatedAt,
640+			&post.Hidden,
641+			&post.FileSize,
642+			&post.MimeType,
643+			&post.Shasum,
644+			&tagStr,
645+		)
646+		if err != nil {
647+			return nil, err
648+		}
649+		tags := strings.Split(tagStr, ",")
650+		for _, tag := range tags {
651+			tg := strings.TrimSpace(tag)
652+			if tg == "" {
653+				continue
654+			}
655+			post.Tags = append(post.Tags, tg)
656+		}
657+
658+		posts = append(posts, post)
659+	}
660+	if rs.Err() != nil {
661+		return posts, rs.Err()
662+	}
663+	return posts, nil
664+} */
665+
666+func (me *PsqlDB) HasFeatureForUser(userID string, feature string) bool {
667+	var id string
668+	err := me.Db.QueryRow(sqlSelectFeatureForUser, userID, feature).Scan(&id)
669+	if err != nil {
670+		return false
671+	}
672+	return id != ""
673+}
674+
675+func (me *PsqlDB) FindTotalSizeForUser(userID string) (int, error) {
676+	var fileSize int
677+	err := me.Db.QueryRow(sqlSelectSizeForUser, userID).Scan(&fileSize)
678+	if err != nil {
679+		return 0, err
680+	}
681+	return fileSize, nil
682+}
M filehandlers/post_handler.go
+78, -46
  1@@ -1,8 +1,11 @@
  2 package filehandlers
  3 
  4 import (
  5+	"encoding/binary"
  6 	"fmt"
  7 	"io"
  8+	"net/http"
  9+	"path"
 10 	"strings"
 11 	"time"
 12 
 13@@ -14,19 +17,16 @@ import (
 14 )
 15 
 16 type PostMetaData struct {
 17-	Filename    string
 18-	Slug        string
 19-	Text        string
 20-	Title       string
 21-	Description string
 22-	PublishAt   *time.Time
 23-	Hidden      bool
 24-	Tags        []string
 25+	*db.Post
 26+	Cur       *db.Post
 27+	Tags      []string
 28+	User      *db.User
 29+	FileEntry *utils.FileEntry
 30 }
 31 
 32 type ScpFileHooks interface {
 33-	FileValidate(text string, filename string) (bool, error)
 34-	FileMeta(text string, data *PostMetaData) error
 35+	FileValidate(data *PostMetaData) (bool, error)
 36+	FileMeta(data *PostMetaData) error
 37 }
 38 
 39 type ScpUploadHandler struct {
 40@@ -74,35 +74,56 @@ func (h *ScpUploadHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
 41 		return "", fmt.Errorf("error for %s: %v", filename, err)
 42 	}
 43 
 44-	var text string
 45+	var text []byte
 46 	if b, err := io.ReadAll(entry.Reader); err == nil {
 47-		text = string(b)
 48+		text = b
 49 	}
 50 
 51-	valid, err := h.Hooks.FileValidate(text, entry.Filepath)
 52+	now := time.Now()
 53+	slug := shared.SanitizeFileExt(filename)
 54+	fileSize := binary.Size(text)
 55+	shasum := shared.Shasum(text)
 56+
 57+	nextPost := db.Post{
 58+		Filename:  filename,
 59+		Slug:      slug,
 60+		PublishAt: &now,
 61+		Text:      string(text),
 62+		MimeType:  http.DetectContentType(text),
 63+		FileSize:  fileSize,
 64+		Shasum:    shasum,
 65+	}
 66+
 67+	ext := path.Ext(filename)
 68+	// DetectContentType does not detect markdown
 69+	if ext == ".md" {
 70+		nextPost.MimeType = "text/markdown; charset=UTF-8"
 71+	}
 72+
 73+	metadata := PostMetaData{
 74+		Post:      &nextPost,
 75+		User:      h.User,
 76+		FileEntry: entry,
 77+	}
 78+
 79+	valid, err := h.Hooks.FileValidate(&metadata)
 80 	if !valid {
 81+		logger.Info(err)
 82 		return "", err
 83 	}
 84 
 85-	post, err := h.DBPool.FindPostWithFilename(filename, userID, h.Cfg.Space)
 86+	post, err := h.DBPool.FindPostWithFilename(metadata.Filename, metadata.User.ID, h.Cfg.Space)
 87 	if err != nil {
 88-		logger.Debugf("unable to load post (%s), continuing", filename)
 89-		logger.Debug(err)
 90+		logger.Infof("unable to load post (%s), continuing", filename)
 91+		logger.Info(err)
 92 	}
 93 
 94-	now := time.Now()
 95-	slug := shared.SanitizeFileExt(filename)
 96-	metadata := PostMetaData{
 97-		Filename:  filename,
 98-		Slug:      slug,
 99-		Title:     shared.ToUpper(slug),
100-		PublishAt: &now,
101-	}
102 	if post != nil {
103-		metadata.PublishAt = post.PublishAt
104+		metadata.Cur = post
105+		metadata.Post.PublishAt = post.PublishAt
106 	}
107 
108-	err = h.Hooks.FileMeta(text, &metadata)
109+	err = h.Hooks.FileMeta(&metadata)
110 	if err != nil {
111 		logger.Error(err)
112 		return "", err
113@@ -124,17 +145,23 @@ func (h *ScpUploadHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
114 		}
115 	} else if post == nil {
116 		logger.Infof("(%s) not found, adding record", filename)
117-		post, err = h.DBPool.InsertPost(
118-			userID,
119-			filename,
120-			metadata.Slug,
121-			metadata.Title,
122-			text,
123-			metadata.Description,
124-			metadata.PublishAt,
125-			metadata.Hidden,
126-			h.Cfg.Space,
127-		)
128+		insertPost := db.Post{
129+			UserID: userID,
130+			Space:  h.Cfg.Space,
131+
132+			Data:        metadata.Data,
133+			Description: metadata.Description,
134+			Filename:    metadata.Filename,
135+			FileSize:    metadata.FileSize,
136+			Hidden:      metadata.Hidden,
137+			MimeType:    metadata.MimeType,
138+			PublishAt:   metadata.PublishAt,
139+			Shasum:      metadata.Shasum,
140+			Slug:        metadata.Slug,
141+			Text:        metadata.Text,
142+			Title:       metadata.Title,
143+		}
144+		post, err = h.DBPool.InsertPost(&insertPost)
145 		if err != nil {
146 			logger.Errorf("error for %s: %v", filename, err)
147 			return "", fmt.Errorf("error for %s: %v", filename, err)
148@@ -152,20 +179,25 @@ func (h *ScpUploadHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
149 			}
150 		}
151 	} else {
152-		if text == post.Text {
153+		if metadata.Text == post.Text {
154 			logger.Infof("(%s) found, but text is identical, skipping", filename)
155 			return h.Cfg.FullPostURL(user.Name, metadata.Slug, h.Cfg.IsSubdomains(), true), nil
156 		}
157 
158 		logger.Infof("(%s) found, updating record", filename)
159-		_, err = h.DBPool.UpdatePost(
160-			post.ID,
161-			metadata.Slug,
162-			metadata.Title,
163-			text,
164-			metadata.Description,
165-			metadata.PublishAt,
166-		)
167+		updatePost := db.Post{
168+			ID: post.ID,
169+
170+			Data:        metadata.Data,
171+			FileSize:    metadata.FileSize,
172+			Description: metadata.Description,
173+			PublishAt:   metadata.PublishAt,
174+			Slug:        metadata.Slug,
175+			Shasum:      metadata.Shasum,
176+			Text:        metadata.Text,
177+			Title:       metadata.Title,
178+		}
179+		_, err = h.DBPool.UpdatePost(&updatePost)
180 		if err != nil {
181 			logger.Errorf("error for %s: %v", filename, err)
182 			return "", fmt.Errorf("error for %s: %v", filename, err)
A imgs/api.go
+928, -0
  1@@ -0,0 +1,928 @@
  2+package imgs
  3+
  4+import (
  5+	"bytes"
  6+	"fmt"
  7+	"html/template"
  8+	"net/http"
  9+	"net/url"
 10+	"sort"
 11+	"strings"
 12+	"time"
 13+
 14+	"git.sr.ht/~erock/pico/db"
 15+	"git.sr.ht/~erock/pico/db/postgres"
 16+	"git.sr.ht/~erock/pico/prose"
 17+	"git.sr.ht/~erock/pico/shared"
 18+	"github.com/gorilla/feeds"
 19+	"golang.org/x/exp/slices"
 20+)
 21+
 22+type PageData struct {
 23+	Site shared.SitePageData
 24+}
 25+
 26+type PostItemData struct {
 27+	URL            template.URL
 28+	BlogURL        template.URL
 29+	Username       string
 30+	Title          string
 31+	Description    string
 32+	PublishAtISO   string
 33+	PublishAt      string
 34+	UpdatedAtISO   string
 35+	UpdatedTimeAgo string
 36+	Tags           []string
 37+}
 38+
 39+type TagPageData struct {
 40+	BlogURL   template.URL
 41+	PageTitle string
 42+	Username  string
 43+	URL       template.URL
 44+	Site      shared.SitePageData
 45+	Tag       string
 46+	Posts     []TagPostData
 47+}
 48+
 49+type TagPostData struct {
 50+	URL     template.URL
 51+	ImgURL  template.URL
 52+	Caption string
 53+}
 54+
 55+type BlogPageData struct {
 56+	Site      shared.SitePageData
 57+	PageTitle string
 58+	URL       template.URL
 59+	RSSURL    template.URL
 60+	Username  string
 61+	Readme    *ReadmeTxt
 62+	Header    *HeaderTxt
 63+	Posts     []*PostTagData
 64+	HasFilter bool
 65+}
 66+
 67+type PostPageData struct {
 68+	Site         shared.SitePageData
 69+	PageTitle    string
 70+	URL          template.URL
 71+	BlogURL      template.URL
 72+	Slug         string
 73+	Title        string
 74+	Caption      string
 75+	Contents     template.HTML
 76+	Text         string
 77+	Username     string
 78+	BlogName     string
 79+	PublishAtISO string
 80+	PublishAt    string
 81+	Tags         []Link
 82+	ImgURL       template.URL
 83+	PrevPage     template.URL
 84+	NextPage     template.URL
 85+}
 86+
 87+type TransparencyPageData struct {
 88+	Site      shared.SitePageData
 89+	Analytics *db.Analytics
 90+}
 91+
 92+type Link struct {
 93+	URL  template.URL
 94+	Text string
 95+}
 96+
 97+type HeaderTxt struct {
 98+	Title    string
 99+	Bio      string
100+	Nav      []Link
101+	HasLinks bool
102+}
103+
104+type ReadmeTxt struct {
105+	HasText  bool
106+	Contents template.HTML
107+}
108+
109+type MergePost struct {
110+	Db     db.DB
111+	UserID string
112+	Space  string
113+}
114+
115+var allTag = "all"
116+
117+func ImgURL(c *shared.ConfigSite, username string, slug string, onSubdomain bool, withUserName bool) string {
118+	fname := url.PathEscape(strings.TrimLeft(slug, "/"))
119+	if c.IsSubdomains() && onSubdomain {
120+		return fmt.Sprintf("%s://%s.%s/%s", c.Protocol, username, c.Domain, fname)
121+	}
122+
123+	if withUserName {
124+		return fmt.Sprintf("/%s/%s", username, fname)
125+	}
126+
127+	return fmt.Sprintf("/%s", fname)
128+}
129+
130+func TagURL(c *shared.ConfigSite, username, tag string, onSubdomain, withUserName bool) string {
131+	tg := url.PathEscape(tag)
132+	if c.IsSubdomains() && onSubdomain {
133+		return fmt.Sprintf("%s://%s.%s/t/%s", c.Protocol, username, c.Domain, tg)
134+	}
135+
136+	if withUserName {
137+		return fmt.Sprintf("/%s/t/%s", username, tg)
138+	}
139+
140+	return fmt.Sprintf("/t/%s", tg)
141+}
142+
143+func TagPostURL(c *shared.ConfigSite, username, tag, slug string, onSubdomain, withUserName bool) string {
144+	fname := url.PathEscape(strings.TrimLeft(slug, "/"))
145+	if c.IsSubdomains() && onSubdomain {
146+		return fmt.Sprintf("%s://%s.%s/%s/%s", c.Protocol, username, c.Domain, tag, fname)
147+	}
148+
149+	if withUserName {
150+		return fmt.Sprintf("/%s/%s/%s", username, tag, fname)
151+	}
152+
153+	return fmt.Sprintf("/%s/%s", tag, fname)
154+}
155+
156+func GetPostTitle(post *db.Post) string {
157+	if post.Description == "" {
158+		return post.Title
159+	}
160+
161+	return fmt.Sprintf("%s: %s", post.Title, post.Description)
162+}
163+
164+func GetBlogName(username string) string {
165+	return username
166+}
167+
168+func isRequestTrackable(r *http.Request) bool {
169+	return true
170+}
171+
172+type PostTagData struct {
173+	URL       template.URL
174+	ImgURL    template.URL
175+	Tag       string
176+	PublishAt *time.Time
177+}
178+
179+func blogHandler(w http.ResponseWriter, r *http.Request) {
180+	username := shared.GetUsernameFromRequest(r)
181+	dbpool := shared.GetDB(r)
182+	logger := shared.GetLogger(r)
183+	cfg := shared.GetCfg(r)
184+
185+	user, err := dbpool.FindUserForName(username)
186+	if err != nil {
187+		logger.Infof("blog not found: %s", username)
188+		http.Error(w, "blog not found", http.StatusNotFound)
189+		return
190+	}
191+
192+	posts, err := dbpool.FindPostsForUser(user.ID, cfg.Space)
193+
194+	if err != nil {
195+		logger.Error(err)
196+		http.Error(w, "could not fetch posts for blog", http.StatusInternalServerError)
197+		return
198+	}
199+
200+	hostDomain := strings.Split(r.Host, ":")[0]
201+	appDomain := strings.Split(cfg.ConfigCms.Domain, ":")[0]
202+
203+	onSubdomain := cfg.IsSubdomains() && strings.Contains(hostDomain, appDomain)
204+	withUserName := (!onSubdomain && hostDomain == appDomain) || !cfg.IsCustomdomains()
205+
206+	ts, err := shared.RenderTemplate(cfg, []string{
207+		cfg.StaticPath("html/blog.page.tmpl"),
208+	})
209+
210+	if err != nil {
211+		logger.Error(err)
212+		http.Error(w, err.Error(), http.StatusInternalServerError)
213+		return
214+	}
215+
216+	headerTxt := &HeaderTxt{
217+		Title: GetBlogName(username),
218+		Bio:   "",
219+	}
220+	readmeTxt := &ReadmeTxt{}
221+
222+	tagMap := make(map[string]*db.Post, len(posts))
223+	for _, post := range posts {
224+		if post.Hidden {
225+			continue
226+		}
227+
228+		for _, tag := range post.Tags {
229+			if tagMap[tag] == nil {
230+				tagMap[tag] = post
231+			}
232+		}
233+
234+		if tagMap[allTag] == nil {
235+			tagMap[allTag] = post
236+		}
237+	}
238+
239+	postCollection := make([]*PostTagData, 0, len(tagMap))
240+	for key, post := range tagMap {
241+		postCollection = append(postCollection, &PostTagData{
242+			Tag:       key,
243+			URL:       template.URL(TagURL(cfg, post.Username, key, onSubdomain, withUserName)),
244+			ImgURL:    template.URL(ImgURL(cfg, post.Username, post.Filename, onSubdomain, withUserName)),
245+			PublishAt: post.PublishAt,
246+		})
247+	}
248+
249+	sort.Slice(postCollection, func(i, j int) bool {
250+		return postCollection[i].PublishAt.After(*postCollection[j].PublishAt)
251+	})
252+
253+	data := BlogPageData{
254+		Site:      *cfg.GetSiteData(),
255+		PageTitle: headerTxt.Title,
256+		URL:       template.URL(cfg.FullBlogURL(username, onSubdomain, withUserName)),
257+		RSSURL:    template.URL(cfg.RssBlogURL(username, onSubdomain, withUserName, "")),
258+		Readme:    readmeTxt,
259+		Header:    headerTxt,
260+		Username:  username,
261+		Posts:     postCollection,
262+	}
263+
264+	err = ts.Execute(w, data)
265+	if err != nil {
266+		logger.Error(err)
267+		http.Error(w, err.Error(), http.StatusInternalServerError)
268+	}
269+}
270+
271+func imgHandler(w http.ResponseWriter, r *http.Request) {
272+	username := shared.GetUsernameFromRequest(r)
273+	subdomain := shared.GetSubdomain(r)
274+	cfg := shared.GetCfg(r)
275+
276+	var filename string
277+	if !cfg.IsSubdomains() || subdomain == "" {
278+		filename, _ = url.PathUnescape(shared.GetField(r, 1))
279+	} else {
280+		filename, _ = url.PathUnescape(shared.GetField(r, 0))
281+	}
282+
283+	dbpool := shared.GetDB(r)
284+	logger := shared.GetLogger(r)
285+
286+	user, err := dbpool.FindUserForName(username)
287+	if err != nil {
288+		logger.Infof("blog not found: %s", username)
289+		http.Error(w, "blog not found", http.StatusNotFound)
290+		return
291+	}
292+
293+	post, err := dbpool.FindPostWithFilename(filename, user.ID, cfg.Space)
294+	if err != nil {
295+		logger.Infof("image not found %s/%s", username, filename)
296+		http.Error(w, err.Error(), http.StatusInternalServerError)
297+		return
298+	}
299+
300+	// validate and fire off analytic event
301+	if isRequestTrackable(r) {
302+		_, err := dbpool.AddViewCount(post.ID)
303+		if err != nil {
304+			logger.Error(err)
305+		}
306+	}
307+
308+	storage := NewStorageFS(cfg.StorageDir)
309+	bucket, err := storage.GetBucket(user.ID)
310+	if err != nil {
311+		logger.Infof("bucket not found %s/%s", username, filename)
312+		http.Error(w, err.Error(), http.StatusInternalServerError)
313+		return
314+	}
315+	contents, err := storage.GetFile(bucket, post.Filename)
316+	if err != nil {
317+		logger.Infof("file not found %s/%s", username, post.Filename)
318+		http.Error(w, err.Error(), http.StatusInternalServerError)
319+		return
320+	}
321+
322+	w.Header().Add("Content-Type", "image/png")
323+	_, err = w.Write(contents)
324+	if err != nil {
325+		logger.Error(err)
326+	}
327+}
328+
329+func tagHandler(w http.ResponseWriter, r *http.Request) {
330+	username := shared.GetUsernameFromRequest(r)
331+	subdomain := shared.GetSubdomain(r)
332+	cfg := shared.GetCfg(r)
333+
334+	tag := ""
335+	if !cfg.IsSubdomains() || subdomain == "" {
336+		tag, _ = url.PathUnescape(shared.GetField(r, 1))
337+	} else {
338+		tag, _ = url.PathUnescape(shared.GetField(r, 0))
339+	}
340+
341+	dbpool := shared.GetDB(r)
342+	logger := shared.GetLogger(r)
343+
344+	user, err := dbpool.FindUserForName(username)
345+	if err != nil {
346+		logger.Infof("blog not found: %s", username)
347+		http.Error(w, "blog not found", http.StatusNotFound)
348+		return
349+	}
350+
351+	hostDomain := strings.Split(r.Host, ":")[0]
352+	appDomain := strings.Split(cfg.ConfigCms.Domain, ":")[0]
353+
354+	onSubdomain := cfg.IsSubdomains() && strings.Contains(hostDomain, appDomain)
355+	withUserName := (!onSubdomain && hostDomain == appDomain) || !cfg.IsCustomdomains()
356+
357+	posts, err := dbpool.FindPostsForUser(user.ID, cfg.Space)
358+	if err != nil {
359+		logger.Infof("tag not found: %s/%s", username, tag)
360+		http.Error(w, "tag not found", http.StatusNotFound)
361+		return
362+	}
363+
364+	mergedPosts := make([]TagPostData, 0)
365+	for _, post := range posts {
366+		if post.Hidden {
367+			continue
368+		}
369+
370+		if tag != allTag && !slices.Contains(post.Tags, tag) {
371+			continue
372+		}
373+		mergedPosts = append(mergedPosts, TagPostData{
374+			URL:     template.URL(TagPostURL(cfg, username, tag, post.Slug, onSubdomain, withUserName)),
375+			ImgURL:  template.URL(ImgURL(cfg, username, post.Filename, onSubdomain, withUserName)),
376+			Caption: post.Title,
377+		})
378+	}
379+
380+	data := TagPageData{
381+		BlogURL:   template.URL(cfg.FullBlogURL(username, onSubdomain, withUserName)),
382+		Username:  username,
383+		PageTitle: fmt.Sprintf("%s -- %s", tag, username),
384+		Site:      *cfg.GetSiteData(),
385+		Tag:       tag,
386+		Posts:     mergedPosts,
387+		URL:       template.URL(TagURL(cfg, username, tag, onSubdomain, withUserName)),
388+	}
389+
390+	ts, err := shared.RenderTemplate(cfg, []string{
391+		cfg.StaticPath("html/tag.page.tmpl"),
392+	})
393+
394+	if err != nil {
395+		http.Error(w, err.Error(), http.StatusInternalServerError)
396+	}
397+
398+	err = ts.Execute(w, data)
399+	if err != nil {
400+		logger.Error(err)
401+		http.Error(w, err.Error(), http.StatusInternalServerError)
402+	}
403+}
404+
405+func tagPostHandler(w http.ResponseWriter, r *http.Request) {
406+	username := shared.GetUsernameFromRequest(r)
407+	subdomain := shared.GetSubdomain(r)
408+	cfg := shared.GetCfg(r)
409+
410+	tag := ""
411+	slug := ""
412+	if !cfg.IsSubdomains() || subdomain == "" {
413+		tag, _ = url.PathUnescape(shared.GetField(r, 1))
414+		slug, _ = url.PathUnescape(shared.GetField(r, 2))
415+	} else {
416+		tag, _ = url.PathUnescape(shared.GetField(r, 0))
417+		slug, _ = url.PathUnescape(shared.GetField(r, 1))
418+	}
419+
420+	dbpool := shared.GetDB(r)
421+	logger := shared.GetLogger(r)
422+
423+	user, err := dbpool.FindUserForName(username)
424+	if err != nil {
425+		logger.Infof("blog not found: %s", username)
426+		http.Error(w, "blog not found", http.StatusNotFound)
427+		return
428+	}
429+
430+	blogName := GetBlogName(username)
431+	hostDomain := strings.Split(r.Host, ":")[0]
432+	appDomain := strings.Split(cfg.ConfigCms.Domain, ":")[0]
433+
434+	onSubdomain := cfg.IsSubdomains() && strings.Contains(hostDomain, appDomain)
435+	withUserName := (!onSubdomain && hostDomain == appDomain) || !cfg.IsCustomdomains()
436+
437+	posts, err := dbpool.FindPostsForUser(user.ID, cfg.Space)
438+	if err != nil {
439+		logger.Infof("tag not found: %s/%s", username, tag)
440+		http.Error(w, "tag not found", http.StatusNotFound)
441+		return
442+	}
443+
444+	mergedPosts := make([]db.Post, 0)
445+	for _, post := range posts {
446+		if post.Hidden {
447+			continue
448+		}
449+
450+		if !slices.Contains(post.Tags, tag) {
451+			continue
452+		}
453+		mergedPosts = append(mergedPosts, *post)
454+	}
455+
456+	prevPage := ""
457+	nextPage := ""
458+	for i, post := range mergedPosts {
459+		if post.Slug != slug {
460+			continue
461+		}
462+
463+		if i+1 < len(mergedPosts) {
464+			nextPage = TagPostURL(
465+				cfg,
466+				username,
467+				tag,
468+				mergedPosts[i+1].Slug,
469+				onSubdomain,
470+				withUserName,
471+			)
472+		}
473+
474+		if i-1 >= 0 {
475+			prevPage = TagPostURL(
476+				cfg,
477+				username,
478+				tag,
479+				mergedPosts[i-1].Slug,
480+				onSubdomain,
481+				withUserName,
482+			)
483+		}
484+	}
485+
486+	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
487+	if err != nil {
488+		logger.Infof("post not found: %s/%s", username, slug)
489+		http.Error(w, "post not found", http.StatusNotFound)
490+		return
491+	}
492+
493+	parsed, err := prose.ParseText(post.Text)
494+	if err != nil {
495+		logger.Error(err)
496+	}
497+	text := ""
498+	if parsed != nil {
499+		text = parsed.Html
500+	}
501+
502+	tagLinks := make([]Link, 0, len(post.Tags))
503+	for _, tag := range post.Tags {
504+		tagLinks = append(tagLinks, Link{
505+			URL:  template.URL(TagURL(cfg, username, tag, onSubdomain, withUserName)),
506+			Text: tag,
507+		})
508+	}
509+
510+	data := PostPageData{
511+		Site:         *cfg.GetSiteData(),
512+		PageTitle:    GetPostTitle(post),
513+		URL:          template.URL(cfg.FullPostURL(post.Username, post.Slug, onSubdomain, withUserName)),
514+		BlogURL:      template.URL(cfg.FullBlogURL(username, onSubdomain, withUserName)),
515+		Caption:      post.Description,
516+		Title:        post.Title,
517+		Slug:         post.Slug,
518+		PublishAt:    post.PublishAt.Format("02 Jan, 2006"),
519+		PublishAtISO: post.PublishAt.Format(time.RFC3339),
520+		Username:     username,
521+		BlogName:     blogName,
522+		Contents:     template.HTML(text),
523+		ImgURL:       template.URL(ImgURL(cfg, username, post.Filename, onSubdomain, withUserName)),
524+		Tags:         tagLinks,
525+		PrevPage:     template.URL(prevPage),
526+		NextPage:     template.URL(nextPage),
527+	}
528+
529+	ts, err := shared.RenderTemplate(cfg, []string{
530+		cfg.StaticPath("html/tag_post.page.tmpl"),
531+	})
532+
533+	if err != nil {
534+		http.Error(w, err.Error(), http.StatusInternalServerError)
535+	}
536+
537+	err = ts.Execute(w, data)
538+	if err != nil {
539+		logger.Error(err)
540+		http.Error(w, err.Error(), http.StatusInternalServerError)
541+	}
542+}
543+
544+func postHandler(w http.ResponseWriter, r *http.Request) {
545+	username := shared.GetUsernameFromRequest(r)
546+	subdomain := shared.GetSubdomain(r)
547+	cfg := shared.GetCfg(r)
548+
549+	var slug string
550+	if !cfg.IsSubdomains() || subdomain == "" {
551+		slug, _ = url.PathUnescape(shared.GetField(r, 1))
552+	} else {
553+		slug, _ = url.PathUnescape(shared.GetField(r, 0))
554+	}
555+
556+	dbpool := shared.GetDB(r)
557+	logger := shared.GetLogger(r)
558+
559+	user, err := dbpool.FindUserForName(username)
560+	if err != nil {
561+		logger.Infof("blog not found: %s", username)
562+		http.Error(w, "blog not found", http.StatusNotFound)
563+		return
564+	}
565+
566+	blogName := GetBlogName(username)
567+	hostDomain := strings.Split(r.Host, ":")[0]
568+	appDomain := strings.Split(cfg.ConfigCms.Domain, ":")[0]
569+
570+	onSubdomain := cfg.IsSubdomains() && strings.Contains(hostDomain, appDomain)
571+	withUserName := (!onSubdomain && hostDomain == appDomain) || !cfg.IsCustomdomains()
572+
573+	var data PostPageData
574+	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
575+	if err == nil {
576+		parsed, err := prose.ParseText(post.Text)
577+		if err != nil {
578+			logger.Error(err)
579+		}
580+		text := ""
581+		if parsed != nil {
582+			text = parsed.Html
583+		}
584+
585+		tagLinks := make([]Link, 0, len(post.Tags))
586+		for _, tag := range post.Tags {
587+			tagLinks = append(tagLinks, Link{
588+				URL:  template.URL(TagURL(cfg, username, tag, onSubdomain, withUserName)),
589+				Text: tag,
590+			})
591+		}
592+
593+		data = PostPageData{
594+			Site:         *cfg.GetSiteData(),
595+			PageTitle:    GetPostTitle(post),
596+			URL:          template.URL(cfg.FullPostURL(post.Username, post.Slug, onSubdomain, withUserName)),
597+			BlogURL:      template.URL(cfg.FullBlogURL(username, onSubdomain, withUserName)),
598+			Caption:      post.Description,
599+			Title:        post.Title,
600+			Slug:         post.Slug,
601+			PublishAt:    post.PublishAt.Format("02 Jan, 2006"),
602+			PublishAtISO: post.PublishAt.Format(time.RFC3339),
603+			Username:     username,
604+			BlogName:     blogName,
605+			Contents:     template.HTML(text),
606+			ImgURL:       template.URL(ImgURL(cfg, username, post.Filename, onSubdomain, withUserName)),
607+			Tags:         tagLinks,
608+		}
609+	} else {
610+		data = PostPageData{
611+			Site:         *cfg.GetSiteData(),
612+			BlogURL:      template.URL(cfg.FullBlogURL(username, onSubdomain, withUserName)),
613+			PageTitle:    "Post not found",
614+			Caption:      "Post not found",
615+			Title:        "Post not found",
616+			PublishAt:    time.Now().Format("02 Jan, 2006"),
617+			PublishAtISO: time.Now().Format(time.RFC3339),
618+			Username:     username,
619+			BlogName:     blogName,
620+		}
621+		logger.Infof("post not found %s/%s", username, slug)
622+	}
623+
624+	ts, err := shared.RenderTemplate(cfg, []string{
625+		cfg.StaticPath("html/post.page.tmpl"),
626+	})
627+
628+	if err != nil {
629+		http.Error(w, err.Error(), http.StatusInternalServerError)
630+	}
631+
632+	err = ts.Execute(w, data)
633+	if err != nil {
634+		logger.Error(err)
635+		http.Error(w, err.Error(), http.StatusInternalServerError)
636+	}
637+}
638+
639+func transparencyHandler(w http.ResponseWriter, r *http.Request) {
640+	dbpool := shared.GetDB(r)
641+	logger := shared.GetLogger(r)
642+	cfg := shared.GetCfg(r)
643+
644+	analytics, err := dbpool.FindSiteAnalytics(cfg.Space)
645+	if err != nil {
646+		logger.Error(err)
647+		http.Error(w, err.Error(), http.StatusInternalServerError)
648+		return
649+	}
650+
651+	ts, err := template.ParseFiles(
652+		cfg.StaticPath("html/transparency.page.tmpl"),
653+		cfg.StaticPath("html/footer.partial.tmpl"),
654+		cfg.StaticPath("html/marketing-footer.partial.tmpl"),
655+		cfg.StaticPath("html/base.layout.tmpl"),
656+	)
657+
658+	if err != nil {
659+		http.Error(w, err.Error(), http.StatusInternalServerError)
660+	}
661+
662+	data := TransparencyPageData{
663+		Site:      *cfg.GetSiteData(),
664+		Analytics: analytics,
665+	}
666+	err = ts.Execute(w, data)
667+	if err != nil {
668+		logger.Error(err)
669+		http.Error(w, err.Error(), http.StatusInternalServerError)
670+	}
671+}
672+
673+func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
674+	username := shared.GetUsernameFromRequest(r)
675+	dbpool := shared.GetDB(r)
676+	logger := shared.GetLogger(r)
677+	cfg := shared.GetCfg(r)
678+
679+	user, err := dbpool.FindUserForName(username)
680+	if err != nil {
681+		logger.Infof("rss feed not found: %s", username)
682+		http.Error(w, "rss feed not found", http.StatusNotFound)
683+		return
684+	}
685+
686+	posts, err := dbpool.FindPostsForUser(user.ID, cfg.Space)
687+
688+	if err != nil {
689+		logger.Error(err)
690+		http.Error(w, err.Error(), http.StatusInternalServerError)
691+		return
692+	}
693+
694+	ts, err := template.ParseFiles(cfg.StaticPath("html/rss.page.tmpl"))
695+	if err != nil {
696+		logger.Error(err)
697+		http.Error(w, err.Error(), http.StatusInternalServerError)
698+		return
699+	}
700+
701+	headerTxt := &HeaderTxt{
702+		Title: GetBlogName(username),
703+	}
704+
705+	hostDomain := strings.Split(r.Host, ":")[0]
706+	appDomain := strings.Split(cfg.ConfigCms.Domain, ":")[0]
707+
708+	onSubdomain := cfg.IsSubdomains() && strings.Contains(hostDomain, appDomain)
709+	withUserName := (!onSubdomain && hostDomain == appDomain) || !cfg.IsCustomdomains()
710+
711+	feed := &feeds.Feed{
712+		Title:       headerTxt.Title,
713+		Link:        &feeds.Link{Href: cfg.FullBlogURL(username, onSubdomain, withUserName)},
714+		Description: headerTxt.Bio,
715+		Author:      &feeds.Author{Name: username},
716+		Created:     time.Now(),
717+	}
718+
719+	var feedItems []*feeds.Item
720+	for _, post := range posts {
721+		if slices.Contains(cfg.HiddenPosts, post.Filename) {
722+			continue
723+		}
724+		var tpl bytes.Buffer
725+		data := &PostPageData{
726+			ImgURL: template.URL(ImgURL(cfg, username, post.Filename, onSubdomain, withUserName)),
727+		}
728+		if err := ts.Execute(&tpl, data); err != nil {
729+			continue
730+		}
731+
732+		realUrl := cfg.FullPostURL(post.Username, post.Slug, onSubdomain, withUserName)
733+		if !onSubdomain && !withUserName {
734+			realUrl = fmt.Sprintf("%s://%s%s", cfg.Protocol, r.Host, realUrl)
735+		}
736+
737+		item := &feeds.Item{
738+			Id:      realUrl,
739+			Title:   post.Title,
740+			Link:    &feeds.Link{Href: realUrl},
741+			Created: *post.PublishAt,
742+			Content: tpl.String(),
743+		}
744+
745+		if post.Description != "" {
746+			item.Description = post.Description
747+		}
748+
749+		feedItems = append(feedItems, item)
750+	}
751+	feed.Items = feedItems
752+
753+	rss, err := feed.ToAtom()
754+	if err != nil {
755+		logger.Fatal(err)
756+		http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
757+	}
758+
759+	w.Header().Add("Content-Type", "application/atom+xml")
760+	_, err = w.Write([]byte(rss))
761+	if err != nil {
762+		logger.Error(err)
763+	}
764+}
765+
766+func rssHandler(w http.ResponseWriter, r *http.Request) {
767+	dbpool := shared.GetDB(r)
768+	logger := shared.GetLogger(r)
769+	cfg := shared.GetCfg(r)
770+
771+	pager, err := dbpool.FindAllPosts(&db.Pager{Num: 25, Page: 0}, cfg.Space)
772+	if err != nil {
773+		logger.Error(err)
774+		http.Error(w, err.Error(), http.StatusInternalServerError)
775+		return
776+	}
777+
778+	ts, err := template.ParseFiles(cfg.StaticPath("html/rss.page.tmpl"))
779+	if err != nil {
780+		logger.Error(err)
781+		http.Error(w, err.Error(), http.StatusInternalServerError)
782+		return
783+	}
784+
785+	feed := &feeds.Feed{
786+		Title:       fmt.Sprintf("%s imgs feed", cfg.Domain),
787+		Link:        &feeds.Link{Href: cfg.ReadURL()},
788+		Description: fmt.Sprintf("%s latest image", cfg.Domain),
789+		Author:      &feeds.Author{Name: cfg.Domain},
790+		Created:     time.Now(),
791+	}
792+
793+	hostDomain := strings.Split(r.Host, ":")[0]
794+	appDomain := strings.Split(cfg.ConfigCms.Domain, ":")[0]
795+
796+	onSubdomain := cfg.IsSubdomains() && strings.Contains(hostDomain, appDomain)
797+	withUserName := (!onSubdomain && hostDomain == appDomain) || !cfg.IsCustomdomains()
798+
799+	var feedItems []*feeds.Item
800+	for _, post := range pager.Data {
801+		var tpl bytes.Buffer
802+		data := &PostPageData{
803+			ImgURL: template.URL(ImgURL(cfg, post.Username, post.Filename, onSubdomain, withUserName)),
804+		}
805+		if err := ts.Execute(&tpl, data); err != nil {
806+			continue
807+		}
808+
809+		realUrl := cfg.FullPostURL(post.Username, post.Slug, onSubdomain, withUserName)
810+		if !onSubdomain && !withUserName {
811+			realUrl = fmt.Sprintf("%s://%s%s", cfg.Protocol, r.Host, realUrl)
812+		}
813+
814+		item := &feeds.Item{
815+			Id:      realUrl,
816+			Title:   post.Title,
817+			Link:    &feeds.Link{Href: realUrl},
818+			Content: tpl.String(),
819+			Created: *post.PublishAt,
820+		}
821+
822+		if post.Description != "" {
823+			item.Description = post.Description
824+		}
825+
826+		feedItems = append(feedItems, item)
827+	}
828+	feed.Items = feedItems
829+
830+	rss, err := feed.ToAtom()
831+	if err != nil {
832+		logger.Fatal(err)
833+		http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
834+	}
835+
836+	w.Header().Add("Content-Type", "application/atom+xml")
837+	_, err = w.Write([]byte(rss))
838+	if err != nil {
839+		logger.Error(err)
840+	}
841+}
842+
843+func createStaticRoutes() []shared.Route {
844+	return []shared.Route{
845+		shared.NewRoute("GET", "/main.css", shared.ServeFile("main.css", "text/css")),
846+		shared.NewRoute("GET", "/imgs.css", shared.ServeFile("imgs.css", "text/css")),
847+		shared.NewRoute("GET", "/card.png", shared.ServeFile("card.png", "image/png")),
848+		shared.NewRoute("GET", "/favicon-16x16.png", shared.ServeFile("favicon-16x16.png", "image/png")),
849+		shared.NewRoute("GET", "/favicon-32x32.png", shared.ServeFile("favicon-32x32.png", "image/png")),
850+		shared.NewRoute("GET", "/apple-touch-icon.png", shared.ServeFile("apple-touch-icon.png", "image/png")),
851+		shared.NewRoute("GET", "/favicon.ico", shared.ServeFile("favicon.ico", "image/x-icon")),
852+		shared.NewRoute("GET", "/robots.txt", shared.ServeFile("robots.txt", "text/plain")),
853+	}
854+}
855+
856+func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
857+	routes := []shared.Route{
858+		shared.NewRoute("GET", "/", shared.CreatePageHandler("html/marketing.page.tmpl")),
859+		shared.NewRoute("GET", "/ops", shared.CreatePageHandler("html/ops.page.tmpl")),
860+		shared.NewRoute("GET", "/privacy", shared.CreatePageHandler("html/privacy.page.tmpl")),
861+		shared.NewRoute("GET", "/help", shared.CreatePageHandler("html/help.page.tmpl")),
862+		shared.NewRoute("GET", "/transparency", transparencyHandler),
863+		shared.NewRoute("GET", "/check", shared.CheckHandler),
864+	}
865+
866+	routes = append(
867+		routes,
868+		staticRoutes...,
869+	)
870+
871+	routes = append(
872+		routes,
873+		shared.NewRoute("GET", "/rss", rssHandler),
874+		shared.NewRoute("GET", "/rss.xml", rssHandler),
875+		shared.NewRoute("GET", "/atom.xml", rssHandler),
876+		shared.NewRoute("GET", "/feed.xml", rssHandler),
877+
878+		shared.NewRoute("GET", "/([^/]+)", blogHandler),
879+		shared.NewRoute("GET", "/([^/]+)/rss", rssBlogHandler),
880+		shared.NewRoute("GET", "/([^/]+)/([^/]+\\..+)", imgHandler),
881+		shared.NewRoute("GET", "/([^/]+)/([^/]+)", postHandler),
882+	)
883+
884+	return routes
885+}
886+
887+func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
888+	routes := []shared.Route{
889+		shared.NewRoute("GET", "/", blogHandler),
890+		shared.NewRoute("GET", "/rss", rssBlogHandler),
891+	}
892+
893+	routes = append(
894+		routes,
895+		staticRoutes...,
896+	)
897+
898+	routes = append(
899+		routes,
900+		shared.NewRoute("GET", "/([^/]+\\..+)", imgHandler),
901+		shared.NewRoute("GET", "/t/([^/]+)", tagHandler),
902+		shared.NewRoute("GET", "/([^/]+)/([^/]+)", tagPostHandler),
903+		shared.NewRoute("GET", "/([^/]+)", postHandler),
904+	)
905+
906+	return routes
907+}
908+
909+func StartApiServer() {
910+	cfg := NewConfigSite()
911+	db := postgres.NewDB(&cfg.ConfigCms)
912+	defer db.Close()
913+	logger := cfg.Logger
914+
915+	staticRoutes := createStaticRoutes()
916+	mainRoutes := createMainRoutes(staticRoutes)
917+	subdomainRoutes := createSubdomainRoutes(staticRoutes)
918+
919+	handler := shared.CreateServe(mainRoutes, subdomainRoutes, cfg, db, logger)
920+	router := http.HandlerFunc(handler)
921+
922+	portStr := fmt.Sprintf(":%s", cfg.Port)
923+	logger.Infof("Starting server on port %s", cfg.Port)
924+	logger.Infof("Subdomains enabled: %t", cfg.SubdomainsEnabled)
925+	logger.Infof("Domain: %s", cfg.Domain)
926+	logger.Infof("Email: %s", cfg.Email)
927+
928+	logger.Fatal(http.ListenAndServe(portStr, router))
929+}
A imgs/config.go
+55, -0
 1@@ -0,0 +1,55 @@
 2+package imgs
 3+
 4+import (
 5+	"fmt"
 6+
 7+	"git.sr.ht/~erock/pico/shared"
 8+	"git.sr.ht/~erock/pico/wish/cms/config"
 9+)
10+
11+func NewConfigSite() *shared.ConfigSite {
12+	domain := shared.GetEnv("IMGS_DOMAIN", "prose.sh")
13+	email := shared.GetEnv("IMGS_EMAIL", "hello@prose.sh")
14+	subdomains := shared.GetEnv("IMGS_SUBDOMAINS", "0")
15+	customdomains := shared.GetEnv("IMGS_CUSTOMDOMAINS", "0")
16+	port := shared.GetEnv("IMGS_WEB_PORT", "3000")
17+	protocol := shared.GetEnv("IMGS_PROTOCOL", "https")
18+	allowRegister := shared.GetEnv("IMGS_ALLOW_REGISTER", "1")
19+	storageDir := shared.GetEnv("IMGS_STORAGE_DIR", ".storage")
20+	dbURL := shared.GetEnv("DATABASE_URL", "")
21+	subdomainsEnabled := false
22+	if subdomains == "1" {
23+		subdomainsEnabled = true
24+	}
25+
26+	customdomainsEnabled := false
27+	if customdomains == "1" {
28+		customdomainsEnabled = true
29+	}
30+
31+	intro := "To get started, enter a username.\n"
32+	intro += "Then create a folder locally (e.g. ~/imgs).\n"
33+	intro += "Finally, send your images to us:\n\n"
34+	intro += fmt.Sprintf("scp ~/imgs/*.jpg %s:/", domain)
35+
36+	cfg := shared.ConfigSite{
37+		SubdomainsEnabled:    subdomainsEnabled,
38+		CustomdomainsEnabled: customdomainsEnabled,
39+		StorageDir:           storageDir,
40+		ConfigCms: config.ConfigCms{
41+			Domain:        domain,
42+			Email:         email,
43+			Port:          port,
44+			Protocol:      protocol,
45+			DbURL:         dbURL,
46+			Description:   "a premium image hosting service for hackers.",
47+			IntroText:     intro,
48+			Space:         "imgs",
49+			AllowedExt:    []string{".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"},
50+			Logger:        shared.CreateLogger(),
51+			AllowRegister: allowRegister == "1",
52+		},
53+	}
54+
55+	return &cfg
56+}
A imgs/html/base.layout.tmpl
+20, -0
 1@@ -0,0 +1,20 @@
 2+{{define "base"}}
 3+<!doctype html>
 4+<html lang="en">
 5+    <head>
 6+        <meta charset='utf-8'>
 7+        <meta name="viewport" content="width=device-width, initial-scale=1" />
 8+        <title>{{template "title" .}}</title>
 9+
10+        <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
11+
12+        <meta name="keywords" content="image, images, hosting" />
13+
14+        <link rel="stylesheet" href="/main.css" />
15+        <link rel="stylesheet" href="/imgs.css" />
16+
17+        {{template "meta" .}}
18+    </head>
19+    <body {{template "attrs" .}}>{{template "body" .}}</body>
20+</html>
21+{{end}}
A imgs/html/blog.page.tmpl
+63, -0
 1@@ -0,0 +1,63 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}{{.PageTitle}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="{{if .Header.Bio}}{{.Header.Bio}}{{else}}{{.Header.Title}}{{end}}" />
 8+
 9+<meta property="og:type" content="website">
10+<meta property="og:site_name" content="{{.Site.Domain}}">
11+<meta property="og:url" content="{{.URL}}">
12+<meta property="og:title" content="{{.Header.Title}}">
13+{{if .Header.Bio}}<meta property="og:description" content="{{.Header.Bio}}">{{end}}
14+<meta property="og:image:width" content="300" />
15+<meta property="og:image:height" content="300" />
16+<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
17+<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
18+
19+<meta property="twitter:card" content="summary">
20+<meta property="twitter:url" content="{{.URL}}">
21+<meta property="twitter:title" content="{{.Header.Title}}">
22+{{if .Header.Bio}}<meta property="twitter:description" content="{{.Header.Bio}}">{{end}}
23+<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
24+<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
25+{{end}}
26+
27+{{define "attrs"}}id="blog"{{end}}
28+
29+{{define "body"}}
30+<header class="text-center">
31+    <h1 class="text-2xl font-bold">{{.Header.Title}}</h1>
32+    {{if .Header.Bio}}<p class="text-lg">{{.Header.Bio}}</p>{{end}}
33+    <nav>
34+        {{range .Header.Nav}}
35+        <a href="{{.URL}}" class="text-lg">{{.Text}}</a> |
36+        {{end}}
37+        <a href="{{.RSSURL}}" class="text-lg">rss</a>
38+    </nav>
39+    <hr />
40+</header>
41+<main>
42+    {{if .Readme.HasText}}
43+    <section>
44+        <article class="md">
45+            {{.Readme.Contents}}
46+        </article>
47+        <hr />
48+    </section>
49+    {{end}}
50+
51+    <section class="albums">
52+        {{range .Posts}}
53+        <article  class="thumbnail-container">
54+            <a href="{{.URL}}" class="thumbnail-link">
55+                <div class="tag-overlay"></div>
56+                <div class="tag-text text-2xl">{{.Tag}}</div>
57+                <img class="thumbnail" src="{{.ImgURL}}" alt="{{.Tag}}" />
58+            </a>
59+        </article>
60+        {{end}}
61+    </section>
62+</main>
63+{{template "footer" .}}
64+{{end}}
A imgs/html/footer.partial.tmpl
+6, -0
1@@ -0,0 +1,6 @@
2+{{define "footer"}}
3+<footer>
4+    <hr />
5+    published with <a href={{.Site.HomeURL}}>{{.Site.Domain}}</a>
6+</footer>
7+{{end}}
A imgs/html/help.page.tmpl
+117, -0
  1@@ -0,0 +1,117 @@
  2+{{template "base" .}}
  3+
  4+{{define "title"}}help -- {{.Site.Domain}}{{end}}
  5+
  6+{{define "meta"}}
  7+<meta name="description" content="questions and answers" />
  8+{{end}}
  9+
 10+{{define "attrs"}}{{end}}
 11+
 12+{{define "body"}}
 13+<header>
 14+    <h1 class="text-2xl">Need help?</h1>
 15+    <p>Here are some common questions on using this platform that we would like to answer.</p>
 16+</header>
 17+<main>
 18+    <section id="permission-denied">
 19+        <h2 class="text-xl">
 20+            <a href="#permission-denied" rel="nofollow noopener">#</a>
 21+            I get a permission denied when trying to SSH
 22+        </h2>
 23+        <p>
 24+            Unfortunately SHA-2 RSA keys are <strong>not</strong> currently supported.
 25+        </p>
 26+        <p>
 27+            Unfortunately, due to a shortcoming in Go’s x/crypto/ssh package, we
 28+            not currently support access via new SSH RSA keys: only the old SHA-1 ones will work.
 29+            Until we sort this out you’ll either need an SHA-1 RSA key or a key with another
 30+            algorithm, e.g. Ed25519. Not sure what type of keys you have? You can check with the
 31+            following:
 32+        </p>
 33+        <pre>$ find ~/.ssh/id_*.pub -exec ssh-keygen -l -f {} \;</pre>
 34+        <p>If you’re curious about the inner workings of this problem have a look at:</p>
 35+        <ul>
 36+            <li><a href="https://github.com/golang/go/issues/37278">golang/go#37278</a></li>
 37+            <li><a href="https://go-review.googlesource.com/c/crypto/+/220037">go-review</a></li>
 38+            <li><a href="https://github.com/golang/crypto/pull/197">golang/crypto#197</a></li>
 39+        </ul>
 40+    </section>
 41+
 42+    <section id="blog-ssh-key">
 43+        <h2 class="text-xl">
 44+            <a href="#blog-ssh-key" rel="nofollow noopener">#</a>
 45+            Generating a new SSH key
 46+        </h2>
 47+        <p>
 48+            <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>
 49+        </p>
 50+        <pre>ssh-keygen -t ed25519 -C "your_email@example.com"</pre>
 51+        <ol>
 52+            <li>When you're prompted to "Enter a file in which to save the key," press Enter. This accepts the default file location.</li>
 53+            <li>At the prompt, type a secure passphrase.</li>
 54+        </ol>
 55+    </section>
 56+
 57+    <section id="img-update">
 58+        <h2 class="text-xl">
 59+            <a href="#img-update" rel="nofollow noopener">#</a>
 60+            How do I update a img?
 61+        </h2>
 62+        <p>
 63+            Updating a img requires that you update the source document and then run the <code>scp</code>
 64+            command again.  If the filename remains the same, then the img will be updated.
 65+        </p>
 66+    </section>
 67+
 68+    <section id="img-delete">
 69+        <h2 class="text-xl">
 70+            <a href="#img-delete" rel="nofollow noopener">#</a>
 71+            How do I delete a img?
 72+        </h2>
 73+        <p>
 74+            Because <code>scp</code> does not natively support deleting files, I didn't want to bake
 75+            that behavior into my ssh server.
 76+        </p>
 77+
 78+        <p>
 79+            However, if a user wants to delete a img they can delete the contents of the file and
 80+            then upload it to our server.  If the file contains 0 bytes, we will remove the img.
 81+            For example, if you want to delete <code>delete.md</code> you could:
 82+        </p>
 83+
 84+        <pre>
 85+cp /dev/null delete.md
 86+scp ./delete.md {{.Site.Domain}}:/</pre>
 87+
 88+        <p>
 89+            Alternatively, you can go to <code>ssh {{.Site.Domain}}</code> and select "Manage img."
 90+            Then you can highlight the img you want to delete and then press "X."  It will ask for
 91+            confirmation before actually removing the img.
 92+        </p>
 93+    </section>
 94+
 95+    <section id="custom-domain">
 96+        <h2 class="text-xl">
 97+            <a href="#custom-domain" rel="nofollow noopener">#</a>
 98+            Setup a custom domain
 99+        </h2>
100+        <p>
101+            A blog can be accessed from a custom domain.
102+            HTTPS will be automatically enabled and a certificate will be retrieved
103+            from <a href="https://letsencrypt.org/">Let's Encrypt</a>. In order for this to work,
104+            2 DNS records need to be created:
105+        </p>
106+
107+        <p>CNAME for the domain to prose (subdomains or DNS hosting with CNAME flattening) or A record</p>
108+        <pre>CNAME subdomain.yourcustomdomain.com -> prose.sh</pre>
109+        <p>Resulting in:</p>
110+        <pre>subdomain.yourcustomdomain.com.         300     IN      CNAME   prose.sh.</pre>
111+        <p>And a TXT record to tell Prose what blog is hosted on that domain at the subdomain entry _prose</p>
112+        <pre>TXT _prose.subdomain.yourcustomdomain.com -> yourproseusername</pre>
113+        <p>Resulting in:</p>
114+        <pre>_prose.subdomain.yourcustomdomain.com.         300     IN      TXT     "hey"</pre>
115+    </section>
116+</main>
117+{{template "marketing-footer" .}}
118+{{end}}
A imgs/html/marketing-footer.partial.tmpl
+13, -0
 1@@ -0,0 +1,13 @@
 2+{{define "marketing-footer"}}
 3+<footer>
 4+    <hr />
 5+    <p class="font-italic">Built and maintained by <a href="https://pico.sh">pico.sh</a>.</p>
 6+    <div>
 7+        <a href="/">home</a> |
 8+        <a href="/ops">ops</a> |
 9+        <a href="/help">help</a> |
10+        <a href="/rss">rss</a> |
11+        <a href="https://git.sr.ht/~erock/pico">source</a>
12+    </div>
13+</footer>
14+{{end}}
A imgs/html/marketing.page.tmpl
+91, -0
 1@@ -0,0 +1,91 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}{{.Site.Domain}} -- premium image hosting for hackers{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="premium image hosting for hackers" />
 8+
 9+<meta property="og:type" content="website">
10+<meta property="og:site_name" content="{{.Site.Domain}}">
11+<meta property="og:url" content="https://{{.Site.Domain}}">
12+<meta property="og:title" content="{{.Site.Domain}}">
13+<meta property="og:description" content="premium image hosting for hackers">
14+
15+<meta name="twitter:card" content="summary" />
16+<meta property="twitter:url" content="https://{{.Site.Domain}}">
17+<meta property="twitter:title" content="{{.Site.Domain}}">
18+<meta property="twitter:description" content="premium image hosting for hackers">
19+<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
20+<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
21+
22+<meta property="og:image:width" content="300" />
23+<meta property="og:image:height" content="300" />
24+<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
25+<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
26+{{end}}
27+
28+{{define "attrs"}}{{end}}
29+
30+{{define "body"}}
31+<header class="text-center">
32+    <h1 class="text-2xl font-bold">{{.Site.Domain}}</h1>
33+    <p class="text-lg">premium image hosting for hackers</p>
34+    <hr />
35+</header>
36+
37+<main>
38+    <section>
39+        <h2 class="text-lg font-bold">Examples</h2>
40+        <p>Fill this in</p>
41+    </section>
42+
43+    <section>
44+        <h2 class="text-lg font-bold">Create your account with Public-Key Cryptography</h2>
45+        <p>We don't want your email address.</p>
46+        <p>To get started, simply ssh into our content management system:</p>
47+        <pre>ssh new@{{.Site.Domain}}</pre>
48+        <div class="text-sm font-italic note">
49+            note: <code>new</code> is a special username that will always send you to account
50+            creation, even with multiple accounts associated with your key-pair.
51+        </div>
52+        <div class="text-sm font-italic note">
53+            note: getting permission denied? <a href="/help#permission-denied">read this</a>
54+        </div>
55+        <p>
56+            After that, just set a username and you're ready to start writing! When you SSH
57+            again, use your username that you set in the CMS.
58+        </p>
59+    </section>
60+
61+    <section>
62+        <h2 class="text-lg font-bold">Publish your images with one command</h2>
63+        <p>
64+            When your image is ready to be published, copy the file to our server with a familiar
65+            command:
66+        </p>
67+        <pre>scp my-image.jpg {{.Site.Domain}}:/</pre>
68+        <p>We'll either create or update the images for you.</p>
69+    </section>
70+
71+    <section>
72+        <h2 class="text-lg font-bold">Features</h2>
73+        <ul>
74+            <li>Fill this in</li>
75+        </ul>
76+    </section>
77+
78+    <section>
79+        <h2 class="text-lg font-bold">Philosophy</h2>
80+        <p>Fill this in</p>
81+    </section>
82+
83+    <section>
84+        <h2 class="text-lg font-bold">Roadmap</h2>
85+        <ol>
86+            <li>Fill this in</li>
87+        </ol>
88+    </section>
89+</main>
90+
91+{{template "marketing-footer" .}}
92+{{end}}
A imgs/html/ops.page.tmpl
+147, -0
  1@@ -0,0 +1,147 @@
  2+{{template "base" .}}
  3+
  4+{{define "title"}}operations -- {{.Site.Domain}}{{end}}
  5+
  6+{{define "meta"}}
  7+<meta name="description" content="{{.Site.Domain}} operations" />
  8+{{end}}
  9+
 10+{{define "attrs"}}{{end}}
 11+
 12+{{define "body"}}
 13+<header>
 14+    <h1 class="text-2xl">Operations</h1>
 15+    <ul>
 16+        <li><a href="/privacy">privacy</a></li>
 17+        <li><a href="/transparency">transparency</a></li>
 18+    </ul>
 19+</header>
 20+<main>
 21+    <section>
 22+        <h2 class="text-xl">Purpose</h2>
 23+        <p>
 24+            {{.Site.Domain}} exists to allow people to create and share their thoughts
 25+            without the need to set up their own server or be part of a platform
 26+            that shows ads or tracks its users.
 27+        </p>
 28+    </section>
 29+    <section>
 30+        <h2 class="text-xl">Ethics</h2>
 31+        <p>We are committed to:</p>
 32+        <ul>
 33+            <li>No browser-based tracking of visitor behavior.</li>
 34+            <li>No attempt to identify users.</li>
 35+            <li>Never sell any user or visitor data.</li>
 36+            <li>No ads — ever.</li>
 37+        </ul>
 38+    </section>
 39+    <section>
 40+        <h2 class="text-xl">Code of Content Publication</h2>
 41+        <p>
 42+            Content in {{.Site.Domain}} blogs is unfiltered and unmonitored. Users are free to publish any
 43+            combination of words and pixels except for: content of animosity or disparagement of an
 44+            individual or a group on account of a group characteristic such as race, color, national
 45+            origin, sex, disability, religion, or sexual orientation, which will be taken down
 46+            immediately.
 47+        </p>
 48+        <p>
 49+            If one notices something along those lines in a blog please let us know at
 50+            <a href="mailto:{{.Site.Email}}">{{.Site.Email}}</a>.
 51+        </p>
 52+    </section>
 53+    <section>
 54+        <h2 class="text-xl">Liability</h2>
 55+        <p>
 56+            The user expressly understands and agrees that Eric Bower and Antonio Mika, the operator of this website
 57+            shall not be liable, in law or in equity, to them or to any third party for any direct,
 58+            indirect, incidental, lost profits, special, consequential, punitive or exemplary damages.
 59+        </p>
 60+    </section>
 61+    <section>
 62+        <h2 class="text-xl">Analytics</h2>
 63+        <p>
 64+            We are committed to zero browser-based tracking or trying to identify visitors.  This
 65+            means we do not try to understand the user based on cookies or IP address.  We do not
 66+            store personally identifiable information.
 67+        </p>
 68+        <p>
 69+            However, in order to provide a better service, we do have some analytics on posts.
 70+            List of metrics we track for posts:
 71+        </p>
 72+        <ul>
 73+            <li>anonymous view counts</li>
 74+        </ul>
 75+        <p>
 76+            We might also inspect the headers of HTTP requests to determine some tertiary information
 77+            about the request.  For example we might inspect the <code>User-Agent</code> or
 78+            <code>Referer</code> to filter out requests from bots.
 79+        </p>
 80+    </section>
 81+    <section>
 82+        <h2 class="text-xl">Account Terms</h2>
 83+        <p>
 84+            <ul>
 85+                <li>
 86+                    The user is responsible for all content posted and all actions performed with
 87+                    their account.
 88+                </li>
 89+                <li>
 90+                    We reserve the right to disable or delete a user's account for any reason at
 91+                    any time. We have this clause because, statistically speaking, there will be
 92+                    people trying to do something nefarious.
 93+                </li>
 94+            </ul>
 95+        </p>
 96+    </section>
 97+    <section>
 98+        <h2 class="text-xl">Service Availability</h2>
 99+        <p>
100+         We provide the {{.Site.Domain}} service on an "as is" and "as available" basis. We do not offer
101+         service-level agreements but do take uptime seriously.
102+        </p>
103+    </section>
104+    <section>
105+        <h2 class="text-xl">Contact and Support</h2>
106+        <p>
107+            Email us at <a href="mailto:{{.Site.Email}}">{{.Site.Email}}</a>
108+            with any questions.
109+        </p>
110+    </section>
111+    <section>
112+        <h2 class="text-xl">Acknowledgments</h2>
113+        <p>
114+            {{.Site.Domain}} was inspired by <a href="https://mataroa.blog">Mataroa Blog</a>
115+            and <a href="https://bearblog.dev/">Bear Blog</a>.
116+        </p>
117+        <p>
118+            {{.Site.Domain}} is built with many open source technologies.
119+        </p>
120+        <p>
121+            In particular we would like to thank:
122+        </p>
123+        <ul>
124+            <li>
125+                <span>The </span>
126+                <a href="https://charm.sh">charm.sh</a>
127+                <span> community</span>
128+            </li>
129+            <li>
130+                <span>The </span>
131+                <a href="https://go.dev">golang</a>
132+                <span> community</span>
133+            </li>
134+            <li>
135+                <span>The </span>
136+                <a href="https://www.postgresql.org/">postgresql</a>
137+                <span> community</span>
138+            </li>
139+            <li>
140+                <span>The </span>
141+                <a href="https://github.com/caddyserver/caddy">caddy</a>
142+                <span> community</span>
143+            </li>
144+        </ul>
145+    </section>
146+</main>
147+{{template "marketing-footer" .}}
148+{{end}}
A imgs/html/post.page.tmpl
+52, -0
 1@@ -0,0 +1,52 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}{{.PageTitle}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="{{.Caption}}" />
 8+
 9+<meta property="og:type" content="website">
10+<meta property="og:site_name" content="{{.Site.Domain}}">
11+<meta property="og:url" content="{{.URL}}">
12+<meta property="og:title" content="{{.PageTitle}}">
13+{{if .Caption}}<meta property="og:description" content="{{.Caption}}">{{end}}
14+<meta property="og:image:width" content="300" />
15+<meta property="og:image:height" content="300" />
16+<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
17+<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
18+
19+<meta property="twitter:card" content="summary">
20+<meta property="twitter:url" content="{{.URL}}">
21+<meta property="twitter:title" content="{{.PageTitle}}">
22+{{if .Caption}}<meta property="twitter:description" content="{{.Caption}}">{{end}}
23+<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
24+<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
25+{{end}}
26+
27+{{define "attrs"}}id="post" class="{{.Slug}}"{{end}}
28+
29+{{define "body"}}
30+<header>
31+    {{if .Title}}<h1 class="text-2xl font-bold">{{.Title}}</h1>{{end}}
32+    <p class="font-bold m-0">
33+        <time datetime="{{.PublishAtISO}}">{{.PublishAt}}</time>
34+    </p>
35+    <div class="tags">
36+    <a href="{{.BlogURL}}">&lt; {{.BlogName}}</a>
37+    {{range .Tags}}
38+        <a class="tag" href="{{.URL}}">#{{.Text}}</a>
39+    {{end}}
40+    </div>
41+</header>
42+<main>
43+    <article>
44+        <figure class="img">
45+            <img src="{{.ImgURL}}" alt="" />
46+            {{if .Caption}}<figcaption class="my font-italic">{{.Caption}}</figcaption>{{end}}
47+        </figure>
48+
49+        <div class="md">{{.Contents}}</div>
50+    </article>
51+</main>
52+{{template "footer" .}}
53+{{end}}
A imgs/html/privacy.page.tmpl
+54, -0
 1@@ -0,0 +1,54 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}privacy -- {{.Site.Domain}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="{{.Site.Domain}} privacy policy" />
 8+{{end}}
 9+
10+{{define "attrs"}}{{end}}
11+
12+{{define "body"}}
13+<header>
14+    <h1 class="text-2xl">Privacy</h1>
15+    <p>Details on our privacy and security approach.</p>
16+</header>
17+<main>
18+    <section>
19+        <h2 class="text-xl">Account Data</h2>
20+        <p>
21+            In order to have a functional account at {{.Site.Domain}}, we need to store
22+            your public key.  That is the only piece of information we record for a user.
23+        </p>
24+        <p>
25+            Because we use public-key cryptography, our security posture is a battle-tested
26+            and proven technique for authentication.
27+        </p>
28+    </section>
29+
30+    <section>
31+        <h2 class="text-xl">Third parties</h2>
32+        <p>
33+            We have a strong commitment to never share any user data with any third-parties.
34+        </p>
35+    </section>
36+
37+    <section>
38+        <h2 class="text-xl">Service Providers</h2>
39+        <ul>
40+            <li>
41+                <span>We host our server on </span>
42+                <a href="https://digitalocean.com">digital ocean</a>
43+            </li>
44+        </ul>
45+    </section>
46+
47+    <section>
48+        <h2 class="text-xl">Cookies</h2>
49+        <p>
50+            We do not use any cookies, not even account authentication.
51+        </p>
52+    </section>
53+</main>
54+{{template "marketing-footer" .}}
55+{{end}}
A imgs/html/read.page.tmpl
+38, -0
 1@@ -0,0 +1,38 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}discover prose -- {{.Site.Domain}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="discover interesting posts" />
 8+{{end}}
 9+
10+{{define "attrs"}}{{end}}
11+
12+{{define "body"}}
13+<header class="text-center">
14+    <h1 class="text-2xl font-bold">read</h1>
15+    <p class="text-lg">recent posts</p>
16+    <p class="text-lg"><a href="/rss">rss</a></p>
17+    <hr />
18+</header>
19+<main>
20+    <div class="my">
21+        {{if .PrevPage}}<a href="{{.PrevPage}}">prev</a>{{else}}<span class="text-grey">prev</span>{{end}}
22+        {{if .NextPage}}<a href="{{.NextPage}}">next</a>{{else}}<span class="text-grey">next</span>{{end}}
23+    </div>
24+    {{range .Posts}}
25+    <article>
26+        <div class="flex items-center">
27+            <time datetime="{{.PublishAtISO}}" class="font-italic text-sm post-date">{{.PublishAt}}</time>
28+            <div class="flex-1">
29+                <div class="inline"><a href="{{.URL}}">{{.Title}}</a></div>
30+                <address class="text-sm inline">
31+                    <a href="{{.BlogURL}}" class="link-grey">({{.Username}})</a>
32+                </address>
33+            </div>
34+        </div>
35+    </article>
36+    {{end}}
37+</main>
38+{{template "marketing-footer" .}}
39+{{end}}
A imgs/html/rss.page.tmpl
+1, -0
1@@ -0,0 +1 @@
2+<img src="{{.ImgURL}}" />
A imgs/html/tag.page.tmpl
+46, -0
 1@@ -0,0 +1,46 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}{{.PageTitle}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="{{.PageTitle}}" />
 8+
 9+<meta property="og:type" content="website">
10+<meta property="og:site_name" content="{{.Site.Domain}}">
11+<meta property="og:url" content="{{.URL}}">
12+<meta property="og:title" content="{{.PageTitle}}">
13+<meta property="og:description" content="{{.Tag}}">
14+<meta property="og:image:width" content="300" />
15+<meta property="og:image:height" content="300" />
16+<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
17+<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
18+
19+<meta property="twitter:card" content="summary">
20+<meta property="twitter:url" content="{{.URL}}">
21+<meta property="twitter:title" content="{{.PageTitle}}">
22+<meta property="twitter:description" content="{{.Tag}}">
23+<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
24+<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
25+{{end}}
26+
27+{{define "attrs"}}id="blog"{{end}}
28+
29+{{define "body"}}
30+<header class="text-center">
31+    <h1 class="text-2xl font-bold">{{.Tag}}</h1>
32+    <a href="{{.BlogURL}}">&lt; {{.Username}}</a>
33+    <hr />
34+</header>
35+<main>
36+    <section class="albums">
37+        {{range .Posts}}
38+        <article  class="thumbnail-container">
39+            <a href="{{.URL}}" class="thumbnail-link">
40+                <img class="thumbnail" src="{{.ImgURL}}" alt="{{.Caption}}" />
41+            </a>
42+        </article>
43+        {{end}}
44+    </section>
45+</main>
46+{{template "footer" .}}
47+{{end}}
A imgs/html/tag_post.page.tmpl
+56, -0
 1@@ -0,0 +1,56 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}{{.PageTitle}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="{{.Caption}}" />
 8+
 9+<meta property="og:type" content="website">
10+<meta property="og:site_name" content="{{.Site.Domain}}">
11+<meta property="og:url" content="{{.URL}}">
12+<meta property="og:title" content="{{.PageTitle}}">
13+{{if .Caption}}<meta property="og:description" content="{{.Caption}}">{{end}}
14+<meta property="og:image:width" content="300" />
15+<meta property="og:image:height" content="300" />
16+<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
17+<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
18+
19+<meta property="twitter:card" content="summary">
20+<meta property="twitter:url" content="{{.URL}}">
21+<meta property="twitter:title" content="{{.PageTitle}}">
22+{{if .Caption}}<meta property="twitter:description" content="{{.Caption}}">{{end}}
23+<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
24+<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
25+{{end}}
26+
27+{{define "attrs"}}id="post" class="{{.Slug}}"{{end}}
28+
29+{{define "body"}}
30+<header>
31+    {{if .Title}}<h1 class="text-2xl font-bold">{{.Title}}</h1>{{end}}
32+    <p class="font-bold m-0">
33+        <time datetime="{{.PublishAtISO}}">{{.PublishAt}}</time>
34+    </p>
35+    <div class="tags">
36+    <a href="{{.BlogURL}}">&lt; {{.BlogName}}</a>
37+    {{range .Tags}}
38+        <a class="tag" href="{{.URL}}">#{{.Text}}</a>
39+    {{end}}
40+    </div>
41+</header>
42+<main>
43+    <div class="my">
44+        {{if .PrevPage}}<a href="{{.PrevPage}}">prev</a>{{else}}<span class="text-grey">prev</span>{{end}}
45+        {{if .NextPage}}<a href="{{.NextPage}}">next</a>{{else}}<span class="text-grey">next</span>{{end}}
46+    </div>
47+    <article>
48+        <figure class="img">
49+            <a href="{{.ImgURL}}"><img src="{{.ImgURL}}" alt="" /></a>
50+            {{if .Caption}}<figcaption class="my font-italic">{{.Caption}}</figcaption>{{end}}
51+        </figure>
52+
53+        <div class="md">{{.Contents}}</div>
54+    </article>
55+</main>
56+{{template "footer" .}}
57+{{end}}
A imgs/html/transparency.page.tmpl
+59, -0
 1@@ -0,0 +1,59 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}transparency -- {{.Site.Domain}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="full transparency of analytics and cost at {{.Site.Domain}}" />
 8+{{end}}
 9+
10+{{define "attrs"}}{{end}}
11+
12+{{define "body"}}
13+<header>
14+    <h1 class="text-2xl">Transparency</h1>
15+    <hr />
16+</header>
17+<main>
18+    <section>
19+        <h2 class="text-xl">Analytics</h2>
20+        <p>
21+            Here are some interesting stats on usage.
22+        </p>
23+
24+        <article>
25+            <h2 class="text-lg">Total users</h2>
26+            <div>{{.Analytics.TotalUsers}}</div>
27+        </article>
28+
29+        <article>
30+            <h2 class="text-lg">New users in the last month</h2>
31+            <div>{{.Analytics.UsersLastMonth}}</div>
32+        </article>
33+
34+        <article>
35+            <h2 class="text-lg">Total images</h2>
36+            <div>{{.Analytics.TotalPosts}}</div>
37+        </article>
38+
39+        <article>
40+            <h2 class="text-lg">New images in the last month</h2>
41+            <div>{{.Analytics.PostsLastMonth}}</div>
42+        </article>
43+
44+        <article>
45+            <h2 class="text-lg">Users with at least one image</h2>
46+            <div>{{.Analytics.UsersWithPost}}</div>
47+        </article>
48+    </section>
49+
50+    <section>
51+        <h2 class="text-xl">Service maintenance costs</h2>
52+        <ul>
53+            <li>Server $5.00/mo</li>
54+            <li>Domain name $3.25/mo</li>
55+            <li>Programmer $0.00/mo</li>
56+        </ul>
57+    </section>
58+</main>
59+{{template "marketing-footer" .}}
60+{{end}}
A imgs/public/apple-touch-icon.png
+0, -0
A imgs/public/card.png
+0, -0
A imgs/public/favicon-16x16.png
+0, -0
A imgs/public/favicon.ico
+0, -0
A imgs/public/imgs.css
+98, -0
 1@@ -0,0 +1,98 @@
 2+body {
 3+  max-width: 52rem;
 4+}
 5+
 6+img {
 7+  max-width: 100%;
 8+  max-height: 90vh;
 9+}
10+
11+.albums {
12+  width: 100%;
13+  display: grid;
14+  grid-template-columns: repeat(3, 1fr);
15+  grid-template-rows: repeat(auto-fill, 300px);
16+  grid-row-gap: 0.5rem;
17+  grid-column-gap: 1rem;
18+}
19+
20+.thumbnail-container {
21+  position: relative;
22+}
23+
24+.thumbnail-container a, .thumbnail-container a:visited, .thumbnail-container a:hover {
25+  color: var(--white);
26+}
27+
28+.tag-text {
29+  position: absolute;
30+  bottom: 20px;
31+  z-index: 1;
32+  text-align: center;
33+  width: 100%;
34+}
35+
36+.tag-overlay {
37+  width: 100%;
38+  height: 100%;
39+  position: absolute;
40+  top: 0;
41+  left: 0;
42+  background: rgba(0, 0, 0, 0.4);
43+  z-index: 0;
44+  transition-duration: 0.2s;
45+}
46+
47+.tag-overlay:hover {
48+  background: rgba(0, 0, 0, 0.25);
49+}
50+
51+.thumbnail {
52+  z-index: 1;
53+  object-fit: cover;
54+  width: 300px;
55+  height: 300px;
56+}
57+
58+.thumbnail-link {
59+  z-index: 1;
60+}
61+
62+.md h1 {
63+  font-size: 1.85rem;
64+  line-height: 1.15;
65+  font-weight: bold;
66+  padding: 0.6rem 0 0 0;
67+}
68+
69+.md h2 {
70+  font-size: 1.45rem;
71+  line-height: 1.15;
72+  font-weight: bold;
73+  padding: 0.6rem 0 0 0;
74+}
75+
76+.md h3 {
77+  font-size: 1.25rem;
78+  font-weight: bold;
79+  padding: 0.6rem 0 0 0;
80+}
81+
82+.md h4 {
83+  font-size: 1rem;
84+  font-weight: bold;
85+  padding: 0.6rem 0 0 0;
86+}
87+
88+@media only screen and (max-width: 900px) {
89+  .albums {
90+    grid-template-columns: repeat(1, 1fr);
91+    justify-content: center;
92+  }
93+
94+  .albums article {
95+    display: flex;
96+    flex-direction: column;
97+    align-items: center;
98+  }
99+}
A imgs/public/main.css
+323, -0
  1@@ -0,0 +1,323 @@
  2+*,
  3+::before,
  4+::after {
  5+  box-sizing: border-box;
  6+}
  7+
  8+::-moz-focus-inner {
  9+  border-style: none;
 10+  padding: 0;
 11+}
 12+:-moz-focusring {
 13+  outline: 1px dotted ButtonText;
 14+}
 15+:-moz-ui-invalid {
 16+  box-shadow: none;
 17+}
 18+
 19+@media (prefers-color-scheme: light) {
 20+  :root {
 21+    --white: #6a737d;
 22+    --code: #fff8d3;
 23+    --code-border: #f0d547;
 24+    --pre: #f6f8fa;
 25+    --bg-color: #fff;
 26+    --text-color: #24292f;
 27+    --link-color: #005cc5;
 28+    --visited: #6f42c1;
 29+    --blockquote: #005cc5;
 30+    --blockquote-bg: #fff;
 31+    --hover: #d73a49;
 32+    --grey: #ccc;
 33+  }
 34+}
 35+
 36+@media (prefers-color-scheme: dark) {
 37+  :root {
 38+    --white: #f2f2f2;
 39+    --code: #414558;
 40+    --code-border: #252525;
 41+    --pre: #252525;
 42+    --bg-color: #282a36;
 43+    --text-color: #f2f2f2;
 44+    --link-color: #8be9fd;
 45+    --visited: #bd93f9;
 46+    --blockquote: #bd93f9;
 47+    --blockquote-bg: #414558;
 48+    --hover: #ff80bf;
 49+    --grey: #414558;
 50+  }
 51+}
 52+
 53+html {
 54+  background-color: var(--bg-color);
 55+  color: var(--text-color);
 56+  line-height: 1.5;
 57+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
 58+    Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", Arial,
 59+    sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
 60+  -webkit-text-size-adjust: 100%;
 61+  -moz-tab-size: 4;
 62+  tab-size: 4;
 63+  font-size: 17px;
 64+}
 65+
 66+body {
 67+  margin: 0 auto;
 68+  max-width: 42rem;
 69+}
 70+
 71+img {
 72+  max-width: 100%;
 73+  height: auto;
 74+}
 75+
 76+b,
 77+strong {
 78+  font-weight: bold;
 79+}
 80+
 81+code,
 82+kbd,
 83+samp,
 84+pre {
 85+  font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo,
 86+    monospace;
 87+  font-size: 0.8rem;
 88+}
 89+
 90+code,
 91+kbd,
 92+samp {
 93+  background-color: var(--code);
 94+  border: 1px solid var(--code-border);
 95+}
 96+
 97+pre > code {
 98+  background-color: inherit;
 99+  padding: 0;
100+  border: none;
101+}
102+
103+code {
104+  border-radius: 0.3rem;
105+  padding: 0.15rem 0.2rem 0.05rem;
106+}
107+
108+pre {
109+  border-radius: 5px;
110+  padding: 1rem;
111+  margin: 1rem 0;
112+  overflow-x: auto;
113+  background-color: var(--pre) !important;
114+}
115+
116+small {
117+  font-size: 0.8rem;
118+}
119+
120+summary {
121+  display: list-item;
122+}
123+
124+h1,
125+h2,
126+h3 {
127+  margin: 0;
128+  padding: 0.6rem 0 0 0;
129+  border: 0;
130+  font-style: normal;
131+  font-weight: inherit;
132+  font-size: inherit;
133+}
134+
135+hr {
136+  color: inherit;
137+  border: 0;
138+  margin: 0;
139+  height: 1px;
140+  background: var(--grey);
141+  margin: 2rem auto;
142+  text-align: center;
143+}
144+
145+a {
146+  text-decoration: underline;
147+  color: var(--link-color);
148+}
149+
150+a:hover,
151+a:visited:hover {
152+  color: var(--hover);
153+}
154+
155+a:visited {
156+  color: var(--visited);
157+}
158+
159+a.link-grey {
160+  text-decoration: underline;
161+  color: var(--white);
162+}
163+
164+a.link-grey:visited {
165+  color: var(--white);
166+}
167+
168+section {
169+  margin-bottom: 1.4rem;
170+}
171+
172+section:last-child {
173+  margin-bottom: 0;
174+}
175+
176+header {
177+  margin: 1rem auto;
178+}
179+
180+p {
181+  margin: 1rem 0;
182+}
183+
184+article {
185+  overflow-wrap: break-word;
186+}
187+
188+blockquote {
189+  border-left: 5px solid var(--blockquote);
190+  background-color: var(--blockquote-bg);
191+  padding: 0.8rem;
192+  margin: 1rem 0;
193+}
194+
195+blockquote > p {
196+  margin: 0;
197+}
198+
199+ul,
200+ol {
201+  padding: 0 0 0 2rem;
202+  list-style-position: outside;
203+}
204+
205+ul[style*="list-style-type: none;"] {
206+  padding: 0;
207+}
208+
209+li {
210+  margin: 0.5rem 0;
211+}
212+
213+li > pre {
214+  padding: 0;
215+}
216+
217+footer {
218+  text-align: center;
219+  margin-bottom: 4rem;
220+}
221+
222+dt {
223+  font-weight: bold;
224+}
225+
226+dd {
227+  margin-left: 0;
228+}
229+
230+dd:not(:last-child) {
231+  margin-bottom: 0.5rem;
232+}
233+
234+figure {
235+  margin: 0;
236+}
237+
238+.post-date {
239+  width: 130px;
240+}
241+
242+.text-grey {
243+  color: var(--grey);
244+}
245+
246+.text-2xl {
247+  font-size: 1.85rem;
248+  line-height: 1.15;
249+}
250+
251+.text-xl {
252+  font-size: 1.45rem;
253+  line-height: 1.15;
254+}
255+
256+.text-lg {
257+  font-size: 1.25rem;
258+  line-height: 1.15;
259+}
260+
261+.text-sm {
262+  font-size: 0.875rem;
263+}
264+
265+.text-center {
266+  text-align: center;
267+}
268+
269+.font-bold {
270+  font-weight: bold;
271+}
272+
273+.font-italic {
274+  font-style: italic;
275+}
276+
277+.inline {
278+  display: inline;
279+}
280+
281+.flex {
282+  display: flex;
283+}
284+
285+.items-center {
286+  align-items: center;
287+}
288+
289+.m-0 {
290+  margin: 0;
291+}
292+
293+.my {
294+  margin-top: 0.5rem;
295+  margin-bottom: 0.5rem;
296+}
297+
298+.mx {
299+  margin-left: 0.5rem;
300+  margin-right: 0.5rem;
301+}
302+
303+.mx-2 {
304+  margin-left: 1rem;
305+  margin-right: 1rem;
306+}
307+
308+.justify-between {
309+  justify-content: space-between;
310+}
311+
312+.flex-1 {
313+  flex: 1;
314+}
315+
316+@media only screen and (max-width: 600px) {
317+  body {
318+    padding: 1rem;
319+  }
320+
321+  header {
322+    margin: 0;
323+  }
324+}
A imgs/public/robots.txt
+2, -0
1@@ -0,0 +1,2 @@
2+User-agent: *
3+Allow: /
A imgs/storage.go
+97, -0
 1@@ -0,0 +1,97 @@
 2+package imgs
 3+
 4+import (
 5+	"fmt"
 6+	"os"
 7+	"path"
 8+)
 9+
10+type Bucket struct {
11+	Name string
12+	Path string
13+}
14+
15+type ObjectStorage interface {
16+	GetBucket(name string) (Bucket, error)
17+	UpsertBucket(name string) (Bucket, error)
18+
19+	GetFile(bucket Bucket, fname string) ([]byte, error)
20+	PutFile(bucket Bucket, fname string, contents []byte) (string, error)
21+	DeleteFile(bucket Bucket, fname string) error
22+}
23+
24+type StorageFS struct {
25+	Dir string
26+}
27+
28+func NewStorageFS(dir string) *StorageFS {
29+	return &StorageFS{Dir: dir}
30+}
31+
32+// GetBucket - A bucket for the filesystem is just a directory.
33+func (s *StorageFS) GetBucket(name string) (Bucket, error) {
34+	dirPath := path.Join(s.Dir, name)
35+	bucket := Bucket{
36+		Name: name,
37+		Path: dirPath,
38+	}
39+
40+	info, err := os.Stat(dirPath)
41+	if os.IsNotExist(err) {
42+		return bucket, fmt.Errorf("directory does not exist: %v %w\n", dirPath, err)
43+	}
44+
45+	if err != nil {
46+		return bucket, fmt.Errorf("directory error: %v %w\n", dirPath, err)
47+
48+	}
49+
50+	if !info.IsDir() {
51+		return bucket, fmt.Errorf("directory is a file, not a directory: %#v\n", dirPath)
52+	}
53+
54+	return bucket, nil
55+}
56+
57+func (s *StorageFS) UpsertBucket(name string) (Bucket, error) {
58+	bucket, err := s.GetBucket(name)
59+	if err == nil {
60+		return bucket, nil
61+	}
62+
63+	err = os.MkdirAll(bucket.Path, os.ModePerm)
64+	if err != nil {
65+		return bucket, err
66+	}
67+
68+	return bucket, nil
69+}
70+
71+func (s *StorageFS) GetFile(bucket Bucket, fname string) ([]byte, error) {
72+	dat, err := os.ReadFile(path.Join(bucket.Path, fname))
73+	if err != nil {
74+		return []byte{}, err
75+	}
76+
77+	return dat, nil
78+}
79+
80+func (s *StorageFS) PutFile(bucket Bucket, fname string, contents []byte) (string, error) {
81+	loc := path.Join(bucket.Path, fname)
82+	err := os.WriteFile(loc, contents, 0644)
83+	if err != nil {
84+		return "", err
85+	}
86+
87+	return loc, nil
88+}
89+
90+func (s *StorageFS) DeleteFile(bucket Bucket, fname string) error {
91+	loc := path.Join(bucket.Path, fname)
92+	err := os.Remove(loc)
93+	if err != nil {
94+		return err
95+	}
96+
97+	return nil
98+}
A imgs/upload/handler.go
+145, -0
  1@@ -0,0 +1,145 @@
  2+package upload
  3+
  4+import (
  5+	"encoding/binary"
  6+	"fmt"
  7+	"io"
  8+	"net/http"
  9+	"path"
 10+	"time"
 11+
 12+	"git.sr.ht/~erock/pico/db"
 13+	"git.sr.ht/~erock/pico/filehandlers"
 14+	"git.sr.ht/~erock/pico/imgs"
 15+	"git.sr.ht/~erock/pico/shared"
 16+	"git.sr.ht/~erock/pico/wish/cms/util"
 17+	"git.sr.ht/~erock/pico/wish/send/utils"
 18+	"github.com/gliderlabs/ssh"
 19+)
 20+
 21+var GB = 1024 * 1024 * 1024
 22+var maxSize = 2 * GB
 23+var mdMime = "text/markdown; charset=UTF-8"
 24+
 25+type UploadImgHandler struct {
 26+	User    *db.User
 27+	DBPool  db.DB
 28+	Cfg     *shared.ConfigSite
 29+	Storage *imgs.StorageFS
 30+}
 31+
 32+func NewUploadImgHandler(dbpool db.DB, cfg *shared.ConfigSite, storage *imgs.StorageFS) *UploadImgHandler {
 33+	return &UploadImgHandler{
 34+		DBPool:  dbpool,
 35+		Cfg:     cfg,
 36+		Storage: storage,
 37+	}
 38+}
 39+
 40+func (h *UploadImgHandler) removePost(data *filehandlers.PostMetaData) error {
 41+	// skip empty files from being added to db
 42+	if data.Post == nil {
 43+		h.Cfg.Logger.Infof("(%s) is empty, skipping record", data.Filename)
 44+		return nil
 45+	}
 46+
 47+	err := h.DBPool.RemovePosts([]string{data.Post.ID})
 48+	h.Cfg.Logger.Infof("(%s) is empty, removing record", data.Filename)
 49+	if err != nil {
 50+		h.Cfg.Logger.Errorf("error for %s: %v", data.Filename, err)
 51+		return fmt.Errorf("error for %s: %v", data.Filename, err)
 52+	}
 53+
 54+	return nil
 55+}
 56+
 57+func (h *UploadImgHandler) Validate(s ssh.Session) error {
 58+	var err error
 59+	key, err := util.KeyText(s)
 60+	if err != nil {
 61+		return fmt.Errorf("key not found")
 62+	}
 63+
 64+	user, err := h.DBPool.FindUserForKey(s.User(), key)
 65+	if err != nil {
 66+		return err
 67+	}
 68+
 69+	if user.Name == "" {
 70+		return fmt.Errorf("must have username set")
 71+	}
 72+
 73+	h.User = user
 74+	return nil
 75+}
 76+
 77+func (h *UploadImgHandler) Write(s ssh.Session, entry *utils.FileEntry) (string, error) {
 78+	filename := entry.Name
 79+
 80+	var text []byte
 81+	if b, err := io.ReadAll(entry.Reader); err == nil {
 82+		text = b
 83+	}
 84+
 85+	now := time.Now()
 86+	slug := shared.SanitizeFileExt(filename)
 87+	fileSize := binary.Size(text)
 88+	shasum := shared.Shasum(text)
 89+
 90+	nextPost := db.Post{
 91+		Filename:  filename,
 92+		Slug:      slug,
 93+		PublishAt: &now,
 94+		Text:      string(text),
 95+		MimeType:  http.DetectContentType(text),
 96+		FileSize:  fileSize,
 97+		Shasum:    shasum,
 98+	}
 99+
100+	ext := path.Ext(filename)
101+	// DetectContentType does not detect markdown
102+	if ext == ".md" {
103+		nextPost.MimeType = "text/markdown; charset=UTF-8"
104+	}
105+
106+	post, err := h.DBPool.FindPostWithSlug(
107+		nextPost.Slug,
108+		h.User.ID,
109+		h.Cfg.Space,
110+	)
111+	if err != nil {
112+		h.Cfg.Logger.Infof("unable to load post (%s), continuing", nextPost.Filename)
113+		h.Cfg.Logger.Info(err)
114+	}
115+
116+	metadata := filehandlers.PostMetaData{
117+		Post:      &nextPost,
118+		User:      h.User,
119+		FileEntry: entry,
120+		Cur:       post,
121+	}
122+
123+	if post != nil {
124+		metadata.Post.PublishAt = post.PublishAt
125+	}
126+
127+	if metadata.MimeType == mdMime {
128+		err := h.writeMd(&metadata)
129+		if err != nil {
130+			return "", err
131+		}
132+	} else {
133+		err := h.writeImg(&metadata)
134+		if err != nil {
135+			return "", err
136+		}
137+	}
138+
139+	url := h.Cfg.FullPostURL(
140+		h.User.Name,
141+		metadata.Slug,
142+		h.Cfg.IsSubdomains(),
143+		true,
144+	)
145+	return url, nil
146+}
A imgs/upload/img.go
+146, -0
  1@@ -0,0 +1,146 @@
  2+package upload
  3+
  4+import (
  5+	"fmt"
  6+	"strings"
  7+
  8+	"git.sr.ht/~erock/pico/db"
  9+	"git.sr.ht/~erock/pico/filehandlers"
 10+	"git.sr.ht/~erock/pico/shared"
 11+)
 12+
 13+func (h *UploadImgHandler) validateImg(data *filehandlers.PostMetaData) (bool, error) {
 14+	if !h.DBPool.HasFeatureForUser(data.User.ID, "imgs") {
 15+		return false, fmt.Errorf("ERROR: user (%s) does not have access to this feature (imgs)", data.User.Name)
 16+	}
 17+
 18+	fileSize, err := h.DBPool.FindTotalSizeForUser(data.User.ID)
 19+	if err != nil {
 20+		return false, err
 21+	}
 22+	if fileSize+int(data.FileSize) > maxSize {
 23+		return false, fmt.Errorf("ERROR: user (%s) has exceeded (%d) max (%d)", data.User.Name, fileSize, maxSize)
 24+	}
 25+
 26+	if !shared.IsExtAllowed(data.Filename, h.Cfg.AllowedExt) {
 27+		extStr := strings.Join(h.Cfg.AllowedExt, ",")
 28+		err := fmt.Errorf(
 29+			"ERROR: (%s) invalid file, format must be (%s), skipping",
 30+			data.Filename,
 31+			extStr,
 32+		)
 33+		return false, err
 34+	}
 35+
 36+	return true, nil
 37+}
 38+
 39+func (h *UploadImgHandler) metaImg(data *filehandlers.PostMetaData) error {
 40+	// create or get
 41+	bucket, err := h.Storage.UpsertBucket(data.User.ID)
 42+	if err != nil {
 43+		return err
 44+	}
 45+	fname, err := h.Storage.PutFile(bucket, data.Filename, []byte(data.Text))
 46+	if err != nil {
 47+		return err
 48+	}
 49+
 50+	data.Data = db.PostData{
 51+		ImgPath: fname,
 52+	}
 53+
 54+	if data.Cur != nil {
 55+		data.Text = data.Cur.Text
 56+		data.Title = data.Cur.Title
 57+		data.PublishAt = data.Cur.PublishAt
 58+		data.Description = data.Cur.Description
 59+	}
 60+
 61+	return nil
 62+}
 63+
 64+func (h *UploadImgHandler) writeImg(data *filehandlers.PostMetaData) error {
 65+	valid, err := h.validateImg(data)
 66+	if !valid {
 67+		return err
 68+	}
 69+
 70+	err = h.metaImg(data)
 71+	if err != nil {
 72+		h.Cfg.Logger.Info(err)
 73+		return err
 74+	}
 75+
 76+	if len(data.Text) == 0 {
 77+		err = h.removePost(data)
 78+		if err != nil {
 79+			return err
 80+		}
 81+
 82+		bucket, err := h.Storage.UpsertBucket(data.User.ID)
 83+		if err != nil {
 84+			return err
 85+		}
 86+		err = h.Storage.DeleteFile(bucket, data.Filename)
 87+		if err != nil {
 88+			return err
 89+		}
 90+	} else if data.Cur == nil {
 91+		h.Cfg.Logger.Infof("(%s) not found, adding record", data.Filename)
 92+		insertPost := db.Post{
 93+			UserID: h.User.ID,
 94+			Space:  h.Cfg.Space,
 95+
 96+			Data:      data.Data,
 97+			Filename:  data.Filename,
 98+			FileSize:  data.FileSize,
 99+			Hidden:    data.Hidden,
100+			MimeType:  data.MimeType,
101+			PublishAt: data.PublishAt,
102+			Shasum:    data.Shasum,
103+			Slug:      data.Slug,
104+		}
105+		_, err := h.DBPool.InsertPost(&insertPost)
106+		if err != nil {
107+			h.Cfg.Logger.Errorf("error for %s: %v", data.Filename, err)
108+			return fmt.Errorf("error for %s: %v", data.Filename, err)
109+		}
110+	} else {
111+		if shared.Shasum([]byte(data.Text)) == data.Cur.Shasum {
112+			h.Cfg.Logger.Infof("(%s) found, but text is identical, skipping", data.Filename)
113+			return nil
114+		}
115+
116+		h.Cfg.Logger.Infof("(%s) found, updating record", data.Filename)
117+		updatePost := db.Post{
118+			ID: data.Cur.ID,
119+
120+			Data:        data.Data,
121+			FileSize:    data.FileSize,
122+			Description: data.Description,
123+			PublishAt:   data.PublishAt,
124+			Slug:        data.Slug,
125+			Shasum:      data.Shasum,
126+			Text:        data.Text,
127+			Title:       data.Title,
128+		}
129+		_, err = h.DBPool.UpdatePost(&updatePost)
130+		if err != nil {
131+			h.Cfg.Logger.Errorf("error for %s: %v", data.Filename, err)
132+			return fmt.Errorf("error for %s: %v", data.Filename, err)
133+		}
134+
135+		h.Cfg.Logger.Infof(
136+			"Found (%s) post tags, replacing with old tags",
137+			strings.Join(data.Tags, ","),
138+		)
139+		err = h.DBPool.ReplaceTagsForPost(data.Tags, data.Cur.ID)
140+		if err != nil {
141+			h.Cfg.Logger.Errorf("error for %s: %v", data.Filename, err)
142+			return fmt.Errorf("error for %s: %v", data.Filename, err)
143+		}
144+	}
145+
146+	return nil
147+}
A imgs/upload/md.go
+139, -0
  1@@ -0,0 +1,139 @@
  2+package upload
  3+
  4+import (
  5+	"fmt"
  6+	"strings"
  7+
  8+	"git.sr.ht/~erock/pico/db"
  9+	"git.sr.ht/~erock/pico/filehandlers"
 10+	"git.sr.ht/~erock/pico/prose"
 11+	"git.sr.ht/~erock/pico/shared"
 12+)
 13+
 14+func (h *UploadImgHandler) validateMd(data *filehandlers.PostMetaData) (bool, error) {
 15+	if !shared.IsTextFile(data.Text) {
 16+		err := fmt.Errorf(
 17+			"WARNING: (%s) invalid file must be plain text (utf-8), skipping",
 18+			data.Filename,
 19+		)
 20+		return false, err
 21+	}
 22+
 23+	if !shared.IsExtAllowed(data.Filename, []string{".md"}) {
 24+		err := fmt.Errorf(
 25+			"(%s) invalid file, format must be (.md), skipping",
 26+			data.Filename,
 27+		)
 28+		return false, err
 29+	}
 30+
 31+	return true, nil
 32+}
 33+
 34+func (h *UploadImgHandler) metaMd(data *filehandlers.PostMetaData) error {
 35+	hooks := prose.MarkdownHooks{Cfg: h.Cfg}
 36+	err := hooks.FileMeta(data)
 37+	if err != nil {
 38+		return err
 39+	}
 40+
 41+	if data.Cur != nil {
 42+		data.Filename = data.Cur.Filename
 43+		data.FileSize = data.Cur.FileSize
 44+		data.MimeType = data.Cur.MimeType
 45+		data.Data = data.Cur.Data
 46+		data.Shasum = data.Cur.Shasum
 47+		data.Slug = data.Cur.Slug
 48+	}
 49+
 50+	if data.Description == "" {
 51+		data.Description = data.Title
 52+	}
 53+
 54+	return nil
 55+}
 56+
 57+func (h *UploadImgHandler) writeMd(data *filehandlers.PostMetaData) error {
 58+	valid, err := h.validateMd(data)
 59+	if !valid {
 60+		return err
 61+	}
 62+
 63+	err = h.metaMd(data)
 64+	if err != nil {
 65+		return err
 66+	}
 67+
 68+	if len(data.Text) == 0 {
 69+		err = h.removePost(data)
 70+		if err != nil {
 71+			return err
 72+		}
 73+	} else if data.Cur == nil {
 74+		h.Cfg.Logger.Infof("(%s) not found, adding record", data.Filename)
 75+		insertPost := db.Post{
 76+			UserID: h.User.ID,
 77+			Space:  h.Cfg.Space,
 78+
 79+			Data:        data.Data,
 80+			Description: data.Description,
 81+			Filename:    data.Filename,
 82+			FileSize:    data.FileSize,
 83+			Hidden:      data.Hidden,
 84+			MimeType:    data.MimeType,
 85+			PublishAt:   data.PublishAt,
 86+			Shasum:      data.Shasum,
 87+			Slug:        data.Slug,
 88+			Text:        data.Text,
 89+			Title:       data.Title,
 90+		}
 91+		post, err := h.DBPool.InsertPost(&insertPost)
 92+		if err != nil {
 93+			h.Cfg.Logger.Errorf("error for %s: %v", data.Filename, err)
 94+			return fmt.Errorf("error for %s: %v", data.Filename, err)
 95+		}
 96+
 97+		if len(data.Tags) > 0 {
 98+			h.Cfg.Logger.Infof(
 99+				"Found (%s) post tags, replacing with old tags",
100+				strings.Join(data.Tags, ","),
101+			)
102+			err = h.DBPool.ReplaceTagsForPost(data.Tags, post.ID)
103+			if err != nil {
104+				h.Cfg.Logger.Errorf("error for %s: %v", data.Filename, err)
105+				return fmt.Errorf("error for %s: %v", data.Filename, err)
106+			}
107+		}
108+	} else {
109+		h.Cfg.Logger.Infof("(%s) found, updating record", data.Filename)
110+		updatePost := db.Post{
111+			ID: data.Cur.ID,
112+
113+			Data:        data.Data,
114+			FileSize:    data.FileSize,
115+			Description: data.Description,
116+			PublishAt:   data.PublishAt,
117+			Slug:        data.Slug,
118+			Shasum:      data.Shasum,
119+			Text:        data.Text,
120+			Title:       data.Title,
121+		}
122+		_, err = h.DBPool.UpdatePost(&updatePost)
123+		if err != nil {
124+			h.Cfg.Logger.Errorf("error for %s: %v", data.Filename, err)
125+			return fmt.Errorf("error for %s: %v", data.Filename, err)
126+		}
127+
128+		h.Cfg.Logger.Infof(
129+			"Found (%s) post tags, replacing with old tags",
130+			strings.Join(data.Tags, ","),
131+		)
132+		err = h.DBPool.ReplaceTagsForPost(data.Tags, data.Cur.ID)
133+		if err != nil {
134+			h.Cfg.Logger.Errorf("error for %s: %v", data.Filename, err)
135+			return fmt.Errorf("error for %s: %v", data.Filename, err)
136+		}
137+	}
138+
139+	return nil
140+}
M lists/public/main.css
+4, -0
 1@@ -230,6 +230,10 @@ dd:not(:last-child) {
 2   margin-bottom: 0.5rem;
 3 }
 4 
 5+figure {
 6+  margin: 0;
 7+}
 8+
 9 .post-date {
10   width: 130px;
11 }
M lists/scp_hooks.go
+12, -8
 1@@ -4,6 +4,7 @@ import (
 2 	"fmt"
 3 	"strings"
 4 
 5+	"git.sr.ht/~erock/pico/db"
 6 	"git.sr.ht/~erock/pico/filehandlers"
 7 	"git.sr.ht/~erock/pico/shared"
 8 	"golang.org/x/exp/slices"
 9@@ -11,22 +12,23 @@ import (
10 
11 type ListHooks struct {
12 	Cfg *shared.ConfigSite
13+	Db  db.DB
14 }
15 
16-func (p *ListHooks) FileValidate(text string, filename string) (bool, error) {
17-	if !shared.IsTextFile(text) {
18+func (p *ListHooks) FileValidate(data *filehandlers.PostMetaData) (bool, error) {
19+	if !shared.IsTextFile(string(data.Text)) {
20 		err := fmt.Errorf(
21 			"WARNING: (%s) invalid file must be plain text (utf-8), skipping",
22-			filename,
23+			data.Filename,
24 		)
25 		return false, err
26 	}
27 
28-	if !shared.IsExtAllowed(filename, p.Cfg.AllowedExt) {
29+	if !shared.IsExtAllowed(data.Filename, p.Cfg.AllowedExt) {
30 		extStr := strings.Join(p.Cfg.AllowedExt, ",")
31 		err := fmt.Errorf(
32 			"WARNING: (%s) invalid file, format must be (%s), skipping",
33-			filename,
34+			data.Filename,
35 			extStr,
36 		)
37 		return false, err
38@@ -35,10 +37,12 @@ func (p *ListHooks) FileValidate(text string, filename string) (bool, error) {
39 	return true, nil
40 }
41 
42-func (p *ListHooks) FileMeta(text string, data *filehandlers.PostMetaData) error {
43-	parsedText := ParseText(text)
44+func (p *ListHooks) FileMeta(data *filehandlers.PostMetaData) error {
45+	parsedText := ParseText(string(data.Text))
46 
47-	if parsedText.MetaData.Title != "" {
48+	if parsedText.MetaData.Title == "" {
49+		data.Title = shared.ToUpper(data.Slug)
50+	} else {
51 		data.Title = parsedText.MetaData.Title
52 	}
53 
M pastes/public/main.css
+4, -0
 1@@ -230,6 +230,10 @@ dd:not(:last-child) {
 2   margin-bottom: 0.5rem;
 3 }
 4 
 5+figure {
 6+  margin: 0;
 7+}
 8+
 9 .post-date {
10   width: 130px;
11 }
M pastes/scp_hooks.go
+7, -4
 1@@ -3,19 +3,21 @@ package pastes
 2 import (
 3 	"fmt"
 4 
 5+	"git.sr.ht/~erock/pico/db"
 6 	"git.sr.ht/~erock/pico/filehandlers"
 7 	"git.sr.ht/~erock/pico/shared"
 8 )
 9 
10 type FileHooks struct {
11 	Cfg *shared.ConfigSite
12+	Db  db.DB
13 }
14 
15-func (p *FileHooks) FileValidate(text string, filename string) (bool, error) {
16-	if !shared.IsTextFile(text) {
17+func (p *FileHooks) FileValidate(data *filehandlers.PostMetaData) (bool, error) {
18+	if !shared.IsTextFile(string(data.Text)) {
19 		err := fmt.Errorf(
20 			"WARNING: (%s) invalid file must be plain text (utf-8), skipping",
21-			filename,
22+			data.Filename,
23 		)
24 		return false, err
25 	}
26@@ -23,7 +25,8 @@ func (p *FileHooks) FileValidate(text string, filename string) (bool, error) {
27 	return true, nil
28 }
29 
30-func (p *FileHooks) FileMeta(text string, data *filehandlers.PostMetaData) error {
31+func (p *FileHooks) FileMeta(data *filehandlers.PostMetaData) error {
32+	data.Title = shared.ToUpper(data.Slug)
33 	// we want the slug to be the filename for pastes
34 	data.Slug = data.Filename
35 	return nil
M prose/public/main.css
+4, -0
 1@@ -230,6 +230,10 @@ dd:not(:last-child) {
 2   margin-bottom: 0.5rem;
 3 }
 4 
 5+figure {
 6+  margin: 0;
 7+}
 8+
 9 .post-date {
10   width: 130px;
11 }
M prose/scp_hooks.go
+13, -9
 1@@ -4,6 +4,7 @@ import (
 2 	"fmt"
 3 	"strings"
 4 
 5+	"git.sr.ht/~erock/pico/db"
 6 	"git.sr.ht/~erock/pico/filehandlers"
 7 	"git.sr.ht/~erock/pico/shared"
 8 	"golang.org/x/exp/slices"
 9@@ -11,13 +12,14 @@ import (
10 
11 type MarkdownHooks struct {
12 	Cfg *shared.ConfigSite
13+	Db  db.DB
14 }
15 
16-func (p *MarkdownHooks) FileValidate(text string, filename string) (bool, error) {
17-	if !shared.IsTextFile(text) {
18+func (p *MarkdownHooks) FileValidate(data *filehandlers.PostMetaData) (bool, error) {
19+	if !shared.IsTextFile(data.Text) {
20 		err := fmt.Errorf(
21 			"WARNING: (%s) invalid file must be plain text (utf-8), skipping",
22-			filename,
23+			data.Filename,
24 		)
25 		return false, err
26 	}
27@@ -25,15 +27,15 @@ func (p *MarkdownHooks) FileValidate(text string, filename string) (bool, error)
28 	// special styles css file we want to permit but no other css file.
29 	// sometimes the directory is provided in the filename, so we want to
30 	// remove that before we perform this check.
31-	if strings.Replace(filename, "/", "", 1) == "_styles.css" {
32+	if strings.Replace(data.Filename, "/", "", 1) == "_styles.css" {
33 		return true, nil
34 	}
35 
36-	if !shared.IsExtAllowed(filename, p.Cfg.AllowedExt) {
37+	if !shared.IsExtAllowed(data.Filename, p.Cfg.AllowedExt) {
38 		extStr := strings.Join(p.Cfg.AllowedExt, ",")
39 		err := fmt.Errorf(
40 			"WARNING: (%s) invalid file, format must be (%s), skipping",
41-			filename,
42+			data.Filename,
43 			extStr,
44 		)
45 		return false, err
46@@ -42,14 +44,16 @@ func (p *MarkdownHooks) FileValidate(text string, filename string) (bool, error)
47 	return true, nil
48 }
49 
50-func (p *MarkdownHooks) FileMeta(text string, data *filehandlers.PostMetaData) error {
51-	parsedText, err := ParseText(text)
52+func (p *MarkdownHooks) FileMeta(data *filehandlers.PostMetaData) error {
53+	parsedText, err := ParseText(data.Text)
54 	// we return nil here because we don't want the file upload to fail
55 	if err != nil {
56 		return nil
57 	}
58 
59-	if parsedText.Title != "" {
60+	if parsedText.Title == "" {
61+		data.Title = shared.ToUpper(data.Slug)
62+	} else {
63 		data.Title = parsedText.Title
64 	}
65 
M shared/config.go
+1, -0
1@@ -27,6 +27,7 @@ type ConfigSite struct {
2 	config.ConfigURL
3 	SubdomainsEnabled    bool
4 	CustomdomainsEnabled bool
5+	StorageDir           string
6 }
7 
8 func (c *ConfigSite) GetSiteData() *SitePageData {
M shared/util.go
+7, -0
 1@@ -1,7 +1,9 @@
 2 package shared
 3 
 4 import (
 5+	"crypto/sha256"
 6 	"encoding/base64"
 7+	"encoding/hex"
 8 	"fmt"
 9 	"math"
10 	"os"
11@@ -116,3 +118,8 @@ func TimeAgo(t *time.Time) string {
12 		return fmt.Sprintf("%d %ss ago", amount, metric)
13 	}
14 }
15+
16+func Shasum(data []byte) string {
17+	hash := sha256.Sum256(data)
18+	return hex.EncodeToString(hash[:])
19+}
M smol.css
+4, -0
 1@@ -230,6 +230,10 @@ dd:not(:last-child) {
 2   margin-bottom: 0.5rem;
 3 }
 4 
 5+figure {
 6+  margin: 0;
 7+}
 8+
 9 .post-date {
10   width: 130px;
11 }
A sql/migrations/20220811_add_data_to_post.sql
+4, -0
1@@ -0,0 +1,4 @@
2+ALTER TABLE posts ADD COLUMN shasum char(64);
3+ALTER TABLE posts ADD COLUMN mime_type char(32);
4+ALTER TABLE posts ADD COLUMN file_size int NOT NULL DEFAULT 0;
5+ALTER TABLE posts ADD COLUMN data jsonb NOT NULL DEFAULT '{}'::jsonb;
A sql/migrations/20220811_add_feature.sql
+13, -0
 1@@ -0,0 +1,13 @@
 2+CREATE TABLE IF NOT EXISTS feature_flags (
 3+  id uuid NOT NULL DEFAULT uuid_generate_v4(),
 4+  user_id uuid NOT NULL,
 5+  name character varying(50),
 6+  created_at timestamp without time zone NOT NULL DEFAULT NOW(),
 7+  CONSTRAINT user_features_unique_name UNIQUE (user_id, name),
 8+  CONSTRAINT feature_flags_pkey PRIMARY KEY (id),
 9+  CONSTRAINT fk_features_user_post
10+    FOREIGN KEY(user_id)
11+  REFERENCES app_users(id)
12+  ON DELETE CASCADE
13+  ON UPDATE CASCADE
14+);
M wish/pipe/pipe.go
+1, -2
 1@@ -7,13 +7,12 @@ import (
 2 	"strings"
 3 	"time"
 4 
 5-	"git.sr.ht/~erock/pico/filehandlers"
 6 	"git.sr.ht/~erock/pico/wish/send/utils"
 7 	"github.com/charmbracelet/wish"
 8 	"github.com/gliderlabs/ssh"
 9 )
10 
11-func Middleware(writeHandler *filehandlers.ScpUploadHandler, ext string) wish.Middleware {
12+func Middleware(writeHandler utils.CopyFromClientHandler, ext string) wish.Middleware {
13 	return func(sshHandler ssh.Handler) ssh.Handler {
14 		return func(session ssh.Session) {
15 			_, _, activePty := session.Pty()