mirror of https://github.com/jlelse/GoBlog
Simple blogging system written in Go
https://goblog.app
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
577 lines
18 KiB
577 lines
18 KiB
package main |
|
|
|
import ( |
|
"encoding/json" |
|
"errors" |
|
"io" |
|
"mime" |
|
"net/http" |
|
"net/url" |
|
"reflect" |
|
"regexp" |
|
"strconv" |
|
"strings" |
|
"time" |
|
|
|
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype" |
|
"github.com/spf13/cast" |
|
"gopkg.in/yaml.v3" |
|
) |
|
|
|
const micropubPath = "/micropub" |
|
|
|
type micropubConfig struct { |
|
MediaEndpoint string `json:"media-endpoint,omitempty"` |
|
} |
|
|
|
func (a *goBlog) serveMicropubQuery(w http.ResponseWriter, r *http.Request) { |
|
switch r.URL.Query().Get("q") { |
|
case "config": |
|
w.Header().Set(contentType, contenttype.JSONUTF8) |
|
w.WriteHeader(http.StatusOK) |
|
b, _ := json.Marshal(µpubConfig{ |
|
MediaEndpoint: a.getFullAddress(micropubPath + micropubMediaSubPath), |
|
}) |
|
_, _ = a.min.Write(w, contenttype.JSON, b) |
|
case "source": |
|
var mf interface{} |
|
if urlString := r.URL.Query().Get("url"); urlString != "" { |
|
u, err := url.Parse(r.URL.Query().Get("url")) |
|
if err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusBadRequest) |
|
return |
|
} |
|
p, err := a.db.getPost(u.Path) |
|
if err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusBadRequest) |
|
return |
|
} |
|
mf = a.postToMfItem(p) |
|
} else { |
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) |
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) |
|
posts, err := a.db.getPosts(&postsRequestConfig{ |
|
limit: limit, |
|
offset: offset, |
|
}) |
|
if err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusInternalServerError) |
|
return |
|
} |
|
list := map[string][]*microformatItem{} |
|
for _, p := range posts { |
|
list["items"] = append(list["items"], a.postToMfItem(p)) |
|
} |
|
mf = list |
|
} |
|
w.Header().Set(contentType, contenttype.JSONUTF8) |
|
w.WriteHeader(http.StatusOK) |
|
b, _ := json.Marshal(mf) |
|
_, _ = a.min.Write(w, contenttype.JSON, b) |
|
case "category": |
|
allCategories := []string{} |
|
for blog := range a.cfg.Blogs { |
|
values, err := a.db.allTaxonomyValues(blog, a.cfg.Micropub.CategoryParam) |
|
if err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusInternalServerError) |
|
return |
|
} |
|
allCategories = append(allCategories, values...) |
|
} |
|
w.Header().Set(contentType, contenttype.JSONUTF8) |
|
w.WriteHeader(http.StatusOK) |
|
b, _ := json.Marshal(map[string]interface{}{ |
|
"categories": allCategories, |
|
}) |
|
_, _ = a.min.Write(w, contenttype.JSON, b) |
|
default: |
|
a.serve404(w, r) |
|
} |
|
} |
|
|
|
func (a *goBlog) serveMicropubPost(w http.ResponseWriter, r *http.Request) { |
|
defer r.Body.Close() |
|
switch mt, _, _ := mime.ParseMediaType(r.Header.Get(contentType)); mt { |
|
case contenttype.WWWForm, contenttype.MultipartForm: |
|
_ = r.ParseForm() |
|
_ = r.ParseMultipartForm(0) |
|
if r.Form == nil { |
|
a.serveError(w, r, "Failed to parse form", http.StatusBadRequest) |
|
return |
|
} |
|
if action := micropubAction(r.Form.Get("action")); action != "" { |
|
switch action { |
|
case actionDelete: |
|
a.micropubDelete(w, r, r.Form.Get("url")) |
|
default: |
|
a.serveError(w, r, "Action not supported", http.StatusNotImplemented) |
|
} |
|
return |
|
} |
|
a.micropubCreatePostFromForm(w, r) |
|
case contenttype.JSON: |
|
parsedMfItem := µformatItem{} |
|
b, _ := io.ReadAll(io.LimitReader(r.Body, 10000000)) // 10 MB |
|
if err := json.Unmarshal(b, parsedMfItem); err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusBadRequest) |
|
return |
|
} |
|
if parsedMfItem.Action != "" { |
|
switch parsedMfItem.Action { |
|
case actionDelete: |
|
a.micropubDelete(w, r, parsedMfItem.URL) |
|
case actionUpdate: |
|
a.micropubUpdate(w, r, parsedMfItem.URL, parsedMfItem) |
|
default: |
|
a.serveError(w, r, "Action not supported", http.StatusNotImplemented) |
|
} |
|
return |
|
} |
|
a.micropubCreatePostFromJson(w, r, parsedMfItem) |
|
default: |
|
a.serveError(w, r, "wrong content type", http.StatusBadRequest) |
|
} |
|
} |
|
|
|
func (a *goBlog) micropubParseValuePostParamsValueMap(entry *post, values map[string][]string) error { |
|
if h, ok := values["h"]; ok && (len(h) != 1 || h[0] != "entry") { |
|
return errors.New("only entry type is supported so far") |
|
} |
|
delete(values, "h") |
|
entry.Parameters = map[string][]string{} |
|
if content, ok := values["content"]; ok && len(content) > 0 { |
|
entry.Content = content[0] |
|
delete(values, "content") |
|
} |
|
if published, ok := values["published"]; ok && len(published) > 0 { |
|
entry.Published = published[0] |
|
delete(values, "published") |
|
} |
|
if updated, ok := values["updated"]; ok && len(updated) > 0 { |
|
entry.Updated = updated[0] |
|
delete(values, "updated") |
|
} |
|
if status, ok := values["post-status"]; ok && len(status) > 0 { |
|
entry.Status = postStatus(status[0]) |
|
delete(values, "post-status") |
|
} |
|
if slug, ok := values["mp-slug"]; ok && len(slug) > 0 { |
|
entry.Slug = slug[0] |
|
delete(values, "mp-slug") |
|
} |
|
// Parameter |
|
if name, ok := values["name"]; ok { |
|
entry.Parameters["title"] = name |
|
delete(values, "name") |
|
} |
|
if category, ok := values["category"]; ok { |
|
entry.Parameters[a.cfg.Micropub.CategoryParam] = category |
|
delete(values, "category") |
|
} else if categories, ok := values["category[]"]; ok { |
|
entry.Parameters[a.cfg.Micropub.CategoryParam] = categories |
|
delete(values, "category[]") |
|
} |
|
if inReplyTo, ok := values["in-reply-to"]; ok { |
|
entry.Parameters[a.cfg.Micropub.ReplyParam] = inReplyTo |
|
delete(values, "in-reply-to") |
|
} |
|
if likeOf, ok := values["like-of"]; ok { |
|
entry.Parameters[a.cfg.Micropub.LikeParam] = likeOf |
|
delete(values, "like-of") |
|
} |
|
if bookmarkOf, ok := values["bookmark-of"]; ok { |
|
entry.Parameters[a.cfg.Micropub.BookmarkParam] = bookmarkOf |
|
delete(values, "bookmark-of") |
|
} |
|
if audio, ok := values["audio"]; ok { |
|
entry.Parameters[a.cfg.Micropub.AudioParam] = audio |
|
delete(values, "audio") |
|
} else if audio, ok := values["audio[]"]; ok { |
|
entry.Parameters[a.cfg.Micropub.AudioParam] = audio |
|
delete(values, "audio[]") |
|
} |
|
if photo, ok := values["photo"]; ok { |
|
entry.Parameters[a.cfg.Micropub.PhotoParam] = photo |
|
delete(values, "photo") |
|
} else if photos, ok := values["photo[]"]; ok { |
|
entry.Parameters[a.cfg.Micropub.PhotoParam] = photos |
|
delete(values, "photo[]") |
|
} |
|
if photoAlt, ok := values["mp-photo-alt"]; ok { |
|
entry.Parameters[a.cfg.Micropub.PhotoDescriptionParam] = photoAlt |
|
delete(values, "mp-photo-alt") |
|
} else if photoAlts, ok := values["mp-photo-alt[]"]; ok { |
|
entry.Parameters[a.cfg.Micropub.PhotoDescriptionParam] = photoAlts |
|
delete(values, "mp-photo-alt[]") |
|
} |
|
if location, ok := values["location"]; ok { |
|
entry.Parameters[a.cfg.Micropub.LocationParam] = location |
|
delete(values, "location") |
|
} |
|
for n, p := range values { |
|
entry.Parameters[n] = append(entry.Parameters[n], p...) |
|
} |
|
return nil |
|
} |
|
|
|
type micropubAction string |
|
|
|
const ( |
|
actionUpdate micropubAction = "update" |
|
actionDelete micropubAction = "delete" |
|
) |
|
|
|
type microformatItem struct { |
|
Type []string `json:"type,omitempty"` |
|
URL string `json:"url,omitempty"` |
|
Action micropubAction `json:"action,omitempty"` |
|
Properties *microformatProperties `json:"properties,omitempty"` |
|
Replace map[string][]interface{} `json:"replace,omitempty"` |
|
Add map[string][]interface{} `json:"add,omitempty"` |
|
Delete interface{} `json:"delete,omitempty"` |
|
} |
|
|
|
type microformatProperties struct { |
|
Name []string `json:"name,omitempty"` |
|
Published []string `json:"published,omitempty"` |
|
Updated []string `json:"updated,omitempty"` |
|
PostStatus []string `json:"post-status,omitempty"` |
|
Category []string `json:"category,omitempty"` |
|
Content []string `json:"content,omitempty"` |
|
URL []string `json:"url,omitempty"` |
|
InReplyTo []string `json:"in-reply-to,omitempty"` |
|
LikeOf []string `json:"like-of,omitempty"` |
|
BookmarkOf []string `json:"bookmark-of,omitempty"` |
|
MpSlug []string `json:"mp-slug,omitempty"` |
|
Photo []interface{} `json:"photo,omitempty"` |
|
Audio []string `json:"audio,omitempty"` |
|
} |
|
|
|
func (a *goBlog) micropubParsePostParamsMfItem(entry *post, mf *microformatItem) error { |
|
if len(mf.Type) != 1 || mf.Type[0] != "h-entry" { |
|
return errors.New("only entry type is supported so far") |
|
} |
|
entry.Parameters = map[string][]string{} |
|
if mf.Properties == nil { |
|
return nil |
|
} |
|
// Content |
|
if len(mf.Properties.Content) > 0 && mf.Properties.Content[0] != "" { |
|
entry.Content = mf.Properties.Content[0] |
|
} |
|
if len(mf.Properties.Published) > 0 { |
|
entry.Published = mf.Properties.Published[0] |
|
} |
|
if len(mf.Properties.Updated) > 0 { |
|
entry.Updated = mf.Properties.Updated[0] |
|
} |
|
if len(mf.Properties.PostStatus) > 0 { |
|
entry.Status = postStatus(mf.Properties.PostStatus[0]) |
|
} |
|
if len(mf.Properties.MpSlug) > 0 { |
|
entry.Slug = mf.Properties.MpSlug[0] |
|
} |
|
// Parameter |
|
if len(mf.Properties.Name) > 0 { |
|
entry.Parameters["title"] = mf.Properties.Name |
|
} |
|
if len(mf.Properties.Category) > 0 { |
|
entry.Parameters[a.cfg.Micropub.CategoryParam] = mf.Properties.Category |
|
} |
|
if len(mf.Properties.InReplyTo) > 0 { |
|
entry.Parameters[a.cfg.Micropub.ReplyParam] = mf.Properties.InReplyTo |
|
} |
|
if len(mf.Properties.LikeOf) > 0 { |
|
entry.Parameters[a.cfg.Micropub.LikeParam] = mf.Properties.LikeOf |
|
} |
|
if len(mf.Properties.BookmarkOf) > 0 { |
|
entry.Parameters[a.cfg.Micropub.BookmarkParam] = mf.Properties.BookmarkOf |
|
} |
|
if len(mf.Properties.Audio) > 0 { |
|
entry.Parameters[a.cfg.Micropub.AudioParam] = mf.Properties.Audio |
|
} |
|
if len(mf.Properties.Photo) > 0 { |
|
for _, photo := range mf.Properties.Photo { |
|
if theString, justString := photo.(string); justString { |
|
entry.Parameters[a.cfg.Micropub.PhotoParam] = append(entry.Parameters[a.cfg.Micropub.PhotoParam], theString) |
|
entry.Parameters[a.cfg.Micropub.PhotoDescriptionParam] = append(entry.Parameters[a.cfg.Micropub.PhotoDescriptionParam], "") |
|
} else if thePhoto, isPhoto := photo.(map[string]interface{}); isPhoto { |
|
entry.Parameters[a.cfg.Micropub.PhotoParam] = append(entry.Parameters[a.cfg.Micropub.PhotoParam], cast.ToString(thePhoto["value"])) |
|
entry.Parameters[a.cfg.Micropub.PhotoDescriptionParam] = append(entry.Parameters[a.cfg.Micropub.PhotoDescriptionParam], cast.ToString(thePhoto["alt"])) |
|
} |
|
} |
|
} |
|
return nil |
|
} |
|
|
|
func (a *goBlog) computeExtraPostParameters(p *post) error { |
|
p.Content = regexp.MustCompile("\r\n").ReplaceAllString(p.Content, "\n") |
|
if split := strings.Split(p.Content, "---\n"); len(split) >= 3 && len(strings.TrimSpace(split[0])) == 0 { |
|
// Contains frontmatter |
|
fm := split[1] |
|
meta := map[string]interface{}{} |
|
err := yaml.Unmarshal([]byte(fm), &meta) |
|
if err != nil { |
|
return err |
|
} |
|
// Find section and copy frontmatter to params |
|
for key, value := range meta { |
|
// Delete existing content - replace |
|
p.Parameters[key] = []string{} |
|
if a, ok := value.([]interface{}); ok { |
|
for _, ae := range a { |
|
p.Parameters[key] = append(p.Parameters[key], cast.ToString(ae)) |
|
} |
|
} else { |
|
p.Parameters[key] = append(p.Parameters[key], cast.ToString(value)) |
|
} |
|
} |
|
// Remove frontmatter from content |
|
p.Content = strings.Join(split[2:], "---\n") |
|
} |
|
// Check settings |
|
if blog := p.Parameters["blog"]; len(blog) == 1 && blog[0] != "" { |
|
p.Blog = blog[0] |
|
delete(p.Parameters, "blog") |
|
} else { |
|
p.Blog = a.cfg.DefaultBlog |
|
} |
|
if path := p.Parameters["path"]; len(path) == 1 { |
|
p.Path = path[0] |
|
delete(p.Parameters, "path") |
|
} |
|
if section := p.Parameters["section"]; len(section) == 1 { |
|
p.Section = section[0] |
|
delete(p.Parameters, "section") |
|
} |
|
if slug := p.Parameters["slug"]; len(slug) == 1 { |
|
p.Slug = slug[0] |
|
delete(p.Parameters, "slug") |
|
} |
|
if published := p.Parameters["published"]; len(published) == 1 { |
|
p.Published = published[0] |
|
delete(p.Parameters, "published") |
|
} |
|
if updated := p.Parameters["updated"]; len(updated) == 1 { |
|
p.Updated = updated[0] |
|
delete(p.Parameters, "updated") |
|
} |
|
if status := p.Parameters["status"]; len(status) == 1 { |
|
p.Status = postStatus(status[0]) |
|
delete(p.Parameters, "status") |
|
} |
|
if p.Path == "" && p.Section == "" { |
|
// Has no path or section -> default section |
|
p.Section = a.cfg.Blogs[p.Blog].DefaultSection |
|
} |
|
if p.Published == "" && p.Section != "" { |
|
// Has no published date, but section -> published now |
|
p.Published = time.Now().Local().String() |
|
} |
|
// Add images not in content |
|
images := p.Parameters[a.cfg.Micropub.PhotoParam] |
|
imageAlts := p.Parameters[a.cfg.Micropub.PhotoDescriptionParam] |
|
useAlts := len(images) == len(imageAlts) |
|
for i, image := range images { |
|
if !strings.Contains(p.Content, image) { |
|
if useAlts && len(imageAlts[i]) > 0 { |
|
p.Content += "\n\n![" + imageAlts[i] + "](" + image + " \"" + imageAlts[i] + "\")" |
|
} else { |
|
p.Content += "\n\n" |
|
} |
|
} |
|
} |
|
return nil |
|
} |
|
|
|
func (a *goBlog) micropubCreatePostFromForm(w http.ResponseWriter, r *http.Request) { |
|
p := &post{} |
|
err := a.micropubParseValuePostParamsValueMap(p, r.Form) |
|
if err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusBadRequest) |
|
return |
|
} |
|
a.micropubCreate(w, r, p) |
|
} |
|
|
|
func (a *goBlog) micropubCreatePostFromJson(w http.ResponseWriter, r *http.Request, parsedMfItem *microformatItem) { |
|
p := &post{} |
|
err := a.micropubParsePostParamsMfItem(p, parsedMfItem) |
|
if err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusBadRequest) |
|
return |
|
} |
|
a.micropubCreate(w, r, p) |
|
} |
|
|
|
func (a *goBlog) micropubCreate(w http.ResponseWriter, r *http.Request, p *post) { |
|
if !strings.Contains(r.Context().Value(indieAuthScope).(string), "create") { |
|
a.serveError(w, r, "create scope missing", http.StatusForbidden) |
|
return |
|
} |
|
if err := a.computeExtraPostParameters(p); err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusBadRequest) |
|
return |
|
} |
|
if err := a.createPost(p); err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusBadRequest) |
|
return |
|
} |
|
http.Redirect(w, r, a.fullPostURL(p), http.StatusAccepted) |
|
} |
|
|
|
func (a *goBlog) micropubDelete(w http.ResponseWriter, r *http.Request, u string) { |
|
if !strings.Contains(r.Context().Value(indieAuthScope).(string), "delete") { |
|
a.serveError(w, r, "delete 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.deletePost(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) |
|
return |
|
} |
|
uu, err := url.Parse(u) |
|
if err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusBadRequest) |
|
return |
|
} |
|
p, err := a.db.getPost(uu.Path) |
|
if err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusBadRequest) |
|
return |
|
} |
|
oldPath := p.Path |
|
oldStatus := p.Status |
|
if mf.Replace != nil { |
|
for key, value := range mf.Replace { |
|
switch key { |
|
case "content": |
|
p.Content = strings.TrimSpace(strings.Join(cast.ToStringSlice(value), " ")) |
|
case "published": |
|
p.Published = strings.TrimSpace(strings.Join(cast.ToStringSlice(value), " ")) |
|
case "updated": |
|
p.Updated = strings.TrimSpace(strings.Join(cast.ToStringSlice(value), " ")) |
|
case "name": |
|
p.Parameters["title"] = cast.ToStringSlice(value) |
|
case "category": |
|
p.Parameters[a.cfg.Micropub.CategoryParam] = cast.ToStringSlice(value) |
|
case "in-reply-to": |
|
p.Parameters[a.cfg.Micropub.ReplyParam] = cast.ToStringSlice(value) |
|
case "like-of": |
|
p.Parameters[a.cfg.Micropub.LikeParam] = cast.ToStringSlice(value) |
|
case "bookmark-of": |
|
p.Parameters[a.cfg.Micropub.BookmarkParam] = cast.ToStringSlice(value) |
|
case "audio": |
|
p.Parameters[a.cfg.Micropub.AudioParam] = cast.ToStringSlice(value) |
|
// TODO: photo |
|
} |
|
} |
|
} |
|
if mf.Add != nil { |
|
for key, value := range mf.Add { |
|
switch key { |
|
case "content": |
|
p.Content += strings.TrimSpace(strings.Join(cast.ToStringSlice(value), " ")) |
|
case "published": |
|
p.Published = strings.TrimSpace(strings.Join(cast.ToStringSlice(value), " ")) |
|
case "updated": |
|
p.Updated = strings.TrimSpace(strings.Join(cast.ToStringSlice(value), " ")) |
|
case "category": |
|
category := p.Parameters[a.cfg.Micropub.CategoryParam] |
|
if category == nil { |
|
category = []string{} |
|
} |
|
p.Parameters[a.cfg.Micropub.CategoryParam] = append(category, cast.ToStringSlice(value)...) |
|
case "in-reply-to": |
|
p.Parameters[a.cfg.Micropub.ReplyParam] = cast.ToStringSlice(value) |
|
case "like-of": |
|
p.Parameters[a.cfg.Micropub.LikeParam] = cast.ToStringSlice(value) |
|
case "bookmark-of": |
|
p.Parameters[a.cfg.Micropub.BookmarkParam] = cast.ToStringSlice(value) |
|
case "audio": |
|
audio := p.Parameters[a.cfg.Micropub.CategoryParam] |
|
if audio == nil { |
|
audio = []string{} |
|
} |
|
p.Parameters[a.cfg.Micropub.AudioParam] = append(audio, cast.ToStringSlice(value)...) |
|
// TODO: photo |
|
} |
|
} |
|
} |
|
if del := mf.Delete; del != nil { |
|
if reflect.TypeOf(del).Kind() == reflect.Slice { |
|
toDelete, ok := del.([]interface{}) |
|
if ok { |
|
for _, key := range toDelete { |
|
switch key { |
|
case "content": |
|
p.Content = "" |
|
case "published": |
|
p.Published = "" |
|
case "updated": |
|
p.Updated = "" |
|
case "category": |
|
delete(p.Parameters, a.cfg.Micropub.CategoryParam) |
|
case "in-reply-to": |
|
delete(p.Parameters, a.cfg.Micropub.ReplyParam) |
|
case "like-of": |
|
delete(p.Parameters, a.cfg.Micropub.LikeParam) |
|
case "bookmark-of": |
|
delete(p.Parameters, a.cfg.Micropub.BookmarkParam) |
|
case "audio": |
|
delete(p.Parameters, a.cfg.Micropub.AudioParam) |
|
case "photo": |
|
delete(p.Parameters, a.cfg.Micropub.PhotoParam) |
|
delete(p.Parameters, a.cfg.Micropub.PhotoDescriptionParam) |
|
} |
|
} |
|
} |
|
} else { |
|
toDelete, ok := del.(map[string]interface{}) |
|
if ok { |
|
for key := range toDelete { |
|
if ok { |
|
switch key { |
|
case "content": |
|
p.Content = "" |
|
case "published": |
|
p.Published = "" |
|
case "updated": |
|
p.Updated = "" |
|
case "in-reply-to": |
|
delete(p.Parameters, a.cfg.Micropub.ReplyParam) |
|
case "like-of": |
|
delete(p.Parameters, a.cfg.Micropub.LikeParam) |
|
case "bookmark-of": |
|
delete(p.Parameters, a.cfg.Micropub.BookmarkParam) |
|
// Use content to edit other parameters |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
err = a.computeExtraPostParameters(p) |
|
if err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusInternalServerError) |
|
return |
|
} |
|
err = a.replacePost(p, oldPath, oldStatus) |
|
if err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusInternalServerError) |
|
return |
|
} |
|
http.Redirect(w, r, a.fullPostURL(p), http.StatusNoContent) |
|
}
|
|
|