- 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.
+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:
+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+}
+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+}
+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
+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
+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 }
+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+}
+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)
+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+}
+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+}
+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}}
+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}}
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}}
+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}}
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}}
+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}}
+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}}
+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}}">< {{.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}}
+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}}
+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}}
+1,
-0
1@@ -0,0 +1 @@
2+<img src="{{.ImgURL}}" />
+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}}">< {{.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}}
+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}}">< {{.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}}
+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}}
+0,
-0
+0,
-0
+0,
-0
+0,
-0
+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+}
+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+}
+2,
-0
1@@ -0,0 +1,2 @@
2+User-agent: *
3+Allow: /
+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+}
+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+}
+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+}
+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+}
+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 }
+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
+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 }
+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
+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 }
+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
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 {
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 }
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;
+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+);
+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()