Browse Source

Development Step 1

master
Tim Schuster 1 year ago
parent
commit
32315ca281
Signed by: Tim Schuster <mail@timschuster.info> GPG Key ID: F9E27097EFB77F61
60 changed files with 2333 additions and 474 deletions
  1. 1
    0
      .gitignore
  2. 27
    6
      cmd/microblog/main.go
  3. 5
    0
      cmd/microblog/plugins.go
  4. 5
    0
      config.secl
  5. 60
    14
      config/config.go
  6. 15
    0
      model/comments.go
  7. 1
    0
      model/db.go
  8. 0
    86
      model/microformats/mf.go
  9. 94
    0
      model/microformats/types.go
  10. 5
    11
      model/oauth.go
  11. 55
    64
      model/post.go
  12. 7
    0
      model/smalldata.go
  13. 11
    17
      model/user.go
  14. 71
    0
      plugins/archive-is/plugin.go
  15. 8
    0
      plugins/iface.go
  16. 17
    0
      plugins/registry.go
  17. BIN
      resources/.sass-cache/e04010402c656d5a0b88f9c1a11d13fee34ef76b/style.scssc
  18. 7
    2
      resources/_base.tmpl.html
  19. 40
    0
      resources/admin.html
  20. 5
    0
      resources/blog.html
  21. 3
    0
      resources/email/_base.email
  22. 7
    0
      resources/email/_end.email
  23. 7
    0
      resources/email/registration.email
  24. 5
    0
      resources/email/test.email
  25. 58
    18
      resources/h_entry.html
  26. 5
    0
      resources/oauth_yesno.html
  27. 9
    0
      resources/paginator.html
  28. 49
    8
      resources/style.css
  29. 1
    1
      resources/style.css.map
  30. 95
    37
      resources/style.scss
  31. 16
    6
      resources/user.html
  32. 107
    0
      router/admin.go
  33. 28
    4
      router/blog.go
  34. 26
    0
      router/csp.go
  35. 59
    2
      router/ctx.go
  36. 7
    7
      router/err.go
  37. 63
    0
      router/index.go
  38. 65
    1
      router/micropub.go
  39. 97
    58
      router/mpcreate.go
  40. 55
    0
      router/mpdelete.go
  41. 2
    2
      router/mpmedia.go
  42. 105
    0
      router/mpsource.go
  43. 118
    0
      router/mpupdate.go
  44. 38
    9
      router/oauth.go
  45. 30
    0
      router/syndication.go
  46. 0
    110
      router/tmpl.go
  47. 24
    0
      router/url.go
  48. 19
    6
      router/user.go
  49. 58
    0
      router/webmention.go
  50. 81
    0
      util/mail/send.go
  51. 75
    0
      util/tmpl/helper.go
  52. 122
    0
      util/tmpl/tmpl.go
  53. 77
    4
      util/util.go
  54. 95
    1
      util/util_test.go
  55. 99
    0
      util/webmention/discovery.go
  56. 85
    0
      util/webmention/discovery_test.go
  57. 5
    0
      util/webmention/getbody.go
  58. 54
    0
      util/webmention/send.go
  59. 44
    0
      util/webmention/verify.go
  60. 6
    0
      version.go

+ 1
- 0
.gitignore View File

@@ -0,0 +1 @@
1
+microblog.db

+ 27
- 6
cmd/microblog/main.go View File

@@ -7,7 +7,10 @@ import (
7 7
 	"github.com/sirupsen/logrus"
8 8
 	"go.rls.moe/webapps/microblog/config"
9 9
 	"go.rls.moe/webapps/microblog/model"
10
+	"go.rls.moe/webapps/microblog/plugins"
10 11
 	"go.rls.moe/webapps/microblog/router"
12
+	"go.rls.moe/webapps/microblog/util/tmpl"
13
+	"go.rls.moe/webapps/microblog/util/mail"
11 14
 )
12 15
 
13 16
 func main() {
@@ -30,6 +33,12 @@ func main() {
30 33
 		return
31 34
 	}
32 35
 	defer db.Close()
36
+	mailIO, err := mail.NewEMailIO("resources/email/*.email", conf)
37
+	if err != nil {
38
+		log.WithError(err).Fatal("Could not setup EMail Connection")
39
+		return
40
+	}
41
+	log.WithField("email-tmpls", mailIO.GetTemplates()).Debug("Setup EMail IO")
33 42
 	log.WithField("admin-pass", conf.AdminPassword).Debug("Set Administrator Password")
34 43
 	e := echo.New()
35 44
 	e.Pre(
@@ -42,9 +51,12 @@ func main() {
42 51
 		}),
43 52
 		middleware.Recover(),
44 53
 		middleware.BodyLimit("10M"),
45
-		router.CtxMiddleware(db, conf),
54
+		router.CtxMiddleware(db, mailIO, conf),
46 55
 	)
