Add support for undeleting posts within 7 days (#3)

This commit is contained in:
Jan-Lukas Else 2022-01-03 13:55:44 +01:00
parent 86625cc8fe
commit 337392112b
27 changed files with 482 additions and 76 deletions

View File

@ -45,6 +45,9 @@ func (a *goBlog) initActivityPub() error {
a.pDeleteHooks = append(a.pDeleteHooks, func(p *post) { a.pDeleteHooks = append(a.pDeleteHooks, func(p *post) {
a.apDelete(p) a.apDelete(p)
}) })
a.pUndeleteHooks = append(a.pUndeleteHooks, func(p *post) {
a.apUndelete(p)
})
// Prepare webfinger // Prepare webfinger
a.webfingerResources = map[string]*configBlog{} a.webfingerResources = map[string]*configBlog{}
a.webfingerAccts = map[string]string{} a.webfingerAccts = map[string]string{}
@ -339,11 +342,21 @@ func (a *goBlog) apDelete(p *post) {
a.apSendToAllFollowers(p.Blog, map[string]interface{}{ a.apSendToAllFollowers(p.Blog, map[string]interface{}{
"@context": []string{asContext}, "@context": []string{asContext},
"actor": a.apIri(a.cfg.Blogs[p.Blog]), "actor": a.apIri(a.cfg.Blogs[p.Blog]),
"id": a.fullPostURL(p) + "#delete",
"type": "Delete", "type": "Delete",
"object": map[string]string{ "object": a.fullPostURL(p),
"id": a.fullPostURL(p), })
"type": "Tombstone", }
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),
}, },
}) })
} }

9
app.go
View File

@ -43,10 +43,11 @@ type goBlog struct {
// Errors // Errors
errorCheckMediaTypes []ct.MediaType errorCheckMediaTypes []ct.MediaType
// Hooks // Hooks
pPostHooks []postHookFunc pPostHooks []postHookFunc
pUpdateHooks []postHookFunc pUpdateHooks []postHookFunc
pDeleteHooks []postHookFunc pDeleteHooks []postHookFunc
hourlyHooks []hourlyHookFunc pUndeleteHooks []postHookFunc
hourlyHooks []hourlyHookFunc
// HTTP Client // HTTP Client
httpClient *http.Client httpClient *http.Client
// HTTP Routers // HTTP Routers

View File

@ -19,6 +19,7 @@ func (a *goBlog) initBlogStats() {
a.pPostHooks = append(a.pPostHooks, f) a.pPostHooks = append(a.pPostHooks, f)
a.pUpdateHooks = append(a.pUpdateHooks, f) a.pUpdateHooks = append(a.pUpdateHooks, f)
a.pDeleteHooks = append(a.pDeleteHooks, f) a.pDeleteHooks = append(a.pDeleteHooks, f)
a.pUndeleteHooks = append(a.pUndeleteHooks, f)
} }
func (a *goBlog) serveBlogStats(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveBlogStats(w http.ResponseWriter, r *http.Request) {

View File

@ -209,9 +209,10 @@ type configHooks struct {
Hourly []string `mapstructure:"hourly"` Hourly []string `mapstructure:"hourly"`
PreStart []string `mapstructure:"prestart"` PreStart []string `mapstructure:"prestart"`
// Can use template // Can use template
PostPost []string `mapstructure:"postpost"` PostPost []string `mapstructure:"postpost"`
PostUpdate []string `mapstructure:"postupdate"` PostUpdate []string `mapstructure:"postupdate"`
PostDelete []string `mapstructure:"postdelete"` PostDelete []string `mapstructure:"postdelete"`
PostUndelete []string `mapstructure:"postundelete"`
} }
type configMicropub struct { type configMicropub struct {

View File

@ -70,6 +70,8 @@ hooks:
- echo Updated post at {{.URL}} - echo Updated post at {{.URL}}
postdelete: # Commands to execute after deleting a post postdelete: # Commands to execute after deleting a post
- echo Deleted post at {{.URL}} - echo Deleted post at {{.URL}}
postundelete: # Commands to execute after undeleting a post
- echo Undeleted post at {{.URL}}
# ActivityPub # ActivityPub
activityPub: activityPub:

View File

@ -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{}) { func (cfg *configHooks) executeTemplateCommand(hookType string, tmpl string, data map[string]interface{}) {
cmdTmpl, err := template.New("cmd").Parse(tmpl) cmdTmpl, err := template.New("cmd").Parse(tmpl)
if err != nil { if err != nil {

13
http.go
View File

@ -265,7 +265,14 @@ func (a *goBlog) servePostsAliasesRedirects() http.HandlerFunc {
case statusPublished, statusUnlisted: case statusPublished, statusUnlisted:
alicePrivate.Append(a.checkActivityStreamsRequest, a.cacheMiddleware).ThenFunc(a.servePost).ServeHTTP(w, r) alicePrivate.Append(a.checkActivityStreamsRequest, a.cacheMiddleware).ThenFunc(a.servePost).ServeHTTP(w, r)
return 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) alice.New(a.authMiddleware).ThenFunc(a.servePost).ServeHTTP(w, r)
return return
} }
@ -277,9 +284,7 @@ func (a *goBlog) servePostsAliasesRedirects() http.HandlerFunc {
return return
case "deleted": case "deleted":
// Is deleted, serve 410 // Is deleted, serve 410
alicePrivate.Append(a.cacheMiddleware).ThenFunc(func(w http.ResponseWriter, r *http.Request) { alicePrivate.Append(a.cacheMiddleware).ThenFunc(a.serve410).ServeHTTP(w, r)
a.serve410(w, r)
}).ServeHTTP(w, r)
return return
} }
} }

View File

@ -345,6 +345,9 @@ func (a *goBlog) blogEditorRouter(conf *configBlog) func(r chi.Router) {
r.Get("/scheduled", a.serveScheduled) r.Get("/scheduled", a.serveScheduled)
r.Get("/scheduled"+feedPath, a.serveScheduled) r.Get("/scheduled"+feedPath, a.serveScheduled)
r.Get("/scheduled"+paginationPath, 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) r.HandleFunc("/preview", a.serveEditorPreview)
} }
} }

