GoBlog/micropub.go

506 lines
14 KiB
Go

package main
import (
"crypto/sha256"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
urlpkg "net/url"
"path/filepath"
"reflect"
"regexp"
"strings"
"github.com/samber/lo"
"github.com/spf13/cast"
"go.goblog.app/app/pkgs/bodylimit"
"go.hacdias.com/indielib/micropub"
"gopkg.in/yaml.v3"
)
func (a *goBlog) getMicropubImplementation() *micropubImplementation {
if a.mpImpl == nil {
a.mpImpl = &micropubImplementation{a: a}
}
return a.mpImpl
}
const (
micropubPath = "/micropub"
micropubMediaSubPath = "/media"
)
type micropubImplementation struct {
a *goBlog
h http.Handler
mh http.Handler
}
func (s *micropubImplementation) getHandler() http.Handler {
if s.h == nil {
s.h = micropub.NewHandler(
s,
micropub.WithMediaEndpoint(s.a.getFullAddress(micropubPath+micropubMediaSubPath)),
micropub.WithGetCategories(s.getCategories),
micropub.WithGetChannels(s.getChannels),
micropub.WithGetVisibility(s.getVisibility),
)
}
return s.h
}
func (s *micropubImplementation) getCategories() []string {
allCategories := []string{}
for blog := range s.a.cfg.Blogs {
values, _ := s.a.db.allTaxonomyValues(blog, s.a.cfg.Micropub.CategoryParam)
allCategories = append(allCategories, values...)
}
return lo.Uniq(allCategories)
}
func (s *micropubImplementation) getChannels() []micropub.Channel {
allChannels := []micropub.Channel{}
for b, bc := range s.a.cfg.Blogs {
allChannels = append(allChannels, micropub.Channel{
Name: fmt.Sprintf("%s: %s", b, bc.Title),
UID: b,
})
for s, sc := range bc.Sections {
allChannels = append(allChannels, micropub.Channel{
Name: fmt.Sprintf("%s/%s: %s", b, s, sc.Name),
UID: fmt.Sprintf("%s/%s", b, s),
})
}
}
return allChannels
}
func (s *micropubImplementation) getVisibility() []string {
return []string{string(visibilityPrivate), string(visibilityUnlisted), string(visibilityPublic)}
}
func (s *micropubImplementation) getMediaHandler() http.Handler {
if s.mh == nil {
s.mh = micropub.NewMediaHandler(
s.UploadMedia,
s.HasScope,
micropub.WithMaxMemory(0),
micropub.WithMaxMediaSize(30*bodylimit.MB),
)
}
return s.mh
}
func (s *micropubImplementation) HasScope(r *http.Request, scope string) bool {
return strings.Contains(r.Context().Value(indieAuthScope).(string), scope)
}
func (s *micropubImplementation) Source(urlStr string) (map[string]any, error) {
url, err := urlpkg.Parse(urlStr)
if err != nil {
return nil, fmt.Errorf("%w: %w", micropub.ErrBadRequest, err)
}
p, err := s.a.getPost(url.Path)
if err != nil {
return nil, fmt.Errorf("%w: %w", micropub.ErrBadRequest, err)
}
return s.a.postToMfMap(p), nil
}
func (s *micropubImplementation) SourceMany(limit, offset int) ([]map[string]any, error) {
posts, err := s.a.getPosts(&postsRequestConfig{
limit: limit,
offset: offset,
})
if err != nil {
return nil, fmt.Errorf("%w: %w", micropub.ErrBadRequest, err)
}
list := []map[string]any{}
for _, p := range posts {
list = append(list, s.a.postToMfMap(p))
}
return list, nil
}
func (s *micropubImplementation) Create(req *micropub.Request) (string, error) {
if req.Type != "h-entry" {
return "", fmt.Errorf("%w: only h-entry supported", micropub.ErrNotImplemented)
}
entry := &post{}
entry.Parameters = map[string][]string{}
allValues := lo.Assign(req.Properties, req.Commands)
// Parameters with special care
for photoNo, photo := range allValues["photo"] {
pp := s.a.cfg.Micropub.PhotoParam
pdp := s.a.cfg.Micropub.PhotoDescriptionParam
if photoLink, isPhotoLink := photo.(string); isPhotoLink {
entry.Parameters[pp] = append(entry.Parameters[pp], photoLink)
if len(allValues["photo-alt"]) > photoNo && allValues["photo-alt"][photoNo] != nil {
entry.Parameters[pdp] = append(entry.Parameters[pdp], cast.ToString(allValues["photo-alt"][photoNo]))
} else {
entry.Parameters[pdp] = append(entry.Parameters[pdp], "")
}
} else if photoObject, isPhotoObject := photo.(map[string]any); isPhotoObject {
entry.Parameters[pp] = append(entry.Parameters[pp], cast.ToString(photoObject["value"]))
entry.Parameters[pdp] = append(entry.Parameters[pdp], cast.ToString(photoObject["alt"]))
}
}
delete(allValues, "photo")
delete(allValues, "photo-alt")
delete(allValues, "file") // Micropublish.net fix
// Rest of parameters
for key, values := range allValues {
values := cast.ToStringSlice(values)
if len(values) == 0 {
continue
}
switch key {
case "content":
entry.Content = values[0]
case "published":
entry.Published = values[0]
case "updated":
entry.Updated = values[0]
case "slug":
entry.Slug = values[0]
case "channel":
entry.setChannel(values[0])
case "post-status":
entry.Status = micropubStatus(values[0])
case "visibility":
entry.Visibility = micropubVisibility(values[0])
default:
entry.Parameters[s.mapToParameterName(key)] = values
}
}
if err := s.a.extractParamsFromContent(entry); err != nil {
return "", fmt.Errorf("%w: %w", micropub.ErrBadRequest, err)
}
if err := s.a.createPost(entry); err != nil {
return "", fmt.Errorf("%w: %w", micropub.ErrBadRequest, err)
}
return s.a.fullPostURL(entry), nil
}
func (s *micropubImplementation) Update(req *micropub.Request) (string, error) {
// Get post
url, err := urlpkg.Parse(req.URL)
if err != nil {
return "", fmt.Errorf("%w: %w", micropub.ErrBadRequest, err)
}
postPath := defaultIfEmpty(url.Path, "/")
entry, err := s.a.getPost(postPath)
if err != nil {
return "", fmt.Errorf("%w: %w", micropub.ErrBadRequest, err)
}
// Check if post is marked as deleted
if entry.Deleted() {
return "", fmt.Errorf("%w: post is marked as deleted, undelete it first", micropub.ErrBadRequest)
}
// Update post
oldPath := entry.Path
oldStatus := entry.Status
oldVisibility := entry.Visibility
if entry.Parameters == nil {
entry.Parameters = map[string][]string{}
}
// Update properties
properties := s.a.postMfProperties(entry, false)
properties, err = micropubUpdateMfProperties(properties, req.Updates)
if err != nil {
return "", fmt.Errorf("failed to update properties: %w", err)
}
s.updatePostPropertiesFromMf(entry, properties)
err = s.a.extractParamsFromContent(entry)
if err != nil {
return "", fmt.Errorf("%w: %w", micropub.ErrBadRequest, err)
}
err = s.a.replacePost(entry, oldPath, oldStatus, oldVisibility)
if err != nil {
return "", fmt.Errorf("%w: %w", micropub.ErrBadRequest, err)
}
return s.a.fullPostURL(entry), nil
}
func (s *micropubImplementation) Delete(urlStr string) error {
url, err := urlpkg.Parse(urlStr)
if err != nil {
return fmt.Errorf("%w: %w", micropub.ErrBadRequest, err)
}
if err := s.a.deletePost(url.Path); err != nil {
return fmt.Errorf("%w: %w", micropub.ErrBadRequest, err)
}
return nil
}
func (s *micropubImplementation) Undelete(urlStr string) error {
url, err := urlpkg.Parse(urlStr)
if err != nil {
return fmt.Errorf("%w: %w", micropub.ErrBadRequest, err)
}
if err := s.a.undeletePost(url.Path); err != nil {
return fmt.Errorf("%w: %w", micropub.ErrBadRequest, err)
}
return nil
}
func (s *micropubImplementation) UploadMedia(file multipart.File, header *multipart.FileHeader) (string, error) {
// Generate sha256 hash for file
hash := sha256.New()
_, err := io.Copy(hash, file)
if err != nil {
return "", fmt.Errorf("%w: failed to get file hash", micropub.ErrBadRequest)
}
// Get file extension
fileExtension := filepath.Ext(header.Filename)
if fileExtension == "" {
// Find correct file extension if original filename does not contain one
mimeType := header.Header.Get(contentType)
if len(mimeType) > 0 {
allExtensions, _ := mime.ExtensionsByType(mimeType)
if len(allExtensions) > 0 {
fileExtension = allExtensions[0]
}
}
}
// Generate the file name
fileName := fmt.Sprintf("%x%s", hash.Sum(nil), fileExtension)
// Save file
_, err = file.Seek(0, io.SeekStart)
if err != nil {
return "", fmt.Errorf("%w: failed to read multipart file", micropub.ErrBadRequest)
}
location, err := s.a.saveMediaFile(fileName, file)
if err != nil {
return "", fmt.Errorf("%w: failed to save original file", micropub.ErrBadRequest)
}
// Try to compress file (only when not in private mode)
if !s.a.isPrivate() {
compressedLocation, compressionErr := s.a.compressMediaFile(location)
if compressionErr != nil {
return "", fmt.Errorf("%w: failed to compress file: %w", micropub.ErrBadRequest, compressionErr)
}
// Overwrite location
if compressedLocation != "" {
location = compressedLocation
}
}
return location, nil
}
func (s *micropubImplementation) mapToParameterName(key string) string {
switch key {
case "name":
return "title"
case "category":
return s.a.cfg.Micropub.CategoryParam
case "in-reply-to":
return s.a.cfg.Micropub.ReplyParam
case "like-of":
return s.a.cfg.Micropub.LikeParam
case "bookmark-of":
return s.a.cfg.Micropub.BookmarkParam
case "audio":
return s.a.cfg.Micropub.AudioParam
case "location":
return s.a.cfg.Micropub.LocationParam
default:
return key
}
}
func (a *goBlog) extractParamsFromContent(p *post) error {
// Ensure parameters map is initialized
if p.Parameters == nil {
p.Parameters = map[string][]string{}
}
// Normalize line endings in content
p.Content = regexp.MustCompile("\r\n").ReplaceAllString(p.Content, "\n")
// Check for frontmatter
if split := strings.Split(p.Content, "---\n"); len(split) >= 3 && strings.TrimSpace(split[0]) == "" {
// Extract frontmatter
fm := split[1]
meta := map[string]any{}
if err := yaml.Unmarshal([]byte(fm), &meta); err != nil {
return err
}
// Copy frontmatter to parameters
for key, value := range meta {
if a, ok := value.([]any); ok {
p.Parameters[key] = []string{}
for _, ae := range a {
p.Parameters[key] = append(p.Parameters[key], cast.ToString(ae))
}
} else {
p.Parameters[key] = []string{cast.ToString(value)}
}
}
// Remove frontmatter from content
p.Content = strings.Join(split[2:], "---\n")
}
// Extract specific parameters
extractParam := func(paramName string, field any) {
if values, ok := p.Parameters[paramName]; len(values) == 1 && ok {
if stringPointer, ok := field.(*string); ok {
*stringPointer = values[0]
} else if stringFunc, ok := field.(func(string)); ok {
stringFunc(values[0])
}
delete(p.Parameters, paramName)
}
}
extractParam("blog", &p.Blog)
extractParam("path", &p.Path)
extractParam("section", &p.Section)
extractParam("slug", &p.Slug)
extractParam("published", &p.Published)
extractParam("updated", &p.Updated)
extractParam("status", func(status string) { p.Status = postStatus(status) })
extractParam("visibility", func(visibility string) { p.Visibility = postVisibility(visibility) })
extractParam("priority", func(priority string) { p.Priority = cast.ToInt(priority) })
// Add images not in content
images, imageAlts := p.Parameters[a.cfg.Micropub.PhotoParam], p.Parameters[a.cfg.Micropub.PhotoDescriptionParam]
useAlts := len(images) == len(imageAlts)
for i, image := range images {
if !strings.Contains(p.Content, image) {
if useAlts && imageAlts[i] != "" {
p.Content += fmt.Sprintf("\n\n![%s](%s \"%s\")", imageAlts[i], image, imageAlts[i])
} else {
p.Content += fmt.Sprintf("\n\n![](%s)", image)
}
}
}
return nil
}
func micropubStatus(status string) postStatus {
switch status {
case "draft":
return statusDraft
default:
return statusPublished
}
}
func micropubVisibility(visibility string) postVisibility {
switch visibility {
case "unlisted":
return visibilityUnlisted
case "private":
return visibilityPrivate
default:
return visibilityPublic
}
}
func micropubUpdateMfProperties(properties map[string][]any, req micropub.RequestUpdate) (map[string][]any, error) {
if req.Replace != nil {
for key, value := range req.Replace {
properties[key] = value
}
}
if req.Add != nil {
for key, value := range req.Add {
if _, ok := properties[key]; !ok {
properties[key] = []any{}
}
properties[key] = append(properties[key], value...)
}
}
if req.Delete != nil {
if reflect.TypeOf(req.Delete).Kind() == reflect.Slice {
toDelete, ok := req.Delete.([]any)
if !ok {
return nil, errors.New("invalid delete array")
}
for _, key := range toDelete {
delete(properties, cast.ToString(key))
}
} else {
toDelete, ok := req.Delete.(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid delete object: expected map[string]any, got: %s", reflect.TypeOf(req.Delete))
}
for key, v := range toDelete {
value, ok := v.([]any)
if !ok {
// Wrong type, ignore
continue
}
if _, ok := properties[key]; !ok {
// Parameter not present, ignore delete
continue
}
properties[key] = lo.Filter(properties[key], func(ss any, _ int) bool {
for _, s := range value {
if s == ss {
return false
}
}
return true
})
}
}
}
return properties, nil
}
func (s *micropubImplementation) updatePostPropertiesFromMf(p *post, properties map[string][]any) {
if properties == nil || p == nil {
return
}
// Ignore the following properties
delete(properties, "url")
delete(properties, "photo")
delete(properties, "photo-alt")
// Helper function
getFirstStringFromArray := func(arr any) string {
if strArr, ok := arr.([]any); ok && len(strArr) > 0 {
if str, ok := strArr[0].(string); ok {
return str
}
}
return ""
}
// Set other properties
p.Content = getFirstStringFromArray(properties["content"])
delete(properties, "content")
p.Published = getFirstStringFromArray(properties["published"])
delete(properties, "published")
p.Updated = getFirstStringFromArray(properties["updated"])
delete(properties, "updated")
p.Slug = getFirstStringFromArray(properties["mp-slug"])
delete(properties, "mp-slug")
p.setChannel(getFirstStringFromArray(properties["mp-channel"]))
delete(properties, "mp-channel")
p.Visibility = postVisibility(defaultIfEmpty(getFirstStringFromArray(properties["visibility"]), string(p.Visibility)))
delete(properties, "visibility")
if newStatusString := getFirstStringFromArray(properties["post-status"]); newStatusString != "" {
if newStatus := postStatus(newStatusString); newStatus == statusPublished || newStatus == statusDraft {
p.Status = newStatus
}
}
delete(properties, "post-status")
for key, value := range properties {
p.Parameters[s.mapToParameterName(key)] = cast.ToStringSlice(value)
}
}