670 lines
15 KiB
Go
670 lines
15 KiB
Go
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/net/html"
|
|
|
|
"source.toby3d.me/toby3d/pub/internal/common"
|
|
"source.toby3d.me/toby3d/pub/internal/domain"
|
|
)
|
|
|
|
type (
|
|
Request struct {
|
|
Action string `json:"action"`
|
|
}
|
|
|
|
RequestCreate struct {
|
|
Properties Properties `json:"properties"`
|
|
Type []string `json:"type"` // h-entry
|
|
}
|
|
|
|
RequestSource struct {
|
|
URL URL
|
|
Q string
|
|
Properties []string
|
|
}
|
|
|
|
RequestUpdate struct {
|
|
Replace *Properties `json:"replace,omitempty"`
|
|
Add *Properties `json:"add,omitempty"`
|
|
Delete *Delete `json:"delete,omitempty"`
|
|
URL URL `json:"url"`
|
|
Action string `json:"action"` // update
|
|
}
|
|
|
|
RequestDelete struct {
|
|
URL URL `json:"url"`
|
|
Action string `json:"action"`
|
|
}
|
|
|
|
RequestUndelete struct {
|
|
URL URL `json:"url"`
|
|
Action string `json:"action"`
|
|
}
|
|
|
|
ResponseSource struct {
|
|
Properties Properties `json:"properties"`
|
|
Type []string `json:"type,omitempty"`
|
|
}
|
|
|
|
Properties struct {
|
|
Audio []Figure `json:"audio,omitempty"`
|
|
Featured []URL `json:"featured,omitempty"`
|
|
InReplyTo []URL `json:"in-reply-to,omitempty"`
|
|
Like []URL `json:"like,omitempty"`
|
|
Repost []URL `json:"repost,omitempty"`
|
|
Syndication []URL `json:"syndication,omitempty"`
|
|
URL []URL `json:"url,omitempty"`
|
|
Video []Figure `json:"video,omitempty"`
|
|
Published []DateTime `json:"published,omitempty"`
|
|
Updated []DateTime `json:"updated,omitempty"`
|
|
Content []Content `json:"content,omitempty"`
|
|
Photo []Figure `json:"photo,omitempty"`
|
|
Category []string `json:"category,omitempty"`
|
|
Name []string `json:"name,omitempty"`
|
|
RSVP []string `json:"rsvp,omitempty"`
|
|
Summary []string `json:"summary,omitempty"`
|
|
UID []string `json:"uid,omitempty"`
|
|
Altitude []float32 `json:"altitude,omitempty"`
|
|
Latitude []float32 `json:"latitude,omitempty"`
|
|
Longitude []float32 `json:"longitude,omitempty"`
|
|
Duration []uint64 `json:"duration,omitempty"`
|
|
Size []uint64 `json:"size,omitempty"`
|
|
// Author []Author `json:"author,omitempty"`
|
|
// Location []Location `json:"location,omitempty"`
|
|
// LikeOf []LikeOf `json:"like-of,omitempty"`
|
|
// RepostOf []RepostOf `json:"repost-of,omitempty"`
|
|
// Comment []Comment `json:"comment,omitempty"`
|
|
// BookmarkOf []BookmarkOf `json:"bookmark-of,omitempty"`
|
|
// ListenOf []ListenOf `json:"listen-of,omitempty"`
|
|
// WatchOf []WatchOf `json:"watch-of,omitempty"`
|
|
// ReadOf []ReadOf `json:"read-of,omitempty"`
|
|
// TranslationOf []TranslationOf `json:"translation-of,omitempty"`
|
|
// Checkin []Checkin `json:"checkin,omitempty"`
|
|
// PlayOf []PlayOf `json:"play-of,omitempty"`
|
|
}
|
|
|
|
Delete struct {
|
|
Keys []string `json:"-"`
|
|
Values Properties `json:"-"`
|
|
}
|
|
|
|
Figure struct {
|
|
Value *url.URL `json:"-"`
|
|
Alt string `json:"-"`
|
|
}
|
|
|
|
Content struct {
|
|
HTML *html.Node `json:"-"`
|
|
Value string `json:"-"`
|
|
}
|
|
|
|
URL struct {
|
|
*url.URL `json:"-"`
|
|
}
|
|
|
|
DateTime struct {
|
|
time.Time `json:"-"`
|
|
}
|
|
|
|
bufferHTML struct {
|
|
HTML string `json:"html,omitempty"`
|
|
}
|
|
|
|
bufferMedia struct {
|
|
Value string `json:"value,omitempty"`
|
|
Alt string `json:"alt,omitempty"`
|
|
}
|
|
)
|
|
|
|
const MaxBodySize int64 = 100 * 1024 * 1024 // 100mb
|
|
|
|
func NewRequestCreate() *RequestCreate {
|
|
return &RequestCreate{
|
|
Type: make([]string, 0),
|
|
Properties: Properties{},
|
|
}
|
|
}
|
|
|
|
func (r *RequestCreate) bind(req *http.Request) error {
|
|
mediaType, _, err := mime.ParseMediaType(req.Header.Get(common.HeaderContentType))
|
|
if err != nil {
|
|
return fmt.Errorf("cannot understand requested Content-Type: %w", err)
|
|
}
|
|
|
|
switch mediaType {
|
|
default:
|
|
return fmt.Errorf("unsupported media type, got '%s', want '%s', '%s' or '%s'", mediaType,
|
|
common.MIMEApplicationJSON, common.MIMEMultipartForm, common.MIMEApplicationForm)
|
|
case common.MIMEApplicationJSON:
|
|
err = json.NewDecoder(req.Body).Decode(r)
|
|
case common.MIMEMultipartForm, common.MIMEApplicationForm:
|
|
in := make(map[string][]string)
|
|
|
|
switch mediaType {
|
|
case common.MIMEMultipartForm:
|
|
if err = req.ParseMultipartForm(MaxBodySize); err != nil {
|
|
return fmt.Errorf("cannot parse creation multipart body: %w", err)
|
|
}
|
|
|
|
in = req.MultipartForm.Value
|
|
case common.MIMEApplicationForm:
|
|
if err = req.ParseForm(); err != nil {
|
|
return fmt.Errorf("cannot parse creation form body: %w", err)
|
|
}
|
|
|
|
in = req.Form
|
|
}
|
|
|
|
r.Type = append(r.Type, in["h"]...)
|
|
|
|
for k, v := range in {
|
|
switch {
|
|
case strings.HasSuffix(k, "[]"):
|
|
in[strings.TrimSuffix(k, "[]")] = v
|
|
|
|
fallthrough
|
|
case strings.HasPrefix(k, "mp-"), k == "h":
|
|
delete(in, k)
|
|
}
|
|
}
|
|
|
|
// NOTE(toby3d): hack to encode URL values
|
|
var src []byte
|
|
if src, err = json.Marshal(in); err != nil {
|
|
return fmt.Errorf("cannot marshal creation values for decoding: %w", err)
|
|
}
|
|
|
|
err = json.Unmarshal(src, &r.Properties)
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("cannot decode creation request body: %w", err)
|
|
}
|
|
|
|
for i := range r.Type {
|
|
r.Type[i] = strings.TrimPrefix(r.Type[i], "h-")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *RequestCreate) populate(dst *domain.Entry) {
|
|
if len(r.Properties.Content) > 0 {
|
|
dst.Content = []byte(r.Properties.Content[0].Value)
|
|
}
|
|
|
|
if len(r.Properties.Summary) > 0 {
|
|
dst.Description = r.Properties.Summary[0]
|
|
}
|
|
|
|
if len(r.Properties.Published) > 0 {
|
|
dst.PublishedAt = r.Properties.Published[0].Time
|
|
}
|
|
|
|
if len(r.Properties.Name) > 0 {
|
|
dst.Title = r.Properties.Name[0]
|
|
}
|
|
|
|
if len(r.Properties.Updated) > 0 {
|
|
dst.UpdatedAt = r.Properties.Updated[0].Time
|
|
}
|
|
|
|
if len(r.Properties.URL) > 0 {
|
|
dst.URL = r.Properties.URL[0].URL
|
|
}
|
|
|
|
dst.Tags = append(dst.Tags, r.Properties.Category...)
|
|
|
|
for i := range r.Properties.Photo {
|
|
dst.Photo = append(dst.Photo, r.Properties.Photo[i].Value)
|
|
}
|
|
|
|
for i := range r.Properties.Syndication {
|
|
dst.Syndications = append(dst.Syndications, r.Properties.Syndication[i].URL)
|
|
}
|
|
}
|
|
|
|
func (r *RequestSource) bind(req *http.Request) error {
|
|
query := req.URL.Query()
|
|
if r.Q = query.Get("q"); !strings.EqualFold(r.Q, "source") {
|
|
return fmt.Errorf("'q' query MUST be 'source', got '%s'", r.Q)
|
|
}
|
|
|
|
var err error
|
|
if r.URL.URL, err = url.Parse(query.Get("url")); err != nil {
|
|
return fmt.Errorf("cannot unmarshal 'url' query: %w", err)
|
|
}
|
|
|
|
for k, v := range query {
|
|
if k != "properties" && k != "properties[]" {
|
|
continue
|
|
}
|
|
|
|
r.Properties = append(r.Properties, v...)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *RequestUpdate) bind(req *http.Request) error {
|
|
if err := json.NewDecoder(req.Body).Decode(r); err != nil {
|
|
return fmt.Errorf("cannot decode JSON body: %w", err)
|
|
}
|
|
|
|
if !strings.EqualFold(r.Action, "update") {
|
|
return fmt.Errorf("invalid action, got '%s', want '%s'", r.Action, "update")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r RequestUpdate) populate(dst *domain.Entry) {
|
|
if r.Add != nil {
|
|
r.Add.CopyTo(dst)
|
|
}
|
|
|
|
if r.Replace != nil {
|
|
if len(r.Replace.Photo) > 0 {
|
|
dst.Photo = make([]*url.URL, 0)
|
|
}
|
|
|
|
if len(r.Replace.Category) > 0 {
|
|
dst.Tags = make([]string, 0)
|
|
}
|
|
|
|
if len(r.Replace.Syndication) > 0 {
|
|
dst.Syndications = make([]*url.URL, 0)
|
|
}
|
|
|
|
r.Replace.CopyTo(dst)
|
|
}
|
|
|
|
if r.Delete != nil {
|
|
r.Delete.CopyTo(dst)
|
|
}
|
|
}
|
|
|
|
func (r *RequestDelete) bind(req *http.Request) error {
|
|
mediaType, _, err := mime.ParseMediaType(req.Header.Get(common.HeaderContentType))
|
|
if err != nil {
|
|
return fmt.Errorf("cannot understand requested Content-Type: %w", err)
|
|
}
|
|
|
|
switch mediaType {
|
|
default:
|
|
return fmt.Errorf("unsupported media type, got '%s', want '%s' or '%s'", mediaType,
|
|
common.MIMEApplicationJSON, common.MIMEApplicationForm)
|
|
case common.MIMEApplicationJSON:
|
|
err = json.NewDecoder(req.Body).Decode(r)
|
|
case common.MIMEApplicationForm:
|
|
if err = req.ParseForm(); err != nil {
|
|
return fmt.Errorf("cannot decode deletion request: %w", err)
|
|
}
|
|
|
|
r.Action = req.PostForm.Get("action")
|
|
if r.URL.URL, err = url.Parse(req.PostForm.Get("url")); err != nil {
|
|
return fmt.Errorf("cannot unmarshal url in deletion request: %w", err)
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("cannot parse deletion request: %w", err)
|
|
}
|
|
|
|
if !strings.EqualFold(r.Action, "delete") {
|
|
return fmt.Errorf("invalid action, got '%s', want '%s'", r.Action, "delete")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *RequestUndelete) bind(req *http.Request) error {
|
|
mediaType, _, err := mime.ParseMediaType(req.Header.Get(common.HeaderContentType))
|
|
if err != nil {
|
|
return fmt.Errorf("cannot understand requested Content-Type: %w", err)
|
|
}
|
|
|
|
switch mediaType {
|
|
default:
|
|
return fmt.Errorf("unsupported media type, got '%s', want '%s' or '%s'", mediaType,
|
|
common.MIMEApplicationJSON, common.MIMEApplicationForm)
|
|
case common.MIMEApplicationJSON:
|
|
if err = json.NewDecoder(req.Body).Decode(r); err != nil {
|
|
return fmt.Errorf("cannot decode JSON body: %w", err)
|
|
}
|
|
|
|
return nil
|
|
case common.MIMEApplicationForm:
|
|
if err = req.ParseForm(); err != nil {
|
|
return fmt.Errorf("cannot parse form body: %w", err)
|
|
}
|
|
|
|
r.Action = req.PostFormValue("action")
|
|
if r.URL.URL, err = url.Parse(req.PostFormValue("url")); err != nil {
|
|
return fmt.Errorf("cannot parse url query: %w", err)
|
|
}
|
|
}
|
|
|
|
if !strings.EqualFold(r.Action, "undelete") {
|
|
return fmt.Errorf("invalid action, got '%s', want '%s'", r.Action, "undelete")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func NewResponseSource(src *domain.Entry, properties ...string) *ResponseSource {
|
|
out := &ResponseSource{
|
|
Type: make([]string, 0),
|
|
Properties: Properties{
|
|
Updated: make([]DateTime, 0),
|
|
Published: make([]DateTime, 0),
|
|
Photo: make([]Figure, 0),
|
|
Syndication: make([]URL, 0),
|
|
Content: make([]Content, 0),
|
|
Category: make([]string, 0),
|
|
Name: make([]string, 0),
|
|
Summary: make([]string, 0),
|
|
},
|
|
}
|
|
|
|
if len(properties) == 0 {
|
|
out.Type = append(out.Type, "h-entry")
|
|
properties = []string{
|
|
"updated", "published", "photo", "syndication", "content", "category", "name", "summary",
|
|
}
|
|
}
|
|
|
|
if src == nil {
|
|
return out
|
|
}
|
|
|
|
for i := range properties {
|
|
switch properties[i] {
|
|
case "updated":
|
|
if src.UpdatedAt.IsZero() {
|
|
continue
|
|
}
|
|
|
|
out.Properties.Updated = append(out.Properties.Updated, DateTime{Time: src.UpdatedAt})
|
|
case "published":
|
|
if src.PublishedAt.IsZero() {
|
|
continue
|
|
}
|
|
|
|
out.Properties.Published = append(out.Properties.Published, DateTime{
|
|
Time: src.PublishedAt,
|
|
})
|
|
case "photo":
|
|
for j := range src.Photo {
|
|
out.Properties.Photo = append(out.Properties.Photo, Figure{
|
|
Value: src.Photo[j],
|
|
})
|
|
}
|
|
case "syndication":
|
|
for j := range src.Syndications {
|
|
out.Properties.Syndication = append(out.Properties.Syndication, URL{
|
|
URL: src.Syndications[j],
|
|
})
|
|
}
|
|
case "content":
|
|
if len(src.Content) == 0 {
|
|
continue
|
|
}
|
|
|
|
out.Properties.Content = append(out.Properties.Content, Content{
|
|
Value: string(src.Content),
|
|
})
|
|
case "category":
|
|
out.Properties.Category = append(out.Properties.Category, src.Tags...)
|
|
case "name":
|
|
out.Properties.Name = append(out.Properties.Name, out.Properties.Name...)
|
|
case "summary":
|
|
if src.Description == "" {
|
|
continue
|
|
}
|
|
|
|
out.Properties.Summary = append(out.Properties.Summary, src.Description)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func (p Properties) CopyTo(dst *domain.Entry) {
|
|
if len(p.Updated) > 0 && !p.Updated[0].IsZero() {
|
|
dst.UpdatedAt = p.Updated[0].Time
|
|
}
|
|
|
|
if len(p.Published) > 0 && !p.Published[0].IsZero() {
|
|
dst.PublishedAt = p.Published[0].Time
|
|
}
|
|
|
|
if len(p.URL) > 0 {
|
|
dst.URL = p.URL[0].URL
|
|
}
|
|
|
|
if len(p.Content) > 0 {
|
|
dst.Content = []byte(p.Content[0].Value)
|
|
}
|
|
|
|
if len(p.Name) > 0 {
|
|
dst.Title = p.Name[0]
|
|
}
|
|
|
|
if len(p.Summary) > 0 {
|
|
dst.Description = p.Summary[0]
|
|
}
|
|
|
|
for i := range p.Photo {
|
|
dst.Photo = append(dst.Photo, p.Photo[i].Value)
|
|
}
|
|
|
|
dst.Tags = append(dst.Tags, p.Category...)
|
|
|
|
for i := range p.Syndication {
|
|
dst.Syndications = append(dst.Syndications, p.Syndication[i].URL)
|
|
}
|
|
}
|
|
|
|
func (f *Figure) UnmarshalJSON(v []byte) error {
|
|
var err error
|
|
|
|
buf := new(bufferMedia)
|
|
|
|
switch v[0] {
|
|
case '"':
|
|
buf.Value, err = strconv.Unquote(string(v))
|
|
case '{':
|
|
err = json.Unmarshal(v, buf)
|
|
}
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if f.Value, err = url.Parse(buf.Value); err != nil {
|
|
return err
|
|
}
|
|
|
|
f.Alt = buf.Alt
|
|
|
|
return nil
|
|
}
|
|
|
|
func (f Figure) MarshalJSON() ([]byte, error) {
|
|
if f.Value == nil {
|
|
return []byte(`""`), nil
|
|
}
|
|
|
|
return []byte(strconv.Quote(f.Value.String())), nil
|
|
}
|
|
|
|
func (d Delete) CopyTo(dst *domain.Entry) {
|
|
for i := range d.Keys {
|
|
switch d.Keys[i] {
|
|
case "category":
|
|
dst.Tags = make([]string, 0)
|
|
case "content":
|
|
dst.Content = make([]byte, 0)
|
|
case "name":
|
|
dst.Title = ""
|
|
case "photo":
|
|
dst.Photo = make([]*url.URL, 0)
|
|
case "published":
|
|
dst.PublishedAt = time.Time{}
|
|
case "summary":
|
|
dst.Description = ""
|
|
case "updated":
|
|
dst.UpdatedAt = time.Time{}
|
|
case "url":
|
|
dst.URL = new(url.URL)
|
|
case "syndication":
|
|
dst.Syndications = make([]*url.URL, 0)
|
|
}
|
|
}
|
|
|
|
// TODO(toby3d): delete property values
|
|
}
|
|
|
|
func (d *Delete) UnmarshalJSON(v []byte) error {
|
|
var err error
|
|
|
|
switch v[0] {
|
|
case '[':
|
|
err = json.Unmarshal(v, &d.Keys)
|
|
case '{':
|
|
err = json.Unmarshal(v, &d.Values)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (u *URL) UnmarshalJSON(v []byte) error {
|
|
raw, err := strconv.Unquote(string(v))
|
|
if err != nil {
|
|
return fmt.Errorf("cannot unqoute URL value: %w", err)
|
|
}
|
|
|
|
out, err := url.Parse(raw)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot parse URL value: %w", err)
|
|
}
|
|
|
|
u.URL = out
|
|
|
|
return nil
|
|
}
|
|
|
|
func (u URL) MarshalJSON() ([]byte, error) {
|
|
if u.URL == nil {
|
|
return []byte(`""`), nil
|
|
}
|
|
|
|
return []byte(strconv.Quote(u.URL.String())), nil
|
|
}
|
|
|
|
func (c Content) String() string {
|
|
if c.HTML == nil {
|
|
return c.Value
|
|
}
|
|
|
|
// NOTE(toby3d): trim '<html><head></head><body>' prefix and
|
|
// '</body></html>' suffix
|
|
buf := bytes.NewBuffer(nil)
|
|
if err := html.Render(buf, c.HTML); err != nil {
|
|
return ""
|
|
}
|
|
|
|
out := buf.String()
|
|
|
|
return out[25 : len(out)-14]
|
|
}
|
|
|
|
func (c *Content) UnmarshalJSON(v []byte) error {
|
|
var err error
|
|
|
|
buf := new(bufferHTML)
|
|
|
|
switch v[0] {
|
|
case '{':
|
|
err = json.Unmarshal(v, buf)
|
|
case '"':
|
|
c.Value, err = strconv.Unquote(string(v))
|
|
}
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if buf.HTML == "" {
|
|
return nil
|
|
}
|
|
|
|
if c.HTML, err = html.Parse(strings.NewReader(buf.HTML)); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c Content) MarshalJSON() ([]byte, error) {
|
|
if c.HTML == nil {
|
|
return []byte(strconv.Quote(c.Value)), nil
|
|
}
|
|
|
|
buf := bytes.NewBuffer(nil)
|
|
if err := html.Render(buf, c.HTML); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := buf.String()
|
|
|
|
// NOTE(toby3d): trim '<html><head></head><body>' prefix and
|
|
// '</body></html>' suffix
|
|
return []byte(`{"html":"` + out[25:len(out)-14] + `"}`), nil
|
|
}
|
|
|
|
func (dt *DateTime) UnmarshalJSON(b []byte) error {
|
|
v, err := strconv.Unquote(string(b))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var out time.Time
|
|
|
|
for _, format := range []string{
|
|
time.RFC3339,
|
|
// NOTE(toby3d): fallback for datetime-local input HTML node
|
|
// format
|
|
"2006-01-02T15:04",
|
|
} {
|
|
if out, err = time.Parse(format, v); err != nil {
|
|
continue
|
|
}
|
|
|
|
dt.Time = out
|
|
|
|
return nil
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (dt DateTime) MarshalJSON() ([]byte, error) {
|
|
if dt.IsZero() {
|
|
return nil, nil
|
|
}
|
|
|
|
return []byte(strconv.Quote(dt.Format(time.RFC3339))), nil
|
|
}
|