View File

@ -40,6 +40,6 @@ func (a *goBlog) checkIndieAuth(next http.Handler) http.Handler {
func addAllScopes(next http.Handler) http.Handler { func addAllScopes(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 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")))
}) })
} }

View File

@ -70,6 +70,7 @@ func Test_addAllScopes(t *testing.T) {
assert.Contains(t, scope, "create") assert.Contains(t, scope, "create")
assert.Contains(t, scope, "update") assert.Contains(t, scope, "update")
assert.Contains(t, scope, "delete") assert.Contains(t, scope, "delete")
assert.Contains(t, scope, "undelete")
assert.Contains(t, scope, "media") assert.Contains(t, scope, "media")
checked = true checked = true
})).ServeHTTP(rec, req) })).ServeHTTP(rec, req)

View File

@ -181,6 +181,7 @@ func (app *goBlog) initComponents(logging bool) {
app.initSessions() app.initSessions()
app.initIndieAuth() app.initIndieAuth()
app.startPostsScheduler() app.startPostsScheduler()
app.initPostsDeleter()
// Log finish // Log finish
if logging { if logging {
log.Println("Initialized components") log.Println("Initialized components")

View File

@ -102,6 +102,8 @@ func (a *goBlog) serveMicropubPost(w http.ResponseWriter, r *http.Request) {
switch action { switch action {
case actionDelete: case actionDelete:
a.micropubDelete(w, r, r.Form.Get("url")) a.micropubDelete(w, r, r.Form.Get("url"))
case actionUndelete:
a.micropubUndelete(w, r, r.Form.Get("url"))
default: default:
a.serveError(w, r, "Action not supported", http.StatusNotImplemented) 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 { switch parsedMfItem.Action {
case actionDelete: case actionDelete:
a.micropubDelete(w, r, parsedMfItem.URL) a.micropubDelete(w, r, parsedMfItem.URL)
case actionUndelete:
a.micropubUndelete(w, r, parsedMfItem.URL)
case actionUpdate: case actionUpdate:
a.micropubUpdate(w, r, parsedMfItem.URL, parsedMfItem) a.micropubUpdate(w, r, parsedMfItem.URL, parsedMfItem)
default: default:
@ -226,8 +230,9 @@ func (a *goBlog) micropubParseValuePostParamsValueMap(entry *post, values map[st
type micropubAction string type micropubAction string
const ( const (
actionUpdate micropubAction = "update" actionUpdate micropubAction = "update"
actionDelete micropubAction = "delete" actionDelete micropubAction = "delete"
actionUndelete micropubAction = "undelete"
) )
type microformatItem struct { 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) 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) { func (a *goBlog) micropubUpdate(w http.ResponseWriter, r *http.Request, u string, mf *microformatItem) {
if !strings.Contains(r.Context().Value(indieAuthScope).(string), "update") { if !strings.Contains(r.Context().Value(indieAuthScope).(string), "update") {
a.serveError(w, r, "update scope missing", http.StatusForbidden) 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) a.serveError(w, r, err.Error(), http.StatusBadRequest)
return 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 oldPath := p.Path
oldStatus := p.Status oldStatus := p.Status
a.micropubUpdateReplace(p, mf.Replace) a.micropubUpdateReplace(p, mf.Replace)

View File

@ -26,7 +26,7 @@ func Test_ntfySending(t *testing.T) {
_ = app.initConfig() _ = app.initConfig()
_ = app.initDatabase(false) _ = app.initDatabase(false)
app.initComponents(true) app.initComponents(false)
app.sendNotification("Test notification") app.sendNotification("Test notification")

View File

@ -37,17 +37,24 @@ type post struct {
type postStatus string type postStatus string
const ( const (
statusNil postStatus = "" statusDeletedSuffix string = "-deleted"
statusPublished postStatus = "published"
statusDraft postStatus = "draft" statusNil postStatus = ""
statusPrivate postStatus = "private" statusPublished postStatus = "published"
statusUnlisted postStatus = "unlisted" statusPublishedDeleted postStatus = "published-deleted"
statusScheduled postStatus = "scheduled" 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) { func (a *goBlog) servePost(w http.ResponseWriter, r *http.Request) {
p, err := a.getPost(r.URL.Path) p, err := a.getPost(r.URL.Path)
if err == errPostNotFound { if errors.Is(err, errPostNotFound) {
a.serve404(w, r) a.serve404(w, r)
return return
} else if err != nil { } else if err != nil {
@ -71,7 +78,11 @@ func (a *goBlog) servePost(w http.ResponseWriter, r *http.Request) {
template = templateStaticHome template = templateStaticHome
} }
w.Header().Add("Link", fmt.Sprintf("<%s>; rel=shortlink", a.shortPostURL(p))) 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, BlogString: p.Blog,
Canonical: canonical, Canonical: canonical,
Data: p, 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) { func (a *goBlog) serveDrafts(w http.ResponseWriter, r *http.Request) {
_, bc := a.getBlog(r) _, bc := a.getBlog(r)
a.serveIndex(w, r.WithContext(context.WithValue(r.Context(), indexConfigKey, &indexConfig{ a.serveIndex(w, r.WithContext(context.WithValue(r.Context(), indexConfigKey, &indexConfig{
path: bc.getRelativePath("/editor/drafts"), path: bc.getRelativePath("/editor/drafts"),
title: a.ts.GetTemplateStringVariant(bc.Lang, "drafts"), title: a.ts.GetTemplateStringVariant(bc.Lang, "drafts"),
status: statusDraft, description: a.ts.GetTemplateStringVariant(bc.Lang, "draftsdesc"),
status: statusDraft,
}))) })))
} }
func (a *goBlog) servePrivate(w http.ResponseWriter, r *http.Request) { func (a *goBlog) servePrivate(w http.ResponseWriter, r *http.Request) {
_, bc := a.getBlog(r) _, bc := a.getBlog(r)
a.serveIndex(w, r.WithContext(context.WithValue(r.Context(), indexConfigKey, &indexConfig{ a.serveIndex(w, r.WithContext(context.WithValue(r.Context(), indexConfigKey, &indexConfig{
path: bc.getRelativePath("/editor/private"), path: bc.getRelativePath("/editor/private"),
title: a.ts.GetTemplateStringVariant(bc.Lang, "privateposts"), title: a.ts.GetTemplateStringVariant(bc.Lang, "privateposts"),
status: statusPrivate, description: a.ts.GetTemplateStringVariant(bc.Lang, "privatepostsdesc"),
status: statusPrivate,
}))) })))
} }
func (a *goBlog) serveUnlisted(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveUnlisted(w http.ResponseWriter, r *http.Request) {
_, bc := a.getBlog(r) _, bc := a.getBlog(r)
a.serveIndex(w, r.WithContext(context.WithValue(r.Context(), indexConfigKey, &indexConfig{ a.serveIndex(w, r.WithContext(context.WithValue(r.Context(), indexConfigKey, &indexConfig{
path: bc.getRelativePath("/editor/unlisted"), path: bc.getRelativePath("/editor/unlisted"),
title: a.ts.GetTemplateStringVariant(bc.Lang, "unlistedposts"), title: a.ts.GetTemplateStringVariant(bc.Lang, "unlistedposts"),
status: statusUnlisted, description: a.ts.GetTemplateStringVariant(bc.Lang, "unlistedpostsdesc"),
status: statusUnlisted,
}))) })))
} }
func (a *goBlog) serveScheduled(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveScheduled(w http.ResponseWriter, r *http.Request) {
_, bc := a.getBlog(r) _, bc := a.getBlog(r)
a.serveIndex(w, r.WithContext(context.WithValue(r.Context(), indexConfigKey, &indexConfig{ a.serveIndex(w, r.WithContext(context.WithValue(r.Context(), indexConfigKey, &indexConfig{
path: bc.getRelativePath("/editor/scheduled"), path: bc.getRelativePath("/editor/scheduled"),
title: a.ts.GetTemplateStringVariant(bc.Lang, "scheduledposts"), title: a.ts.GetTemplateStringVariant(bc.Lang, "scheduledposts"),
status: statusScheduled, 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 description string
summaryTemplate string summaryTemplate string
status postStatus status postStatus
statusse []postStatus
} }
const defaultPhotosPath = "/photos" const defaultPhotosPath = "/photos"
@ -239,9 +265,12 @@ func (a *goBlog) serveIndex(w http.ResponseWriter, r *http.Request) {
sections = append(sections, sectionKey) sections = append(sections, sectionKey)
} }
} }
status := ic.status statusse := ic.statusse
if status == statusNil { if ic.status != statusNil {
status = statusPublished statusse = []postStatus{ic.status}
}
if len(statusse) == 0 {
statusse = []postStatus{statusPublished}
} }
p := paginator.New(&postPaginationAdapter{config: &postsRequestConfig{ p := paginator.New(&postPaginationAdapter{config: &postsRequestConfig{
blog: blog, blog: blog,
@ -253,7 +282,7 @@ func (a *goBlog) serveIndex(w http.ResponseWriter, r *http.Request) {
publishedYear: ic.year, publishedYear: ic.year,
publishedMonth: ic.month, publishedMonth: ic.month,
publishedDay: ic.day, publishedDay: ic.day,
status: status, statusse: statusse,
priorityOrder: true, priorityOrder: true,
}, a: a}, bc.Pagination) }, a: a}, bc.Pagination)
p.SetPage(pageNo) p.SetPage(pageNo)

View File

@ -196,40 +196,86 @@ func (db *database) savePost(p *post, o *postCreationOptions) error {
} }
func (a *goBlog) deletePost(path string) error { func (a *goBlog) deletePost(path string) error {
p, err := a.deletePostFromDb(path) if path == "" {
if err != nil || p == 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 err return err
} }
// Purge cache // Post exists, check if it's already marked as deleted
a.cache.purge() if strings.HasSuffix(string(p.Status), statusDeletedSuffix) {
// Trigger hooks // Post is already marked as deleted, delete it from database
a.postDeleteHooks(p) 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 return nil
} }
func (a *goBlog) deletePostFromDb(path string) (*post, error) { func (a *goBlog) undeletePost(path string) error {
if path == "" { if path == "" {
return nil, nil return errors.New("path required")
} }
// Lock post creation
a.db.pcm.Lock() a.db.pcm.Lock()
defer a.db.pcm.Unlock() defer a.db.pcm.Unlock()
// Check if post exists
p, err := a.getPost(path) p, err := a.getPost(path)
if err != nil { if err != nil {
return nil, err return err
} }
_, err = a.db.exec( // Post exists, update status and parameters
`begin; p.Status = postStatus(strings.TrimSuffix(string(p.Status), statusDeletedSuffix))
delete from posts where path = ?; // Remove parameter
delete from post_parameters where path = ?; p.Parameters["deleted"] = nil
insert or ignore into deleted (path) values (?); // Update database
commit;`, if _, err = a.db.exec(
dbNoCache, p.Path, p.Path, p.Path, `begin; update posts set status = ? where path = ?; delete from post_parameters where path = ? and parameter = 'deleted'; commit;`,
) dbNoCache, p.Status, p.Path, p.Path,
if err != nil { ); err != nil {
return nil, err return err
} }
// Rebuild FTS index
a.db.rebuildFTSIndex() 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 { func (db *database) replacePostParam(path, param string, values []string) error {
@ -270,6 +316,7 @@ type postsRequestConfig struct {
offset int offset int
sections []string sections []string
status postStatus status postStatus
statusse []postStatus
taxonomy *configTaxonomy taxonomy *configTaxonomy
taxonomyValue string taxonomyValue string
parameters []string // Ignores parameterValue parameters []string // Ignores parameterValue
@ -307,6 +354,19 @@ func buildPostsQuery(c *postsRequestConfig, selection string) (query string, arg
queryBuilder.WriteString(" and status = @status") queryBuilder.WriteString(" and status = @status")
args = append(args, sql.Named("status", c.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 != "" { if c.blog != "" {
queryBuilder.WriteString(" and blog = @blog") queryBuilder.WriteString(" and blog = @blog")
args = append(args, sql.Named("blog", c.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) { func (d *database) allTaxonomyValues(blog string, taxonomy string) ([]string, error) {
var values []string 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)) 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 { if err != nil {
return nil, err return nil, err

View File

@ -14,22 +14,19 @@ func Test_postsDb(t *testing.T) {
must := require.New(t) must := require.New(t)
app := &goBlog{ app := &goBlog{
cfg: &config{ cfg: createDefaultTestConfig(t),
Db: &configDb{ }
File: filepath.Join(t.TempDir(), "test.db"), app.cfg.Blogs = map[string]*configBlog{
}, "en": {
Blogs: map[string]*configBlog{ Sections: map[string]*configSection{
"en": { "test": {},
Sections: map[string]*configSection{ "micro": {},
"test": {},
"micro": {},
},
},
}, },
}, },
} }
_ = app.initConfig()
_ = app.initDatabase(false) _ = app.initDatabase(false)
app.initMarkdown() app.initComponents(false)
now := toLocalSafe(time.Now().String()) now := toLocalSafe(time.Now().String())
nowPlus1Hour := toLocalSafe(time.Now().Add(1 * time.Hour).String()) nowPlus1Hour := toLocalSafe(time.Now().Add(1 * time.Hour).String())
@ -95,7 +92,19 @@ func Test_postsDb(t *testing.T) {
is.Equal(1, count) is.Equal(1, count)
// Delete post // 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) must.NoError(err)
// Check that there is no post // Check that there is no post

32
postsDeleter.go Normal file
View File

@ -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)
}
}
}

61
postsDeleter_test.go Normal file
View File

@ -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)
}

View File

@ -264,3 +264,7 @@ func (p *post) Old() bool {
func (p *post) TTS() string { func (p *post) TTS() string {
return p.firstParameter(ttsParameter) return p.firstParameter(ttsParameter)
} }
func (p *post) Deleted() bool {
return strings.HasSuffix(string(p.Status), statusDeletedSuffix)
}

View File

@ -99,3 +99,107 @@ func Test_serveDate(t *testing.T) {
Client(client).Fetch(context.Background()) Client(client).Fetch(context.Background())
require.NoError(t, err) 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, "<h1 class=p-name>Test Post</h1>")
// Delete the post
err = app.deletePost("/testpost")
require.NoError(t, err)
// Check if the post is no longer served
err = requests.
URL("http://localhost:8080/testpost").
CheckStatus(http.StatusGone).
ToString(&resString).
Client(client).Fetch(context.Background())
require.NoError(t, err)
assert.Contains(t, resString, "410 Gone")
// Check if the post is still served for logged in user
err = requests.
URL("http://localhost:8080/testpost").
BasicAuth("test", "test").
CheckStatus(http.StatusGone).
ToString(&resString).
Client(client).Fetch(context.Background())
require.NoError(t, err)
assert.Contains(t, resString, "<h1 class=p-name>Test Post</h1>")
// Undelete the post
err = app.undeletePost("/testpost")
require.NoError(t, err)
// 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, "<h1 class=p-name>Test Post</h1>")
// Delete the post completely
err = app.deletePost("/testpost")
require.NoError(t, err)
err = app.deletePost("/testpost")
require.NoError(t, err)
// Check if the post is no longer served
err = requests.
URL("http://localhost:8080/testpost").
BasicAuth("test", "test").
CheckStatus(http.StatusGone).
ToString(&resString).
Client(client).Fetch(context.Background())
require.NoError(t, err)
assert.NotContains(t, resString, "<h1 class=p-name>Test Post</h1>")
}

View File

@ -102,8 +102,18 @@ func (a *goBlog) initTelegram() {
if err != nil { if err != nil {
log.Printf("Failed to delete Telegram message: %v", err) log.Printf("Failed to delete Telegram message: %v", err)
} }
// Delete chat and message id from post
err = a.db.replacePostParam(p.Path, "telegramchat", []string{})
if err != nil {
log.Printf("Failed to remove Telegram chat id: %v", err)
}
err = a.db.replacePostParam(p.Path, "telegrammsg", []string{})
if err != nil {
log.Printf("Failed to remove Telegram message id: %v", err)
}
} }
}) })
// TODO: Handle undelete
} }
func (tg *configTelegram) enabled() bool { func (tg *configTelegram) enabled() bool {

View File

@ -31,6 +31,7 @@
<p><a href="{{ .Blog.RelativePath "/editor/private" }}">{{ string .Blog.Lang "privateposts" }}</a></p> <p><a href="{{ .Blog.RelativePath "/editor/private" }}">{{ string .Blog.Lang "privateposts" }}</a></p>
<p><a href="{{ .Blog.RelativePath "/editor/unlisted" }}">{{ string .Blog.Lang "unlistedposts" }}</a></p> <p><a href="{{ .Blog.RelativePath "/editor/unlisted" }}">{{ string .Blog.Lang "unlistedposts" }}</a></p>
<p><a href="{{ .Blog.RelativePath "/editor/scheduled" }}">{{ string .Blog.Lang "scheduledposts" }}</a></p> <p><a href="{{ .Blog.RelativePath "/editor/scheduled" }}">{{ string .Blog.Lang "scheduledposts" }}</a></p>
<p><a href="{{ .Blog.RelativePath "/editor/deleted" }}">{{ string .Blog.Lang "deletedposts" }}</a></p>
<h2>{{ string .Blog.Lang "upload" }}</h2> <h2>{{ string .Blog.Lang "upload" }}</h2>
<form class="fw p" method="post" enctype="multipart/form-data"> <form class="fw p" method="post" enctype="multipart/form-data">

View File

@ -36,6 +36,13 @@
<input type="hidden" name="url" value="{{ .Canonical }}"> <input type="hidden" name="url" value="{{ .Canonical }}">
<input type="submit" value="{{ string .Blog.Lang "delete" }}" class="confirm" data-confirmmessage="{{ string .Blog.Lang "confirmdelete" }}"> <input type="submit" value="{{ string .Blog.Lang "delete" }}" class="confirm" data-confirmmessage="{{ string .Blog.Lang "confirmdelete" }}">
</form> </form>
{{ if .Data.Deleted }}
<form class="in" method="post" action="{{ .Blog.RelativePath "/editor" }}">
<input type="hidden" name="action" value="undelete">
<input type="hidden" name="url" value="{{ .Canonical }}">
<input type="submit" value="{{ string .Blog.Lang "undelete" }}">
</form>
{{ end }}
{{ if ttsenabled }} {{ if ttsenabled }}
<form class="in" method="post" action="{{ .Blog.RelativePath "/editor" }}"> <form class="in" method="post" action="{{ .Blog.RelativePath "/editor" }}">
<input type="hidden" name="editoraction" value="tts"> <input type="hidden" name="editoraction" value="tts">

View File

@ -10,9 +10,12 @@ contactagreesend: "Akzeptieren & Senden"
contactsend: "Senden" contactsend: "Senden"
create: "Erstellen" create: "Erstellen"
delete: "Löschen" delete: "Löschen"
deletedposts: "Gelöschte Posts"
deletedpostsdesc: "Gelöschte Posts, die nach 7 Tagen endgültig gelöscht werden."
docomment: "Kommentieren" docomment: "Kommentieren"
download: "Herunterladen" download: "Herunterladen"
drafts: "Entwürfe" drafts: "Entwürfe"
draftsdesc: "Posts mit dem Status `draft`."
editor: "Editor" editor: "Editor"
editorpostdesc: "💡 Leere Parameter werden automatisch entfernt. Mehr mögliche Parameter: %s. Mögliche Zustände für `%s`: %s." editorpostdesc: "💡 Leere Parameter werden automatisch entfernt. Mehr mögliche Parameter: %s. Mögliche Zustände für `%s`: %s."
emailopt: "E-Mail (optional)" emailopt: "E-Mail (optional)"
@ -41,9 +44,11 @@ pinned: "Angepinnt"
posts: "Posts" posts: "Posts"
prev: "Zurück" prev: "Zurück"
privateposts: "Private Posts" privateposts: "Private Posts"
privatepostsdesc: "Posts mit dem Status `private`, die nur eingeloggt sichtbar sind."
publishedon: "Veröffentlicht am" publishedon: "Veröffentlicht am"
replyto: "Antwort an" replyto: "Antwort an"
scheduledposts: "Geplante Posts" scheduledposts: "Geplante Posts"
scheduledpostsdesc: "Beiträge mit dem Status `scheduled`, die veröffentlicht werden, wenn das `published`-Datum erreicht ist."
search: "Suchen" search: "Suchen"
send: "Senden (zur Überprüfung)" send: "Senden (zur Überprüfung)"
share: "Online teilen" share: "Online teilen"
@ -55,7 +60,9 @@ submit: "Abschicken"
total: "Gesamt" total: "Gesamt"
translate: "Übersetzen" translate: "Übersetzen"
translations: "Übersetzungen" translations: "Übersetzungen"
undelete: "Wiederherstellen"
unlistedposts: "Ungelistete Posts" unlistedposts: "Ungelistete Posts"
unlistedpostsdesc: "Posts mit dem Status `unlisted`, die nicht in Archiven angezeigt werden."
update: "Aktualisieren" update: "Aktualisieren"
updatedon: "Aktualisiert am" updatedon: "Aktualisiert am"
upload: "Hochladen" upload: "Hochladen"

View File

@ -13,9 +13,12 @@ contactagreesend: "Accept & Send"
contactsend: "Send" contactsend: "Send"
create: "Create" create: "Create"
delete: "Delete" delete: "Delete"
deletedposts: "Deleted posts"
deletedpostsdesc: "Deleted posts that will be permanently deleted after 7 days."
docomment: "Comment" docomment: "Comment"
download: "Download" download: "Download"
drafts: "Drafts" drafts: "Drafts"
draftsdesc: "Posts with status `draft`."
editor: "Editor" editor: "Editor"
editorpostdesc: "💡 Empty parameters are removed automatically. More possible parameters: %s. Possible states for `%s`: %s." editorpostdesc: "💡 Empty parameters are removed automatically. More possible parameters: %s. Possible states for `%s`: %s."
emailopt: "Email (optional)" emailopt: "Email (optional)"
@ -51,10 +54,12 @@ pinned: "Pinned"
posts: "Posts" posts: "Posts"
prev: "Previous" prev: "Previous"
privateposts: "Private posts" privateposts: "Private posts"
privatepostsdesc: "Posts with status `private` that are visible only when logged in."
publishedon: "Published on" publishedon: "Published on"
replyto: "Reply to" replyto: "Reply to"
reverify: "Reverify" reverify: "Reverify"
scheduledposts: "Scheduled posts" scheduledposts: "Scheduled posts"
scheduledpostsdesc: "Posts with status `scheduled` that are published when the `published` date is reached."
scopes: "Scopes" scopes: "Scopes"
search: "Search" search: "Search"
send: "Send (to review)" send: "Send (to review)"
@ -68,7 +73,9 @@ total: "Total"
totp: "TOTP" totp: "TOTP"
translate: "Translate" translate: "Translate"
translations: "Translations" translations: "Translations"
undelete: "Undelete"
unlistedposts: "Unlisted posts" unlistedposts: "Unlisted posts"
unlistedpostsdesc: "Posts with status `unlisted` that are not displayed in archives."
update: "Update" update: "Update"
updatedon: "Updated on" updatedon: "Updated on"
upload: "Upload" upload: "Upload"

1
tts.go
View File

@ -38,6 +38,7 @@ func (a *goBlog) initTTS() {
} }
a.pPostHooks = append(a.pPostHooks, createOrUpdate) a.pPostHooks = append(a.pPostHooks, createOrUpdate)
a.pUpdateHooks = append(a.pUpdateHooks, createOrUpdate) a.pUpdateHooks = append(a.pUpdateHooks, createOrUpdate)
a.pUndeleteHooks = append(a.pUndeleteHooks, createOrUpdate)
a.pDeleteHooks = append(a.pDeleteHooks, func(p *post) { a.pDeleteHooks = append(a.pDeleteHooks, func(p *post) {
// Try to delete the audio file // Try to delete the audio file
_ = a.deletePostTTSAudio(p) _ = a.deletePostTTSAudio(p)

View File

@ -44,6 +44,7 @@ func (a *goBlog) initWebmention() {
a.pPostHooks = append(a.pPostHooks, hookFunc) a.pPostHooks = append(a.pPostHooks, hookFunc)
a.pUpdateHooks = append(a.pUpdateHooks, hookFunc) a.pUpdateHooks = append(a.pUpdateHooks, hookFunc)
a.pDeleteHooks = append(a.pDeleteHooks, hookFunc) a.pDeleteHooks = append(a.pDeleteHooks, hookFunc)
a.pUndeleteHooks = append(a.pUndeleteHooks, hookFunc)
// Start verifier // Start verifier
a.initWebmentionQueue() a.initWebmentionQueue()
} }