repos / pico

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

commit
d177fb9
parent
b5a85a0
author
Eric Bower
date
2022-07-31 00:55:14 +0000 UTC
refactor: use post column slug for url

By creating a new column for our url slugs, we can allow users to be
able to change the slug.

It also makes the logic around how to generate a post url easier since
it is explicit inside the post record.
10 files changed,  +108, -61
A db/migrations/20220730_post_change_filename_to_slug.sql
+3, -0
1@@ -0,0 +1,3 @@
2+ALTER TABLE posts ADD COLUMN slug character varying(255) NOT NULL DEFAULT '';
3+ALTER TABLE posts ADD CONSTRAINT unique_slug_for_user UNIQUE (user_id, cur_space, slug);
4+UPDATE posts SET slug = filename;
M filehandlers/post_handler.go
+7, -2
 1@@ -13,12 +13,13 @@ import (
 2 )
 3 
 4 type PostMetaData struct {
 5+	Filename    string
 6+	Slug        string
 7 	Text        string
 8 	Title       string
 9 	Description string
10 	PublishAt   *time.Time
11 	Hidden      bool
12-	Filename    string
13 }
14 
15 type ScpFileHooks interface {
16@@ -88,9 +89,11 @@ func (h *ScpUploadHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
17 	}
18 
19 	now := time.Now()
20+	slug := shared.SanitizeFileExt(filename)
21 	metadata := PostMetaData{
22 		Filename:  filename,
23-		Title:     shared.SanitizeFileExt(filename),
24+		Slug:      slug,
25+		Title:     shared.ToUpper(slug),
26 		PublishAt: &now,
27 	}
28 	if post != nil {
29@@ -122,6 +125,7 @@ func (h *ScpUploadHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
30 		_, err = h.DBPool.InsertPost(
31 			userID,
32 			filename,
33+			metadata.Slug,
34 			metadata.Title,
35 			text,
36 			metadata.Description,
37@@ -142,6 +146,7 @@ func (h *ScpUploadHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
38 		logger.Infof("(%s) found, updating record", filename)
39 		_, err = h.DBPool.UpdatePost(
40 			post.ID,
41+			metadata.Slug,
42 			metadata.Title,
43 			text,
44 			metadata.Description,
M lists/api.go
+10, -10
 1@@ -185,7 +185,7 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
 2 
 3 	postCollection := make([]PostItemData, 0, len(posts))
 4 	for _, post := range posts {
 5-		if post.Filename == "_header" {
 6+		if post.Filename == "_header.txt" {
 7 			parsedText := pkg.ParseText(post.Text)
 8 			if parsedText.MetaData.Title != "" {
 9 				headerTxt.Title = parsedText.MetaData.Title
10@@ -199,7 +199,7 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
11 			if len(headerTxt.Nav) > 0 {
12 				headerTxt.HasItems = true
13 			}
14-		} else if post.Filename == "_readme" {
15+		} else if post.Filename == "_readme.txt" {
16 			parsedText := pkg.ParseText(post.Text)
17 			readmeTxt.Items = parsedText.Items
18 			readmeTxt.ListType = parsedText.MetaData.ListType
19@@ -255,11 +255,11 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
20 	subdomain := shared.GetSubdomain(r)
21 	cfg := shared.GetCfg(r)
22 
23-	var filename string
24+	var slug string
25 	if !cfg.IsSubdomains() || subdomain == "" {
26-		filename, _ = url.PathUnescape(shared.GetField(r, 1))
27+		slug, _ = url.PathUnescape(shared.GetField(r, 1))
28 	} else {
29-		filename, _ = url.PathUnescape(shared.GetField(r, 0))
30+		slug, _ = url.PathUnescape(shared.GetField(r, 0))
31 	}
32 
33 	dbpool := shared.GetDB(r)
34@@ -272,7 +272,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
35 		return
36 	}
37 
38-	header, _ := dbpool.FindPostWithFilename("_header", user.ID, cfg.Space)
39+	header, _ := dbpool.FindPostWithFilename("_header.txt", user.ID, cfg.Space)
40 	blogName := GetBlogName(username)
41 	if header != nil {
42 		headerParsed := pkg.ParseText(header.Text)
43@@ -282,12 +282,12 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
44 	}
45 
46 	var data PostPageData
47-	post, err := dbpool.FindPostWithFilename(filename, user.ID, cfg.Space)
48+	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
49 	if err == nil {
50 		parsedText := pkg.ParseText(post.Text)
51 
52 		// we need the blog name from the readme unfortunately
53-		readme, err := dbpool.FindPostWithFilename("_readme", user.ID, cfg.Space)
54+		readme, err := dbpool.FindPostWithFilename("_readme.txt", user.ID, cfg.Space)
55 		if err == nil {
56 			readmeParsed := pkg.ParseText(readme.Text)
57 			if readmeParsed.MetaData.Title != "" {
58@@ -318,7 +318,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
59 			Items:        parsedText.Items,
60 		}
61 	} else {
62-		logger.Infof("post not found %s/%s", username, filename)
63+		logger.Infof("post not found %s/%s", username, slug)
64 		data = PostPageData{
65 			Site:         *cfg.GetSiteData(),
66 			PageTitle:    "Post not found",
67@@ -481,7 +481,7 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
68 	}
69 
70 	for _, post := range posts {
71-		if post.Filename == "_header" {
72+		if post.Filename == "_header.txt" {
73 			parsedText := pkg.ParseText(post.Text)
74 			if parsedText.MetaData.Title != "" {
75 				headerTxt.Title = parsedText.MetaData.Title
M lists/gemini/gemini.go
+8, -8
 1@@ -108,7 +108,7 @@ func blogHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request
 2 
 3 	postCollection := make([]lists.PostItemData, 0, len(posts))
 4 	for _, post := range posts {
 5-		if post.Filename == "_header" {
 6+		if post.Filename == "_header.txt" {
 7 			parsedText := pkg.ParseText(post.Text)
 8 			if parsedText.MetaData.Title != "" {
 9 				headerTxt.Title = parsedText.MetaData.Title
10@@ -122,7 +122,7 @@ func blogHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request
11 			if len(headerTxt.Nav) > 0 {
12 				headerTxt.HasItems = true
13 			}
14-		} else if post.Filename == "_readme" {
15+		} else if post.Filename == "_readme.txt" {
16 			parsedText := pkg.ParseText(post.Text)
17 			readmeTxt.Items = parsedText.Items
18 			readmeTxt.ListType = parsedText.MetaData.ListType
19@@ -233,7 +233,7 @@ func readHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request
20 
21 func postHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
22 	username := GetField(ctx, 0)
23-	filename, _ := url.PathUnescape(GetField(ctx, 1))
24+	slug, _ := url.PathUnescape(GetField(ctx, 1))
25 
26 	dbpool := GetDB(ctx)
27 	logger := GetLogger(ctx)
28@@ -246,7 +246,7 @@ func postHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request
29 		return
30 	}
31 
32-	header, _ := dbpool.FindPostWithFilename("_header", user.ID, cfg.Space)
33+	header, _ := dbpool.FindPostWithFilename("_header.txt", user.ID, cfg.Space)
34 	blogName := lists.GetBlogName(username)
35 	if header != nil {
36 		headerParsed := pkg.ParseText(header.Text)
37@@ -255,9 +255,9 @@ func postHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request
38 		}
39 	}
40 
41-	post, err := dbpool.FindPostWithFilename(filename, user.ID, cfg.Space)
42+	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
43 	if err != nil {
44-		logger.Infof("post not found %s/%s", username, filename)
45+		logger.Infof("post not found %s/%s", username, slug)
46 		w.WriteHeader(gemini.StatusNotFound, "post not found")
47 		return
48 	}
49@@ -265,7 +265,7 @@ func postHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request
50 	parsedText := pkg.ParseText(post.Text)
51 
52 	// we need the blog name from the readme unfortunately
53-	readme, err := dbpool.FindPostWithFilename("_readme", user.ID, cfg.Space)
54+	readme, err := dbpool.FindPostWithFilename("_readme.txt", user.ID, cfg.Space)
55 	if err == nil {
56 		readmeParsed := pkg.ParseText(readme.Text)
57 		if readmeParsed.MetaData.Title != "" {
58@@ -379,7 +379,7 @@ func rssBlogHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Requ
59 	}
60 
61 	for _, post := range posts {
62-		if post.Filename == "_header" {
63+		if post.Filename == "_header.txt" {
64 			parsedText := pkg.ParseText(post.Text)
65 			if parsedText.MetaData.Title != "" {
66 				headerTxt.Title = parsedText.MetaData.Title
M pastes/api.go
+10, -10
 1@@ -211,11 +211,11 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 2 	subdomain := shared.GetSubdomain(r)
 3 	cfg := shared.GetCfg(r)
 4 
 5-	var filename string
 6+	var slug string
 7 	if !cfg.IsSubdomains() || subdomain == "" {
 8-		filename, _ = url.PathUnescape(shared.GetField(r, 1))
 9+		slug, _ = url.PathUnescape(shared.GetField(r, 1))
10 	} else {
11-		filename, _ = url.PathUnescape(shared.GetField(r, 0))
12+		slug, _ = url.PathUnescape(shared.GetField(r, 0))
13 	}
14 
15 	dbpool := shared.GetDB(r)
16@@ -231,7 +231,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
17 	blogName := GetBlogName(username)
18 
19 	var data PostPageData
20-	post, err := dbpool.FindPostWithFilename(filename, user.ID, cfg.Space)
21+	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
22 	if err == nil {
23 		parsedText, err := ParseText(post.Filename, post.Text)
24 		if err != nil {
25@@ -253,7 +253,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
26 			Contents:     template.HTML(parsedText),
27 		}
28 	} else {
29-		logger.Infof("post not found %s/%s", username, filename)
30+		logger.Infof("post not found %s/%s", username, slug)
31 		data = PostPageData{
32 			Site:         *cfg.GetSiteData(),
33 			PageTitle:    "Paste not found",
34@@ -288,11 +288,11 @@ func postHandlerRaw(w http.ResponseWriter, r *http.Request) {
35 	subdomain := shared.GetSubdomain(r)
36 	cfg := shared.GetCfg(r)
37 
38-	var filename string
39+	var slug string
40 	if !cfg.IsSubdomains() || subdomain == "" {
41-		filename, _ = url.PathUnescape(shared.GetField(r, 1))
42+		slug, _ = url.PathUnescape(shared.GetField(r, 1))
43 	} else {
44-		filename, _ = url.PathUnescape(shared.GetField(r, 0))
45+		slug, _ = url.PathUnescape(shared.GetField(r, 0))
46 	}
47 
48 	dbpool := shared.GetDB(r)
49@@ -305,9 +305,9 @@ func postHandlerRaw(w http.ResponseWriter, r *http.Request) {
50 		return
51 	}
52 
53-	post, err := dbpool.FindPostWithFilename(filename, user.ID, cfg.Space)
54+	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
55 	if err != nil {
56-		logger.Infof("post not found %s/%s", username, filename)
57+		logger.Infof("post not found %s/%s", username, slug)
58 		http.Error(w, "post not found", http.StatusNotFound)
59 		return
60 	}
M pastes/scp_hooks.go
+2, -0
1@@ -24,5 +24,7 @@ func (p *FileHooks) FileValidate(text string, filename string) (bool, error) {
2 }
3 
4 func (p *FileHooks) FileMeta(text string, data *filehandlers.PostMetaData) error {
5+	// we want the slug to be the filename for pastes
6+	data.Slug = data.Filename
7 	return nil
8 }
M prose/api.go
+15, -15
  1@@ -160,7 +160,7 @@ func blogStyleHandler(w http.ResponseWriter, r *http.Request) {
  2 		http.Error(w, "blog not found", http.StatusNotFound)
  3 		return
  4 	}
  5-	styles, err := dbpool.FindPostWithFilename("_styles", user.ID, cfg.Space)
  6+	styles, err := dbpool.FindPostWithFilename("_styles.css", user.ID, cfg.Space)
  7 	if err != nil {
  8 		logger.Infof("css not found for: %s", username)
  9 		http.Error(w, "css not found", http.StatusNotFound)
 10@@ -220,9 +220,9 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
 11 	hasCSS := false
 12 	postCollection := make([]PostItemData, 0, len(posts))
 13 	for _, post := range posts {
 14-		if post.Filename == "_styles" && len(post.Text) > 0 {
 15+		if post.Filename == "_styles.css" && len(post.Text) > 0 {
 16 			hasCSS = true
 17-		} else if post.Filename == "_readme" {
 18+		} else if post.Filename == "_readme.md" {
 19 			parsedText, err := ParseText(post.Text)
 20 			if err != nil {
 21 				logger.Error(err)
 22@@ -287,11 +287,11 @@ func postRawHandler(w http.ResponseWriter, r *http.Request) {
 23 	subdomain := shared.GetSubdomain(r)
 24 	cfg := shared.GetCfg(r)
 25 
 26-	var filename string
 27+	var slug string
 28 	if !cfg.IsSubdomains() || subdomain == "" {
 29-		filename, _ = url.PathUnescape(shared.GetField(r, 1))
 30+		slug, _ = url.PathUnescape(shared.GetField(r, 1))
 31 	} else {
 32-		filename, _ = url.PathUnescape(shared.GetField(r, 0))
 33+		slug, _ = url.PathUnescape(shared.GetField(r, 0))
 34 	}
 35 
 36 	dbpool := shared.GetDB(r)
 37@@ -304,7 +304,7 @@ func postRawHandler(w http.ResponseWriter, r *http.Request) {
 38 		return
 39 	}
 40 
 41-	post, err := dbpool.FindPostWithFilename(filename, user.ID, cfg.Space)
 42+	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
 43 	if err != nil {
 44 		logger.Infof("post not found")
 45 		http.Error(w, "post not found", http.StatusNotFound)
 46@@ -325,11 +325,11 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 47 	subdomain := shared.GetSubdomain(r)
 48 	cfg := shared.GetCfg(r)
 49 
 50-	var filename string
 51+	var slug string
 52 	if !cfg.IsSubdomains() || subdomain == "" {
 53-		filename, _ = url.PathUnescape(shared.GetField(r, 1))
 54+		slug, _ = url.PathUnescape(shared.GetField(r, 1))
 55 	} else {
 56-		filename, _ = url.PathUnescape(shared.GetField(r, 0))
 57+		slug, _ = url.PathUnescape(shared.GetField(r, 0))
 58 	}
 59 
 60 	dbpool := shared.GetDB(r)
 61@@ -351,7 +351,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 62 
 63 	hasCSS := false
 64 	var data PostPageData
 65-	post, err := dbpool.FindPostWithFilename(filename, user.ID, cfg.Space)
 66+	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
 67 	if err == nil {
 68 		parsedText, err := ParseText(post.Text)
 69 		if err != nil {
 70@@ -359,7 +359,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 71 		}
 72 
 73 		// we need the blog name from the readme unfortunately
 74-		readme, err := dbpool.FindPostWithFilename("_readme", user.ID, cfg.Space)
 75+		readme, err := dbpool.FindPostWithFilename("_readme.md", user.ID, cfg.Space)
 76 		if err == nil {
 77 			readmeParsed, err := ParseText(readme.Text)
 78 			if err != nil {
 79@@ -371,7 +371,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 80 		}
 81 
 82 		// we need the blog name from the readme unfortunately
 83-		css, err := dbpool.FindPostWithFilename("_styles", user.ID, cfg.Space)
 84+		css, err := dbpool.FindPostWithFilename("_styles.css", user.ID, cfg.Space)
 85 		if err == nil {
 86 			if len(css.Text) > 0 {
 87 				hasCSS = true
 88@@ -414,7 +414,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 89 			BlogName:     blogName,
 90 			Contents:     "Oops!  we can't seem to find this post.",
 91 		}
 92-		logger.Infof("post not found %s/%s", username, filename)
 93+		logger.Infof("post not found %s/%s", username, slug)
 94 	}
 95 
 96 	ts, err := renderTemplate(cfg, []string{
 97@@ -579,7 +579,7 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
 98 	}
 99 
100 	for _, post := range posts {
101-		if post.Filename == "_readme" {
102+		if post.Filename == "_readme.md" {
103 			parsedText, err := ParseText(post.Text)
104 			if err != nil {
105 				logger.Error(err)
M shared/util.go
+5, -1
 1@@ -24,7 +24,11 @@ func FilenameToTitle(filename string, title string) string {
 2 		return title
 3 	}
 4 
 5-	pre := fnameRe.ReplaceAllString(title, " ")
 6+	return ToUpper(title)
 7+}
 8+
 9+func ToUpper(str string) string {
10+	pre := fnameRe.ReplaceAllString(str, " ")
11 	r := []rune(pre)
12 	r[0] = unicode.ToUpper(r[0])
13 	return string(r)
M wish/cms/db/db.go
+4, -2
 1@@ -26,6 +26,7 @@ type Post struct {
 2 	ID          string     `json:"id"`
 3 	UserID      string     `json:"user_id"`
 4 	Filename    string     `json:"filename"`
 5+	Slug        string     `json:"slug"`
 6 	Title       string     `json:"title"`
 7 	Text        string     `json:"text"`
 8 	Description string     `json:"description"`
 9@@ -108,10 +109,11 @@ type DB interface {
10 	FindPostsBeforeDate(date *time.Time, space string) ([]*Post, error)
11 	FindUpdatedPostsForUser(userID string, space string) ([]*Post, error)
12 	FindPostWithFilename(filename string, userID string, space string) (*Post, error)
13+	FindPostWithSlug(slug string, userID string, space string) (*Post, error)
14 	FindAllPosts(pager *Pager, space string) (*Paginate[*Post], error)
15 	FindAllUpdatedPosts(pager *Pager, space string) (*Paginate[*Post], error)
16-	InsertPost(userID string, filename string, title string, text string, description string, publishAt *time.Time, hidden bool, space string) (*Post, error)
17-	UpdatePost(postID string, title string, text string, description string, publishAt *time.Time) (*Post, error)
18+	InsertPost(userID string, filename string, slug string, title string, text string, description string, publishAt *time.Time, hidden bool, space string) (*Post, error)
19+	UpdatePost(postID string, slug string, title string, text string, description string, publishAt *time.Time) (*Post, error)
20 	RemovePosts(postIDs []string) error
21 
22 	AddViewCount(postID string) (int, error)
M wish/cms/db/postgres/storage.go
+44, -13
  1@@ -30,19 +30,21 @@ const (
  2 	sqlSelectTotalPostsAfterDate = `SELECT count(id) FROM posts WHERE created_at >= $1 AND cur_space = $2`
  3 	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);`
  4 
  5-	sqlSelectPosts               = `SELECT id, user_id, filename, title, text, description, created_at, publish_at, updated_at, hidden FROM posts`
  6-	sqlSelectPostsBeforeDate     = `SELECT posts.id, user_id, filename, 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`
  7-	sqlSelectPostWithFilename    = `SELECT posts.id, user_id, filename, 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`
  8-	sqlSelectPost                = `SELECT posts.id, user_id, filename, 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`
  9-	sqlSelectPostsForUser        = `SELECT posts.id, user_id, filename, 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 publish_at DESC`
 10-	sqlSelectUpdatedPostsForUser = `SELECT posts.id, user_id, filename, 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`
 11-	sqlSelectAllUpdatedPosts     = `SELECT posts.id, user_id, filename, 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`
 12+	sqlSelectPosts               = `SELECT id, user_id, filename, slug, title, text, description, created_at, publish_at, updated_at, hidden FROM posts`
 13+	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`
 14+	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`
 15+	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`
 16+	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`
 17+	sqlSelectPostsForUser        = `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 publish_at DESC`
 18+	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`
 19+	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`
 20 	sqlSelectPostCount           = `SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`
 21 	sqlSelectPostsByRank         = `
 22 	SELECT
 23 		posts.id,
 24 		user_id,
 25 		filename,
 26+		slug,
 27 		title,
 28 		text,
 29 		description,
 30@@ -66,10 +68,10 @@ const (
 31 	LIMIT $1 OFFSET $2`
 32 
 33 	sqlInsertPublicKey = `INSERT INTO public_keys (user_id, public_key) VALUES ($1, $2)`
 34-	sqlInsertPost      = `INSERT INTO posts (user_id, filename, title, text, description, publish_at, hidden, cur_space) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`
 35+	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) RETURNING id`
 36 	sqlInsertUser      = `INSERT INTO app_users DEFAULT VALUES returning id`
 37 
 38-	sqlUpdatePost     = `UPDATE posts SET title = $1, text = $2, description = $3, updated_at = $4, publish_at = $5 WHERE id = $6`
 39+	sqlUpdatePost     = `UPDATE posts SET slug = $1, title = $2, text = $3, description = $4, updated_at = $5, publish_at = $6 WHERE id = $7`
 40 	sqlUpdateUserName = `UPDATE app_users SET name = $1 WHERE id = $2`
 41 	sqlIncrementViews = `UPDATE posts SET views = views + 1 WHERE id = $1 RETURNING views`
 42 
 43@@ -235,6 +237,7 @@ func (me *PsqlDB) FindPostsBeforeDate(date *time.Time, space string) ([]*db.Post
 44 			&post.ID,
 45 			&post.UserID,
 46 			&post.Filename,
 47+			&post.Slug,
 48 			&post.Title,
 49 			&post.Text,
 50 			&post.Description,
 51@@ -350,6 +353,29 @@ func (me *PsqlDB) FindPostWithFilename(filename string, persona_id string, space
 52 		&post.ID,
 53 		&post.UserID,
 54 		&post.Filename,
 55+		&post.Slug,
 56+		&post.Title,
 57+		&post.Text,
 58+		&post.Description,
 59+		&post.PublishAt,
 60+		&post.Username,
 61+		&post.UpdatedAt,
 62+	)
 63+	if err != nil {
 64+		return nil, err
 65+	}
 66+
 67+	return post, nil
 68+}
 69+
 70+func (me *PsqlDB) FindPostWithSlug(slug string, user_id string, space string) (*db.Post, error) {
 71+	post := &db.Post{}
 72+	r := me.Db.QueryRow(sqlSelectPostWithSlug, slug, user_id, space)
 73+	err := r.Scan(
 74+		&post.ID,
 75+		&post.UserID,
 76+		&post.Filename,
 77+		&post.Slug,
 78 		&post.Title,
 79 		&post.Text,
 80 		&post.Description,
 81@@ -371,6 +397,7 @@ func (me *PsqlDB) FindPost(postID string) (*db.Post, error) {
 82 		&post.ID,
 83 		&post.UserID,
 84 		&post.Filename,
 85+		&post.Slug,
 86 		&post.Title,
 87 		&post.Text,
 88 		&post.Description,
 89@@ -393,6 +420,7 @@ func (me *PsqlDB) postPager(rs *sql.Rows, pageNum int, space string) (*db.Pagina
 90 			&post.ID,
 91 			&post.UserID,
 92 			&post.Filename,
 93+			&post.Slug,
 94 			&post.Title,
 95 			&post.Text,
 96 			&post.Description,
 97@@ -441,9 +469,9 @@ func (me *PsqlDB) FindAllUpdatedPosts(page *db.Pager, space string) (*db.Paginat
 98 	return me.postPager(rs, page.Num, space)
 99 }
100 
101-func (me *PsqlDB) InsertPost(userID string, filename string, title string, text string, description string, publishAt *time.Time, hidden bool, space string) (*db.Post, error) {
102+func (me *PsqlDB) InsertPost(userID, filename, slug, title, text, description string, publishAt *time.Time, hidden bool, space string) (*db.Post, error) {
103 	var id string
104-	err := me.Db.QueryRow(sqlInsertPost, userID, filename, title, text, description, publishAt, hidden, space).Scan(&id)
105+	err := me.Db.QueryRow(sqlInsertPost, userID, filename, slug, title, text, description, publishAt, hidden, space).Scan(&id)
106 	if err != nil {
107 		return nil, err
108 	}
109@@ -451,8 +479,8 @@ func (me *PsqlDB) InsertPost(userID string, filename string, title string, text
110 	return me.FindPost(id)
111 }
112 
113-func (me *PsqlDB) UpdatePost(postID string, title string, text string, description string, publishAt *time.Time) (*db.Post, error) {
114-	_, err := me.Db.Exec(sqlUpdatePost, title, text, description, time.Now(), publishAt, postID)
115+func (me *PsqlDB) UpdatePost(postID, slug, title, text, description string, publishAt *time.Time) (*db.Post, error) {
116+	_, err := me.Db.Exec(sqlUpdatePost, slug, title, text, description, time.Now(), publishAt, postID)
117 	if err != nil {
118 		return nil, err
119 	}
120@@ -478,6 +506,7 @@ func (me *PsqlDB) FindPostsForUser(userID string, space string) ([]*db.Post, err
121 			&post.ID,
122 			&post.UserID,
123 			&post.Filename,
124+			&post.Slug,
125 			&post.Title,
126 			&post.Text,
127 			&post.Description,
128@@ -509,6 +538,7 @@ func (me *PsqlDB) FindPosts() ([]*db.Post, error) {
129 			&post.ID,
130 			&post.UserID,
131 			&post.Filename,
132+			&post.Slug,
133 			&post.Title,
134 			&post.Text,
135 			&post.Description,
136@@ -541,6 +571,7 @@ func (me *PsqlDB) FindUpdatedPostsForUser(userID string, space string) ([]*db.Po
137 			&post.ID,
138 			&post.UserID,
139 			&post.Filename,
140+			&post.Slug,
141 			&post.Title,
142 			&post.Text,
143 			&post.Description,