47
-	newRenderer, err := router.NewTemplateRenderer("resources/*.html", conf)
56
+	newRenderer, err := tmpl.NewTemplateRenderer("resources/*.html", conf, func(t *tmpl.TemplateRenderer) error {
57
+		t.AddFunc("url", router.BuildUrl)
58
+		return nil
59
+	})
48 60
 	if err != nil {
49 61
 		log.WithError(err).Fatal("Could not setup renderer")
50 62
 		return
@@ -62,17 +74,26 @@ func main() {
62 74
 	e.POST("/@mp/token", router.OAuthAccess)
63 75
 
64 76
 	e.GET("/@:user", router.UserProfile)
65
-	e.GET("/u/:user", router.UserProfile)
77
+
78
+	e.GET("/admin", router.AdminPanel)
79
+	e.POST("/admin", router.AdminAction)
66 80
 
67 81
 	e.GET("/@:user/:post", router.GetBlogPost)
68
-	e.GET("/u/:user/:post", router.GetBlogPost)
69
-	e.GET("/@:user/:post/:slug", router.GetBlogPost)
70
-	e.GET("/u/:user/:post/:slug", router.GetBlogPost)
82
+	e.GET("/@:user/:post/:slug", router.GetBlogPostSlugged)
71 83
 
72 84
 	e.File("/style.css", "resources/style.css")
73 85
 	e.File("/style.css.map", "resources/style.css.map")
74 86
 	e.GET("/media/:file", router.Media)
75 87
 
88
+	e.GET("/", router.IndexShowcase)
89
+
90
+	e.GET("/@mp/webmention", router.WebmentionEndpoint)
91
+
92
+	if err := plugins.Startup(conf); err != nil {
93
+		log.WithError(err).Fatal("Could not start plugins")
94
+		return
95
+	}
96
+
76 97
 	e.Debug = false
77 98
 	e.Logger.SetLevel(log2.DEBUG)
78 99
 	log.Fatal(e.Start(conf.ServeOn))

+ 5
- 0
cmd/microblog/plugins.go View File

@@ -0,0 +1,5 @@
1
+package main
2
+
3
+import (
4
+	_ "go.rls.moe/webapps/microblog/plugins/archive-is"
5
+)

+ 5
- 0
config.secl View File

@@ -0,0 +1,5 @@
1
+pages: (
2
+    show-header: true
3
+    showcase: "@tscs37"
4
+    pagelimit: 10
5
+)

+ 60
- 14
config/config.go View File

@@ -6,15 +6,19 @@ import (
6 6
 )
7 7
 
8 8
 type Config struct {
9
-	LogLevel      string  `secl:"log-level"`
10
-	ServeOn       string  `secl:"serve-on"`
11
-	DatabaseType  string  `secl:"database.type"`
12
-	DatabaseURI   string  `secl:"database.uri"`
13
-	AdminPassword string  `secl:"admin-password"`
14
-	Crypto        Crypto  `secl:"crypto"`
15
-	Session       Session `secl:"session"`
16
-	NoCache       bool    `secl:"no-cache"`
17
-	NoMinify      bool    `secl:"no-minify"`
9
+	LogLevel      string     `secl:"log-level"`
10
+	ServeOn       string     `secl:"serve-on"`
11
+	DatabaseType  string     `secl:"database.type"`
12
+	DatabaseURI   string     `secl:"database.uri"`
13
+	AdminPassword string     `secl:"admin-password"`
14
+	AsyncWorker   bool       `secl:"async-workers"`
15
+	Crypto        Crypto     `secl:"crypto"`
16
+	Session       Session    `secl:"session"`
17
+	NoCache       bool       `secl:"no-cache"`
18
+	NoMinify      bool       `secl:"no-minify"`
19
+	Pages         Pages      `secl:"pages"`
20
+	Plugins       Plugins    `secl:"plugins"`
21
+	SMTP          SMTPConfig `secl:"smtp"`
18 22
 }
19 23
 
20 24
 type Crypto struct {
@@ -26,14 +30,33 @@ type Session struct {
26 30
 	Secret   string `secl:"secret"`
27 31
 }
28 32
 
33
+type Pages struct {
34
+	EnableHeader          bool   `secl:"show-header"`
35
+	Showcase              string `secl:"showcase"`
36
+	Limit                 int    `secl:"pagelimit"`
37
+	DisableExternalImages bool   `secl:"disable-ext-img"`
38
+}
39
+
40
+type Plugins struct {
41
+	EnableArchiveIs bool `secl:"archive-is"`
42
+}
43
+
44
+type SMTPConfig struct {
45
+	Enabled bool   `secl:"enabled"`
46
+	Mode    string `secl:"mode"`
47
+	Host    string `secl:"host"`
48
+	Port    uint16 `secl:"port"`
49
+	User    string `secl:"user"`
50
+	Pass    string `secl:"pass"`
51
+	Sender  string `secl:"sender"`
52
+}
53
+
29 54
 const DefaultConfig = `
30
-!(merge !(loadf !(
31
-	env default:"config.secl"
32
-	"MICROBLOG_CONFIG_FILE"
33
-)) (
55
+!(merge (
34 56
 	log-level: DEBUG
35 57
 	serve-on: "localhost:8080"
36 58
 	admin-password: randstr64
59
+	async-workers: off
37 60
 	database: (
38 61
 		type: sqlite3
39 62
 		uri: microblog.db
@@ -49,7 +72,30 @@ const DefaultConfig = `
49 72
 			cost: 15
50 73
 		)
51 74
 	)
52
-))
75
+	pages: (
76
+		show-header: false
77
+		showcase: "@admin"
78
+		pagelimit: 25
79
+		disable-ext-img: false
80
+	)
81
+	plugins: (
82
+		archive-is: on
83
+	)
84
+	smtp: (
85
+		enabled: false
86
+		mode: plain
87
+		host: localhost
88
+		port: 1025
89
+		user: user
90
+		pass: pass
91
+		sender: "root@dev.qat7.localhost"
92
+	)
93
+) !(loadf
94
+	!(env
95
+		default: "config.secl"
96
+		"MICROBLOG_CONFIG_FILE"
97
+	)
98
+) )
53 99
 `
54 100
 
55 101
 func LoadConfig() (*Config, error) {

+ 15
- 0
model/comments.go View File

@@ -0,0 +1,15 @@
1
+package model
2
+
3
+import "time"
4
+
5
+type Comment struct {
6
+	ID           IDType `gorm:"primary_key"`
7
+	CreatedAt    time.Time
8
+	UpdatedAt    time.Time
9
+	DeletedAt    *time.Time
10
+	IsWebMention bool
11
+	SourceURL    string // either the source post or the profile URL / email in URL form
12
+	PostID       IDType
13
+	Post         Post
14
+	Body         string
15
+}

+ 1
- 0
model/db.go View File

@@ -39,6 +39,7 @@ func OpenDB(config *config.Config) (*DB, error) {
39 39
 	db.AutoMigrate(&OAuthCode{})
40 40
 	db.AutoMigrate(&OAuthAccess{})
41 41
 	db.AutoMigrate(&Media{})
42
+	db.AutoMigrate(&AlternateURLs{})
42 43
 	errs := db.GetErrors()
43 44
 	for err := range errs {
44 45
 		if errs[err] != nil {

+ 0
- 86
model/microformats/mf.go View File

@@ -1,86 +0,0 @@
1
-package microformats
2
-
3
-import (
4
-	"encoding/json"
5
-
6
-	"github.com/pkg/errors"
7
-)
8
-
9
-type MicroFormat struct {
10
-	Value      string
11
-	HTML       string
12
-	Type       []string
13
-	Properties map[string][]*MicroFormat
14
-	Children   []*MicroFormat
15
-}
16
-
17
-func (mf *MicroFormat) UnmarshalJSON(data []byte) error {
18
-	mapping := map[string]interface{}{}
19
-	err := json.Unmarshal(data, &mapping)
20
-	if err != nil {
21
-		return err
22
-	}
23
-	return mf.UnmarshalMapString(mapping)
24
-}
25
-
26
-func (mf *MicroFormat) UnmarshalMapString(data map[string]interface{}) error {
27
-	properties, ok := data["properties"].(map[string]interface{})
28
-	if !ok {
29
-		return errors.New("Properties is mandatory")
30
-	}
31
-	values, ok := properties["content"].([]interface{})
32
-	if !ok {
33
-		return errors.Errorf("Could not unmarshal Content")
34
-	}
35
-	if len(values) > 2 {
36
-		return errors.New("Too many content values")
37
-	}
38
-	for k := range values {
39
-		if valText, ok := values[k].(string); ok {
40
-			if mf.Value == "" {
41
-				mf.Value = valText
42
-			} else {
43
-				return errors.New("Value repeated in Content")
44
-			}
45
-		} else if valHtml, ok := values[k].(map[string]interface{}); ok {
46
-			if mf.HTML == "" || mf.Value == "" {
47
-				mf.Value, _ = valHtml["value"].(string)
48
-				mf.HTML, _ = valHtml["html"].(string)
49
-			} else {
50
-				return errors.New("HTML repeated in Content")
51
-			}
52
-		} else {
53
-			return errors.New("Unknown Content Packing")
54
-		}
55
-	}
56
-	delete(properties, "content")
57
-	mf.Properties = map[string][]*MicroFormat{}
58
-	for k := range properties {
59
-		mf.Properties[k] = []*MicroFormat{}
60
-		switch pType := properties[k].(type) {
61
-		case []interface{}:
62
-			for m := range pType {
63
-				mf.Properties[k] = append(mf.Properties[k], &MicroFormat{
64
-					Value: pType[m].(string),
65
-				})
66
-			}
67
-		case map[string]interface{}:
68
-			mfNext := &MicroFormat{}
69
-			if err := mfNext.UnmarshalMapString(pType); err != nil {
70
-				return err
71
-			}
72
-		default:
73
-			return errors.New("Unknown entry format")
74
-		}
75
-	}
76
-	types, ok := data["type"].([]interface{})
77
-	if !ok {
78
-		return errors.New("Could not unmarshal Types")
79
-	} else {
80
-		mf.Type = []string{}
81
-		for k := range types {
82
-			mf.Type = append(mf.Type, types[k].(string))
83
-		}
84
-	}
85
-	return nil
86
-}

+ 94
- 0
model/microformats/types.go View File

@@ -0,0 +1,94 @@
1
+package microformats
2
+
3
+import (
4
+	"encoding/json"
5
+
6
+	"github.com/pkg/errors"
7
+)
8
+
9
+type Submission struct {
10
+	Action  string `json:"action"`
11
+	URL     string `json:"url"`
12
+	Data    Entry  `json:"properties"`
13
+	Add     Entry  `json:"add"`
14
+	Replace Entry  `json:"replace"`
15
+	Delete  Entry  `json:"delete"`
16
+}
17
+
18
+func NewSubmission() *Submission {
19
+	return &Submission{
20
+		Action: "create",
21
+	}
22
+}
23
+
24
+type Entry struct {
25
+	Content  Content  `json:"content"`
26
+	Photo    Photo    `json:"photo"`
27
+	Category []string `json:"category"`
28
+	Summary  []string `json:"summary"`
29
+	Name     []string `json:"name"`
30
+}
31
+
32
+type Content struct {
33
+	Value string `json:"value"`
34
+	HTML  string `json:"html"`
35
+}
36
+
37
+type Photo struct {
38
+	URL string `json:"url"`
39
+	Alt string `json:"alt"`
40
+}
41
+
42
+func (c *Content) UnmarshalJSON(jsonStr []byte) error {
43
+	var dataSlice []interface{}
44
+	if err := json.Unmarshal(jsonStr, &dataSlice); err != nil {
45
+		return err
46
+	}
47
+	if len(dataSlice) == 0 {
48
+		return nil
49
+	}
50
+	data := dataSlice[0]
51
+	switch pType := data.(type) {
52
+	case string:
53
+		c.Value = pType
54
+	case map[string]interface{}:
55
+		c.Value = iToStr(pType, "value")
56
+		c.HTML = iToStr(pType, "html")
57
+	default:
58
+		return errors.New("Wrong Type in Content")
59
+	}
60
+	return nil
61
+}
62
+
63
+func (p *Photo) UnmarshalJSON(jsonStr []byte) error {
64
+	var dataSlice []interface{}
65
+	if err := json.Unmarshal(jsonStr, &dataSlice); err != nil {
66
+		return err
67
+	}
68
+	if len(dataSlice) == 0 {
69
+		return nil
70
+	}
71
+	data := dataSlice[0]
72
+	switch pType := data.(type) {
73
+	case string:
74
+		p.URL = pType
75
+	case map[string]interface{}:
76
+		p.URL = iToStr(pType, "value")
77
+		p.Alt = iToStr(pType, "alt")
78
+	default:
79
+		return errors.New("Wrong Type in Content")
80
+	}
81
+	return nil
82
+}
83
+
84
+func iToStr(data map[string]interface{}, key string) string {
85
+	i, ok := data[key]
86
+	if !ok {
87
+		return ""
88
+	}
89
+	val, ok := i.(string)
90
+	if !ok {
91
+		return ""
92
+	}
93
+	return val
94
+}

+ 5
- 11
model/oauth.go View File

@@ -1,13 +1,12 @@
1 1
 package model
2 2
 
3 3
 import (
4
-	"encoding/json"
5
-	"github.com/labstack/echo"
6
-	"github.com/pkg/errors"
7 4
 	"net/http"
8 5
 	"net/url"
9 6
 	"strings"
10 7
 	"time"
8
+
9
+	"github.com/pkg/errors"
11 10
 )
12 11
 
13 12
 type OAuthClient struct {
@@ -38,6 +37,7 @@ const (
38 37
 	ScopeDelete         = "delete"
39 38
 	ScopeUndelete       = "undelete"
40 39
 	ScopeMedia          = "media"
40
+	ScopeAuth           = ""
41 41
 )
42 42
 
43 43
 func (s Scope) Message() string {
@@ -52,6 +52,8 @@ func (s Scope) Message() string {
52 52
 		return "Undelete Posts/Notes/etc."
53 53
 	case ScopeMedia:
54 54
 		return "Upload Media Files"
55
+	case ScopeAuth:
56
+		return "Authenticate as You"
55 57
 	default:
56 58
 		return string(s)
57 59
 	}
@@ -106,11 +108,3 @@ func (o OAuthClient) VerifyRedirectURL(redirect string) error {
106 108
 	}
107 109
 	return nil
108 110
 }
109
-
110
-func (o *OAuthAccess) GetJSON(c echo.Context) ([]byte, error) {
111
-	return json.Marshal(map[string]string{
112
-		"me":           o.User.GetURL(c),
113
-		"scope":        o.Scope,
114
-		"access_token": o.AccessToken,
115
-	})
116
-}

+ 55
- 64
model/post.go View File

@@ -1,91 +1,75 @@
1 1
 package model
2 2
 
3 3
 import (
4
-	"net/url"
5 4
 	"strings"
6 5
 	"time"
7 6
 
8
-	"bytes"
9
-
10 7
 	"github.com/PuerkitoBio/goquery"
11
-	"github.com/labstack/echo"
12
-	"github.com/pkg/errors"
13 8
 	"go.rls.moe/webapps/microblog/util"
14 9
 )
15 10
 
16 11
 type Post struct {
17
-	ID          IDType `gorm:"primary_key"`
18
-	CreatedAt   time.Time
19
-	UpdatedAt   time.Time
20
-	DeletedAt   *time.Time
21
-	PublicRefID string `gorm:"not null;unique;index:idx_post_id"`
22
-	UserID      IDType
23
-	User        User
24
-	Categories  []Category `gorm:"many2many:post_categories"`
25
-	Name        string
26
-	Content     string
27
-	HTMLContent string
12
+	ID            IDType `gorm:"primary_key"`
13
+	CreatedAt     time.Time
14
+	UpdatedAt     time.Time
15
+	DeletedAt     *time.Time
16
+	PublicRefID   string `gorm:"not null;unique;index:idx_post_id"`
17
+	UserID        IDType
18
+	User          User
19
+	Categories    []Category `gorm:"many2many:post_categories"`
20
+	AlternateURLs []AlternateURLs
21
+	Name          string
22
+	Content       string
23
+	Summary       string
24
+	HTMLContent   string
28 25
 	// Dynamic Content Data
29
-	Slug       string
30
-	InReplyTo  string
31
-	Type       string
32
-	BookmarkOf string
33
-	Photo      string
26
+	Slug           string
27
+	InReplyTo      string
28
+	InReplyToTitle string
29
+	BookmarkOf     string
30
+	Photo          string
31
+	PhotoAlt       string
32
+	Comments       []Comment
34 33
 }
35 34
 
36
-const (
37
-	PostTypeNote     = "note"
38
-	PostTypeArticle  = "article"
39
-	PostTypeBookmark = "bookmark"
40
-	PostTypePhoto    = "photo"
41
-)
42
-
43
-func (p Post) GetTitle() string {
44
-	if p.Type == PostTypeNote {
45
-		var splitContent []string
46
-		if p.HTMLContent != "" {
47
-			splitContent = strings.SplitN(util.StrictHTML(p.HTMLContent), " ", 5)
48
-		} else {
49
-			splitContent = strings.SplitN(p.Content, " ", 5)
35
+func (p Post) GetTitle() (out string) {
36
+	defer func() {
37
+		if len(out) > 256 {
38
+			out = out[:256]
50 39
 		}
51
-		return strings.Join(splitContent[:len(splitContent)-1], " ") + "..."
52
-	} else if p.Name != "" {
53
-		return p.Name
54
-	} else {
55
-		doc, err := goquery.NewDocumentFromReader(bytes.NewBufferString(p.Content))
40
+	}()
41
+	if p.Name != "" {
42
+		return util.StrictHTML(p.Name)
43
+	}
44
+	if p.Summary != "" {
45
+		return util.ToTitle(p.Summary)
46
+	}
47
+	if p.HTMLContent != "" {
48
+		return util.ToTitle(p.HTMLContent)
49
+	}
50
+	return util.ToTitle(p.Content)
51
+}
52
+
53
+func (p *Post) BeforeSave() error {
54
+	var err error
55
+	p.Slug, err = p.GetSlug()
56
+	if p.InReplyTo != "" {
57
+		doc, err := goquery.NewDocument(p.InReplyTo)
56 58
 		if err != nil {
57
-			if len(p.Content) > 25 {
58
-				return p.Content[:20]
59
-			} else {
60
-				return p.Content
61
-			}
59
+			return err
62 60
 		}
63
-		return doc.Find(".h-entry > .p-name").First().Text()
61
+		p.InReplyToTitle = doc.Find("title").First().Text()
64 62
 	}
63
+	return err
65 64
 }
66 65
 
67 66
 func (p *Post) BeforeCreate() error {
68 67
 	var err error
69
-	if p.Type == "" {
70
-		return errors.New("Post requires a type")
71
-	}
72 68
 	p.PublicRefID, err = util.MakeRandomString(8)
73 69
 	if err != nil {
74 70
 		return err
75 71
 	}
76
-	p.Slug, err = p.GetSlug()
77
-	return err
78
-}
79
-
80
-func (p Post) GetURL(c echo.Context) string {
81
-	postUrl := url.URL{}
82
-	postUrl.Scheme = "https"
83
-	postUrl.Host = c.Request().Host
84
-	postUrl.Path = "/@" + p.User.Username + "/" + p.PublicRefID
85
-	if p.Slug != "" {
86
-		postUrl.Path += "/" + p.Slug
87
-	}
88
-	return postUrl.String()
72
+	return nil
89 73
 }
90 74
 
91 75
 func (p Post) GetReplyTitle() (string, error) {
@@ -102,5 +86,12 @@ func (p Post) GetSlug() (string, error) {
102 86
 }
103 87
 
104 88
 func (p Post) HasContent() bool {
105
-	return p.Content != "" || p.HTMLContent != ""
106
-}
89
+	return p.Content != "" || p.HTMLContent != "" || p.Summary != "" || p.Name != ""
90
+}
91
+
92
+func (p Post) GetCategories() (out []string) {
93
+	for k := range p.Categories {
94
+		out = append(out, p.Categories[k].Name)
95
+	}
96
+	return out
97
+}

model/category.go → model/smalldata.go View File

@@ -3,3 +3,10 @@ package model
3 3
 type Category struct {
4 4
 	Name string `gorm:"index:idx_category_name;primary_key"`
5 5
 }
6
+
7
+type AlternateURLs struct {
8
+	ID     IDType
9
+	PostID IDType
10
+	Name   string
11
+	URL    string
12
+}

+ 11
- 17
model/user.go View File

@@ -1,13 +1,12 @@
1 1
 package model
2 2
 
3 3
 import (
4
-	"github.com/labstack/echo"
4
+	"time"
5
+
6
+	"github.com/pkg/errors"
5 7
 	"go.rls.moe/webapps/microblog/config"
6
-	"go.rls.moe/webapps/microblog/util"
7 8
 	"golang.org/x/crypto/bcrypt"
8 9
 	"golang.org/x/crypto/blake2b"
9
-	"net/url"
10
-	"time"
11 10
 )
12 11
 
13 12
 type User struct {
@@ -15,7 +14,6 @@ type User struct {
15 14
 	CreatedAt    time.Time
16 15
 	UpdatedAt    time.Time
17 16
 	DeletedAt    *time.Time
18
-	PublicRefID  string `gorm:"not null;unique"`
19 17
 	EMail        string `gorm:"index:idx_user_email";not null`
20 18
 	Username     string `gorm:"index:idx_user_username";unique;not null`
21 19
 	PasswordHash []byte
@@ -23,6 +21,9 @@ type User struct {
23 21
 }
24 22
 
25 23
 func (u *User) SetPassword(config *config.Config, passwd string) error {
24
+	if len(passwd) < 8 {
25
+		return errors.New("password must have 8 characters minimum")
26
+	}
26 27
 	hashed := blake2b.Sum256([]byte(passwd))
27 28
 	bcryptHashed, err := bcrypt.GenerateFromPassword(hashed[:], config.Crypto.BCryptCost)
28 29
 	if err == nil {
@@ -36,16 +37,9 @@ func (u *User) VerifyPassword(config *config.Config, passwd string) error {
36 37
 	return bcrypt.CompareHashAndPassword(u.PasswordHash, hashed[:])
37 38
 }
38 39
 
39
-func (u *User) BeforeCreate() error {
40
-	var err error
41
-	u.PublicRefID, err = util.MakeRandomString(8)
42
-	return err
40
+func (u *User) BeforeSave() error {
41
+	if u.Username == "mp" {
42
+		return errors.New("Cannot use MP as username")
43
+	}
44
+	return nil
43 45
 }
44
-
45
-func (u User) GetURL(c echo.Context) string {
46
-	userUrl := url.URL{}
47
-	userUrl.Scheme = "https"
48
-	userUrl.Host = c.Request().Host
49
-	userUrl.Path = "/@" + u.Username
50
-	return userUrl.String()
51
-}

+ 71
- 0
plugins/archive-is/plugin.go View File

@@ -0,0 +1,71 @@
1
+package archiveis
2
+
3
+import (
4
+	"go.rls.moe/webapps/microblog/model"
5
+	"go.rls.moe/webapps/microblog/plugins"
6
+	"go.rls.moe/webapps/microblog/router"
7
+	"net/http"
8
+	"time"
9
+	"net/url"
10
+	"go.rls.moe/webapps/microblog/config"
11
+)
12
+
13
+func init() {
14
+	plugins.Plugins = append(plugins.Plugins,WebArchivePlugin{})
15
+}
16
+
17
+type WebArchivePlugin struct {}
18
+
19
+func (WebArchivePlugin) PluginName() string {
20
+	return "Archive-Is-Syndication"
21
+}
22
+
23
+func (WebArchivePlugin) Start(config *config.Config) error {
24
+	if !config.Plugins.EnableArchiveIs {
25
+		return nil
26
+	}
27
+	router.SyndicationRegistry = append(
28
+		router.SyndicationRegistry,
29
+		router.SyndicationTarget{
30
+			Name: "archive.is",
31
+			UID: "https://archive.fo/",
32
+			Service: &router.SyndicationService{
33
+				Name: "archive.is",
34
+				URL: "https://archive.fo/",
35
+				Photo: "https://archive.fo/favicon.ico",
36
+			},
37
+		},
38
+	)
39
+	return nil
40
+}
41
+
42
+func (WebArchivePlugin) PreCreatePost(c *router.Context, post *model.Post) error {
43
+	client := http.DefaultClient
44
+	client.Timeout = 120 * time.Second
45
+	archiveUrl := url.URL{}
46
+	archiveUrl.Scheme = "https"
47
+	archiveUrl.Host = "archive.today"
48
+	query := archiveUrl.Query()
49
+	query.Set("run", "1")
50
+	postUrl, err := router.BuildUrl(c, *post)
51
+	if err != nil {
52
+		return err
53
+	}
54
+	query.Set("url", postUrl)
55
+	archiveUrl.RawQuery = query.Encode()
56
+	resp, err := client.Get(archiveUrl.String())
57
+	if err != nil {
58
+		return err
59
+	}
60
+	defer resp.Body.Close()
61
+	loc := resp.Request.URL
62
+	post.AlternateURLs = append(post.AlternateURLs, model.AlternateURLs{
63
+		URL: loc.String(),
64
+		Name: "archive.is",
65
+	})
66
+	return nil
67
+}
68
+
69
+func (WebArchivePlugin) PostCreatePost(c *router.Context, post *model.Post) {
70
+	return
71
+}

+ 8
- 0
plugins/iface.go View File

@@ -0,0 +1,8 @@
1
+package plugins
2
+
3
+import "go.rls.moe/webapps/microblog/config"
4
+
5
+type Plugin interface {
6
+	PluginName() string
7
+	Start(config *config.Config) error
8
+}

+ 17
- 0
plugins/registry.go View File

@@ -0,0 +1,17 @@
1
+package plugins
2
+
3
+import (
4
+	"github.com/pkg/errors"
5
+	"go.rls.moe/webapps/microblog/config"
6
+)
7
+
8
+var Plugins = []Plugin{}
9
+
10
+func Startup(config *config.Config) error {
11
+	for k := range Plugins {
12
+		if err := Plugins[k].Start(config); err != nil {
13
+			return errors.Wrap(err, "Could not start plugin " + Plugins[k].PluginName())
14
+		}
15
+	}
16
+	return nil
17
+}

BIN
resources/.sass-cache/e04010402c656d5a0b88f9c1a11d13fee34ef76b/style.scssc View File


+ 7
- 2
resources/_base.tmpl.html View File

@@ -2,10 +2,15 @@
2 2
 <html>
3 3
 <head>
4 4
     {{ if .title }}<title>{{.title}}</title>{{ end }}
5
+    {{ if not .no_canonical }}
5 6
     {{ if .post }}
6
-    <link rel="canonical" href="{{.post.GetURL .context}}">
7
+    <link rel="canonical" href="{{url .context .post}}">
7 8
     {{ end }}
8
-    <link rel="stylesheet" href="{{.baseUrl}}/style.css">
9
+    {{ if .user }}
10
+    <link rel="canonical" href="{{url .context .user}}">
11
+    {{ end }}
12
+    {{ end }}
13
+    <link rel="stylesheet" {{makeCSPAttr .context}} href="{{.baseUrl}}/style.css">
9 14
     <link rel="micropub" href="{{.baseUrl}}/@mp">
10 15
     <link rel="authorization_endpoint" href="{{.baseUrl}}/@mp/auth">
11 16
     <link rel="token_endpoint" href="{{.baseUrl}}/@mp/token">

+ 40
- 0
resources/admin.html View File

@@ -0,0 +1,40 @@
1
+{{ template "_base.tmpl.html" . }}
2
+
3
+
4
+{{ if .err }}
5
+<div class="adminpanel error">
6
+    {{ .err }}
7
+</div>
8
+{{ end }}
9
+
10
+<div class="adminpanel">
11
+    <form method="post">
12
+        <h3>New User</h3>
13
+        <input type="hidden" name="action" value="createuser">
14
+        <input type="text" name="user" placeholder="Username">
15
+        <input type="email" name="email" placeholder="E-Mail">
16
+        <input type="password" name="password" placeholder="Password">
17
+        <input type="password" name="password_repeat" placeholder="Repeat Password">
18
+        <input type="submit">
19
+    </form>
20
+</div>
21
+
22
+<div class="adminpanel">
23
+    <form method="post">
24
+        <h3>Reset User Password</h3>
25
+        <input type="hidden" name="action" value="resetpw">
26
+        <input type="text" name="user" placeholder="Username or EMail">
27
+        <input type="text" name="password" placeholder="New Password">
28
+        <input type="submit">
29
+    </form>
30
+</div>
31
+
32
+<div class="adminpanel">
33
+    <form method="post">
34
+        <h3>Test Email</h3>
35
+        <input type="hidden" name="action" value="testemail">
36
+        <input type="submit">
37
+    </form>
38
+</div>
39
+
40
+{{ template "_end.tmpl.html" . }}

+ 5
- 0
resources/blog.html View File

@@ -0,0 +1,5 @@
1
+{{ template "_base.tmpl.html" . }}
2
+
3
+{{ template "h_entry.html" . }}
4
+
5
+{{ template "_end.tmpl.html" . }}

+ 3
- 0
resources/email/_base.email View File

@@ -0,0 +1,3 @@
1
+Hello @{{.user.Username}},
2
+--EMPTYLINE
3
+--EMPTYLINE

+ 7
- 0
resources/email/_end.email View File

@@ -0,0 +1,7 @@
1
+--EMPTYLINE
2
+--EMPTYLINE
3
+ - Sincerely <canonical domain>
4
+--EMPTYLINE
5
+###############################
6
+# This is an automated e-mail #
7
+###############################

+ 7
- 0
resources/email/registration.email View File

@@ -0,0 +1,7 @@
1
+{{ template "_base.email" }}
2
+
3
+An account has been created on <canonical domain>: @{{.user.Username}}
4
+--EMPTYLINE
5
+Click on this link to activate your account: {{.activationLink}}
6
+
7
+{{ template "_end.email" }}

+ 5
- 0
resources/email/test.email View File

@@ -0,0 +1,5 @@
1
+{{ template "_base.email" }}
2
+
3
+This is a test email. If you can see this, it probably works.
4
+
5
+{{ template "_end.email" }}

+ 58
- 18
resources/h_entry.html View File

@@ -1,38 +1,78 @@
1
-{{ template "_base.tmpl.html" . }}
2
-
3 1
 <article class="h-entry">
4
-    <h1 class="p-name">
2
+    <h2 class="p-name">
3
+        {{ if .linkTitle }}
4
+        <a href="{{url .context .post}}">{{ .post.GetTitle }}</a>
5
+        {{ end }}
6
+        {{ if not .linkTitle }}
5 7
         {{ .post.GetTitle }}
6
-    </h1>
8
+        {{ end }}
9
+    </h2>
7 10
     <div class="_author">Published by
8
-        <a class="p-author h-card" href="{{.post.User.GetURL .context}}">
11
+
12
+        <a class="p-author h-card" href="{{url .context .post.User}}">
9 13
             @{{.post.User.Username}}
10 14
         </a>
15
+
11 16
         {{ $t := formatTime .post.CreatedAt "iso8906" }}
12 17
         {{ $th := formatTime .post.CreatedAt "human" }}
13
-        on
14
-        <time class="dt-published" datetime="{{ $t }}">{{$th}}</time>
18
+        <time class="dt-published" datetime="{{ $t }}" title="{{ $t }}">{{$th}}</time>
19
+
20
+        {{ if (afterTime .post.UpdatedAt .post.CreatedAt 60) }}
21
+        {{ $ut := formatTime .post.UpdatedAt "iso8906" }}
22
+        {{ $uth := formatTime .post.UpdatedAt "human" }}
23
+        <time class="dt-updated" datetime="{{ $t }}" title="{{ $t }}">{{$th}}</time>
24
+        {{ end }}
25
+
15 26
         {{ if notEmpty .post.InReplyTo }}
16
-            in reply to <a href="{{.post.InReplyTo}}">{{.post.GetReplyTitle}}</a>
27
+        in reply to <a href="{{.post.InReplyTo}}">{{.post.GetReplyTitle}}</a>
17 28
         {{ end }}
18 29
     </div>
30
+    {{ if notEmpty .post.InReplyTo }}
31
+    <div>In Reply to
32
+        <a href="{{.post.InReplyTo}}" class="u-in-reply-to">
33
+            {{ if notEmpty .post.InReplyToTitle }}
34
+            {{ .post.InReplyToTitle }}
35
+            {{ end }}
36
+            {{ if isEmpty .post.InReplyToTitle }}
37
+            {{ .post.InReplyTo }}
38
+            {{ end }}
39
+        </a>
40
+    </div>
41
+    {{ end }}
19 42
     {{ if notEmpty .post.Photo }}
20
-    <br>
21
-    <img class="u-photo" src="{{.post.Photo}}">
43
+    <img class="u-photo"
44
+         {{ if notEmpty .post.PhotoAlt }} title="{{.post.PhotoAlt}}" alt="{{.post.PhotoAlt}}" {{ end }}
45
+         {{ if isEmpty .post.PhotoAlt }} alt="Post Image" {{ end }}
46
+         {{makeCSPAttr .context}}
47
+         src="{{.post.Photo}}">
48
+    {{ end }}
49
+    {{ if notEmpty .post.Summary }}
50
+    <div class="p-summary">
51
+        {{ if not .grave }}
52
+        {{.post.Summary}}
53
+        {{ end }}
54
+    </div>
22 55
     {{ end }}
23
-    <br>
24 56
     <div class="e-content">
25 57
         {{ if notEmpty .post.HTMLContent }}
26
-            <p>{{ safeHtml .post.HTMLContent }}</p>
58
+        <p>{{ safeHtml .post.HTMLContent }}</p>
59
+        {{ end }}
60
+        {{ if isEmpty .post.HTMLContent }}
61
+        <p>{{.post.Content}}</p>
27 62
         {{ end }}
28
-        {{ if not (notEmpty .post.HTMLContent) }}
29
-            <p>{{.post.Content}}</p>
63
+    </div>
64
+    {{ if gt (len .post.AlternateURLs) 0 }}
65
+    <div class="syndications">
66
+        Also available at:
67
+        {{ range .post.AlternateURLs }}
68
+        <a class="u-syndication" href="{{.URL}}">{{.Name}}</a>
30 69
         {{ end }}
31 70
     </div>
32
-    <br>
33
-    {{ range .post.Categories }}
34
-    <div class="p-category">
35
-        {{ .Name }}
71
+    {{ end }}
72
+    {{ if gt (len .post.Categories) 0 }}
73
+    <div class="categories">
74
+        Categories:
75
+        {{ joinElements .post.GetCategories ", " "<span class=\"p-category\">" "</span>" }}
36 76
     </div>
37 77
     {{ end }}
38 78
 </article>

+ 5
- 0
resources/oauth_yesno.html View File

@@ -5,12 +5,17 @@
5 5
 <div>
6 6
     Application <code>{{.client.ClientID}}</code>
7 7
     wants to authenticate with your User <code>{{.user.EMail}}</code>
8
+    {{ if eq .request.ResponseType "code" }}
8 9
     with the following privileges:
9 10
     <ol>
10 11
         {{ range .scopes }}
11 12
         <li>{{.Message}}</li>
12 13
         {{ end }}
13 14
     </ol>
15
+    {{ end }}
16
+    {{ if eq .request.ResponseType "id" }}
17
+    using your ID. The application will not be able to act on your behalf.
18
+    {{ end }}
14 19
 </div>
15 20
 <form method="POST" class="oauth_dialog">
16 21
     <input type="hidden" name="me" value="{{.request.Me}}">

+ 9
- 0
resources/paginator.html View File

@@ -0,0 +1,9 @@
1
+<div class="paginator">
2
+    {{ if gt .page 1 }}
3
+    <a class="prev" href="{{.url}}?page={{math_dec .page }}">Prev</a>
4
+    {{ end }}
5
+    <a class="curr" href="{{.url}}?page={{.page}}">Current</a>
6
+    {{ if eq .items .pageSize }}
7
+    <a class="next" href="{{.url}}?page={{math_inc .page }}">Next</a>
8
+    {{ end }}
9
+</div>

+ 49
- 8
resources/style.css View File

@@ -1,14 +1,15 @@
1 1
 html {
2 2
   height: 100%;
3 3
   width: 100%;
4
-  font-size: 16pt;
4
+  font-size: 12pt;
5 5
   font-family: sans-serif; }
6 6
   html body {
7 7
     margin: 0 auto 0 auto;
8 8
     padding: 1em;
9 9
     left: 0;
10 10
     right: 0;
11
-    max-width: 50em; }
11
+    max-width: 50em;
12
+    color: #333; }
12 13
 
13 14
 .login-form {
14 15
   margin: 0 auto 0 auto;
@@ -29,12 +30,52 @@ html {
29 30
     width: 100%;
30 31
     font-size: 16pt; }
31 32
 
32
-.h-entry .u-photo {
33
-  max-width: 100%;
34
-  max-height: 10em;
33
+.user-posts ol {
34
+  list-style-type: none; }
35
+
36
+.h-entry {
37
+  box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.2);
38
+  padding: 4px;
39
+  margin-bottom: 1em; }
40
+  .h-entry * {
41
+    box-shadow: none;
42
+    margin-bottom: 0; }
43
+  .h-entry .u-photo {
44
+    max-width: 100%;
45
+    max-height: 15em;
46
+    display: block;
47
+    margin: 0 auto 0 auto;
48
+    left: 0;
49
+    right: 0; }
50
+  .h-entry .e-content {
51
+    padding: 4px; }
52
+  .h-entry .categories, .h-entry .syndications {
53
+    margin-top: 1em;
54
+    font-size: 8pt; }
55
+    .h-entry .categories .p-category, .h-entry .syndications .p-category {
56
+      font-style: italic; }
57
+
58
+.adminpanel {
59
+  box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.2);
60
+  padding: 5px 1em 1em;
61
+  margin: 1em; }
62
+
63
+a, a:visited {
64
+  text-decoration: none;
65
+  color: #111; }
66
+
67
+blockquote {
68
+  margin: 0;
69
+  padding: 1em 0 1em 1em;
70
+  border-left: 2px solid #eee; }
71
+
72
+.paginator {
35 73
   display: block;
36
-  margin: 0 auto 0 auto;
37
-  left: 0;
38
-  right: 0; }
74
+  width: 100%;
75
+  text-align: center;
76
+  padding-bottom: 2em; }
77
+  .paginator a {
78
+    padding: 0 2em 0 2em;
79
+    width: 33%; }
39 80
 
40 81
 /*# sourceMappingURL=style.css.map */

+ 1
- 1
resources/style.css.map View File

@@ -1,6 +1,6 @@
1 1
 {
2 2
 "version": 3,
3
-"mappings": "AAAA,IAAK;EACD,MAAM,EAAE,IAAI;EACZ,KAAK,EAAE,IAAI;EACX,SAAS,EAAE,IAAI;EACf,WAAW,EAAE,UAAU;EACvB,SAAK;IACD,MAAM,EAAE,aAAa;IACrB,OAAO,EAAE,GAAG;IACZ,IAAI,EAAE,CAAC;IAAE,KAAK,EAAE,CAAC;IACjB,SAAS,EAAE,IAAI;;AAIvB,WAAY;EACR,MAAM,EAAE,aAAa;EACrB,IAAI,EAAE,CAAC;EAAE,KAAK,EAAE,CAAC;EACjB,KAAK,EAAE,IAAI;EAEP,oBAAI;IACA,UAAU,EAAE,GAAG;EAEnB,sBAAM;IACF,OAAO,EAAE,YAAY;IACrB,KAAK,EAAE,IAAI;IACX,4BAAM;MACF,KAAK,EAAE,IAAI;MACX,KAAK,EAAE,KAAK;MACZ,KAAK,EAAE,IAAI;MACX,SAAS,EAAE,IAAI;EAGvB,uBAAO;IACH,KAAK,EAAE,IAAI;IACX,SAAS,EAAE,IAAI;;AAMvB,iBAAS;EACL,SAAS,EAAE,IAAI;EACf,UAAU,EAAE,IAAI;EAChB,OAAO,EAAE,KAAK;EACd,MAAM,EAAE,aAAa;EACrB,IAAI,EAAE,CAAC;EAAE,KAAK,EAAE,CAAC",
3
+"mappings": "AAAA,IAAK;EACH,MAAM,EAAE,IAAI;EACZ,KAAK,EAAE,IAAI;EACX,SAAS,EAAE,IAAI;EACf,WAAW,EAAE,UAAU;EACvB,SAAK;IACH,MAAM,EAAE,aAAa;IACrB,OAAO,EAAE,GAAG;IACZ,IAAI,EAAE,CAAC;IACP,KAAK,EAAE,CAAC;IACR,SAAS,EAAE,IAAI;IACf,KAAK,EAAE,IAAI;;AAIf,WAAY;EACV,MAAM,EAAE,aAAa;EACrB,IAAI,EAAE,CAAC;EACP,KAAK,EAAE,CAAC;EACR,KAAK,EAAE,IAAI;EAET,oBAAI;IACF,UAAU,EAAE,GAAG;EAEjB,sBAAM;IACJ,OAAO,EAAE,YAAY;IACrB,KAAK,EAAE,IAAI;IACX,4BAAM;MACJ,KAAK,EAAE,IAAI;MACX,KAAK,EAAE,KAAK;MACZ,KAAK,EAAE,IAAI;MACX,SAAS,EAAE,IAAI;EAGnB,uBAAO;IACL,KAAK,EAAE,IAAI;IACX,SAAS,EAAE,IAAI;;AAMnB,cAAG;EACD,eAAe,EAAE,IAAI;;AAIzB,QAAS;EACP,UAAU,EAAE,8BAA2B;EACvC,OAAO,EAAE,GAAG;EACZ,aAAa,EAAE,GAAG;EAClB,UAAE;IACA,UAAU,EAAE,IAAI;IAChB,aAAa,EAAE,CAAC;EAElB,iBAAS;IACP,SAAS,EAAE,IAAI;IACf,UAAU,EAAE,IAAI;IAChB,OAAO,EAAE,KAAK;IACd,MAAM,EAAE,aAAa;IACrB,IAAI,EAAE,CAAC;IACP,KAAK,EAAE,CAAC;EAEV,mBAAW;IACT,OAAO,EAAE,GAAG;EAEd,4CAA0B;IACxB,UAAU,EAAE,GAAG;IACf,SAAS,EAAE,GAAG;IACd,oEAAY;MACV,UAAU,EAAE,MAAM;;AAQxB,WAAY;EACV,UAAU,EAAE,8BAA8B;EAC1C,OAAO,EAAE,WAAW;EACpB,MAAM,EAAE,GAAG;;AAGb,YAAa;EACX,eAAe,EAAE,IAAI;EACrB,KAAK,EAAE,IAAI;;AAGb,UAAW;EACT,MAAM,EAAE,CAAC;EACT,OAAO,EAAE,aAAa;EACtB,WAAW,EAAE,cAAc;;AAG7B,UAAW;EACT,OAAO,EAAE,KAAK;EACd,KAAK,EAAE,IAAI;EACX,UAAU,EAAE,MAAM;EAKlB,cAAc,EAAE,GAAG;EAJnB,YAAE;IACA,OAAO,EAAE,WAAW;IACpB,KAAK,EAAE,GAAG",
4 4
 "sources": ["style.scss"],
5 5
 "names": [],
6 6
 "file": "style.css"

+ 95
- 37
resources/style.scss View File

@@ -1,47 +1,105 @@
1 1
 html {
2
-    height: 100%;
3
-    width: 100%;
4
-    font-size: 16pt;
5
-    font-family: sans-serif;
6
-    body {
7
-        margin: 0 auto 0 auto;
8
-        padding: 1em;
9
-        left: 0; right: 0;
10
-        max-width: 50em;
11
-    }
2
+  height: 100%;
3
+  width: 100%;
4
+  font-size: 12pt;
5
+  font-family: sans-serif;
6
+  body {
7
+    margin: 0 auto 0 auto;
8
+    padding: 1em;
9
+    left: 0;
10
+    right: 0;
11
+    max-width: 50em;
12
+    color: #333;
13
+  }
12 14
 }
13 15
 
14 16
 .login-form {
15
-    margin: 0 auto 0 auto;
16
-    left: 0; right: 0;
17
-    width: 30em;
18
-    form {
19
-        > * {
20
-            margin-top: 1em;
21
-        }
22
-        label {
23
-            display: inline-block;
24
-            width: 100%;
25
-            input {
26
-                width: 20em;
27
-                float: right;
28
-                clear: both;
29
-                font-size: 16pt;
30
-            }
31
-        }
32
-        button {
33
-            width: 100%;
34
-            font-size: 16pt;
35
-        }
17
+  margin: 0 auto 0 auto;
18
+  left: 0;
19
+  right: 0;
20
+  width: 30em;
21
+  form {
22
+    > * {
23
+      margin-top: 1em;
24
+    }
25
+    label {
26
+      display: inline-block;
27
+      width: 100%;
28
+      input {
29
+        width: 20em;
30
+        float: right;
31
+        clear: both;
32
+        font-size: 16pt;
33
+      }
34
+    }
35
+    button {
36
+      width: 100%;
37
+      font-size: 16pt;
36 38
     }
39
+  }
40
+}
41
+
42
+.user-posts {
43
+  ol {
44
+    list-style-type: none;
45
+  }
37 46
 }
38 47
 
39 48
 .h-entry {
40
-    .u-photo {
41
-        max-width: 100%;
42
-        max-height: 10em;
43
-        display: block;
44
-        margin: 0 auto 0 auto;
45
-        left: 0; right: 0;
49
+  box-shadow: 1px 1px 4px rgba(0,0,0,0.2);
50
+  padding: 4px;
51
+  margin-bottom: 1em;
52
+  * {
53
+    box-shadow: none;
54
+    margin-bottom: 0;
55
+  }
56
+  .u-photo {
57
+    max-width: 100%;
58
+    max-height: 15em;
59
+    display: block;
60
+    margin: 0 auto 0 auto;
61
+    left: 0;
62
+    right: 0;
63
+  }
64
+  .e-content {
65
+    padding: 4px;
66
+  }
67
+  .categories,.syndications {
68
+    margin-top: 1em;
69
+    font-size: 8pt;
70
+    .p-category {
71
+      font-style: italic;
72
+    }
73
+    .u-syndication{
74
+
46 75
     }
76
+  }
77
+}
78
+
79
+.adminpanel {
80
+  box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.2);
81
+  padding: 5px 1em 1em;
82
+  margin: 1em;
83
+}
84
+
85
+a, a:visited {
86
+  text-decoration: none;
87
+  color: #111;
88
+}
89
+
90
+blockquote {
91
+  margin: 0;
92
+  padding: 1em 0 1em 1em;
93
+  border-left: 2px solid #eee;
94
+}
95
+
96
+.paginator {
97
+  display: block;
98
+  width: 100%;
99
+  text-align: center;
100
+  a {
101
+    padding: 0 2em 0 2em;
102
+    width: 33%;
103
+  }
104
+  padding-bottom: 2em;
47 105
 }

+ 16
- 6
resources/user.html View File

@@ -1,14 +1,24 @@
1 1
 {{ template "_base.tmpl.html" . }}
2 2
 
3
-<div class="user-profile">
4
-    <h1>/@{{.user.Username}}/</h1>
5
-    <code>{{.user.PublicRefID}}</code>
3
+<div class="user-profile h-feed">
4
+    <h1 class="p-name">
5
+        <a class="p-author h-card" href="{{url .context .user}}">@{{.user.Username}}</a>
6
+    </h1>
6 7
 
8
+    {{ if gt (len .posts) 0 }}
9
+    {{ template "paginator.html" (withContext .context "items" (len .posts) "pageSize" .pageSize "page" .page) }}
7 10
     <div class="user-posts">
8
-        {{ range .posts }}
9
-            {{.GetTitleFromPlain}}
10
-        {{ end }}
11
+        <ol>
12
+            {{ $c := .context }}
13
+            {{ $parent := . }}
14
+            {{ range .posts }}
15
+            <li class="h-entry">
16
+                {{ template "h_entry.html" (withContext $c "post" . "linkTitle" true) }}
17
+            </li>
18
+            {{ end }}
19
+        </ol>
11 20
     </div>
21
+    {{ end }}
12 22
 </div>
13 23
 
14 24
 {{ template "_end.tmpl.html" . }}

+ 107
- 0
router/admin.go View File

@@ -0,0 +1,107 @@
1
+package router
2
+
3
+import (
4
+	"net/http"
5
+
6
+	"github.com/labstack/echo"
7
+	"github.com/pkg/errors"
8
+	"go.rls.moe/webapps/microblog/model"
9
+)
10
+
11
+type adminPanelAction struct {
12
+	Action         string `form:"action"`
13
+	User           string `form:"user"`
14
+	Password       string `form:"password"`
15
+	PasswordRepeat string `form:"password_repeat"`
16
+	EMail          string `form:"email"`
17
+}
18
+
19
+func AdminPanel(c echo.Context) error {
20
+	cc := c.(*Context)
21
+	if u, err := cc.GetUser(); err != nil || !u.Admin {
22
+		return &echo.HTTPError{
23
+			Code:    http.StatusForbidden,
24
+			Message: "Forbidden",
25
+			Inner:   err,
26
+		}
27
+	} else {
28
+		return c.Render(http.StatusOK, "admin.html", map[string]interface{}{
29
+			"no_canonical": true,
30
+			"user":         u,
31
+			"err":          cc.QueryParam("err"),
32
+		})
33
+	}
34
+}
35
+
36
+func AdminAction(c echo.Context) error {
37
+	cc := c.(*Context)
38
+	if u, err := cc.GetUser(); err != nil || !u.Admin {
39
+		return &echo.HTTPError{
40
+			Code:    http.StatusForbidden,
41
+			Message: "Forbidden",
42
+			Inner:   err,
43
+		}
44
+	}
45
+	action := adminPanelAction{}
46
+	if err := cc.Bind(&action); err != nil {
47
+		return err
48
+	}
49
+	redirUrl := cc.Request().URL
50
+	redirQuery := redirUrl.Query()
51
+	switch action.Action {
52
+	case "createuser":
53
+		if action.Password != action.PasswordRepeat {
54
+			return errors.New("Passwords did not match")
55
+		}
56
+		user := model.User{}
57
+		user.Username = action.User
58
+		user.EMail = action.EMail
59
+		user.Admin = false
60
+		if err := user.SetPassword(cc.Config, action.Password); err != nil {
61
+			redirQuery.Set("err", err.Error())
62
+			redirUrl.RawQuery = redirQuery.Encode()
63
+			return cc.Redirect(http.StatusSeeOther, redirUrl.String())
64
+		}
65
+		if err := cc.DB.Create(&user).Error; err != nil {
66
+			redirQuery.Set("err", err.Error())
67
+			redirUrl.RawQuery = redirQuery.Encode()
68
+			return cc.Redirect(http.StatusSeeOther, redirUrl.String())
69
+		}
70
+		return cc.Redirect(http.StatusSeeOther, redirUrl.String())
71
+	case "changepw":
72
+		user := model.User{}
73
+		if user.Username == "admin" {
74
+			redirQuery.Set("err", "cannot modify administrator")
75
+			redirUrl.RawQuery = redirQuery.Encode()
76
+			return cc.Redirect(http.StatusSeeOther, redirUrl.String())
77
+		}
78
+		if err := cc.DB.Where("username = ? or email = ?", action.User, action.User).First(&user).Error; err != nil {
79
+			redirQuery.Set("err", err.Error())
80
+			redirUrl.RawQuery = redirQuery.Encode()
81
+			return cc.Redirect(http.StatusSeeOther, redirUrl.String())
82
+		}
83
+		if err := user.SetPassword(cc.Config, action.Password); err != nil {
84
+			redirQuery.Set("err", err.Error())
85
+			redirUrl.RawQuery = redirQuery.Encode()
86
+			return cc.Redirect(http.StatusSeeOther, redirUrl.String())
87
+		}
88
+		if err := cc.DB.Save(&user).Error; err != nil {
89
+			redirQuery.Set("err", err.Error())
90
+			redirUrl.RawQuery = redirQuery.Encode()
91
+			return cc.Redirect(http.StatusSeeOther, redirUrl.String())
92
+		}
93
+		return cc.Redirect(http.StatusSeeOther, redirUrl.String())
94
+	case "testemail":
95
+		if err := cc.EmailIO.SendMail([]string{"root@localhost"}, "test.email", map[string]interface{}{
96
+			"user": cc.Session.User,
97
+		}); err != nil {
98
+			cc.Logger().Error(err)
99
+			redirQuery.Set("err", err.Error())
100
+			redirUrl.RawQuery = redirQuery.Encode()
101
+		}
102
+		return cc.Redirect(http.StatusSeeOther, redirUrl.String())
103
+	}
104
+	redirQuery.Set("err", "invalid action")
105
+	redirUrl.RawQuery = redirQuery.Encode()
106
+	return cc.Redirect(http.StatusSeeOther, redirUrl.String())
107
+}

+ 28
- 4
router/blog.go View File

@@ -1,19 +1,43 @@
1 1
 package router
2 2
 
3 3
 import (
4
+	"net/http"
5
+
4 6
 	"github.com/labstack/echo"
5 7
 	"go.rls.moe/webapps/microblog/model"
6
-	"net/http"
7 8
 )
8 9
 
9 10
 func GetBlogPost(c echo.Context) error {
10 11
 	cc := c.(*Context)
11 12
 	post := model.Post{}
12 13
 	post.PublicRefID = cc.Param("post")
13
-	if err := cc.DB.Preload("User").Preload("Categories").Where(&post).First(&post).Error; err != nil {
14
-		return err
14
+	query := cc.DB.Preload("User").Preload("Categories").Preload("AlternateURLs").Where(&post)
15
+	if err := query.First(&post).Error; err != nil {
16
+		if err := query.Unscoped().First(&post).Error; err != nil {
17
+			return err
18
+		} else {
19
+			grave := model.Post{
20
+				PublicRefID: post.PublicRefID,
21
+				Content:     "[Deleted]",
22
+				Name:        "[Deleted]",
23
+				User:        post.User,
24
+				UserID:      post.UserID,
25
+				CreatedAt:   post.CreatedAt,
26
+				HTMLContent: "[Deleted]",
27
+				Slug:        "deleted",
28
+			}
29
+			return c.Render(http.StatusOK, "blog.html", map[string]interface{}{
30
+				"post": grave,
31
+				"title": "[Deleted]",
32
+			})
33
+		}
15 34
 	}
16
-	return c.Render(http.StatusOK, "h_entry.html", map[string]interface{}{
35
+	return c.Render(http.StatusOK, "blog.html", map[string]interface{}{
17 36
 		"post": post,
37
+		"title": post.GetTitle(),
18 38
 	})
19 39
 }
40
+
41
+func GetBlogPostSlugged(c echo.Context) error {
42
+	return GetBlogPost(c)
43
+}

+ 26
- 0
router/csp.go View File

@@ -0,0 +1,26 @@
1
+package router
2
+
3
+import (
4
+	"strings"
5
+
6
+	"go.rls.moe/webapps/microblog/util"
7
+)
8
+
9
+func CSPHeader(cc *Context) (err error) {
10
+	cc.CSPNonce, err = util.MakeRandomBase64String(32)
11
+	if err != nil {
12
+		return err
13
+	}
14
+	scriptSrc := "script-src 'nonce-" + cc.CSPNonce + `'`
15
+	styleSrc := "style-src 'nonce-" + cc.CSPNonce + "'"
16
+	imageSrc := "img-src https:"
17
+	if cc.Config.Pages.DisableExternalImages {
18
+		imageSrc = "img-src 'self'"
19
+	}
20
+	defaultSrc := "default-src 'none'"
21
+	blockHTTP := "block-all-mixed-content"
22
+	cc.Response().Header().Add("Content-Security-Policy", strings.Join([]string{
23
+		defaultSrc, scriptSrc, imageSrc, styleSrc, blockHTTP,
24
+	}, "; ")+";")
25
+	return nil
26
+}

