repos / pico

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

commit
ba016c3
parent
9798290
author
Eric Bower
date
2022-08-19 02:44:40 +0000 UTC
feat(prose): allow images to be uploaded from other services
16 files changed,  +266, -115
M cmd/imgs/ssh/main.go
+7, -2
 1@@ -9,8 +9,9 @@ import (
 2 	"time"
 3 
 4 	"git.sr.ht/~erock/pico/db/postgres"
 5+	"git.sr.ht/~erock/pico/filehandlers/imgs"
 6 	"git.sr.ht/~erock/pico/imgs"
 7-	"git.sr.ht/~erock/pico/imgs/upload"
 8+	"git.sr.ht/~erock/pico/imgs/storage"
 9 	"git.sr.ht/~erock/pico/shared"
10 	"git.sr.ht/~erock/pico/wish/cms"
11 	"git.sr.ht/~erock/pico/wish/pipe"
12@@ -72,7 +73,11 @@ func main() {
13 	logger := cfg.Logger
14 	dbh := postgres.NewDB(&cfg.ConfigCms)
15 	defer dbh.Close()
16-	handler := upload.NewUploadImgHandler(dbh, cfg, imgs.NewStorageFS(cfg.StorageDir))
17+	handler := uploadimgs.NewUploadImgHandler(
18+		dbh,
19+		cfg,
20+		storage.NewStorageFS(cfg.StorageDir),
21+	)
22 
23 	sshServer := &SSHServer{}
24 	s, err := wish.NewServer(
M cmd/scripts/dates/dates.go
+1, -1
1@@ -97,7 +97,7 @@ func main() {
2 	logger.Info("updating dates")
3 	for _, post := range posts {
4 		if post.Space == "prose" {
5-			parsed, err := shared.ParseText(post.Text)
6+			parsed, err := shared.ParseText(post.Text, "")
7 			if err != nil {
8 				logger.Error(err)
9 				continue
M cmd/scripts/tags/tags.go
+1, -1
1@@ -75,7 +75,7 @@ func main() {
2 
3 	logger.Info("replacing tags")
4 	for _, post := range posts {
5-		parsed, err := shared.ParseText(post.Text)
6+		parsed, err := shared.ParseText(post.Text, "")
7 		if err != nil {
8 			continue
9 		}
R imgs/upload/handler.go => filehandlers/imgs/handler.go
+16, -7
 1@@ -1,4 +1,4 @@
 2-package upload
 3+package uploadimgs
 4 
 5 import (
 6 	"encoding/binary"
 7@@ -9,8 +9,7 @@ import (
 8 	"time"
 9 
10 	"git.sr.ht/~erock/pico/db"
11-	"git.sr.ht/~erock/pico/filehandlers"
12-	"git.sr.ht/~erock/pico/imgs"
13+	"git.sr.ht/~erock/pico/imgs/storage"
14 	"git.sr.ht/~erock/pico/shared"
15 	"git.sr.ht/~erock/pico/wish/cms/util"
16 	"git.sr.ht/~erock/pico/wish/send/utils"
17@@ -21,14 +20,23 @@ var GB = 1024 * 1024 * 1024
18 var maxSize = 2 * GB
19 var mdMime = "text/markdown; charset=UTF-8"
20 
21+type PostMetaData struct {
22+	*db.Post
23+	OrigText  []byte
24+	Cur       *db.Post
25+	Tags      []string
26+	User      *db.User
27+	FileEntry *utils.FileEntry
28+}
29+
30 type UploadImgHandler struct {
31 	User    *db.User
32 	DBPool  db.DB
33 	Cfg     *shared.ConfigSite
34-	Storage *imgs.StorageFS
35+	Storage *storage.StorageFS
36 }
37 
38-func NewUploadImgHandler(dbpool db.DB, cfg *shared.ConfigSite, storage *imgs.StorageFS) *UploadImgHandler {
39+func NewUploadImgHandler(dbpool db.DB, cfg *shared.ConfigSite, storage *storage.StorageFS) *UploadImgHandler {
40 	return &UploadImgHandler{
41 		DBPool:  dbpool,
42 		Cfg:     cfg,
43@@ -36,7 +44,7 @@ func NewUploadImgHandler(dbpool db.DB, cfg *shared.ConfigSite, storage *imgs.Sto
44 	}
45 }
46 
47-func (h *UploadImgHandler) removePost(data *filehandlers.PostMetaData) error {
48+func (h *UploadImgHandler) removePost(data *PostMetaData) error {
49 	// skip empty files from being added to db
50 	if data.Post == nil {
51 		h.Cfg.Logger.Infof("(%s) is empty, skipping record", data.Filename)
52@@ -112,7 +120,8 @@ func (h *UploadImgHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
53 		h.Cfg.Logger.Info(err)
54 	}
55 
56-	metadata := filehandlers.PostMetaData{
57+	metadata := PostMetaData{
58+		OrigText:  text,
59 		Post:      &nextPost,
60 		User:      h.User,
61 		FileEntry: entry,
R imgs/upload/img.go => filehandlers/imgs/img.go
+7, -8
 1@@ -1,15 +1,14 @@
 2-package upload
 3+package uploadimgs
 4 
 5 import (
 6 	"fmt"
 7 	"strings"
 8 
 9 	"git.sr.ht/~erock/pico/db"
10-	"git.sr.ht/~erock/pico/filehandlers"
11 	"git.sr.ht/~erock/pico/shared"
12 )
13 
14-func (h *UploadImgHandler) validateImg(data *filehandlers.PostMetaData) (bool, error) {
15+func (h *UploadImgHandler) validateImg(data *PostMetaData) (bool, error) {
16 	if !h.DBPool.HasFeatureForUser(data.User.ID, "imgs") {
17 		return false, fmt.Errorf("ERROR: user (%s) does not have access to this feature (imgs)", data.User.Name)
18 	}
19@@ -35,7 +34,7 @@ func (h *UploadImgHandler) validateImg(data *filehandlers.PostMetaData) (bool, e
20 	return true, nil
21 }
22 
23-func (h *UploadImgHandler) metaImg(data *filehandlers.PostMetaData) error {
24+func (h *UploadImgHandler) metaImg(data *PostMetaData) error {
25 	// create or get
26 	bucket, err := h.Storage.UpsertBucket(data.User.ID)
27 	if err != nil {
28@@ -60,7 +59,7 @@ func (h *UploadImgHandler) metaImg(data *filehandlers.PostMetaData) error {
29 	return nil
30 }
31 
32-func (h *UploadImgHandler) writeImg(data *filehandlers.PostMetaData) error {
33+func (h *UploadImgHandler) writeImg(data *PostMetaData) error {
34 	valid, err := h.validateImg(data)
35 	if !valid {
36 		return err
37@@ -72,7 +71,7 @@ func (h *UploadImgHandler) writeImg(data *filehandlers.PostMetaData) error {
38 		return err
39 	}
40 
41-	if len(data.Text) == 0 {
42+	if len(data.OrigText) == 0 {
43 		err = h.removePost(data)
44 		if err != nil {
45 			return err
46@@ -107,8 +106,8 @@ func (h *UploadImgHandler) writeImg(data *filehandlers.PostMetaData) error {
47 			return fmt.Errorf("error for %s: %v", data.Filename, err)
48 		}
49 	} else {
50-		if shared.Shasum([]byte(data.Text)) == data.Cur.Shasum {
51-			h.Cfg.Logger.Infof("(%s) found, but text is identical, skipping", data.Filename)
52+		if shared.Shasum(data.OrigText) == data.Cur.Shasum {
53+			h.Cfg.Logger.Infof("(%s) found, but image is identical, skipping", data.Filename)
54 			return nil
55 		}
56 
R imgs/upload/md.go => filehandlers/imgs/md.go
+25, -9
 1@@ -1,16 +1,14 @@
 2-package upload
 3+package uploadimgs
 4 
 5 import (
 6 	"fmt"
 7 	"strings"
 8 
 9 	"git.sr.ht/~erock/pico/db"
10-	"git.sr.ht/~erock/pico/filehandlers"
11-	"git.sr.ht/~erock/pico/prose"
12 	"git.sr.ht/~erock/pico/shared"
13 )
14 
15-func (h *UploadImgHandler) validateMd(data *filehandlers.PostMetaData) (bool, error) {
16+func (h *UploadImgHandler) validateMd(data *PostMetaData) (bool, error) {
17 	if !shared.IsTextFile(data.Text) {
18 		err := fmt.Errorf(
19 			"WARNING: (%s) invalid file must be plain text (utf-8), skipping",
20@@ -30,11 +28,24 @@ func (h *UploadImgHandler) validateMd(data *filehandlers.PostMetaData) (bool, er
21 	return true, nil
22 }
23 
24-func (h *UploadImgHandler) metaMd(data *filehandlers.PostMetaData) error {
25-	hooks := prose.MarkdownHooks{Cfg: h.Cfg}
26-	err := hooks.FileMeta(data)
27+func (h *UploadImgHandler) metaMd(data *PostMetaData) error {
28+	parsedText, err := shared.ParseText(data.Text, "")
29+	// we return nil here because we don't want the file upload to fail
30 	if err != nil {
31-		return err
32+		return nil
33+	}
34+
35+	if parsedText.Title == "" {
36+		data.Title = shared.ToUpper(data.Slug)
37+	} else {
38+		data.Title = parsedText.Title
39+	}
40+
41+	data.Tags = parsedText.Tags
42+	data.Description = parsedText.Description
43+
44+	if parsedText.PublishAt != nil && !parsedText.PublishAt.IsZero() {
45+		data.PublishAt = parsedText.MetaData.PublishAt
46 	}
47 
48 	if data.Cur != nil {
49@@ -53,7 +64,7 @@ func (h *UploadImgHandler) metaMd(data *filehandlers.PostMetaData) error {
50 	return nil
51 }
52 
53-func (h *UploadImgHandler) writeMd(data *filehandlers.PostMetaData) error {
54+func (h *UploadImgHandler) writeMd(data *PostMetaData) error {
55 	valid, err := h.validateMd(data)
56 	if !valid {
57 		return err
58@@ -105,6 +116,11 @@ func (h *UploadImgHandler) writeMd(data *filehandlers.PostMetaData) error {
59 			}
60 		}
61 	} else {
62+		if data.Text == data.Cur.Text {
63+			h.Cfg.Logger.Infof("(%s) found, but metadata is identical, skipping", data.Filename)
64+			return nil
65+		}
66+
67 		h.Cfg.Logger.Infof("(%s) found, updating record", data.Filename)
68 		updatePost := db.Post{
69 			ID: data.Cur.ID,
M filehandlers/post_handler.go
+19, -12
 1@@ -10,6 +10,7 @@ import (
 2 	"time"
 3 
 4 	"git.sr.ht/~erock/pico/db"
 5+	"git.sr.ht/~erock/pico/imgs"
 6 	"git.sr.ht/~erock/pico/shared"
 7 	"git.sr.ht/~erock/pico/wish/cms/util"
 8 	"git.sr.ht/~erock/pico/wish/send/utils"
 9@@ -69,9 +70,14 @@ func (h *ScpUploadHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
10 	userID := h.User.ID
11 	filename := entry.Name
12 
13-	user, err := h.DBPool.FindUser(userID)
14-	if err != nil {
15-		return "", fmt.Errorf("error for %s: %v", filename, err)
16+	client := imgs.NewImgsAPI(h.DBPool)
17+	if shared.IsExtAllowed(filename, client.Cfg.AllowedExt) {
18+		if !client.HasAccess(userID) {
19+			msg := "user (%s) does not have access to imgs.sh, cannot upload file (%s)"
20+			return "", fmt.Errorf(msg, h.User.Name, filename)
21+		}
22+
23+		return client.Upload(s, entry)
24 	}
25 
26 	var text []byte
27@@ -79,6 +85,13 @@ func (h *ScpUploadHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
28 		text = b
29 	}
30 
31+	mimeType := http.DetectContentType(text)
32+	ext := path.Ext(filename)
33+	// DetectContentType does not detect markdown
34+	if ext == ".md" {
35+		mimeType = "text/markdown; charset=UTF-8"
36+	}
37+
38 	now := time.Now()
39 	slug := shared.SanitizeFileExt(filename)
40 	fileSize := binary.Size(text)
41@@ -89,17 +102,11 @@ func (h *ScpUploadHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
42 		Slug:      slug,
43 		PublishAt: &now,
44 		Text:      string(text),
45-		MimeType:  http.DetectContentType(text),
46+		MimeType:  mimeType,
47 		FileSize:  fileSize,
48 		Shasum:    shasum,
49 	}
50 
51-	ext := path.Ext(filename)
52-	// DetectContentType does not detect markdown
53-	if ext == ".md" {
54-		nextPost.MimeType = "text/markdown; charset=UTF-8"
55-	}
56-
57 	metadata := PostMetaData{
58 		Post:      &nextPost,
59 		User:      h.User,
60@@ -181,7 +188,7 @@ func (h *ScpUploadHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
61 	} else {
62 		if metadata.Text == post.Text {
63 			logger.Infof("(%s) found, but text is identical, skipping", filename)
64-			return h.Cfg.FullPostURL(user.Name, metadata.Slug, h.Cfg.IsSubdomains(), true), nil
65+			return h.Cfg.FullPostURL(h.User.Name, metadata.Slug, h.Cfg.IsSubdomains(), true), nil
66 		}
67 
68 		logger.Infof("(%s) found, updating record", filename)
69@@ -214,5 +221,5 @@ func (h *ScpUploadHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
70 		}
71 	}
72 
73-	return h.Cfg.FullPostURL(user.Name, metadata.Slug, h.Cfg.IsSubdomains(), true), nil
74+	return h.Cfg.FullPostURL(h.User.Name, metadata.Slug, h.Cfg.IsSubdomains(), true), nil
75 }
M imgs/api.go
+19, -59
  1@@ -12,6 +12,7 @@ import (
  2 
  3 	"git.sr.ht/~erock/pico/db"
  4 	"git.sr.ht/~erock/pico/db/postgres"
  5+	"git.sr.ht/~erock/pico/imgs/storage"
  6 	"git.sr.ht/~erock/pico/shared"
  7 	"github.com/gorilla/feeds"
  8 	"golang.org/x/exp/slices"
  9@@ -112,45 +113,6 @@ type MergePost struct {
 10 
 11 var allTag = "all"
 12 
 13-func ImgURL(c *shared.ConfigSite, username string, slug string, onSubdomain bool, withUserName bool) string {
 14-	fname := url.PathEscape(strings.TrimLeft(slug, "/"))
 15-	if c.IsSubdomains() && onSubdomain {
 16-		return fmt.Sprintf("%s://%s.%s/%s", c.Protocol, username, c.Domain, fname)
 17-	}
 18-
 19-	if withUserName {
 20-		return fmt.Sprintf("/%s/%s", username, fname)
 21-	}
 22-
 23-	return fmt.Sprintf("/%s", fname)
 24-}
 25-
 26-func TagURL(c *shared.ConfigSite, username, tag string, onSubdomain, withUserName bool) string {
 27-	tg := url.PathEscape(tag)
 28-	if c.IsSubdomains() && onSubdomain {
 29-		return fmt.Sprintf("%s://%s.%s/t/%s", c.Protocol, username, c.Domain, tg)
 30-	}
 31-
 32-	if withUserName {
 33-		return fmt.Sprintf("/%s/t/%s", username, tg)
 34-	}
 35-
 36-	return fmt.Sprintf("/t/%s", tg)
 37-}
 38-
 39-func TagPostURL(c *shared.ConfigSite, username, tag, slug string, onSubdomain, withUserName bool) string {
 40-	fname := url.PathEscape(strings.TrimLeft(slug, "/"))
 41-	if c.IsSubdomains() && onSubdomain {
 42-		return fmt.Sprintf("%s://%s.%s/%s/%s", c.Protocol, username, c.Domain, tag, fname)
 43-	}
 44-
 45-	if withUserName {
 46-		return fmt.Sprintf("/%s/%s/%s", username, tag, fname)
 47-	}
 48-
 49-	return fmt.Sprintf("/%s/%s", tag, fname)
 50-}
 51-
 52 func GetPostTitle(post *db.Post) string {
 53 	if post.Description == "" {
 54 		return post.Title
 55@@ -238,8 +200,8 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
 56 	for key, post := range tagMap {
 57 		postCollection = append(postCollection, &PostTagData{
 58 			Tag:       key,
 59-			URL:       template.URL(TagURL(cfg, post.Username, key, onSubdomain, withUserName)),
 60-			ImgURL:    template.URL(ImgURL(cfg, post.Username, post.Filename, onSubdomain, withUserName)),
 61+			URL:       template.URL(cfg.TagURL(post.Username, key, onSubdomain, withUserName)),
 62+			ImgURL:    template.URL(cfg.ImgURL(post.Username, post.Filename, onSubdomain, withUserName)),
 63 			PublishAt: post.PublishAt,
 64 		})
 65 	}
 66@@ -303,14 +265,14 @@ func imgHandler(w http.ResponseWriter, r *http.Request) {
 67 		}
 68 	}
 69 
 70-	storage := NewStorageFS(cfg.StorageDir)
 71-	bucket, err := storage.GetBucket(user.ID)
 72+	st := storage.NewStorageFS(cfg.StorageDir)
 73+	bucket, err := st.GetBucket(user.ID)
 74 	if err != nil {
 75 		logger.Infof("bucket not found %s/%s", username, filename)
 76 		http.Error(w, err.Error(), http.StatusInternalServerError)
 77 		return
 78 	}
 79-	contents, err := storage.GetFile(bucket, post.Filename)
 80+	contents, err := st.GetFile(bucket, post.Filename)
 81 	if err != nil {
 82 		logger.Infof("file not found %s/%s", username, post.Filename)
 83 		http.Error(w, err.Error(), http.StatusInternalServerError)
 84@@ -369,8 +331,8 @@ func tagHandler(w http.ResponseWriter, r *http.Request) {
 85 			continue
 86 		}
 87 		mergedPosts = append(mergedPosts, TagPostData{
 88-			URL:     template.URL(TagPostURL(cfg, username, tag, post.Slug, onSubdomain, withUserName)),
 89-			ImgURL:  template.URL(ImgURL(cfg, username, post.Filename, onSubdomain, withUserName)),
 90+			URL:     template.URL(cfg.TagPostURL(username, tag, post.Slug, onSubdomain, withUserName)),
 91+			ImgURL:  template.URL(cfg.ImgURL(username, post.Filename, onSubdomain, withUserName)),
 92 			Caption: post.Title,
 93 		})
 94 	}
 95@@ -382,7 +344,7 @@ func tagHandler(w http.ResponseWriter, r *http.Request) {
 96 		Site:      *cfg.GetSiteData(),
 97 		Tag:       tag,
 98 		Posts:     mergedPosts,
 99-		URL:       template.URL(TagURL(cfg, username, tag, onSubdomain, withUserName)),
100+		URL:       template.URL(cfg.TagURL(username, tag, onSubdomain, withUserName)),
101 	}
102 
103 	ts, err := shared.RenderTemplate(cfg, []string{
104@@ -459,8 +421,7 @@ func tagPostHandler(w http.ResponseWriter, r *http.Request) {
105 		}
106 
107 		if i+1 < len(mergedPosts) {
108-			nextPage = TagPostURL(
109-				cfg,
110+			nextPage = cfg.TagPostURL(
111 				username,
112 				tag,
113 				mergedPosts[i+1].Slug,
114@@ -470,8 +431,7 @@ func tagPostHandler(w http.ResponseWriter, r *http.Request) {
115 		}
116 
117 		if i-1 >= 0 {
118-			prevPage = TagPostURL(
119-				cfg,
120+			prevPage = cfg.TagPostURL(
121 				username,
122 				tag,
123 				mergedPosts[i-1].Slug,
124@@ -488,7 +448,7 @@ func tagPostHandler(w http.ResponseWriter, r *http.Request) {
125 		return
126 	}
127 
128-	parsed, err := shared.ParseText(post.Text)
129+	parsed, err := shared.ParseText(post.Text, cfg.ImgURL(username, "", true, false))
130 	if err != nil {
131 		logger.Error(err)
132 	}
133@@ -500,7 +460,7 @@ func tagPostHandler(w http.ResponseWriter, r *http.Request) {
134 	tagLinks := make([]Link, 0, len(post.Tags))
135 	for _, tag := range post.Tags {
136 		tagLinks = append(tagLinks, Link{
137-			URL:  template.URL(TagURL(cfg, username, tag, onSubdomain, withUserName)),
138+			URL:  template.URL(cfg.TagURL(username, tag, onSubdomain, withUserName)),
139 			Text: tag,
140 		})
141 	}
142@@ -518,7 +478,7 @@ func tagPostHandler(w http.ResponseWriter, r *http.Request) {
143 		Username:     username,
144 		BlogName:     blogName,
145 		Contents:     template.HTML(text),
146-		ImgURL:       template.URL(ImgURL(cfg, username, post.Filename, onSubdomain, withUserName)),
147+		ImgURL:       template.URL(cfg.ImgURL(username, post.Filename, onSubdomain, withUserName)),
148 		Tags:         tagLinks,
149 		PrevPage:     template.URL(prevPage),
150 		NextPage:     template.URL(nextPage),
151@@ -571,7 +531,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
152 	var data PostPageData
153 	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
154 	if err == nil {
155-		parsed, err := shared.ParseText(post.Text)
156+		parsed, err := shared.ParseText(post.Text, cfg.ImgURL(username, "", true, false))
157 		if err != nil {
158 			logger.Error(err)
159 		}
160@@ -583,7 +543,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
161 		tagLinks := make([]Link, 0, len(post.Tags))
162 		for _, tag := range post.Tags {
163 			tagLinks = append(tagLinks, Link{
164-				URL:  template.URL(TagURL(cfg, username, tag, onSubdomain, withUserName)),
165+				URL:  template.URL(cfg.TagURL(username, tag, onSubdomain, withUserName)),
166 				Text: tag,
167 			})
168 		}
169@@ -601,7 +561,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
170 			Username:     username,
171 			BlogName:     blogName,
172 			Contents:     template.HTML(text),
173-			ImgURL:       template.URL(ImgURL(cfg, username, post.Filename, onSubdomain, withUserName)),
174+			ImgURL:       template.URL(cfg.ImgURL(username, post.Filename, onSubdomain, withUserName)),
175 			Tags:         tagLinks,
176 		}
177 	} else {
178@@ -721,7 +681,7 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
179 		}
180 		var tpl bytes.Buffer
181 		data := &PostPageData{
182-			ImgURL: template.URL(ImgURL(cfg, username, post.Filename, onSubdomain, withUserName)),
183+			ImgURL: template.URL(cfg.ImgURL(username, post.Filename, onSubdomain, withUserName)),
184 		}
185 		if err := ts.Execute(&tpl, data); err != nil {
186 			continue
187@@ -800,7 +760,7 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
188 	for _, post := range pager.Data {
189 		var tpl bytes.Buffer
190 		data := &PostPageData{
191-			ImgURL: template.URL(ImgURL(cfg, post.Username, post.Filename, onSubdomain, withUserName)),
192+			ImgURL: template.URL(cfg.ImgURL(post.Username, post.Filename, onSubdomain, withUserName)),
193 		}
194 		if err := ts.Execute(&tpl, data); err != nil {
195 			continue
A imgs/client.go
+42, -0
 1@@ -0,0 +1,42 @@
 2+package imgs
 3+
 4+import (
 5+	"git.sr.ht/~erock/pico/db"
 6+	"git.sr.ht/~erock/pico/filehandlers/imgs"
 7+	"git.sr.ht/~erock/pico/imgs/storage"
 8+	"git.sr.ht/~erock/pico/shared"
 9+	"git.sr.ht/~erock/pico/wish/send/utils"
10+	"github.com/gliderlabs/ssh"
11+)
12+
13+type IImgsAPI interface {
14+	HasAccess(userID string) bool
15+	Upload(file *utils.FileEntry) (string, error)
16+}
17+
18+type ImgsAPI struct {
19+	Cfg *shared.ConfigSite
20+	Db  db.DB
21+}
22+
23+func NewImgsAPI(dbpool db.DB) *ImgsAPI {
24+	cfg := NewConfigSite()
25+	return &ImgsAPI{
26+		Cfg: cfg,
27+		Db:  dbpool,
28+	}
29+}
30+
31+func (img *ImgsAPI) HasAccess(userID string) bool {
32+	return img.Db.HasFeatureForUser(userID, "imgs")
33+}
34+
35+func (img *ImgsAPI) Upload(s ssh.Session, file *utils.FileEntry) (string, error) {
36+	handler := uploadimgs.NewUploadImgHandler(img.Db, img.Cfg, storage.NewStorageFS(img.Cfg.StorageDir))
37+	err := handler.Validate(s)
38+	if err != nil {
39+		return "", err
40+	}
41+
42+	return handler.Write(s, file)
43+}
M imgs/config.go
+9, -0
 1@@ -7,6 +7,15 @@ import (
 2 	"git.sr.ht/~erock/pico/wish/cms/config"
 3 )
 4 
 5+func ImgBaseURL(username string) string {
 6+	cfg := NewConfigSite()
 7+	if cfg.IsSubdomains() {
 8+		return fmt.Sprintf("%s://%s.%s", cfg.Protocol, username, cfg.Domain)
 9+	}
10+
11+	return "/"
12+}
13+
14 func NewConfigSite() *shared.ConfigSite {
15 	domain := shared.GetEnv("IMGS_DOMAIN", "prose.sh")
16 	email := shared.GetEnv("IMGS_EMAIL", "hello@prose.sh")
M imgs/html/marketing.page.tmpl
+1, -1
1@@ -38,7 +38,7 @@
2         <h2 class="text-lg font-bold">Beta access</h2>
3         <p>
4             Want beta access?  You must join our
5-            <a href="irc://irc.libera.chat/#pico.sh">IRC channel</a> (#pico.sh on libera)
6+            <a href="https://web.libera.chat/gamja/?channels=%23pico.sh">IRC channel</a> (#pico.sh on libera)
7             and ask for access.  We want all beta testers to be in IRC so you can provide us with
8             feedback.
9         </p>
R imgs/storage.go => imgs/storage/storage.go
+1, -1
1@@ -1,4 +1,4 @@
2-package imgs
3+package storage
4 
5 import (
6 	"fmt"
M prose/api.go
+13, -12
 1@@ -13,6 +13,7 @@ import (
 2 
 3 	"git.sr.ht/~erock/pico/db"
 4 	"git.sr.ht/~erock/pico/db/postgres"
 5+	"git.sr.ht/~erock/pico/imgs"
 6 	"git.sr.ht/~erock/pico/shared"
 7 	"github.com/gorilla/feeds"
 8 	"golang.org/x/exp/slices"
 9@@ -191,7 +192,7 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
10 		if post.Filename == "_styles.css" && len(post.Text) > 0 {
11 			hasCSS = true
12 		} else if post.Filename == "_readme.md" {
13-			parsedText, err := shared.ParseText(post.Text)
14+			parsedText, err := shared.ParseText(post.Text, imgs.ImgBaseURL(post.Username))
15 			if err != nil {
16 				logger.Error(err)
17 			}
18@@ -328,7 +329,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
19 	var data PostPageData
20 	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
21 	if err == nil {
22-		parsedText, err := shared.ParseText(post.Text)
23+		parsedText, err := shared.ParseText(post.Text, imgs.ImgBaseURL(username))
24 		if err != nil {
25 			logger.Error(err)
26 		}
27@@ -336,7 +337,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
28 		// we need the blog name from the readme unfortunately
29 		readme, err := dbpool.FindPostWithFilename("_readme.md", user.ID, cfg.Space)
30 		if err == nil {
31-			readmeParsed, err := shared.ParseText(readme.Text)
32+			readmeParsed, err := shared.ParseText(readme.Text, imgs.ImgBaseURL(username))
33 			if err != nil {
34 				logger.Error(err)
35 			}
36@@ -540,9 +541,15 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
37 		Title: GetBlogName(username),
38 	}
39 
40+	hostDomain := strings.Split(r.Host, ":")[0]
41+	appDomain := strings.Split(cfg.ConfigCms.Domain, ":")[0]
42+
43+	onSubdomain := cfg.IsSubdomains() && strings.Contains(hostDomain, appDomain)
44+	withUserName := (!onSubdomain && hostDomain == appDomain) || !cfg.IsCustomdomains()
45+
46 	for _, post := range posts {
47 		if post.Filename == "_readme.md" {
48-			parsedText, err := shared.ParseText(post.Text)
49+			parsedText, err := shared.ParseText(post.Text, imgs.ImgBaseURL(post.Username))
50 			if err != nil {
51 				logger.Error(err)
52 			}
53@@ -558,12 +565,6 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
54 		}
55 	}
56 
57-	hostDomain := strings.Split(r.Host, ":")[0]
58-	appDomain := strings.Split(cfg.ConfigCms.Domain, ":")[0]
59-
60-	onSubdomain := cfg.IsSubdomains() && strings.Contains(hostDomain, appDomain)
61-	withUserName := (!onSubdomain && hostDomain == appDomain) || !cfg.IsCustomdomains()
62-
63 	feed := &feeds.Feed{
64 		Title:       headerTxt.Title,
65 		Link:        &feeds.Link{Href: cfg.FullBlogURL(username, onSubdomain, withUserName)},
66@@ -577,7 +578,7 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
67 		if slices.Contains(cfg.HiddenPosts, post.Filename) {
68 			continue
69 		}
70-		parsed, err := shared.ParseText(post.Text)
71+		parsed, err := shared.ParseText(post.Text, imgs.ImgBaseURL(post.Username))
72 		if err != nil {
73 			logger.Error(err)
74 		}
75@@ -660,7 +661,7 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
76 
77 	var feedItems []*feeds.Item
78 	for _, post := range pager.Data {
79-		parsed, err := shared.ParseText(post.Text)
80+		parsed, err := shared.ParseText(post.Text, imgs.ImgBaseURL(post.Username))
81 		if err != nil {
82 			logger.Error(err)
83 		}
M prose/scp_hooks.go
+1, -1
1@@ -45,7 +45,7 @@ func (p *MarkdownHooks) FileValidate(data *filehandlers.PostMetaData) (bool, err
2 }
3 
4 func (p *MarkdownHooks) FileMeta(data *filehandlers.PostMetaData) error {
5-	parsedText, err := shared.ParseText(data.Text)
6+	parsedText, err := shared.ParseText(data.Text, "")
7 	// we return nil here because we don't want the file upload to fail
8 	if err != nil {
9 		return nil
M shared/config.go
+39, -0
 1@@ -143,6 +143,45 @@ func (c *ConfigSite) RawPostURL(username, slug string) string {
 2 	return fmt.Sprintf("/raw/%s/%s", username, fname)
 3 }
 4 
 5+func (c *ConfigSite) ImgURL(username string, slug string, onSubdomain bool, withUserName bool) string {
 6+	fname := url.PathEscape(strings.TrimLeft(slug, "/"))
 7+	if c.IsSubdomains() && onSubdomain {
 8+		return fmt.Sprintf("%s://%s.%s/%s", c.Protocol, username, c.Domain, fname)
 9+	}
10+
11+	if withUserName {
12+		return fmt.Sprintf("/%s/%s", username, fname)
13+	}
14+
15+	return fmt.Sprintf("/%s", fname)
16+}
17+
18+func (c *ConfigSite) TagURL(username, tag string, onSubdomain, withUserName bool) string {
19+	tg := url.PathEscape(tag)
20+	if c.IsSubdomains() && onSubdomain {
21+		return fmt.Sprintf("%s://%s.%s/t/%s", c.Protocol, username, c.Domain, tg)
22+	}
23+
24+	if withUserName {
25+		return fmt.Sprintf("/%s/t/%s", username, tg)
26+	}
27+
28+	return fmt.Sprintf("/t/%s", tg)
29+}
30+
31+func (c *ConfigSite) TagPostURL(username, tag, slug string, onSubdomain, withUserName bool) string {
32+	fname := url.PathEscape(strings.TrimLeft(slug, "/"))
33+	if c.IsSubdomains() && onSubdomain {
34+		return fmt.Sprintf("%s://%s.%s/%s/%s", c.Protocol, username, c.Domain, tag, fname)
35+	}
36+
37+	if withUserName {
38+		return fmt.Sprintf("/%s/%s/%s", username, tag, fname)
39+	}
40+
41+	return fmt.Sprintf("/%s/%s", tag, fname)
42+}
43+
44 func CreateLogger() *zap.SugaredLogger {
45 	logger, err := zap.NewProduction()
46 	if err != nil {
M shared/mdparser.go
+65, -1
 1@@ -12,9 +12,12 @@ import (
 2 	"github.com/yuin/goldmark"
 3 	highlighting "github.com/yuin/goldmark-highlighting"
 4 	meta "github.com/yuin/goldmark-meta"
 5+	"github.com/yuin/goldmark/ast"
 6 	"github.com/yuin/goldmark/extension"
 7 	"github.com/yuin/goldmark/parser"
 8+	"github.com/yuin/goldmark/renderer"
 9 	ghtml "github.com/yuin/goldmark/renderer/html"
10+	"github.com/yuin/goldmark/util"
11 )
12 
13 type Link struct {
14@@ -107,7 +110,65 @@ func toTags(obj interface{}) ([]string, error) {
15 	return arr, nil
16 }
17 
18-func ParseText(text string) (*ParsedText, error) {
19+type ImgRender struct {
20+	ghtml.Config
21+	ImgURL func(url []byte) []byte
22+}
23+
24+func NewImgsRenderer(url func([]byte) []byte) renderer.NodeRenderer {
25+	return &ImgRender{
26+		ImgURL: url,
27+	}
28+}
29+
30+func (r *ImgRender) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
31+	reg.Register(ast.KindImage, r.renderImage)
32+}
33+
34+func (r *ImgRender) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
35+	if !entering {
36+		return ast.WalkContinue, nil
37+	}
38+	n := node.(*ast.Image)
39+	_, _ = w.WriteString("<img src=\"")
40+	if r.Unsafe || !ghtml.IsDangerousURL(n.Destination) {
41+		dest := r.ImgURL(n.Destination)
42+		_, _ = w.Write(util.EscapeHTML(util.URLEscape(dest, true)))
43+	}
44+	_, _ = w.WriteString(`" alt="`)
45+	_, _ = w.Write(util.EscapeHTML(n.Text(source)))
46+	_ = w.WriteByte('"')
47+	if n.Title != nil {
48+		_, _ = w.WriteString(` title="`)
49+		r.Writer.Write(w, n.Title)
50+		_ = w.WriteByte('"')
51+	}
52+	if n.Attributes() != nil {
53+		ghtml.RenderAttributes(w, n, ghtml.ImageAttributeFilter)
54+	}
55+	if r.XHTML {
56+		_, _ = w.WriteString(" />")
57+	} else {
58+		_, _ = w.WriteString(">")
59+	}
60+	return ast.WalkSkipChildren, nil
61+}
62+
63+func createImgURL(absURL string) func([]byte) []byte {
64+	return func(url []byte) []byte {
65+		if url[0] == '/' {
66+			nextURL := fmt.Sprintf("%s%s", absURL, string(url))
67+			return []byte(nextURL)
68+		} else if bytes.HasPrefix(url, []byte{'.', '/'}) {
69+			fname := url[1:]
70+			nextURL := fmt.Sprintf("%s%s", absURL, string(fname))
71+			return []byte(nextURL)
72+		}
73+		return url
74+	}
75+}
76+
77+func ParseText(text string, absURL string) (*ParsedText, error) {
78 	parsed := ParsedText{
79 		MetaData: &MetaData{
80 			Tags: []string{},
81@@ -131,6 +192,9 @@ func ParseText(text string) (*ParsedText, error) {
82 		),
83 		goldmark.WithRendererOptions(
84 			ghtml.WithUnsafe(),
85+			renderer.WithNodeRenderers(
86+				util.Prioritized(NewImgsRenderer(createImgURL(absURL)), 0),
87+			),
88 		),
89 	)
90 	context := parser.NewContext()