diff --git a/activityPub.go b/activityPub.go index 626fc90..e207671 100644 --- a/activityPub.go +++ b/activityPub.go @@ -45,6 +45,9 @@ func (a *goBlog) initActivityPub() error { a.pDeleteHooks = append(a.pDeleteHooks, func(p *post) { a.apDelete(p) }) + a.pUndeleteHooks = append(a.pUndeleteHooks, func(p *post) { + a.apUndelete(p) + }) // Prepare webfinger a.webfingerResources = map[string]*configBlog{} a.webfingerAccts = map[string]string{} @@ -339,11 +342,21 @@ func (a *goBlog) apDelete(p *post) { a.apSendToAllFollowers(p.Blog, map[string]interface{}{ "@context": []string{asContext}, "actor": a.apIri(a.cfg.Blogs[p.Blog]), - "id": a.fullPostURL(p) + "#delete", "type": "Delete", - "object": map[string]string{ - "id": a.fullPostURL(p), - "type": "Tombstone", + "object": a.fullPostURL(p), + }) +} + +func (a *goBlog) apUndelete(p *post) { + a.apSendToAllFollowers(p.Blog, map[string]interface{}{ + "@context": []string{asContext}, + "actor": a.apIri(a.cfg.Blogs[p.Blog]), + "type": "Undo", + "object": map[string]interface{}{ + "@context": []string{asContext}, + "actor": a.apIri(a.cfg.Blogs[p.Blog]), + "type": "Delete", + "object": a.fullPostURL(p), }, }) } diff --git a/app.go b/app.go index 0cf5897..a415ef8 100644 --- a/app.go +++ b/app.go @@ -43,10 +43,11 @@ type goBlog struct { // Errors errorCheckMediaTypes []ct.MediaType // Hooks - pPostHooks []postHookFunc - pUpdateHooks []postHookFunc - pDeleteHooks []postHookFunc - hourlyHooks []hourlyHookFunc + pPostHooks []postHookFunc + pUpdateHooks []postHookFunc + pDeleteHooks []postHookFunc + pUndeleteHooks []postHookFunc + hourlyHooks []hourlyHookFunc // HTTP Client httpClient *http.Client // HTTP Routers diff --git a/blogstats.go b/blogstats.go index ebe526c..b634135 100644 --- a/blogstats.go +++ b/blogstats.go @@ -19,6 +19,7 @@ func (a *goBlog) initBlogStats() { a.pPostHooks = append(a.pPostHooks, f) a.pUpdateHooks = append(a.pUpdateHooks, f) a.pDeleteHooks = append(a.pDeleteHooks, f) + a.pUndeleteHooks = append(a.pUndeleteHooks, f) } func (a *goBlog) serveBlogStats(w http.ResponseWriter, r *http.Request) { diff --git a/config.go b/config.go index f1aa3a1..706fd10 100644 --- a/config.go +++ b/config.go @@ -209,9 +209,10 @@ type configHooks struct { Hourly []string `mapstructure:"hourly"` PreStart []string `mapstructure:"prestart"` // Can use template - PostPost []string `mapstructure:"postpost"` - PostUpdate []string `mapstructure:"postupdate"` - PostDelete []string `mapstructure:"postdelete"` + PostPost []string `mapstructure:"postpost"` + PostUpdate []string `mapstructure:"postupdate"` + PostDelete []string `mapstructure:"postdelete"` + PostUndelete []string `mapstructure:"postundelete"` } type configMicropub struct { diff --git a/example-config.yml b/example-config.yml index 6b61131..af6bfb8 100644 --- a/example-config.yml +++ b/example-config.yml @@ -70,6 +70,8 @@ hooks: - echo Updated post at {{.URL}} postdelete: # Commands to execute after deleting a post - echo Deleted post at {{.URL}} + postundelete: # Commands to execute after undeleting a post + - echo Undeleted post at {{.URL}} # ActivityPub activityPub: diff --git a/hooks.go b/hooks.go index 02f4229..8e901a2 100644 --- a/hooks.go +++ b/hooks.go @@ -69,6 +69,22 @@ func (a *goBlog) postDeleteHooks(p *post) { } } +func (a *goBlog) postUndeleteHooks(p *post) { + if hc := a.cfg.Hooks; hc != nil { + for _, cmdTmplString := range hc.PostUndelete { + go func(p *post, cmdTmplString string) { + a.cfg.Hooks.executeTemplateCommand("post-undelete", cmdTmplString, map[string]interface{}{ + "URL": a.fullPostURL(p), + "Post": p, + }) + }(p, cmdTmplString) + } + } + for _, f := range a.pUndeleteHooks { + go f(p) + } +} + func (cfg *configHooks) executeTemplateCommand(hookType string, tmpl string, data map[string]interface{}) { cmdTmpl, err := template.New("cmd").Parse(tmpl) if err != nil { diff --git a/http.go b/http.go index 454cbba..4363e43 100644 --- a/http.go +++ b/http.go @@ -265,7 +265,14 @@ func (a *goBlog) servePostsAliasesRedirects() http.HandlerFunc { case statusPublished, statusUnlisted: alicePrivate.Append(a.checkActivityStreamsRequest, a.cacheMiddleware).ThenFunc(a.servePost).ServeHTTP(w, r) return - default: // private, draft, scheduled + case statusPublishedDeleted, statusUnlistedDeleted: + if a.isLoggedIn(r) { + a.servePost(w, r) + return + } + alicePrivate.Append(a.cacheMiddleware).ThenFunc(a.serve410).ServeHTTP(w, r) + return + default: // private, draft, scheduled, etc. alice.New(a.authMiddleware).ThenFunc(a.servePost).ServeHTTP(w, r) return } @@ -277,9 +284,7 @@ func (a *goBlog) servePostsAliasesRedirects() http.HandlerFunc { return case "deleted": // Is deleted, serve 410 - alicePrivate.Append(a.cacheMiddleware).ThenFunc(func(w http.ResponseWriter, r *http.Request) { - a.serve410(w, r) - }).ServeHTTP(w, r) + alicePrivate.Append(a.cacheMiddleware).ThenFunc(a.serve410).ServeHTTP(w, r) return } } diff --git a/httpRouters.go b/httpRouters.go index f7093bb..f0a4127 100644 --- a/httpRouters.go +++ b/httpRouters.go @@ -345,6 +345,9 @@ func (a *goBlog) blogEditorRouter(conf *configBlog) func(r chi.Router) { r.Get("/scheduled", a.serveScheduled) r.Get("/scheduled"+feedPath, a.serveScheduled) r.Get("/scheduled"+paginationPath, a.serveScheduled) + r.Get("/deleted", a.serveDeleted) + r.Get("/deleted"+feedPath, a.serveDeleted) + r.Get("/deleted"+paginationPath, a.serveDeleted) r.HandleFunc("/preview", a.serveEditorPreview) } } diff --git a/indieAuth.go b/indieAuth.go index 2dc4688..a97f46b 100644 --- a/indieAuth.go +++ b/indieAuth.go @@ -40,6 +40,6 @@ func (a *goBlog) checkIndieAuth(next http.Handler) http.Handler { func addAllScopes(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - next.ServeHTTP(rw, r.WithContext(context.WithValue(r.Context(), indieAuthScope, "create update delete media"))) + next.ServeHTTP(rw, r.WithContext(context.WithValue(r.Context(), indieAuthScope, "create update delete undelete media"))) }) } diff --git a/indieAuth_test.go b/indieAuth_test.go index 68c7879..fc9d704 100644 --- a/indieAuth_test.go +++ b/indieAuth_test.go @@ -70,6 +70,7 @@ func Test_addAllScopes(t *testing.T) { assert.Contains(t, scope, "create") assert.Contains(t, scope, "update") assert.Contains(t, scope, "delete") + assert.Contains(t, scope, "undelete") assert.Contains(t, scope, "media") checked = true })).ServeHTTP(rec, req) diff --git a/main.go b/main.go index e8988cd..6e38bf5 100644 --- a/main.go +++ b/main.go @@ -181,6 +181,7 @@ func (app *goBlog) initComponents(logging bool) { app.initSessions() app.initIndieAuth() app.startPostsScheduler() + app.initPostsDeleter() // Log finish if logging { log.Println("Initialized components") diff --git a/micropub.go b/micropub.go index 915d732..d8c3418 100644 --- a/micropub.go +++ b/micropub.go @@ -102,6 +102,8 @@ func (a *goBlog) serveMicropubPost(w http.ResponseWriter, r *http.Request) { switch action { case actionDelete: a.micropubDelete(w, r, r.Form.Get("url")) + case actionUndelete: + a.micropubUndelete(w, r, r.Form.Get("url")) default: a.serveError(w, r, "Action not supported", http.StatusNotImplemented) } @@ -119,6 +121,8 @@ func (a *goBlog) serveMicropubPost(w http.ResponseWriter, r *http.Request) { switch parsedMfItem.Action { case actionDelete: a.micropubDelete(w, r, parsedMfItem.URL) + case actionUndelete: + a.micropubUndelete(w, r, parsedMfItem.URL) case actionUpdate: a.micropubUpdate(w, r, parsedMfItem.URL, parsedMfItem) default: @@ -226,8 +230,9 @@ func (a *goBlog) micropubParseValuePostParamsValueMap(entry *post, values map[st type micropubAction string const ( - actionUpdate micropubAction = "update" - actionDelete micropubAction = "delete" + actionUpdate micropubAction = "update" + actionDelete micropubAction = "delete" + actionUndelete micropubAction = "undelete" ) type microformatItem struct { @@ -469,6 +474,23 @@ func (a *goBlog) micropubDelete(w http.ResponseWriter, r *http.Request, u string http.Redirect(w, r, uu.String(), http.StatusNoContent) } +func (a *goBlog) micropubUndelete(w http.ResponseWriter, r *http.Request, u string) { + if !strings.Contains(r.Context().Value(indieAuthScope).(string), "undelete") { + a.serveError(w, r, "undelete scope missing", http.StatusForbidden) + return + } + uu, err := url.Parse(u) + if err != nil { + a.serveError(w, r, err.Error(), http.StatusBadRequest) + return + } + if err := a.undeletePost(uu.Path); err != nil { + a.serveError(w, r, err.Error(), http.StatusBadRequest) + return + } + http.Redirect(w, r, uu.String(), http.StatusNoContent) +} + func (a *goBlog) micropubUpdate(w http.ResponseWriter, r *http.Request, u string, mf *microformatItem) { if !strings.Contains(r.Context().Value(indieAuthScope).(string), "update") { a.serveError(w, r, "update scope missing", http.StatusForbidden) @@ -489,6 +511,12 @@ func (a *goBlog) micropubUpdate(w http.ResponseWriter, r *http.Request, u string a.serveError(w, r, err.Error(), http.StatusBadRequest) return } + // Check if post is marked as deleted + if strings.HasSuffix(string(p.Status), statusDeletedSuffix) { + a.serveError(w, r, "post is marked as deleted, undelete it first", http.StatusBadRequest) + return + } + // Update post oldPath := p.Path oldStatus := p.Status a.micropubUpdateReplace(p, mf.Replace) diff --git a/ntfy_test.go b/ntfy_test.go index 897fb9d..7b3f17d 100644 --- a/ntfy_test.go +++ b/ntfy_test.go @@ -26,7 +26,7 @@ func Test_ntfySending(t *testing.T) { _ = app.initConfig() _ = app.initDatabase(false) - app.initComponents(true) + app.initComponents(false) app.sendNotification("Test notification") diff --git a/posts.go b/posts.go index fe44fa4..a57541d 100644 --- a/posts.go +++ b/posts.go @@ -37,17 +37,24 @@ type post struct { type postStatus string const ( - statusNil postStatus = "" - statusPublished postStatus = "published" - statusDraft postStatus = "draft" - statusPrivate postStatus = "private" - statusUnlisted postStatus = "unlisted" - statusScheduled postStatus = "scheduled" + statusDeletedSuffix string = "-deleted" + + statusNil postStatus = "" + statusPublished postStatus = "published" + statusPublishedDeleted postStatus = "published-deleted" + statusDraft postStatus = "draft" + statusDraftDeleted postStatus = "draft-deleted" + statusPrivate postStatus = "private" + statusPrivateDeleted postStatus = "private-deleted" + statusUnlisted postStatus = "unlisted" + statusUnlistedDeleted postStatus = "unlisted-deleted" + statusScheduled postStatus = "scheduled" + statusScheduledDeleted postStatus = "scheduled-deleted" ) func (a *goBlog) servePost(w http.ResponseWriter, r *http.Request) { p, err := a.getPost(r.URL.Path) - if err == errPostNotFound { + if errors.Is(err, errPostNotFound) { a.serve404(w, r) return } else if err != nil { @@ -71,7 +78,11 @@ func (a *goBlog) servePost(w http.ResponseWriter, r *http.Request) { template = templateStaticHome } w.Header().Add("Link", fmt.Sprintf("<%s>; rel=shortlink", a.shortPostURL(p))) - a.render(w, r, template, &renderData{ + status := http.StatusOK + if strings.HasSuffix(string(p.Status), statusDeletedSuffix) { + status = http.StatusGone + } + a.renderWithStatusCode(w, r, status, template, &renderData{ BlogString: p.Blog, Canonical: canonical, Data: p, @@ -126,36 +137,50 @@ func (a *goBlog) serveHome(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveDrafts(w http.ResponseWriter, r *http.Request) { _, bc := a.getBlog(r) a.serveIndex(w, r.WithContext(context.WithValue(r.Context(), indexConfigKey, &indexConfig{ - path: bc.getRelativePath("/editor/drafts"), - title: a.ts.GetTemplateStringVariant(bc.Lang, "drafts"), - status: statusDraft, + path: bc.getRelativePath("/editor/drafts"), + title: a.ts.GetTemplateStringVariant(bc.Lang, "drafts"), + description: a.ts.GetTemplateStringVariant(bc.Lang, "draftsdesc"), + status: statusDraft, }))) } func (a *goBlog) servePrivate(w http.ResponseWriter, r *http.Request) { _, bc := a.getBlog(r) a.serveIndex(w, r.WithContext(context.WithValue(r.Context(), indexConfigKey, &indexConfig{ - path: bc.getRelativePath("/editor/private"), - title: a.ts.GetTemplateStringVariant(bc.Lang, "privateposts"), - status: statusPrivate, + path: bc.getRelativePath("/editor/private"), + title: a.ts.GetTemplateStringVariant(bc.Lang, "privateposts"), + description: a.ts.GetTemplateStringVariant(bc.Lang, "privatepostsdesc"), + status: statusPrivate, }))) } func (a *goBlog) serveUnlisted(w http.ResponseWriter, r *http.Request) { _, bc := a.getBlog(r) a.serveIndex(w, r.WithContext(context.WithValue(r.Context(), indexConfigKey, &indexConfig{ - path: bc.getRelativePath("/editor/unlisted"), - title: a.ts.GetTemplateStringVariant(bc.Lang, "unlistedposts"), - status: statusUnlisted, + path: bc.getRelativePath("/editor/unlisted"), + title: a.ts.GetTemplateStringVariant(bc.Lang, "unlistedposts"), + description: a.ts.GetTemplateStringVariant(bc.Lang, "unlistedpostsdesc"), + status: statusUnlisted, }))) } func (a *goBlog) serveScheduled(w http.ResponseWriter, r *http.Request) { _, bc := a.getBlog(r) a.serveIndex(w, r.WithContext(context.WithValue(r.Context(), indexConfigKey, &indexConfig{ - path: bc.getRelativePath("/editor/scheduled"), - title: a.ts.GetTemplateStringVariant(bc.Lang, "scheduledposts"), - status: statusScheduled, + path: bc.getRelativePath("/editor/scheduled"), + title: a.ts.GetTemplateStringVariant(bc.Lang, "scheduledposts"), + description: a.ts.GetTemplateStringVariant(bc.Lang, "scheduledpostsdesc"), + status: statusScheduled, + }))) +} + +func (a *goBlog) serveDeleted(w http.ResponseWriter, r *http.Request) { + _, bc := a.getBlog(r) + a.serveIndex(w, r.WithContext(context.WithValue(r.Context(), indexConfigKey, &indexConfig{ + path: bc.getRelativePath("/editor/deleted"), + title: a.ts.GetTemplateStringVariant(bc.Lang, "deletedposts"), + description: a.ts.GetTemplateStringVariant(bc.Lang, "deletedpostsdesc"), + statusse: []postStatus{statusPublishedDeleted, statusDraftDeleted, statusScheduledDeleted, statusPrivateDeleted, statusUnlistedDeleted}, }))) } @@ -215,6 +240,7 @@ type indexConfig struct { description string summaryTemplate string status postStatus + statusse []postStatus } const defaultPhotosPath = "/photos" @@ -239,9 +265,12 @@ func (a *goBlog) serveIndex(w http.ResponseWriter, r *http.Request) { sections = append(sections, sectionKey) } } - status := ic.status - if status == statusNil { - status = statusPublished + statusse := ic.statusse + if ic.status != statusNil { + statusse = []postStatus{ic.status} + } + if len(statusse) == 0 { + statusse = []postStatus{statusPublished} } p := paginator.New(&postPaginationAdapter{config: &postsRequestConfig{ blog: blog, @@ -253,7 +282,7 @@ func (a *goBlog) serveIndex(w http.ResponseWriter, r *http.Request) { publishedYear: ic.year, publishedMonth: ic.month, publishedDay: ic.day, - status: status, + statusse: statusse, priorityOrder: true, }, a: a}, bc.Pagination) p.SetPage(pageNo) diff --git a/postsDb.go b/postsDb.go index f19cfb5..13cd555 100644 --- a/postsDb.go +++ b/postsDb.go @@ -196,40 +196,86 @@ func (db *database) savePost(p *post, o *postCreationOptions) error { } func (a *goBlog) deletePost(path string) error { - p, err := a.deletePostFromDb(path) - if err != nil || p == nil { + if path == "" { + return errors.New("path required") + } + // Lock post creation + a.db.pcm.Lock() + defer a.db.pcm.Unlock() + // Check if post exists + p, err := a.getPost(path) + if err != nil { return err } - // Purge cache - a.cache.purge() - // Trigger hooks - a.postDeleteHooks(p) + // Post exists, check if it's already marked as deleted + if strings.HasSuffix(string(p.Status), statusDeletedSuffix) { + // Post is already marked as deleted, delete it from database + if _, err = a.db.exec( + `begin; delete from posts where path = ?; delete from post_parameters where path = ?; insert or ignore into deleted (path) values (?); commit;`, + dbNoCache, p.Path, p.Path, p.Path, + ); err != nil { + return err + } + // Rebuild FTS index + a.db.rebuildFTSIndex() + // Purge cache + a.cache.purge() + } else { + // Update post status + p.Status = postStatus(string(p.Status) + statusDeletedSuffix) + // Add parameter + deletedTime := utcNowString() + if p.Parameters == nil { + p.Parameters = map[string][]string{} + } + p.Parameters["deleted"] = []string{deletedTime} + // Mark post as deleted + if _, err = a.db.exec( + `begin; update posts set status = ? where path = ?; delete from post_parameters where path = ? and value = 'deleted'; insert into post_parameters (path, parameter, value) values (?, 'deleted', ?); commit;`, + dbNoCache, p.Status, p.Path, p.Path, p.Path, deletedTime, + ); err != nil { + return err + } + // Rebuild FTS index + a.db.rebuildFTSIndex() + // Purge cache + a.cache.purge() + // Trigger hooks + a.postDeleteHooks(p) + } return nil } -func (a *goBlog) deletePostFromDb(path string) (*post, error) { +func (a *goBlog) undeletePost(path string) error { if path == "" { - return nil, nil + return errors.New("path required") } + // Lock post creation a.db.pcm.Lock() defer a.db.pcm.Unlock() + // Check if post exists p, err := a.getPost(path) if err != nil { - return nil, err + return err } - _, err = a.db.exec( - `begin; - delete from posts where path = ?; - delete from post_parameters where path = ?; - insert or ignore into deleted (path) values (?); - commit;`, - dbNoCache, p.Path, p.Path, p.Path, - ) - if err != nil { - return nil, err + // Post exists, update status and parameters + p.Status = postStatus(strings.TrimSuffix(string(p.Status), statusDeletedSuffix)) + // Remove parameter + p.Parameters["deleted"] = nil + // Update database + if _, err = a.db.exec( + `begin; update posts set status = ? where path = ?; delete from post_parameters where path = ? and parameter = 'deleted'; commit;`, + dbNoCache, p.Status, p.Path, p.Path, + ); err != nil { + return err } + // Rebuild FTS index a.db.rebuildFTSIndex() - return p, nil + // Purge cache + a.cache.purge() + // Trigger hooks + a.postUndeleteHooks(p) + return nil } func (db *database) replacePostParam(path, param string, values []string) error { @@ -270,6 +316,7 @@ type postsRequestConfig struct { offset int sections []string status postStatus + statusse []postStatus taxonomy *configTaxonomy taxonomyValue string parameters []string // Ignores parameterValue @@ -307,6 +354,19 @@ func buildPostsQuery(c *postsRequestConfig, selection string) (query string, arg queryBuilder.WriteString(" and status = @status") args = append(args, sql.Named("status", c.status)) } + if c.statusse != nil && len(c.statusse) > 0 { + queryBuilder.WriteString(" and status in (") + for i, status := range c.statusse { + if i > 0 { + queryBuilder.WriteString(", ") + } + named := "status" + strconv.Itoa(i) + queryBuilder.WriteByte('@') + queryBuilder.WriteString(named) + args = append(args, sql.Named(named, status)) + } + queryBuilder.WriteByte(')') + } if c.blog != "" { queryBuilder.WriteString(" and blog = @blog") args = append(args, sql.Named("blog", c.blog)) @@ -529,6 +589,7 @@ func (a *goBlog) getRandomPostPath(blog string) (path string, err error) { func (d *database) allTaxonomyValues(blog string, taxonomy string) ([]string, error) { var values []string + // TODO: Query posts the normal way rows, err := d.query("select distinct value from post_parameters where parameter = @tax and length(coalesce(value, '')) > 0 and path in (select path from posts where blog = @blog and status = @status) order by value", sql.Named("tax", taxonomy), sql.Named("blog", blog), sql.Named("status", statusPublished)) if err != nil { return nil, err diff --git a/postsDb_test.go b/postsDb_test.go index d1df932..a792d96 100644 --- a/postsDb_test.go +++ b/postsDb_test.go @@ -14,22 +14,19 @@ func Test_postsDb(t *testing.T) { must := require.New(t) app := &goBlog{ - cfg: &config{ - Db: &configDb{ - File: filepath.Join(t.TempDir(), "test.db"), - }, - Blogs: map[string]*configBlog{ - "en": { - Sections: map[string]*configSection{ - "test": {}, - "micro": {}, - }, - }, + cfg: createDefaultTestConfig(t), + } + app.cfg.Blogs = map[string]*configBlog{ + "en": { + Sections: map[string]*configSection{ + "test": {}, + "micro": {}, }, }, } + _ = app.initConfig() _ = app.initDatabase(false) - app.initMarkdown() + app.initComponents(false) now := toLocalSafe(time.Now().String()) nowPlus1Hour := toLocalSafe(time.Now().Add(1 * time.Hour).String()) @@ -95,7 +92,19 @@ func Test_postsDb(t *testing.T) { is.Equal(1, count) // Delete post - _, err = app.deletePostFromDb("/test/abc") + err = app.deletePost("/test/abc") + must.NoError(err) + + // Check if post is marked as deleted + count, err = app.db.countPosts(&postsRequestConfig{status: statusDraft}) + must.NoError(err) + is.Equal(0, count) + count, err = app.db.countPosts(&postsRequestConfig{status: statusDraftDeleted}) + must.NoError(err) + is.Equal(1, count) + + // Delete post again + err = app.deletePost("/test/abc") must.NoError(err) // Check that there is no post diff --git a/postsDeleter.go b/postsDeleter.go new file mode 100644 index 0000000..da3fa0a --- /dev/null +++ b/postsDeleter.go @@ -0,0 +1,32 @@ +package main + +import ( + "log" + "time" + + "github.com/araddon/dateparse" +) + +func (a *goBlog) initPostsDeleter() { + a.hourlyHooks = append(a.hourlyHooks, func() { + a.checkDeletedPosts() + }) +} + +func (a *goBlog) checkDeletedPosts() { + // Get all posts with `deleted` parameter and a deleted status + postsToDelete, err := a.getPosts(&postsRequestConfig{ + statusse: []postStatus{statusPublishedDeleted, statusDraftDeleted, statusPrivateDeleted, statusUnlistedDeleted, statusScheduledDeleted}, + parameter: "deleted", + }) + if err != nil { + log.Println("Error getting deleted posts:", err) + return + } + for _, post := range postsToDelete { + // Check if post is deleted for more than 7 days + if deleted, err := dateparse.ParseLocal(post.firstParameter("deleted")); err == nil && deleted.Add(time.Hour*24*7).Before(time.Now()) { + a.deletePost(post.Path) + } + } +} diff --git a/postsDeleter_test.go b/postsDeleter_test.go new file mode 100644 index 0000000..87cf6a7 --- /dev/null +++ b/postsDeleter_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func Test_checkDeletedPosts(t *testing.T) { + app := &goBlog{ + cfg: createDefaultTestConfig(t), + } + _ = app.initConfig() + _ = app.initDatabase(false) + app.initComponents(false) + + // Create a post + app.createPost(&post{ + Content: "Test", + Status: statusPublished, + Path: "/testpost", + Section: "posts", + }) + + // Check if post count is 1 + count, err := app.db.countPosts(&postsRequestConfig{}) + require.NoError(t, err) + require.Equal(t, 1, count) + + // Run deleter + app.checkDeletedPosts() + + // Check if post count is still 1 + count, err = app.db.countPosts(&postsRequestConfig{}) + require.NoError(t, err) + require.Equal(t, 1, count) + + // Delete the post + err = app.deletePost("/testpost") + require.NoError(t, err) + + // Run deleter + app.checkDeletedPosts() + + // Check if post count is still 1 + count, err = app.db.countPosts(&postsRequestConfig{}) + require.NoError(t, err) + require.Equal(t, 1, count) + + // Set deleted time to more than 7 days ago + app.db.replacePostParam("/testpost", "deleted", []string{time.Now().Add(-time.Hour * 24 * 8).Format(time.RFC3339)}) + + // Run deleter + app.checkDeletedPosts() + + // Check if post count is 0 + count, err = app.db.countPosts(&postsRequestConfig{}) + require.NoError(t, err) + require.Equal(t, 0, count) +} diff --git a/postsFuncs.go b/postsFuncs.go index a10926d..7b4433d 100644 --- a/postsFuncs.go +++ b/postsFuncs.go @@ -264,3 +264,7 @@ func (p *post) Old() bool { func (p *post) TTS() string { return p.firstParameter(ttsParameter) } + +func (p *post) Deleted() bool { + return strings.HasSuffix(string(p.Status), statusDeletedSuffix) +} diff --git a/posts_test.go b/posts_test.go index 80bd3c9..b6d3bc6 100644 --- a/posts_test.go +++ b/posts_test.go @@ -99,3 +99,107 @@ func Test_serveDate(t *testing.T) { Client(client).Fetch(context.Background()) require.NoError(t, err) } + +func Test_servePost(t *testing.T) { + var err error + + app := &goBlog{ + cfg: createDefaultTestConfig(t), + } + app.cfg.User.AppPasswords = append(app.cfg.User.AppPasswords, &configAppPassword{ + Username: "test", + Password: "test", + }) + _ = app.initConfig() + _ = app.initDatabase(false) + app.initComponents(false) + + app.d, err = app.buildRouter() + require.NoError(t, err) + + // Create a post + err = app.createPost(&post{ + Path: "/testpost", + Section: "posts", + Status: "published", + Published: "2020-10-15T10:00:00Z", + Parameters: map[string][]string{"title": {"Test Post"}}, + Content: "Test Content", + }) + require.NoError(t, err) + + client := &http.Client{ + Transport: &handlerRoundTripper{ + handler: app.d, + }, + } + + var resString string + + // Check if the post is served + err = requests. + URL("http://localhost:8080/testpost"). + CheckStatus(http.StatusOK). + ToString(&resString). + Client(client).Fetch(context.Background()) + require.NoError(t, err) + + assert.Contains(t, resString, "
{{ string .Blog.Lang "privateposts" }}
{{ string .Blog.Lang "unlistedposts" }}
{{ string .Blog.Lang "scheduledposts" }}
+{{ string .Blog.Lang "deletedposts" }}