+ 59
- 2
router/ctx.go View File

@@ -4,36 +4,47 @@ import (
4 4
 	"net/http"
5 5
 	"strings"
6 6
 
7
+	"regexp"
8
+
7 9
 	"github.com/labstack/echo"
8 10
 	"go.rls.moe/webapps/microblog/config"
9 11
 	"go.rls.moe/webapps/microblog/model"
12
+	"go.rls.moe/webapps/microblog/util/mail"
10 13
 )
11 14
 
12 15
 type Context struct {
13 16
 	echo.Context
14 17
 	DB              *model.DB
15 18
 	Config          *config.Config
19
+	EmailIO         *mail.EMailIO
16 20
 	Session         model.Session
17 21
 	AccessToken     model.OAuthAccess
18 22
 	HasSession      bool
19 23
 	HasAuth         bool
20 24
 	HasSeenAnyToken bool
25
+	CSPNonce        string
26
+	PostRunWorkers  []func()
21 27
 }
22 28
 
23 29
 func (c *Context) GetDB() *model.DB {
24 30
 	return c.DB
25 31
 }
26 32
 
33
+func (c *Context) AddWorker(f func()) {
34
+	c.PostRunWorkers = append(c.PostRunWorkers, f)
35
+}
36
+
27 37
 func (c *Context) SetDB(db *model.DB) {
28 38
 	c.DB = db
29 39
 }
30 40
 
31
-func CtxMiddleware(db *model.DB, config *config.Config) echo.MiddlewareFunc {
41
+func CtxMiddleware(db *model.DB, mailIO *mail.EMailIO, config *config.Config) echo.MiddlewareFunc {
32 42
 	return func(next echo.HandlerFunc) echo.HandlerFunc {
33 43
 		return func(c echo.Context) error {
34 44
 			cc := &Context{
35 45
 				DB:         db,
36 46
 				Config:     config,
47
+				EmailIO:    mailIO,
37 48
 				HasSession: false,
38 49
 			}
39 50
 			cc.Context = c
@@ -65,7 +76,28 @@ func CtxMiddleware(db *model.DB, config *config.Config) echo.MiddlewareFunc {
65 76
 					cc.Logger().Errorf("Error loading auth record: %s", err.Error())
66 77
 				}
67 78
 			}
68
-			return next(cc)
79
+			if err := CSPHeader(cc); err != nil {
80
+				return err
81
+			}
82
+			cc.Set("CSPNonce", cc.CSPNonce)
83
+			if cc.DB.Error != nil {
84
+				return err
85
+			}
86
+			if err := next(cc); err != nil {
87
+				return err
88
+			}
89
+			if cc.Config.AsyncWorker {
90
+				go func(workers []func()) {
91
+					for k := range workers {
92
+						workers[k]()
93
+					}
94
+				}(cc.PostRunWorkers)
95
+			} else {
96
+				for k := range cc.PostRunWorkers {
97
+					cc.PostRunWorkers[k]()
98
+				}
99
+			}
100
+			return nil
69 101
 		}
70 102
 	}
71 103
 }
@@ -101,3 +133,28 @@ func (cc *Context) GetUser() (model.User, error) {
101 133
 		}
102 134
 	}
103 135
 }
136
+
137
+func (cc *Context) GetRouteFromURL(url string) echo.Context {
138
+	return cc.GetRouteFromRequest("GET", url)
139
+}
140
+
141
+func (cc *Context) GetRouteFromRequest(method, url string) echo.Context {
142
+	cNew := new(Context)
143
+	cc.Echo().Router().Find(method, url, cNew)
144
+	return cNew
145
+}
146
+
147
+var postUrlRegex = regexp.MustCompile(`/([^/]*)/[^/]*$`)
148
+
149
+func getPostIDFromUrl(url string) (string, error) {
150
+	matches := postUrlRegex.FindAllStringSubmatch(url, 1)
151
+	if matches == nil || len(matches) == 0 {
152
+		return "", JSONError{
153
+			http.StatusBadRequest,
154
+			JErrInvalidRequest,
155
+			"URL must match the Route /@:user/:id/:slug",
156
+		}
157
+	} else {
158
+		return matches[0][1], nil
159
+	}
160
+}

+ 7
- 7
router/err.go View File

@@ -3,8 +3,8 @@ package router
3 3
 import "fmt"
4 4
 
5 5
 type JSONError struct {
6
-	Code int `json:"-"`
7
-	Err string `json:"error"`
6
+	Code           int    `json:"-"`
7
+	Err            string `json:"error"`
8 8
 	ErrDescription string `json:"error_description"`
9 9
 }
10 10
 
@@ -14,8 +14,8 @@ func (j JSONError) Error() string {
14 14
 
15 15
 const (
16 16
 	JErrInsufficientScope = "insufficient_scope"
17
-	JErrInvalidRequest = "invalid_request"
18
-	JErrUnauthorized = "unauthorized"
19
-	JErrForbidden = "forbidden"
20
-	JErrInternal = "internal_server_error"
21
-)
17
+	JErrInvalidRequest    = "invalid_request"
18
+	JErrUnauthorized      = "unauthorized"
19
+	JErrForbidden         = "forbidden"
20
+	JErrInternal          = "internal_server_error"
21
+)

+ 63
- 0
router/index.go View File

@@ -0,0 +1,63 @@
1
+package router
2
+
3
+import (
4
+	"net/http"
5
+	"regexp"
6
+
7
+	"github.com/labstack/echo"
8
+	"go.rls.moe/webapps/microblog/model"
9
+	"go.rls.moe/webapps/microblog/util"
10
+)
11
+
12
+var (
13
+	regexSCBlog = regexp.MustCompile(`^@(.+)/(.+)$`)
14
+	regexSCUser = regexp.MustCompile(`^@(.+)$`)
15
+)
16
+
17
+func IndexShowcase(c echo.Context) error {
18
+	cc := c.(*Context)
19
+	routeCC := cc.GetRouteFromURL(cc.Config.Pages.Showcase)
20
+	isBlogSC := regexSCBlog.MatchString(cc.Config.Pages.Showcase)
21
+	isUserSC := regexSCUser.MatchString(cc.Config.Pages.Showcase)
22
+	if isBlogSC {
23
+		matches := regexSCBlog.FindAllStringSubmatch(cc.Config.Pages.Showcase, 1)
24
+		post := model.Post{PublicRefID: matches[0][2]}
25
+		post.PublicRefID = routeCC.Param("post")
26
+		if err := cc.DB.Where(&post).First(&post).Error; err != nil {
27
+			return err
28
+		} else {
29
+			return c.Render(http.StatusOK, "blog.html", map[string]interface{}{
30
+				"post":  post,
31
+				"title": post.GetTitle(),
32
+			})
33
+		}
34
+	} else if isUserSC {
35
+		matches := regexSCUser.FindAllStringSubmatch(cc.Config.Pages.Showcase, 1)
36
+		user := model.User{Username: matches[0][1]}
37
+		if err := cc.DB.Where(&user).First(&user).Error; err != nil {
38
+			return err
39
+		} else {
40
+			posts := []model.Post{}
41
+			query := cc.DB.
42
+				Where("user_id = ?", user.ID).
43
+				Order("created_at DESC").
44
+				Preload("User").
45
+				Preload("Categories").
46
+				Preload("AlternateURLs")
47
+			query = util.Pager(c, 10, query)
48
+			if cc.Config.Pages.Limit > 0 {
49
+				query.Limit(cc.Config.Pages.Limit)
50
+			}
51
+			query.Find(&posts)
52
+			return c.Render(http.StatusOK, "user.html", map[string]interface{}{
53
+				"user":     user,
54
+				"posts":    posts,
55
+				"page":     util.CurrentPage(c),
56
+				"pageSize": 10,
57
+				"title":    "@" + user.Username,
58
+			})
59
+		}
60
+	} else {
61
+		return c.String(http.StatusOK, "Nothing to see here")
62
+	}
63
+}

+ 65
- 1
router/micropub.go View File

@@ -3,8 +3,13 @@ package router
3 3
 import (
4 4
 	"net/http"
5 5
 
6
+	"encoding/json"
7
+	"io/ioutil"
8
+	"strings"
9
+
6 10
 	"github.com/labstack/echo"
7 11
 	"go.rls.moe/webapps/microblog/model"
12
+	"go.rls.moe/webapps/microblog/model/microformats"
8 13
 	"go.rls.moe/webapps/microblog/util"
9 14
 )
10 15
 
@@ -14,9 +19,67 @@ func MicropubEndpoint(c echo.Context) error {
14 19
 	q := c.QueryParam("q")
15 20
 	if q == "config" {
16 21
 		return MicropubConfig(c)
22
+	} else if q == "source" {
23
+		return MicropubSource(cc)
24
+	} else if q == "syndicate-to" {
25
+		return MicropubSyndication(cc)
17 26
 	} else if q == "" {
18 27
 		if c.Request().Method == http.MethodPost {
19
-			return MicropubCreate(cc)
28
+
29
+			requestContentType := c.Request().Header.Get("Content-Type")
30
+			if idx := strings.Index(requestContentType, ";"); idx > 0 {
31
+				requestContentType = requestContentType[:idx]
32
+			}
33
+			c.Logger().Debugf("MP Submitting Content type is %s", requestContentType)
34
+
35
+			if requestContentType == "application/json" {
36
+				sub := microformats.NewSubmission()
37
+				subData, err := ioutil.ReadAll(c.Request().Body)
38
+				if err != nil {
39
+					return err
40
+				}
41
+				c.Request().Body.Close()
42
+				if err := json.Unmarshal(subData, sub); err != nil {
43
+					return JSONError{
44
+						http.StatusBadRequest,
45
+						JErrInvalidRequest,
46
+						err.Error(),
47
+					}
48
+				}
49
+				switch sub.Action {
50
+				case "create":
51
+					return MicropubJSONCreate(cc, sub)
52
+				case "update":
53
+					return MicropubJSONUpdate(cc, sub)
54
+				case "delete":
55
+					return MicropubJSONDelete(cc, sub)
56
+				case "undelete":
57
+					return MicropubJSONUnDelete(cc, sub)
58
+				}
59
+			} else if requestContentType == "application/x-www-form-urlencoded" {
60
+				if c.FormValue("action") != "" {
61
+					if action := c.FormValue("action"); action == "delete" {
62
+						return MicropubDelete(cc)
63
+					} else if action == "undelete" {
64
+						return MicropubUnDelete(cc)
65
+					} else {
66
+						return JSONError{
67
+							http.StatusBadRequest,
68
+							JErrInvalidRequest,
69
+							"Action unknown",
70
+						}
71
+					}
72
+				} else {
73
+					return MicropubCreateForm(cc)
74
+				}
75
+			} else if requestContentType == "multipart/form-data" {
76
+				return MicropubCreateForm(cc)
77
+			} else {
78
+				return c.JSON(http.StatusBadRequest, map[string]interface{}{
79
+					"error":             "invalid_request",
80
+					"error_description": "Unknown Content Type: " + requestContentType,
81
+				})
82
+			}
20 83
 		}
21 84
 	}
22 85
 	return JSONError{
@@ -32,6 +95,7 @@ func MicropubConfig(c echo.Context) error {
32 95
 		if cc.Can(model.ScopeMedia) || cc.Can(model.ScopeCreate) {
33 96
 			return c.JSON(http.StatusOK, map[string]interface{}{
34 97
 				"media-endpoint": util.GetBaseURL(c) + "/@mp/media",
98
+				"syndicate-to":   SyndicationRegistry,
35 99
 			})
36 100
 		} else {
37 101
 			return JSONError{

+ 97
- 58
router/mpcreate.go View File

@@ -3,31 +3,16 @@ package router
3 3
 import (
4 4
 	"net/http"
5 5
 
6
-	"strings"
7
-
8 6
 	"go.rls.moe/webapps/microblog/model"
9 7
 	"go.rls.moe/webapps/microblog/model/microformats"
8
+	"go.rls.moe/webapps/microblog/plugins"
10 9
 	"go.rls.moe/webapps/microblog/util"
10
+	"go.rls.moe/webapps/microblog/util/webmention"
11 11
 )
12 12
 
13
-func MicropubCreate(c *Context) error {
14
-	c.Logger().Debug("Entering MP Endpoint for creating posts")
15
-	requestContentType := c.Request().Header.Get("Content-Type")
16
-	if idx := strings.Index(requestContentType, ";"); idx > 0 {
17
-		requestContentType = requestContentType[:idx]
18
-	}
19
-	c.Logger().Debugf("MP Submitting Content type is %s", requestContentType)
20
-	if requestContentType == "application/json" {
21
-		return MicropubCreateJSON(c)
22
-	} else if requestContentType == "application/x-www-form-urlencoded" {
23
-		return MicropubCreateForm(c)
24
-	} else if requestContentType == "multipart/form-data" {
25
-		return MicropubCreateForm(c)
26
-	}
27
-	return c.JSON(http.StatusBadRequest, map[string]interface{}{
28
-		"error":             "invalid_request",
29
-		"error_description": "Unknown Content Type: " + requestContentType,
30
-	})
13
+type CreatePostPlugin interface {
14
+	PreCreatePost(context *Context, post *model.Post) error
15
+	PostCreatePost(context *Context, post *model.Post)
31 16
 }
32 17
 
33 18
 func MicropubCreateForm(c *Context) error {
@@ -35,13 +20,17 @@ func MicropubCreateForm(c *Context) error {
35 20
 	if c.FormValue("h") == "entry" {
36 21
 		categories, hasCategories := c.Request().Form["category[]"]
37 22
 		post := model.Post{
38
-			Content: c.FormValue("content"),
23
+			Content:     c.FormValue("content"),
24
+			HTMLContent: c.FormValue("content[html]"),
25
+			Slug:        c.FormValue("mp-slug"),
26
+			Name:        c.FormValue("name"),
39 27
 		}
40
-		if len(post.Content) < 5 {
41
-			return c.JSON(http.StatusBadRequest, map[string]interface{}{
42
-				"error":             "invalid_request",
43
-				"error_description": "Content must have 5 characters or more",
44
-			})
28
+		if !post.HasContent() {
29
+			return JSONError{
30
+				http.StatusBadRequest,
31
+				JErrInvalidRequest,
32
+				"Content is mandatory",
33
+			}
45 34
 		}
46 35
 		if hasCategories {
47 36
 			post.Categories = []model.Category{}
@@ -60,13 +49,10 @@ func MicropubCreateForm(c *Context) error {
60 49
 		if c.FormValue("photo") != "" {
61 50
 			post.Photo = c.FormValue("photo")
62 51
 		}
63
-		post.Type = model.PostTypeNote
64 52
 		if c.FormValue("name") != "" {
65
-			post.Type = model.PostTypeArticle
66 53
 			post.Name = c.FormValue("name")
67 54
 		}
68 55
 		if c.FormValue("bookmark-of") != "" {
69
-			post.Type = model.PostTypeBookmark
70 56
 			post.BookmarkOf = c.FormValue("bookmark-of")
71 57
 		}
72 58
 		if file, err := c.FormFile("photo"); err == nil {
@@ -78,11 +64,39 @@ func MicropubCreateForm(c *Context) error {
78 64
 		} else {
79 65
 			c.Logger().Debugf("Error reading file: %s", err.Error())
80 66
 		}
67
+
81 68
 		if err := c.DB.Create(&post).Error; err != nil {
82 69
 			return err
83 70
 		}
84
-		c.Logger().Debugf("Finished request for URL %s", post.GetURL(c))
85
-		c.Response().Header().Set("Location", post.GetURL(c))
71
+		for k := range plugins.Plugins {
72
+			if plg, ok := plugins.Plugins[k].(CreatePostPlugin); ok {
73
+				if err := plg.PreCreatePost(c, &post); err != nil {
74
+					return err
75
+				}
76
+			}
77
+		}
78
+		if err := c.DB.Save(&post).Error; err != nil {
79
+			return err
80
+		}
81
+		postUrl, err := BuildUrl(c, post)
82
+		if err != nil {
83
+			return err
84
+		}
85
+		log := c.Logger()
86
+		worker := func() {
87
+			if post.HTMLContent == "" {
88
+				if err := webmention.SendWebmentionPlain(postUrl, post.Content); err != nil {
89
+					log.Error(err)
90
+				}
91
+			} else {
92
+				if err := webmention.SendWebmentionHTML(postUrl, post.HTMLContent); err != nil {
93
+					log.Error(err)
94
+				}
95
+			}
96
+		}
97
+		c.AddWorker(worker)
98
+		c.Logger().Debugf("Finished request for URL %s", postUrl)
99
+		c.Response().Header().Set("Location", postUrl)
86 100
 		return c.NoContent(http.StatusCreated)
87 101
 	} else {
88 102
 		return c.JSON(http.StatusBadRequest, map[string]interface{}{
@@ -92,43 +106,68 @@ func MicropubCreateForm(c *Context) error {
92 106
 	}
93 107
 }
94 108
 
95
-func MicropubCreateJSON(c *Context) error {
96
-	c.Logger().Debug("Beginning JSON Create Post")
97
-	mf := microformats.MicroFormat{}
98
-	if err := c.Bind(&mf); err != nil {
99
-		return err
100
-	}
101
-	post := model.Post{}
102
-	post.Content = mf.Value
103
-	post.HTMLContent = mf.HTML
104
-	if !post.HasContent() {
105
-		return JSONError{
106
-			http.StatusBadRequest,
107
-			JErrInvalidRequest,
108
-			"Content is mandatory",
109
-		}
109
+func MicropubJSONCreate(c *Context, submission *microformats.Submission) error {
110
+	c.Logger().Debug("Beginning Create Post")
111
+	data := submission.Data
112
+	post := model.Post{
113
+		Content:     data.Content.Value,
114
+		HTMLContent: data.Content.HTML,
115
+		Photo:       data.Photo.URL,
116
+		PhotoAlt:    data.Photo.Alt,
117
+		Categories: func() (out []model.Category) {
118
+			out = []model.Category{}
119
+			for k := range submission.Data.Category {
120
+				out = append(out, model.Category{Name: submission.Data.Category[k]})
121
+			}
122
+			return out
123
+		}(),
110 124
 	}
111
-	if categories, ok := mf.Properties["category"]; ok {
112
-		post.Categories = []model.Category{}
113
-		for k := range categories {
114
-			post.Categories = append(post.Categories, model.Category{Name: categories[k].Value})
115
-		}
125
+
126
+	if len(data.Summary) > 0 {
127
+		post.Summary = data.Summary[0]
116 128
 	}
117
-	if photos, ok := mf.Properties["photo"]; ok {
118
-		for k := range photos {
119
-			post.Photo = photos[k].Value
120
-		}
129
+	if len(data.Name) > 0 {
130
+		post.Name = data.Name[0]
121 131
 	}
132
+
122 133
 	user, err := c.GetUser()
123 134
 	if err != nil {
135
+		c.Logger().Warnf("Could not find user: %s", err.Error())
124 136
 		return err
125 137
 	}
126 138
 	post.User = user
127
-	post.Type = model.PostTypeNote
139
+
128 140
 	if err := c.DB.Create(&post).Error; err != nil {
129 141
 		return err
130 142
 	}
131
-	c.Logger().Debugf("Finished request for URL %s", post.GetURL(c))
132
-	c.Response().Header().Set("Location", post.GetURL(c))
143
+	for k := range plugins.Plugins {
144
+		if plg, ok := plugins.Plugins[k].(CreatePostPlugin); ok {
145
+			if err := plg.PreCreatePost(c, &post); err != nil {
146
+				return err
147
+			}
148
+		}
149
+	}
150
+	if err := c.DB.Save(&post).Error; err != nil {
151
+		return err
152
+	}
153
+	postUrl, err := BuildUrl(c, post)
154
+	if err != nil {
155
+		return err
156
+	}
157
+	log := c.Logger()
158
+	worker := func() {
159
+		if post.HTMLContent == "" {
160
+			if err := webmention.SendWebmentionPlain(postUrl, post.Content); err != nil {
161
+				log.Error(err)
162
+			}
163
+		} else {
164
+			if err := webmention.SendWebmentionHTML(postUrl, post.HTMLContent); err != nil {
165
+				log.Error(err)
166
+			}
167
+		}
168
+	}
169
+	c.AddWorker(worker)
170
+	c.Logger().Debugf("Finished request for URL %s", postUrl)
171
+	c.Response().Header().Set("Location", postUrl)
133 172
 	return c.NoContent(http.StatusCreated)
134 173
 }

+ 55
- 0
router/mpdelete.go View File

@@ -0,0 +1,55 @@
1
+package router
2
+
3
+import (
4
+	"go.rls.moe/webapps/microblog/model"
5
+	"net/http"
6
+	"go.rls.moe/webapps/microblog/model/microformats"
7
+)
8
+
9
+func MicropubDelete(c *Context) error {
10
+	return MicropubDeleteID(c, c.FormValue("url"))
11
+}
12
+
13
+func MicropubJSONDelete(c *Context, submission *microformats.Submission) error {
14
+	return MicropubDeleteID(c, submission.URL)
15
+}
16
+
17
+func MicropubDeleteID(c *Context, postUrl string) error {
18
+	post := model.Post{}
19
+	postID, err := getPostIDFromUrl(postUrl)
20
+	if err != nil {
21
+		return err
22
+	}
23
+	post.PublicRefID = postID
24
+	c.Response().Header().Set("X-Detected-Post-ID", post.PublicRefID)
25
+	if err := c.DB.Where(&post).Delete(&post).Error; err != nil {
26
+		return err
27
+	}
28
+	return c.NoContent(http.StatusNoContent)
29
+}
30
+
31
+func MicropubUnDelete(c *Context) error {
32
+	return MicropubUnDeleteID(c, c.FormValue("url"))
33
+}
34
+
35
+func MicropubJSONUnDelete(c *Context, submission *microformats.Submission) error {
36
+	return MicropubUnDeleteID(c, submission.URL)
37
+}
38
+
39
+func MicropubUnDeleteID(c *Context, postUrl string) error {
40
+	post := model.Post{}
41
+	postID, err := getPostIDFromUrl(postUrl)
42
+	if err != nil {
43
+		return err
44
+	}
45
+	post.PublicRefID = postID
46
+	c.Response().Header().Set("X-Detected-Post-ID", post.PublicRefID)
47
+	if err := c.DB.Unscoped().Where(&post).First(&post).Error; err != nil {
48
+		return err
49
+	}
50
+	post.DeletedAt = nil
51
+	if err := c.DB.Unscoped().Save(&post).Error; err != nil {
52
+		return err
53
+	}
54
+	return c.NoContent(http.StatusNoContent)
55
+}

+ 2
- 2
router/mpmedia.go View File

@@ -5,10 +5,10 @@ import (
5 5
 	"go.rls.moe/webapps/microblog/model"
6 6
 	"go.rls.moe/webapps/microblog/util"
7 7
 	"mime"
8
+	"mime/multipart"
8 9
 	"net/http"
9 10
 	"path/filepath"
10 11
 	"strings"
11
-	"mime/multipart"
12 12
 )
13 13
 
14 14
 func extInMime(ext, mimeType string) bool {
@@ -94,7 +94,7 @@ func CreateMedia(c *Context, header *multipart.FileHeader) (model.Media, error)
94 94
 			err.Error(),
95 95
 		}
96 96
 	}
97
-	media.User,err = c.GetUser()
97
+	media.User, err = c.GetUser()
98 98
 	if err != nil {
99 99
 		return media, JSONError{
100 100
 			http.StatusBadRequest,

+ 105
- 0
router/mpsource.go View File

@@ -0,0 +1,105 @@
1
+package router
2
+
3
+import (
4
+	"net/http"
5
+
6
+	"go.rls.moe/webapps/microblog/model"
7
+)
8
+
9
+func MicropubSource(c *Context) error {
10
+	post := model.Post{}
11
+	postID, err := getPostIDFromUrl(c.QueryParam("url"))
12
+	if err != nil {
13
+		return err
14
+	}
15
+	post.PublicRefID = postID
16
+	c.Response().Header().Set("X-Detected-Post-ID", post.PublicRefID)
17
+	if err := c.DB.Where(post).
18
+		Preload("User").
19
+		Preload("Categories").
20
+		Preload("AlternateURLs").
21
+		First(&post).Error; err != nil {
22
+		return err
23
+	}
24
+	enabledProps := map[string]bool{
25
+		"name":         false,
26
+		"content":      false,
27
+		"summary":      false,
28
+		"category":     false,
29
+		"slug":         false,
30
+		"photo":        false,
31
+		"syndicate-to": false,
32
+		"syndication":  false,
33
+	}
34
+	if c.QueryParam("properties[]") == "" {
35
+		for k := range enabledProps {
36
+			enabledProps[k] = true
37
+		}
38
+	} else {
39
+		queries, ok := c.QueryParams()["properties[]"]
40
+		if ok {
41
+			for k := range queries {
42
+				enabledProps[queries[k]] = true
43
+			}
44
+		}
45
+	}
46
+
47
+	props := map[string]interface{}{}
48
+	if enabledProps["name"] {
49
+		if post.Name != "" {
50
+			props["name"] = post.Name
51
+		}
52
+	}
53
+	if enabledProps["content"] {
54
+		if post.Content != "" && post.HTMLContent != "" {
55
+			props["content"] = map[string]interface{}{
56
+				"value": post.Content,
57
+				"html":  post.HTMLContent,
58
+			}
59
+		} else {
60
+			props["content"] = []string{post.Content}
61
+		}
62
+	}
63
+	if enabledProps["summary"] {
64
+		if post.Summary != "" {
65
+			props["summary"] = post.Summary
66
+		}
67
+	}
68
+	if enabledProps["category"] {
69
+		if len(post.Categories) > 0 {
70
+			cats := []string{}
71
+			for k := range post.Categories {
72
+				cats = append(cats, post.Categories[k].Name)
73
+			}
74
+			props["category"] = cats
75
+		}
76
+	}
77
+	if enabledProps["slug"] || enabledProps["mp-slug"] {
78
+		if post.Slug != "" {
79
+			props["slug"] = post.Slug
80
+		}
81
+	}
82
+	if enabledProps["photo"] {
83
+		if post.PhotoAlt != "" {
84
+			props["photo"] = map[string]string{
85
+				"value": post.Photo,
86
+				"alt":   post.PhotoAlt,
87
+			}
88
+		} else if post.Photo != "" {
89
+			props["photo"] = post.Photo
90
+		}
91
+	}
92
+	if enabledProps["syndication"] {
93
+		if len(post.AlternateURLs) > 0 {
94
+			urls := []string{}
95
+			for k := range post.AlternateURLs {
96
+				urls = append(urls, post.AlternateURLs[k].URL)
97
+			}
98
+			props["syndication"] = urls
99
+		}
100
+	}
101
+	return c.JSON(http.StatusOK, map[string]interface{}{
102
+		"type":       []string{"h-entry"},
103
+		"properties": props,
104
+	})
105
+}

+ 118
- 0
router/mpupdate.go View File

@@ -0,0 +1,118 @@
1
+package router
2
+
3
+import (
4
+	"net/http"
5
+
6
+	"go.rls.moe/webapps/microblog/model"
7
+	"go.rls.moe/webapps/microblog/model/microformats"
8
+)
9
+
10
+func MicropubJSONUpdate(c *Context, submission *microformats.Submission) error {
11
+	postID, err := getPostIDFromUrl(submission.URL)
12
+	if err != nil {
13
+		return err
14
+	}
15
+	post := model.Post{}
16
+	post.PublicRefID = postID
17
+	c.Response().Header().Set("X-Detected-Post-ID", post.PublicRefID)
18
+	if err := c.DB.Where(post).
19
+		Preload("User").
20
+		Preload("AlternateURLs").
21
+		Preload("Categories").First(&post).Error; err != nil {
22
+		return err
23
+	}
24
+	oldSlug := post.Slug
25
+
26
+	if submission.Replace.Name != nil && len(submission.Replace.Name) > 0 {
27
+		post.Name = submission.Replace.Name[0]
28
+	}
29
+	if submission.Replace.Summary != nil && len(submission.Replace.Name) > 0 {
30
+		post.Summary = submission.Replace.Summary[0]
31
+	}
32
+	if submission.Replace.Category != nil && len(submission.Replace.Category) > 0 {
33
+		post.Categories = func() (out []model.Category) {
34
+			out = []model.Category{}
35
+			for k := range submission.Replace.Category {
36
+				out = append(out, model.Category{Name: submission.Replace.Category[k]})
37
+			}
38
+			return out
39
+		}()
40
+	}
41
+	if submission.Replace.Photo.URL != "" {
42
+		post.Photo = submission.Replace.Photo.URL
43
+	}
44
+	if submission.Replace.Photo.Alt != "" {
45
+		post.PhotoAlt = submission.Replace.Photo.Alt
46
+	}
47
+	if submission.Replace.Content.HTML != "" {
48
+		post.HTMLContent = submission.Replace.Content.HTML
49
+	}
50
+	if submission.Replace.Content.Value != "" {
51
+		post.Content = submission.Replace.Content.Value
52
+	}
53
+
54
+	if submission.Add.Name != nil && len(submission.Add.Name) > 0 && post.Name == "" {
55
+		post.Name = submission.Add.Name[0]
56
+	}
57
+	if submission.Add.Summary != nil && len(submission.Add.Summary) > 0 && post.Summary == "" {
58
+		post.Summary = submission.Add.Summary[0]
59
+	}
60
+	if submission.Add.Category != nil && len(submission.Add.Category) > 0 {
61
+		post.Categories = append(post.Categories, func() (out []model.Category) {
62
+			out = []model.Category{}
63
+			for k := range submission.Add.Category {
64
+				out = append(out, model.Category{Name: submission.Add.Category[k]})
65
+			}
66
+			return out
67
+		}()...)
68
+	}
69
+	if submission.Add.Photo.URL != "" && post.Photo == "" {
70
+		post.Photo = submission.Add.Photo.URL
71
+	}
72
+	if submission.Add.Photo.Alt != "" && post.PhotoAlt == "" {
73
+		post.PhotoAlt = submission.Add.Photo.Alt
74
+	}
75
+	if submission.Add.Content.Value != "" && post.Content == "" {
76
+		post.Content = submission.Add.Content.Value
77
+	}
78
+	if submission.Add.Content.HTML != "" && post.HTMLContent == "" {
79
+		post.HTMLContent = submission.Add.Content.HTML
80
+	}
81
+
82
+	if submission.Delete.Category != nil {
83
+		newCategories := func() (out []model.Category) {
84
+			out = []model.Category{}
85
+			for k := range post.Categories {
86
+				idx := findIn(post.Categories[k].Name, submission.Delete.Category)
87
+				if idx < 0 {
88
+					out = append(out, post.Categories[k])
89
+				}
90
+			}
91
+			return out
92
+		}()
93
+		c.DB.Model(&post).Association("Categories").Replace(newCategories)
94
+	}
95
+
96
+	if err := c.DB.Save(&post).Error; err != nil {
97
+		return err
98
+	}
99
+	if url, err := BuildUrl(c, post); err != nil {
100
+		return err
101
+	} else {
102
+		c.Response().Header().Set("Location", url)
103
+		if oldSlug == post.Slug {
104
+			return c.NoContent(http.StatusNoContent)
105
+		} else {
106
+			return c.NoContent(http.StatusCreated)
107
+		}
108
+	}
109
+}
110
+
111
+func findIn(needle string, stack []string) int {
112
+	for k := range stack {
113
+		if stack[k] == needle {
114
+			return k
115
+		}
116
+	}
117
+	return -1
118
+}

+ 38
- 9
router/oauth.go View File

@@ -1,14 +1,16 @@
1 1
 package router
2 2
 
3 3
 import (
4
+	"encoding/json"
5
+	"net/http"
6
+	"net/url"
7
+	"strings"
8
+
4 9
 	"github.com/jinzhu/gorm"
5 10
 	"github.com/labstack/echo"
6 11
 	"github.com/pkg/errors"
7 12
 	"go.rls.moe/webapps/microblog/model"
8 13
 	"go.rls.moe/webapps/microblog/util"
9
-	"net/http"
10
-	"net/url"
11
-	"strings"
12 14
 )
13 15
 
14 16
 type oAuthAuthRequest struct {
@@ -22,6 +24,9 @@ type oAuthAuthRequest struct {
22 24
 }
23 25
 
24 26
 func (o *oAuthAuthRequest) ParseScope() ([]model.Scope, error) {
27
+	if o.Scope == "" {
28
+		return []model.Scope{model.ScopeAuth}, nil
29
+	}
25 30
 	scopes := strings.Split(o.Scope, " ")
26 31
 	out := []model.Scope{}
27 32
 	for i := range scopes {
@@ -36,6 +41,8 @@ func (o *oAuthAuthRequest) ParseScope() ([]model.Scope, error) {
36 41
 			out = append(out, model.ScopeUndelete)
37 42
 		case model.ScopeMedia:
38 43
 			out = append(out, model.ScopeMedia)
44
+		case model.ScopeAuth:
45
+			out = append(out, model.ScopeAuth)
39 46
 		default:
40 47
 			return nil, errors.New("Unknown scope: " + scopes[i])
41 48
 		}
@@ -50,7 +57,11 @@ func OAuthAuthEndpoint(c echo.Context) error {
50 57
 	}
51 58
 	cc := c.(*Context)
52 59
 	if cc.HasSession {
53
-		if authRequest.Me != cc.Session.User.GetURL(c) {
60
+		userMe, err := BuildUrl(cc, cc.Session.User)
61
+		if err != nil {
62
+			return err
63
+		}
64
+		if authRequest.Me != userMe {
54 65
 			return errors.New("User Profile and Requested Login Identity do not match")
55 66
 		}
56 67
 		if authRequest.Accepted {
@@ -64,7 +75,13 @@ func OAuthAuthEndpoint(c echo.Context) error {
64 75
 			}
65 76
 
66 77
 			code := model.OAuthCode{}
67
-			code.Scope = authRequest.Scope
78
+			if authRequest.ResponseType == "code" {
79
+				code.Scope = authRequest.Scope
80
+			} else if authRequest.ResponseType == "id" {
81
+				code.Scope = model.ScopeAuth
82
+			} else {
83
+				return errors.New("Response Type invalid")
84
+			}
68 85
 			code.ClientID = client.ClientID
69 86
 			code.User = cc.Session.User
70 87
 			if authCode, err := util.MakeRandomString(32); err != nil {
@@ -82,7 +99,7 @@ func OAuthAuthEndpoint(c echo.Context) error {
82 99
 				return err
83 100
 			}
84 101
 			q := redirectUrl.Query()
85
-			q.Set("me", cc.Session.User.GetURL(cc))
102
+			q.Set("me", userMe)
86 103
 			q.Set("code", code.Code)
87 104
 			q.Set("state", authRequest.State)
88 105
 			redirectUrl.RawQuery = q.Encode()
@@ -146,7 +163,7 @@ func OAuthAccess(c echo.Context) error {
146 163
 		if err := cc.DB.Where(&accessToken).First(&accessToken).Error; err != nil {
147 164
 			return err
148 165
 		}
149
-		dat, err := accessToken.GetJSON(c)
166
+		dat, err := BuildUrl(c, accessToken)
150 167
 		if err != nil {
151 168
 			return err
152 169
 		}
@@ -176,7 +193,7 @@ func OAuthAccess(c echo.Context) error {
176 193
 		return err
177 194
 	}
178 195
 
179
-	if user.GetURL(cc) != accessRequest.Me {
196
+	if me, err := BuildUrl(cc, user); err != nil || me != accessRequest.Me {
180 197
 		return errors.New("Identity and Me do not match")
181 198
 	}
182 199
 
@@ -192,9 +209,21 @@ func OAuthAccess(c echo.Context) error {
192 209
 		return err
193 210
 	}
194 211
 
195
-	if dat, err := access.GetJSON(cc); err != nil {
212
+	if dat, err := getOAuthJson(access, cc); err != nil {
196 213
 		return err
197 214
 	} else {
198 215
 		return c.JSONBlob(http.StatusOK, dat)
199 216
 	}
200 217
 }
218
+
219
+func getOAuthJson(o *model.OAuthAccess, c echo.Context) ([]byte, error) {
220
+	me, err := BuildUrl(c, o.User)
221
+	if err != nil {
222
+		return nil, err
223
+	}
224
+	return json.Marshal(map[string]string{
225
+		"me":           me,
226
+		"scope":        o.Scope,
227
+		"access_token": o.AccessToken,
228
+	})
229
+}

+ 30
- 0
router/syndication.go View File

@@ -0,0 +1,30 @@
1
+package router
2
+
3
+import "net/http"
4
+
5
+type SyndicationTarget struct {
6
+	UID     string              `json:"uid"`
7
+	Name    string              `json:"name"`
8
+	Service *SyndicationService `json:"service,omitempty"`
9
+	User    *SyndicationUser    `json:"user,omitempty"`
10
+}
11
+
12
+type SyndicationService struct {
13
+	Name  string `json:"name"`
14
+	URL   string `json:"url"`
15
+	Photo string `json:"photo"`
16
+}
17
+
18
+type SyndicationUser struct {
19
+	Name  string `json:"name"`
20
+	URL   string `json:"url"`
21
+	Photo string `json:"photo"`
22
+}
23
+
24
+var SyndicationRegistry = []SyndicationTarget{}
25
+
26
+func MicropubSyndication(c *Context) error {
27
+	return c.JSON(http.StatusOK, map[string][]SyndicationTarget{
28
+		"syndicate-to": SyndicationRegistry,
29
+	})
30
+}

+ 0
- 110
router/tmpl.go View File

@@ -1,110 +0,0 @@
1
-package router
2
-
3
-import (
4
-	"bytes"
5
-	"html/template"
6
-	"io"
7
-	"net/http"
8
-	"time"
9
-
10
-	"github.com/dustin/go-humanize"
11
-	"github.com/labstack/echo"
12
-	"go.rls.moe/webapps/microblog/config"
13
-	"go.rls.moe/webapps/microblog/util"
14
-	"go.rls.moe/webapps/microblog/util/minifier"
15
-)
16
-
17
-type TemplateRenderer struct {
18
-	templates    *template.Template
19
-	glob         string
20
-	disableCache bool
21
-	enableMinify bool
22
-}
23
-
24
-func NewTemplateRenderer(glob string, config *config.Config) (*TemplateRenderer, error) {
25
-	t := &TemplateRenderer{
26
-		templates:    nil,
27
-		glob:         glob,
28
-		disableCache: config.NoCache,
29
-		enableMinify: !config.NoMinify,
30
-	}
31
-	err := t.init()
32
-	return t, err
33
-}
34
-
35
-func (t *TemplateRenderer) init() error {
36
-	t.templates = template.New("root")
37
-	t.templates.Funcs(map[string]interface{}{
38
-		"formatTime": intFormatTime,
39
-		"notEmpty":   intNotEmpty,
40
-		"safeHtml":   intSafeHtml,
41
-	})
42
-	_, err := t.templates.ParseGlob(t.glob)
43
-	if err != nil {
44
-		return err
45
-	}
46
-	return nil
47
-}
48
-
49
-func (t *TemplateRenderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
50
-	if t.enableMinify {
51
-		w = minifier.MinifyHTMLWriter(w)
52
-	}
53
-	if t.disableCache {
54
-		err := t.init()
55
-		if err != nil {
56
-			return err
57
-		}
58
-	}
59
-	if viewContext, isMap := data.(map[string]interface{}); isMap {
60
-		viewContext["context"] = c
61
-		viewContext["baseUrl"] = util.GetBaseURL(c)
62
-		return t.templates.ExecuteTemplate(w, name, data)
63
-	} else {
64
-		return t.templates.ExecuteTemplate(w, name, map[string]interface{}{
65
-			"context": c,
66
-			"baseUrl": util.GetBaseURL(c),
67
-			"data":    data,
68
-		})
69
-	}
70
-}
71
-
72
-func (t *TemplateRenderer) ErrRender(err error, c echo.Context) {
73
-	if jErr, ok := err.(JSONError); ok {
74
-		c.JSON(jErr.Code, jErr)
75
-		return
76
-	}
77
-	var outBuf = bytes.NewBuffer([]byte{})
78
-	var out = minifier.MinifyHTMLWriter(outBuf)
79
-	params := map[string]interface{}{
80
-		"err": err,
81
-	}
82
-	if httpErr, ok := err.(*echo.HTTPError); ok {
83
-		params["plain"] = httpErr.Message
84
-		params["err"] = httpErr.Inner
85
-		params["code"] = httpErr.Code
86
-		t.templates.ExecuteTemplate(out, "error.html", params)
87
-		c.HTML(httpErr.Code, outBuf.String())
88
-	} else {
89
-		t.templates.ExecuteTemplate(out, "error.html", params)
90
-		c.HTML(http.StatusOK, outBuf.String())
91
-	}
92
-}
93
-
94
-func intFormatTime(t time.Time, mode string) string {
95
-	if mode == "iso8906" {
96
-		return t.Format(time.RFC3339)
97
-	} else if mode == "human" {
98
-		return humanize.Time(t)
99
-	} else {
100
-		return t.Format(mode)
101
-	}
102
-}
103
-
104
-func intNotEmpty(s string) bool {
105
-	return s != ""
106
-}
107
-
108
-func intSafeHtml(s string) template.HTML {
109
-	return template.HTML(util.NormalHTML(s))
110
-}

+ 24