Browse Source

Implemented most of the micropub spec basics

master
Tim Schuster 1 year ago
parent
commit
1d58169420
Signed by: Tim Schuster <mail@timschuster.info> GPG Key ID: F9E27097EFB77F61

+ 25
- 15
cmd/microblog/main.go View File

@@ -3,11 +3,11 @@ package main
3 3
 import (
4 4
 	"github.com/labstack/echo"
5 5
 	"github.com/labstack/echo/middleware"
6
+	log2 "github.com/labstack/gommon/log"
6 7
 	"github.com/sirupsen/logrus"
7 8
 	"go.rls.moe/webapps/microblog/config"
8 9
 	"go.rls.moe/webapps/microblog/model"
9 10
 	"go.rls.moe/webapps/microblog/router"
10
-	"github.com/RangelReale/osin"
11 11
 )
12 12
 
13 13
 func main() {
@@ -30,40 +30,50 @@ func main() {
30 30
 		return
31 31
 	}
32 32
 	defer db.Close()
33
-	osinStorage := model.NewOsinStorage(db)
34
-	osinConfig := osin.NewServerConfig()
35
-	osinConfig.AllowedAuthorizeTypes = osin.AllowedAuthorizeType{osin.CODE, osin.TOKEN}
36
-	osinConfig.AllowedAccessTypes = osin.AllowedAccessType{osin.AUTHORIZATION_CODE, osin.REFRESH_TOKEN, osin.PASSWORD, osin.CLIENT_CREDENTIALS}
37
-	osinConfig.AllowGetAccessRequest = true
38
-	osinConfig.AllowClientSecretInParams = true
39 33
 	log.WithField("admin-pass", conf.AdminPassword).Debug("Set Administrator Password")
40 34
 	e := echo.New()
35
+	e.Pre(
36
+		middleware.RemoveTrailingSlash(),
37
+	)
41 38
 	e.Use(
42 39
 		middleware.RequestID(),
43 40
 		middleware.LoggerWithConfig(middleware.LoggerConfig{
44
-			Format: "method=${method}, uri=${uri}, status=${status}\n",
41
+			Format: "method=${method}, uri=${uri}, path=${path}, status=${status}\n",
45 42
 		}),
46 43
 		middleware.Recover(),
47 44
 		middleware.BodyLimit("10M"),
48
-		router.CtxMiddleware(db, conf, osin.NewServer(osinConfig, osinStorage)),
45
+		router.CtxMiddleware(db, conf),
49 46
 	)
50 47
 	newRenderer, err := router.NewTemplateRenderer("resources/*.html", conf)
51 48
 	if err != nil {
52 49
 		log.WithError(err).Fatal("Could not setup renderer")
53 50
 		return
54
-	} else  {
51
+	} else {
55 52
 		e.Renderer = newRenderer
56 53
 		e.HTTPErrorHandler = newRenderer.ErrRender
57 54
 	}
58 55
 	e.GET("/login", router.LoginForm)
59 56
 	e.POST("/login", router.LoginPost)
57
+
58
+	e.Any("/@mp", router.MicropubEndpoint)
59
+	e.POST("/@mp/media", router.MicropubMedia)
60
+	e.GET("/@mp/auth", router.OAuthAuthEndpoint)
61
+	e.POST("/@mp/auth", router.OAuthAuthEndpoint)
62
+	e.POST("/@mp/token", router.OAuthAccess)
63
+
64
+	e.GET("/@:user", router.UserProfile)
60 65
 	e.GET("/u/:user", router.UserProfile)
66
+
67
+	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)
71
+
61 72
 	e.File("/style.css", "resources/style.css")
62
-	e.GET("/@mp", router.MicropubEndpoint)
63
-	e.POST("/@mp/media", router.MicropubMedia)
64
-	e.Any("/@mp/auth", router.OAuthLogin)
65
-	e.Any("/@mp/token", router.OAuthToken)
66
-	e.GET("/media/:file.:ext", router.Media)
73
+	e.File("/style.css.map", "resources/style.css.map")
74
+	e.GET("/media/:file", router.Media)
75
+
67 76
 	e.Debug = false
77
+	e.Logger.SetLevel(log2.DEBUG)
68 78
 	log.Fatal(e.Start(conf.ServeOn))
69 79
 }

+ 2
- 0
config/config.go View File

@@ -14,6 +14,7 @@ type Config struct {
14 14
 	Crypto        Crypto  `secl:"crypto"`
15 15
 	Session       Session `secl:"session"`
16 16
 	NoCache       bool    `secl:"no-cache"`
17
+	NoMinify      bool    `secl:"no-minify"`
17 18
 }
18 19
 
19 20
 type Crypto struct {
@@ -38,6 +39,7 @@ const DefaultConfig = `
38 39
 		uri: microblog.db
39 40
 	)
40 41
 	no-cache: yes
42
+	no-minify: yes
41 43
 	session: (
42 44
 		lifetime-days: 120
43 45
 		secret: randstr128

BIN
microblog.db View File


+ 5
- 0
model/category.go View File

@@ -0,0 +1,5 @@
1
+package model
2
+
3
+type Category struct {
4
+	Name string `gorm:"index:idx_category_name;primary_key"`
5
+}

+ 10
- 18
model/db.go View File

@@ -1,7 +1,6 @@
1 1
 package model
2 2
 
3 3
 import (
4
-	"encoding/base32"
5 4
 	"github.com/jinzhu/gorm"
6 5
 	_ "github.com/jinzhu/gorm/dialects/mysql"
7 6
 	_ "github.com/jinzhu/gorm/dialects/postgres"
@@ -9,6 +8,8 @@ import (
9 8
 	"go.rls.moe/webapps/microblog/config"
10 9
 )
11 10
 
11
+type IDType = uint64
12
+
12 13
 type DB = gorm.DB
13 14
 
14 15
 func OpenDB(config *config.Config) (*DB, error) {
@@ -16,11 +17,7 @@ func OpenDB(config *config.Config) (*DB, error) {
16 17
 	if err != nil {
17 18
 		return nil, err
18 19
 	}
19
-	db.AutoMigrate(&Post{})
20
-	db.AutoMigrate(&Session{})
21
-	db.AutoMigrate(&OAuthClient{})
22
-	db.AutoMigrate(&OAuthAuthorization{})
23
-	db.AutoMigrate(&OAuthAccessData{})
20
+	db.LogMode(true)
24 21
 	u := &User{
25 22
 		Username: "admin",
26 23
 		EMail:    "root@localhost",
@@ -35,6 +32,13 @@ func OpenDB(config *config.Config) (*DB, error) {
35 32
 		u.SetPassword(config, config.AdminPassword)
36 33
 		db.Save(u)
37 34
 	}
35
+	db.AutoMigrate(&Category{})
36
+	db.AutoMigrate(&Post{})
37
+	db.AutoMigrate(&Session{})
38
+	db.AutoMigrate(&OAuthClient{})
39
+	db.AutoMigrate(&OAuthCode{})
40
+	db.AutoMigrate(&OAuthAccess{})
41
+	db.AutoMigrate(&Media{})
38 42
 	errs := db.GetErrors()
39 43
 	for err := range errs {
40 44
 		if errs[err] != nil {
@@ -43,15 +47,3 @@ func OpenDB(config *config.Config) (*DB, error) {
43 47
 	}
44 48
 	return db, nil
45 49
 }
46
-
47
-func RefToString(ref []byte) string {
48
-	return base32.HexEncoding.EncodeToString(ref[:])
49
-}
50
-
51
-func StringToRef(ref string) ([]byte, error) {
52
-	dec, err := base32.HexEncoding.DecodeString(ref)
53
-	if err != nil {
54
-		return []byte{}, err
55
-	}
56
-	return dec, nil
57
-}

+ 15
- 11
model/media.go View File

@@ -1,25 +1,29 @@
1 1
 package model
2 2
 
3 3
 import (
4
-	"github.com/jinzhu/gorm"
5
-	"crypto/rand"
4
+	"go.rls.moe/webapps/microblog/util"
5
+	"time"
6 6
 )
7 7
 
8 8
 type Media struct {
9
-	gorm.Model
10
-	PublicRefID []byte `gorm:"not null;unique"`
9
+	ID            IDType `gorm:"primary_key"`
10
+	CreatedAt     time.Time
11
+	UpdatedAt     time.Time
12
+	DeletedAt     *time.Time
13
+	PublicRefID   string `gorm:"not null;unique"`
11 14
 	FileExtension string
12
-	MimeType string
13
-	File []byte
15
+	MimeType      string
16
+	File          []byte
17
+	User          User
18
+	UserID        IDType
14 19
 }
15 20
 
16
-
17 21
 func (m *Media) BeforeCreate() error {
18
-	m.PublicRefID = make([]byte, 8)
19
-	_, err := rand.Read(m.PublicRefID)
22
+	var err error
23
+	m.PublicRefID, err = util.MakeRandomString(8)
20 24
 	return err
21 25
 }
22 26
 
23 27
 func (m Media) Filename() string {
24
-	return RefToString(m.PublicRefID) + "." + m.FileExtension
25
-}
28
+	return m.PublicRefID + m.FileExtension
29
+}

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

@@ -0,0 +1,86 @@
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
+}

+ 82
- 149
model/oauth.go View File

@@ -1,183 +1,116 @@
1 1
 package model
2 2
 
3 3
 import (
4
-	"github.com/RangelReale/osin"
5
-	"github.com/jinzhu/gorm"
4
+	"encoding/json"
5
+	"github.com/labstack/echo"
6
+	"github.com/pkg/errors"
7
+	"net/http"
8
+	"net/url"
9
+	"strings"
10
+	"time"
6 11
 )
7 12
 
8
-type OsinStorage struct {
9
-	db *DB
10
-}
11
-
12 13
 type OAuthClient struct {
13
-	gorm.Model
14
-	OAuthID     string
15
-	Secret      string
16
-	RedirectURI string
17
-}
18
-
19
-type OAuthAuthorization struct {
20
-	gorm.Model
21
-	Client              OAuthClient
22
-	Code                string
23
-	ExpiresIn           int32
24
-	Scope               string
25
-	RedirectUri         string
26
-	State               string
27
-	CodeChallenge       string
28
-	CodeChallengeMethod string
14
+	ClientID    string `form:"client_id" query:"client_id" gorm:"primary_key"`
15
+	RedirectURI string `form:"redirect_uri" query:"client_id"`
29 16
 }
30 17
 
31
-type OAuthAccessData struct {
32
-	gorm.Model
33
-	Client       OAuthClient
34
-	Auth         OAuthAuthorization
35
-	AccessToken  string
36
-	RefreshToken string
37
-	ExpiresIn    int32
38
-	Scope        string
39
-	RedirectUri  string
18
+type OAuthCode struct {
19
+	Code     string `gorm:"primary_key"`
20
+	ClientID string
21
+	UserID   IDType
22
+	User     User
23
+	Scope    string
40 24
 }
41 25
 
42
-func (o *OAuthClient) GetId() string {
43
-	return o.OAuthID
26
+type OAuthAccess struct {
27
+	AccessToken string `gorm:"primary_key"`
28
+	Scope       string
29
+	UserID      IDType
30
+	User        User
44 31
 }
45 32
 
46
-func (o *OAuthClient) GetSecret() string {
47
-	return o.Secret
48
-}
49
-
50
-func (o *OAuthClient) GetRedirectUri() string {
51
-	return o.RedirectURI
52
-}
33
+type Scope string
53 34
 
54
-func (o *OAuthClient) GetUserData() interface{} {
55
-	panic("unused")
56
-}
57
-
58
-func NewOsinStorage(db *DB) *OsinStorage {
59
-	return &OsinStorage{db}
60
-}
61
-
62
-func (o *OsinStorage) Close() { return }
63
-
64
-func (o *OsinStorage) Clone() osin.Storage {
65
-	return o
66
-}
67
-
68
-func (o *OsinStorage) GetClient(id string) (osin.Client, error) {
69
-	c := OAuthClient{}
70
-	c.OAuthID = id
71
-	o.db.First(&c)
72
-	return &c, o.db.Error
73
-}
35
+const (
36
+	ScopeCreate   Scope = "create"
37
+	ScopeUpdate         = "update"
38
+	ScopeDelete         = "delete"
39
+	ScopeUndelete       = "undelete"
40
+	ScopeMedia          = "media"
41
+)
74 42
 
75
-func (o *OsinStorage) SetClient(id string, client osin.Client) error {
76
-	c := OAuthClient{}
77
-	c.OAuthID = client.GetId()
78
-	c.Secret = client.GetSecret()
79
-	c.RedirectURI = client.GetRedirectUri()
80
-	if o.db.NewRecord(&c) {
81
-		return o.db.Create(&c).Error
82
-	} else {
83
-		return o.db.Save(&c).Error
43
+func (s Scope) Message() string {
44
+	switch s {
45
+	case ScopeCreate:
46
+		return "Create Posts/Notes/etc."
47
+	case ScopeUpdate:
48
+		return "Update or Change Posts/Notes/etc."
49
+	case ScopeDelete:
50
+		return "Delete Posts/Notes/etc."
51
+	case ScopeUndelete:
52
+		return "Undelete Posts/Notes/etc."
53
+	case ScopeMedia:
54
+		return "Upload Media Files"
55
+	default:
56
+		return string(s)
84 57
 	}
85 58
 }
86 59
 
87
-func (o *OsinStorage) SaveAuthorize(auth *osin.AuthorizeData) error {
88
-	c := OAuthAuthorization{}
89
-	c.Code = auth.Code
90
-	c.ExpiresIn = auth.ExpiresIn
91
-	c.Scope = auth.Scope
92
-	c.RedirectUri = auth.RedirectUri
93
-	c.State = auth.State
94
-	c.CreatedAt = auth.CreatedAt
95
-	c.CodeChallenge = auth.CodeChallenge
96
-	c.CodeChallengeMethod = auth.CodeChallengeMethod
97
-	if o.db.NewRecord(&c) {
98
-		return o.db.Create(&c).Error
99
-	} else {
100
-		return o.db.Save(&c).Error
60
+func (o OAuthCode) HasScope(s Scope) bool {
61
+	scopes := strings.Split(o.Scope, " ")
62
+	for k := range scopes {
63
+		if scopes[k] == string(s) {
64
+			return true
65
+		}
101 66
 	}
67
+	return false
102 68
 }
103 69
 
104
-func (o *OsinStorage) LoadAuthorize(code string) (*osin.AuthorizeData, error) {
105
-	c := &osin.AuthorizeData{}
106
-	auth := OAuthAuthorization{
107
-		Code: code,
108
-	}
109
-	if err := o.db.Preload("OAuthClient").First(&auth).Error; err != nil {
110
-		return nil, err
70
+func (o OAuthClient) VerifyClientID() error {
71
+	clientUrl, err := url.Parse(o.ClientID)
72
+	if err != nil {
73
+		return err
111 74
 	}
112
-	c.Code = auth.Code
113
-	c.ExpiresIn = auth.ExpiresIn
114
-	c.Scope = auth.Scope
115
-	c.RedirectUri = auth.RedirectUri
116
-	c.State = auth.State
117
-	c.CreatedAt = auth.CreatedAt
118
-	c.CodeChallenge = auth.CodeChallenge
119
-	c.CodeChallengeMethod = auth.CodeChallengeMethod
120
-	var err error
121
-	c.Client, err = o.GetClient(auth.Client.OAuthID)
75
+	clientUrl.Scheme = "https"
76
+	httpClient := http.DefaultClient
77
+	httpClient.Timeout = 10 * time.Second
78
+	resp, err := httpClient.Get(clientUrl.String())
122 79
 	if err != nil {
123
-		return nil, err
80
+		return err
124 81
 	}
125
-	return c, nil
126
-}
82
+	defer resp.Body.Close()
127 83
 
128
-func (o *OsinStorage) RemoveAuthorize(code string) error {
129
-	return o.db.Delete(&OAuthAuthorization{Code: code}).Error
130
-}
131
-
132
-func (o *OsinStorage) SaveAccess(auth *osin.AccessData) error {
133
-	c := OAuthAccessData{}
134
-	c.AccessToken = auth.AccessToken
135
-	c.RefreshToken = auth.RefreshToken
136
-	c.ExpiresIn = auth.ExpiresIn
137
-	c.Scope = auth.Scope
138
-	c.RedirectUri = auth.RedirectUri
139
-	c.CreatedAt = auth.CreatedAt
140
-	if o.db.NewRecord(c) {
141
-		return o.db.Create(&c).Error
142
-	} else {
143
-		return o.db.Save(&c).Error
144
-	}
84
+	return o.VerifyRedirectURL(o.RedirectURI)
145 85
 }
146 86
 
147
-func (o *OsinStorage) LoadAccess(token string) (*osin.AccessData, error) {
148
-	c := osin.AccessData{}
149
-	auth := OAuthAccessData{
150
-		AccessToken: token,
87
+func (o OAuthClient) VerifyRedirectURL(redirect string) error {
88
+	if redirect == "" {
89
+		return errors.New("Redirect URL is empty")
151 90
 	}
152
-	if err := o.db.Preload("OAuthClient").Preload("OAuthAuthorization").First(&auth).Error; err != nil {
153
-		return nil, err
154
-	}
155
-	c.AccessToken = auth.AccessToken
156
-	c.RefreshToken = auth.RefreshToken
157
-	c.ExpiresIn = auth.ExpiresIn
158
-	c.Scope = auth.Scope
159
-	c.RedirectUri = auth.RedirectUri
160
-	c.CreatedAt = auth.CreatedAt
161
-	var err error
162
-	c.Client, err = o.GetClient(auth.Client.OAuthID)
91
+	clientUrl, err := url.Parse(o.ClientID)
163 92
 	if err != nil {
164
-		return nil, err
93
+		return err
165 94
 	}
166
-	c.AuthorizeData, err = o.LoadAuthorize(auth.Auth.Code)
95
+	redirectUrl, err := url.Parse(redirect)
167 96
 	if err != nil {
168
-		return nil, err
97
+		return err
169 98
 	}
170
-	return &c, nil
171
-}
172
-
173
-func (o *OsinStorage) RemoveAccess(token string) error {
174
-	return o.db.Delete(&OAuthAccessData{AccessToken:token}).Error
175
-}
176
-
177
-func (o *OsinStorage) LoadRefresh(token string) (*osin.AccessData, error) {
178
-	return o.LoadAccess(token)
99
+	if strings.ToLower(redirectUrl.Hostname()) != strings.ToLower(clientUrl.Hostname()) {
100
+		return errors.New("Host mismatch on Redirect URI: " + redirectUrl.String())
101
+	}
102
+	if strings.ToLower(redirectUrl.Scheme) != "https" {
103
+		if redirectUrl.Scheme != "https://" {
104
+			return errors.New("Scheme is not HTTPS: " + redirectUrl.String())
105
+		}
106
+	}
107
+	return nil
179 108
 }
180 109
 
181
-func (o *OsinStorage) RemoveRefresh(token string) error {
182
-	return o.RemoveAccess(token)
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
+	})
183 116
 }

+ 93
- 7
model/post.go View File

@@ -1,20 +1,106 @@
1 1
 package model
2 2
 
3 3
 import (
4
-	"crypto/rand"
5
-	"github.com/jinzhu/gorm"
4
+	"net/url"
5
+	"strings"
6 6
 	"time"
7
+
8
+	"bytes"
9
+
10
+	"github.com/PuerkitoBio/goquery"
11
+	"github.com/labstack/echo"
12
+	"github.com/pkg/errors"
13
+	"go.rls.moe/webapps/microblog/util"
7 14
 )
8 15
 
9 16
 type Post struct {
10
-	gorm.Model
11
-	PublicRefID []byte `gorm:"not null;unique"`
12
-	PublishedAt *time.Time
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
13 26
 	Content     string
27
+	HTMLContent string
28
+	// Dynamic Content Data
29
+	Slug       string
30
+	InReplyTo  string
31
+	Type       string
32
+	BookmarkOf string
33
+	Photo      string
34
+}
35
+
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)
50
+		}
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))
56
+		if err != nil {
57
+			if len(p.Content) > 25 {
58
+				return p.Content[:20]
59
+			} else {
60
+				return p.Content
61
+			}
62
+		}
63
+		return doc.Find(".h-entry > .p-name").First().Text()
64
+	}
14 65
 }
15 66
 
16 67
 func (p *Post) BeforeCreate() error {
17
-	p.PublicRefID = make([]byte, 4)
18
-	_, err := rand.Read(p.PublicRefID)
68
+	var err error
69
+	if p.Type == "" {
70
+		return errors.New("Post requires a type")
71
+	}
72
+	p.PublicRefID, err = util.MakeRandomString(8)
73
+	if err != nil {
74
+		return err
75
+	}
76
+	p.Slug, err = p.GetSlug()
19 77
 	return err
20 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()
89
+}
90
+
91
+func (p Post) GetReplyTitle() (string, error) {
92
+	doc, err := goquery.NewDocument(p.InReplyTo)
93
+	if err != nil {
94
+		return "", err
95
+	}
96
+	return doc.Find(".h-entry > .p-name").First().Text(), nil
97
+}
98
+
99
+func (p Post) GetSlug() (string, error) {
100
+	preslug := strings.Replace(strings.ToLower(p.GetTitle()), " ", "-", -1)
101
+	return util.StripNonAscii(preslug)
102
+}
103
+
104
+func (p Post) HasContent() bool {
105
+	return p.Content != "" || p.HTMLContent != ""
106
+}

+ 6
- 5
model/session.go View File

@@ -1,20 +1,21 @@
1 1
 package model
2 2
 
3 3
 import (
4
-	"crypto/rand"
5
-	"time"
6 4
 	"go.rls.moe/webapps/microblog/config"
5
+	"go.rls.moe/webapps/microblog/util"
6
+	"time"
7 7
 )
8 8
 
9 9
 type Session struct {
10 10
 	CreatedAt time.Time
11
-	SessionID []byte `gorm:"primary_key"`
11
+	SessionID string `gorm:"index:idx_session_id"`
12
+	UserId    IDType
12 13
 	User      User
13 14
 }
14 15
 
15 16
 func (s *Session) BeforeCreate() error {
16
-	s.SessionID = make([]byte, 16)
17
-	_, err := rand.Read(s.SessionID)
17
+	var err error
18
+	s.SessionID, err = util.MakeRandomString(32)
18 19
 	return err
19 20
 }
20 21
 

+ 21
- 8
model/user.go View File

@@ -1,18 +1,23 @@
1 1
 package model
2 2
 
3 3
 import (
4
-	"crypto/rand"
5
-	"github.com/jinzhu/gorm"
4
+	"github.com/labstack/echo"
6 5
 	"go.rls.moe/webapps/microblog/config"
6
+	"go.rls.moe/webapps/microblog/util"
7 7
 	"golang.org/x/crypto/bcrypt"
8 8
 	"golang.org/x/crypto/blake2b"
9
+	"net/url"
10
+	"time"
9 11
 )
10 12
 
11 13
 type User struct {
12
-	gorm.Model
13
-	PublicRefID  []byte `gorm:"not null;unique"`
14
-	EMail        string
15
-	Username     string
14
+	ID           IDType `gorm:"primary_key"`
15
+	CreatedAt    time.Time
16
+	UpdatedAt    time.Time
17
+	DeletedAt    *time.Time
18
+	PublicRefID  string `gorm:"not null;unique"`
19
+	EMail        string `gorm:"index:idx_user_email";not null`
20
+	Username     string `gorm:"index:idx_user_username";unique;not null`
16 21
 	PasswordHash []byte
17 22
 	Admin        bool
18 23
 }
@@ -32,7 +37,15 @@ func (u *User) VerifyPassword(config *config.Config, passwd string) error {
32 37
 }
33 38
 
34 39
 func (u *User) BeforeCreate() error {
35
-	u.PublicRefID = make([]byte, 4)
36
-	_, err := rand.Read(u.PublicRefID)
40
+	var err error
41
+	u.PublicRefID, err = util.MakeRandomString(8)
37 42
 	return err
38 43
 }
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
+}

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


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

@@ -1,12 +1,13 @@
1 1
 <!DOCTYPE html>
2 2
 <html>
3 3
 <head>
4
-    {{ if .title }}
5
-    <title>{{.title}}</title>
4
+    {{ if .title }}<title>{{.title}}</title>{{ end }}
5
+    {{ if .post }}
6
+    <link rel="canonical" href="{{.post.GetURL .context}}">
6 7
     {{ end }}
7
-    <link rel="stylesheet" href="/style.css">
8
-    <link rel="micropub" href="/@mp/micropub">
9
-    <link rel="authorization_endpoint" href="/@mp/auth">
10
-    <link rel="token_endpoint" href="/@mp/token">
8
+    <link rel="stylesheet" href="{{.baseUrl}}/style.css">
9
+    <link rel="micropub" href="{{.baseUrl}}/@mp">
10
+    <link rel="authorization_endpoint" href="{{.baseUrl}}/@mp/auth">
11
+    <link rel="token_endpoint" href="{{.baseUrl}}/@mp/token">
11 12
 </head>
12 13
 <body>

+ 1
- 0
resources/dump.html View File

@@ -0,0 +1 @@
1
+{{ printf "%+#v" . }}

+ 9
- 2
resources/error.html View File

@@ -1,10 +1,17 @@
1
-{{ template "_base.tmpl.html" }}
1
+{{ template "_base.tmpl.html" . }}
2 2
 
3 3
 <div class="error">
4
+    {{ if not .plain }}
4 5
     <h1>Internal Server Error</h1>
6
+    {{ end }}
7
+    {{ if .plain }}
8
+    <h1>{{.code}} - {{.plain}}</h1>
9
+    {{ end }}
10
+    {{ if .err }}
5 11
     <div class="message">
6 12
         {{.err.Error}}
7 13
     </div>
14
+    {{ end }}
8 15
 </div>
9 16
 
10
-{{ template "_end.tmpl.html" }}
17
+{{ template "_end.tmpl.html" . }}

+ 38
- 0
resources/h_entry.html View File

@@ -0,0 +1,38 @@
1
+{{ template "_base.tmpl.html" . }}
2
+
3
+<article class="h-entry">
4
+    <h1 class="p-name">
5
+        {{ .post.GetTitle }}
6
+    </h1>
7
+    <div class="_author">Published by
8
+        <a class="p-author h-card" href="{{.post.User.GetURL .context}}">
9
+            @{{.post.User.Username}}
10
+        </a>
11
+        {{ $t := formatTime .post.CreatedAt "iso8906" }}
12
+        {{ $th := formatTime .post.CreatedAt "human" }}
13
+        on
14
+        <time class="dt-published" datetime="{{ $t }}">{{$th}}</time>
15
+        {{ if notEmpty .post.InReplyTo }}
16
+            in reply to <a href="{{.post.InReplyTo}}">{{.post.GetReplyTitle}}</a>
17
+        {{ end }}
18
+    </div>
19
+    {{ if notEmpty .post.Photo }}
20
+    <br>
21
+    <img class="u-photo" src="{{.post.Photo}}">
22
+    {{ end }}
23
+    <br>
24
+    <div class="e-content">
25
+        {{ if notEmpty .post.HTMLContent }}
26
+            <p>{{ safeHtml .post.HTMLContent }}</p>
27
+        {{ end }}
28
+        {{ if not (notEmpty .post.HTMLContent) }}
29
+            <p>{{.post.Content}}</p>
30
+        {{ end }}
31
+    </div>
32
+    <br>
33
+    {{ range .post.Categories }}
34
+    <div class="p-category">
35
+        {{ .Name }}
36
+    </div>
37
+    {{ end }}
38
+</article>

+ 3
- 3
resources/login.html View File

@@ -1,4 +1,4 @@
1
-{{ template "_base.tmpl.html" }}
1
+{{ template "_base.tmpl.html" . }}
2 2
 
3 3
 <div class="login-form">
4 4
     <div class="messages">
@@ -8,11 +8,11 @@
8 8
             </div>
9 9
         {{ end }}
10 10
     </div>
11
-    <form method="POST" action="/login">
11
+    <form method="POST" action="{{.action}}">
12 12
         <label><span>Username or Email:</span><input type="text" name="email"></label>
13 13
         <label><span>Password:</span><input type="password" name="password"></label>
14 14
         <input type="submit" value="Submit">
15 15
     </form>
16 16
 </div>
17 17
 
18
-{{ template "_end.tmpl.html" }}
18
+{{ template "_end.tmpl.html" . }}

+ 2
- 2
resources/notfound.html View File

@@ -1,4 +1,4 @@
1
-{{ template "_base.tmpl.html" }}
1
+{{ template "_base.tmpl.html" . }}
2 2
 
3 3
 <div class="error">
4 4
     <h1>Page not found</h1>
@@ -10,4 +10,4 @@
10 10
     </div>
11 11
 </div>
12 12
 
13
-{{ template "_end.tmpl.html" }}
13
+{{ template "_end.tmpl.html" . }}

+ 26
- 0
resources/oauth_yesno.html View File

@@ -0,0 +1,26 @@
1
+{{ template "_base.tmpl.html" . }}
2
+
3
+<h1>OAuth Request</h1>
4
+
5
+<div>
6
+    Application <code>{{.client.ClientID}}</code>
7
+    wants to authenticate with your User <code>{{.user.EMail}}</code>
8
+    with the following privileges:
9
+    <ol>
10
+        {{ range .scopes }}
11
+        <li>{{.Message}}</li>
12
+        {{ end }}
13
+    </ol>
14
+</div>
15
+<form method="POST" class="oauth_dialog">
16
+    <input type="hidden" name="me" value="{{.request.Me}}">
17
+    <input type="hidden" name="client_id" value="{{.request.ClientID}}">
18
+    <input type="hidden" name="redirect_uri" value="{{.request.RedirectURI}}">
19
+    <input type="hidden" name="state" value="{{.request.State}}">
20
+    <input type="hidden" name="scope" value="{{.request.Scope}}">
21
+    <input type="hidden" name="response_type" value="{{.request.ResponseType}}">
22
+    <input type="hidden" name="accepted" value="true">
23
+    <input type="submit" value="Yes">
24
+</form>
25
+
26
+{{ template "_end.tmpl.html" . }}

+ 8
- 0
resources/style.css View File

@@ -29,4 +29,12 @@ html {
29 29
     width: 100%;
30 30
     font-size: 16pt; }
31 31
 
32
+.h-entry .u-photo {
33
+  max-width: 100%;
34
+  max-height: 10em;
35
+  display: block;
36
+  margin: 0 auto 0 auto;
37
+  left: 0;
38
+  right: 0; }
39
+
32 40
 /*# 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",
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",
4 4
 "sources": ["style.scss"],
5 5
 "names": [],
6 6
 "file": "style.css"

+ 10
- 0
resources/style.scss View File

@@ -34,4 +34,14 @@ html {
34 34
             font-size: 16pt;
35 35
         }
36 36
     }
37
+}
38
+
39
+.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;
46
+    }
37 47
 }

+ 10
- 3
resources/user.html View File

@@ -1,7 +1,14 @@
1
-{{ template "_base.tmpl.html" }}
1
+{{ template "_base.tmpl.html" . }}
2 2
 
3 3
 <div class="user-profile">
4
-    <h1>{{.user.Username}}</h1>
4
+    <h1>/@{{.user.Username}}/</h1>
5
+    <code>{{.user.PublicRefID}}</code>
6
+
7
+    <div class="user-posts">
8
+        {{ range .posts }}
9
+            {{.GetTitleFromPlain}}
10
+        {{ end }}
11
+    </div>
5 12
 </div>
6 13
 
7
-{{ template "_end.tmpl.html" }}
14
+{{ template "_end.tmpl.html" . }}

+ 14
- 6
router/blog.go View File

@@ -1,11 +1,19 @@
1 1
 package router
2 2
 
3
-import "github.com/labstack/echo"
3
+import (
4
+	"github.com/labstack/echo"
5
+	"go.rls.moe/webapps/microblog/model"
6
+	"net/http"
7
+)
4 8
 
5 9
 func GetBlogPost(c echo.Context) error {
6
-	return nil
7
-}
8
-
9
-func PostBlogPost(c echo.Context) error {
10
-	return nil
10
+	cc := c.(*Context)
11
+	post := model.Post{}
12
+	post.PublicRefID = cc.Param("post")
13
+	if err := cc.DB.Preload("User").Preload("Categories").Where(&post).First(&post).Error; err != nil {
14
+		return err
15
+	}
16
+	return c.Render(http.StatusOK, "h_entry.html", map[string]interface{}{
17
+		"post": post,
18
+	})
11 19
 }

+ 68
- 15
router/ctx.go View File

@@ -1,19 +1,23 @@
1 1
 package router
2 2
 
3 3
 import (
4
+	"net/http"
5
+	"strings"
6
+
4 7
 	"github.com/labstack/echo"
5
-	"go.rls.moe/webapps/microblog/model"
6 8
 	"go.rls.moe/webapps/microblog/config"
7
-	"github.com/RangelReale/osin"
9
+	"go.rls.moe/webapps/microblog/model"
8 10
 )
9 11
 
10 12
 type Context struct {
11 13
 	echo.Context
12
-	DB      *model.DB
13
-	Config  *config.Config
14
-	OSINServer *osin.Server
15
-	Session model.Session
16
-	HasSession bool
14
+	DB              *model.DB
15
+	Config          *config.Config
16
+	Session         model.Session
17
+	AccessToken     model.OAuthAccess
18
+	HasSession      bool
19
+	HasAuth         bool
20
+	HasSeenAnyToken bool
17 21
 }
18 22
 
19 23
 func (c *Context) GetDB() *model.DB {
@@ -24,27 +28,76 @@ func (c *Context) SetDB(db *model.DB) {
24 28
 	c.DB = db
25 29
 }
26 30
 
27
-func CtxMiddleware(db *model.DB, config *config.Config, server *osin.Server) echo.MiddlewareFunc {
31
+func CtxMiddleware(db *model.DB, config *config.Config) echo.MiddlewareFunc {
28 32
 	return func(next echo.HandlerFunc) echo.HandlerFunc {
29 33
 		return func(c echo.Context) error {
30 34
 			cc := &Context{
31
-				DB:      db,
32
-				Config:  config,
33
-				OSINServer: server,
35
+				DB:         db,
36
+				Config:     config,
34 37
 				HasSession: false,
35 38
 			}
36 39
 			cc.Context = c
37 40
 			sessId, err := cc.Cookie("session")
38 41
 			if err == nil {
39
-				sessIdData, err := model.StringToRef(sessId.Value)
40
-				if err == nil {
41
-					sess := model.Session{SessionID: sessIdData}
42
-					db.First(&sess)
42
+				sess := model.Session{SessionID: sessId.Value}
43
+				if err := db.Preload("User").Where(&sess).First(&sess).Error; err == nil {
43 44
 					cc.Session = sess
44 45
 					cc.HasSession = true
46
+					cc.HasAuth = true
47
+				} else /*if err != gorm.ErrRecordNotFound*/ {
48
+					cc.Logger().Errorf("Error loading session record: %s", err.Error())
49
+				}
50
+			}
51
+			accessToken := cc.Request().Header.Get("Authorization")
52
+			if accessToken == "" {
53
+				accessToken = cc.FormValue("access_token")
54
+			}
55
+			if accessToken != "" {
56
+				cc.HasSeenAnyToken = true
57
+				accessToken = strings.TrimPrefix(accessToken, "Bearer ")
58
+				accessTokenModel := model.OAuthAccess{
59
+					AccessToken: accessToken,
60
+				}
61
+				if err := cc.DB.Preload("User").Where(&accessTokenModel).First(&accessTokenModel).Error; err == nil {
62
+					cc.HasAuth = true
63
+					cc.AccessToken = accessTokenModel
64
+				} else /*if err != gorm.ErrRecordNotFound*/ {
65
+					cc.Logger().Errorf("Error loading auth record: %s", err.Error())
45 66
 				}
46 67
 			}
47 68
 			return next(cc)
48 69
 		}
49 70
 	}
50 71
 }
72
+
73
+func (cc *Context) Can(s model.Scope) bool {
74
+	if cc.HasSession {
75
+		return true
76
+	}
77
+	if strings.Contains(cc.AccessToken.Scope, string(s)) {
78
+		return true
79
+	}
80
+	return false
81
+}
82
+
83
+func (cc *Context) GetUser() (model.User, error) {
84
+	if cc.HasSession {
85
+		return cc.Session.User, nil
86
+	}
87
+	if cc.HasAuth {
88
+		return cc.AccessToken.User, nil
89
+	}
90
+	if cc.HasSeenAnyToken {
91
+		return model.User{}, JSONError{
92
+			http.StatusUnauthorized,
93
+			JErrInsufficientScope,
94
+			"Token unknown or expired",
95
+		}
96
+	} else {
97
+		return model.User{}, JSONError{
98
+			http.StatusUnauthorized,
99
+			JErrUnauthorized,
100
+			"Token unknown or expired",
101
+		}
102
+	}
103
+}

+ 21
- 0
router/err.go View File

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

+ 29
- 4
router/login.go View File

@@ -2,12 +2,15 @@ package router
2 2
 
3 3
 import (
4 4
 	"github.com/labstack/echo"
5
-	"net/http"
6 5
 	"go.rls.moe/webapps/microblog/model"
6
+	"net/http"
7
+	"time"
7 8
 )
8 9
 
9 10
 func LoginForm(c echo.Context) error {
10
-	return c.Render(http.StatusOK, "login.html", nil)
11
+	return c.Render(http.StatusOK, "login.html", map[string]interface{}{
12
+		"action": c.Request().URL,
13
+	})
11 14
 }
12 15
 
13 16
 func LoginPost(c echo.Context) error {
@@ -27,6 +30,28 @@ func LoginPost(c echo.Context) error {
27 30
 	}
28 31
 	sess := new(model.Session)
29 32
 	sess.User = user
30
-	db.Save(&sess)
31
-	return c.Redirect(http.StatusSeeOther, "/u/@me")
33
+	if err := db.Save(&sess).Error; err != nil {
34
+		return err
35
+	}
36
+	cookie := new(http.Cookie)
37
+	cookie.Name = "session"
38
+	cookie.Value = sess.SessionID
39
+	cookie.Secure = true
40
+	cookie.Path = "/"
41
+	cookie.Expires = time.Now().Add(time.Duration(cc.Config.Session.Lifetime) * time.Hour * 24)
42
+	cookie.HttpOnly = true
43
+	cc.SetCookie(cookie)
44
+	gotoTarget := cc.QueryParam("goto")
45
+	cc.Logger().Error(cc.QueryParams())
46
+	switch gotoTarget {
47
+	case "oauth":
48
+		oauthUrl := c.Request().URL
49
+		oauthUrl.RawQuery = c.QueryParams().Encode()
50
+		oauthUrl.Query().Del("goto")
51
+		oauthUrl.RawQuery = oauthUrl.Query().Encode()
52
+		oauthUrl.Path = "/@mp/auth"
53
+		return c.Redirect(http.StatusSeeOther, oauthUrl.String())
54
+	default:
55
+		return c.Redirect(http.StatusSeeOther, "/@@me")
56
+	}
32 57
 }

+ 33
- 39
router/micropub.go View File

@@ -1,56 +1,50 @@
1 1
 package router
2 2
 
3 3
 import (
4
-	"github.com/labstack/echo"
5 4
 	"net/http"
5
+
6
+	"github.com/labstack/echo"
6 7
 	"go.rls.moe/webapps/microblog/model"
8
+	"go.rls.moe/webapps/microblog/util"
7 9
 )
8 10
 
9 11
 func MicropubEndpoint(c echo.Context) error {
12
+	cc := c.(*Context)
13
+	cc.Logger().Debug("Entering MP Endpoint")
10 14
 	q := c.QueryParam("q")
11 15
 	if q == "config" {
12 16
 		return MicropubConfig(c)
17
+	} else if q == "" {
18
+		if c.Request().Method == http.MethodPost {
19
+			return MicropubCreate(cc)
20
+		}
21
+	}
22
+	return JSONError{
23
+		http.StatusBadRequest,
24
+		JErrInvalidRequest,
25
+		"Request was incomplete or invalid",
13 26
 	}
14
-	return nil
15 27
 }
16 28
 
17 29
 func MicropubConfig(c echo.Context) error {
18
-	return c.JSON(http.StatusOK, map[string]interface{}{
19
-		"media-endpoint": "/@mp/media",
20
-	})
21
-}
22
-
23
-func MicropubMedia(c echo.Context) error {
24
-	file, err := c.FormFile("file")
25
-	if err != nil {
26
-		return c.JSON(http.StatusBadRequest, map[string]interface{}{
27
-			"error": "invalid_request",
28
-			"error_description": err.Error(),
29
-		})
30
-	}
31
-	media := model.Media{}
32
-	media.MimeType = file.Header.Get("Content-Type")
33
-	fileData, err := file.Open()
34
-	if err != nil {
35
-		return c.JSON(http.StatusBadRequest, map[string]interface{}{
36
-			"error": "invalid_request",
37
-			"error_description": err.Error(),
38
-		})
39
-	}
40
-	media.File = make([]byte, file.Size)
41
-	_, err = fileData.Read(media.File)
42
-	if err != nil {
43
-		return c.JSON(http.StatusBadRequest, map[string]interface{}{
44
-			"error": "invalid_request",
45
-			"error_description": err.Error(),
46
-		})
47
-	}
48 30
 	cc := c.(*Context)
49
-	cc.DB.Create(&media)
50
-	c.Response().Header().Set("Location", "/file/" + media.Filename())
51
-	return c.String(http.StatusCreated, "")
31
+	if cc.HasAuth {
32
+		if cc.Can(model.ScopeMedia) || cc.Can(model.ScopeCreate) {
33
+			return c.JSON(http.StatusOK, map[string]interface{}{
34
+				"media-endpoint": util.GetBaseURL(c) + "/@mp/media",
35
+			})
36
+		} else {
37
+			return JSONError{
38
+				http.StatusUnauthorized,
39
+				JErrInsufficientScope,
40
+				"The current Access Token does not have the necessary scope to perform this action",
41
+			}
42
+		}
43
+	} else {
44
+		return JSONError{
45
+			http.StatusUnauthorized,
46
+			JErrUnauthorized,
47
+			"Access Token or Login invalid",
48
+		}
49
+	}
52 50
 }
53
-
54
-func Media(c echo.Context) error {
55
-	return nil
56
-}

+ 134
- 0
router/mpcreate.go View File

@@ -0,0 +1,134 @@
1
+package router
2
+
3
+import (
4
+	"net/http"
5
+
6
+	"strings"
7
+
8
+	"go.rls.moe/webapps/microblog/model"
9
+	"go.rls.moe/webapps/microblog/model/microformats"
10
+	"go.rls.moe/webapps/microblog/util"
11
+)
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
+	})
31
+}
32
+
33
+func MicropubCreateForm(c *Context) error {
34
+	c.Logger().Debug("Handling Form Submitted Content")
35
+	if c.FormValue("h") == "entry" {
36
+		categories, hasCategories := c.Request().Form["category[]"]
37
+		post := model.Post{
38
+			Content: c.FormValue("content"),
39
+		}
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
+			})
45
+		}
46
+		if hasCategories {
47
+			post.Categories = []model.Category{}
48
+			for k := range categories {
49
+				post.Categories = append(post.Categories, model.Category{Name: categories[k]})
50
+			}
51
+		} else if c.FormValue("category") != "" {
52
+			post.Categories = []model.Category{{Name: c.FormValue("category")}}
53
+		}
54
+		user, err := c.GetUser()
55
+		if err != nil {
56
+			c.Logger().Warnf("Could not find user: %s", err.Error())
57
+			return err
58
+		}
59
+		post.User = user
60
+		if c.FormValue("photo") != "" {
61
+			post.Photo = c.FormValue("photo")
62
+		}
63
+		post.Type = model.PostTypeNote
64
+		if c.FormValue("name") != "" {
65
+			post.Type = model.PostTypeArticle
66
+			post.Name = c.FormValue("name")
67
+		}
68
+		if c.FormValue("bookmark-of") != "" {
69
+			post.Type = model.PostTypeBookmark
70
+			post.BookmarkOf = c.FormValue("bookmark-of")
71
+		}
72
+		if file, err := c.FormFile("photo"); err == nil {
73
+			media, err := CreateMedia(c, file)
74
+			if err != nil {
75
+				return err
76
+			}
77
+			post.Photo = util.GetBaseURL(c) + "/media/" + media.Filename()
78
+		} else {
79
+			c.Logger().Debugf("Error reading file: %s", err.Error())
80
+		}
81
+		if err := c.DB.Create(&post).Error; err != nil {
82
+			return err
83
+		}
84
+		c.Logger().Debugf("Finished request for URL %s", post.GetURL(c))
85
+		c.Response().Header().Set("Location", post.GetURL(c))
86
+		return c.NoContent(http.StatusCreated)
87
+	} else {
88
+		return c.JSON(http.StatusBadRequest, map[string]interface{}{
89
+			"error":             "invalid_request",
90
+			"error_description": "The h-type is unknown or invalid",
91
+		})
92
+	}
93
+}
94
+
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
+		}
110
+	}
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
+		}
116
+	}
117
+	if photos, ok := mf.Properties["photo"]; ok {
118
+		for k := range photos {
119
+			post.Photo = photos[k].Value
120
+		}
121
+	}
122
+	user, err := c.GetUser()
123
+	if err != nil {
124
+		return err
125
+	}
126
+	post.User = user
127
+	post.Type = model.PostTypeNote
128
+	if err := c.DB.Create(&post).Error; err != nil {
129
+		return err
130
+	}
131
+	c.Logger().Debugf("Finished request for URL %s", post.GetURL(c))
132
+	c.Response().Header().Set("Location", post.GetURL(c))
133
+	return c.NoContent(http.StatusCreated)
134
+}

+ 126
- 0
router/mpmedia.go View File

@@ -0,0 +1,126 @@
1
+package router
2
+
3
+import (
4
+	"github.com/labstack/echo"
5
+	"go.rls.moe/webapps/microblog/model"
6
+	"go.rls.moe/webapps/microblog/util"
7
+	"mime"
8
+	"net/http"
9
+	"path/filepath"
10
+	"strings"
11
+	"mime/multipart"
12
+)
13
+
14
+func extInMime(ext, mimeType string) bool {
15
+	exts, err := mime.ExtensionsByType(mimeType)
16
+	if err != nil {
17
+		return false
18
+	}
19
+	for k := range exts {
20
+		if exts[k] == ext {
21
+			return true
22
+		}
23
+	}
24
+	return false
25
+}
26
+
27
+func isMediaMime(mimeType string) bool {
28
+	allowedMedia := []string{
29
+		"image/jpeg",
30
+		"image/png",
31
+		"image/tiff",
32
+		"image/x-icon",
33
+		"image/gif",
34
+		"image/svg+xml",
35
+	}
36
+	for k := range allowedMedia {
37
+		if mimeType == allowedMedia[k] {
38
+			return true
39
+		}
40
+	}
41
+	return false
42
+}
43
+
44
+func MicropubMedia(c echo.Context) error {
45
+	file, err := c.FormFile("file")
46
+	if err != nil {
47
+		return c.JSON(http.StatusBadRequest, map[string]interface{}{
48
+			"error":             "invalid_request",
49
+			"error_description": err.Error(),
50
+		})
51
+	}
52
+	cc := c.(*Context)
53
+	media, err := CreateMedia(cc, file)
54
+	if err != nil {
55
+		return err
56
+	}
57
+	c.Response().Header().Set("X-Saved-Mime", media.MimeType)
58
+	c.Response().Header().Set("Location", util.GetBaseURL(cc)+"/media/"+media.Filename())
59
+	return c.NoContent(http.StatusCreated)
60
+}
61
+
62
+func CreateMedia(c *Context, header *multipart.FileHeader) (model.Media, error) {
63
+	media := model.Media{}
64
+	media.MimeType = header.Header.Get("Content-Type")
65
+	if !isMediaMime(media.MimeType) {
66
+		return media, JSONError{
67
+			http.StatusBadRequest,
68
+			JErrInvalidRequest,
69
+			"MIME Type " + media.MimeType + " is not permitted",
70
+		}
71
+	}
72
+	media.FileExtension = filepath.Ext(header.Filename)
73
+	if !extInMime(media.FileExtension, media.MimeType) {
74
+		return media, JSONError{
75
+			http.StatusBadRequest,
76
+			JErrInvalidRequest,
77
+			"MIME Type " + media.MimeType + "does not allow extension " + media.FileExtension,
78
+		}
79
+	}
80
+	file, err := header.Open()
81
+	if err != nil {
82
+		return media, JSONError{
83
+			http.StatusBadRequest,
84
+			JErrInvalidRequest,
85
+			err.Error(),
86
+		}
87
+	}
88
+	media.File = make([]byte, header.Size)
89
+	_, err = file.Read(media.File)
90
+	if err != nil {
91
+		return media, JSONError{
92
+			http.StatusBadRequest,
93
+			JErrInvalidRequest,
94
+			err.Error(),
95
+		}
96
+	}
97
+	media.User,err = c.GetUser()
98
+	if err != nil {
99
+		return media, JSONError{
100
+			http.StatusBadRequest,
101
+			JErrInvalidRequest,
102
+			err.Error(),
103
+		}
104
+	}
105
+	if err := c.DB.Create(&media).Error; err != nil {
106
+		return media, JSONError{
107
+			http.StatusInternalServerError,
108
+			JErrInternal,
109
+			err.Error(),
110
+		}
111
+	}
112
+	return media, nil
113
+}
114
+
115
+func Media(c echo.Context) error {
116
+	cc := c.(*Context)
117
+	media := model.Media{}
118
+	media.PublicRefID = cc.Param("file")
119
+	media.FileExtension = filepath.Ext(media.PublicRefID)
120
+	media.PublicRefID = strings.TrimSuffix(media.PublicRefID, media.FileExtension)
121
+	if err := cc.DB.Where(&media).First(&media).Error; err != nil {
122
+		return err
123
+	}
124
+	cc.Logger().Debugf("Loading file %s with Mimetype %s", media.Filename(), media.MimeType)
125
+	return c.Blob(http.StatusOK, media.MimeType, media.File)
126
+}

+ 185
- 19
router/oauth.go View File

@@ -1,34 +1,200 @@
1 1
 package router
2 2
 
3 3
 import (
4
-	"github.com/RangelReale/osin"
4
+	"github.com/jinzhu/gorm"
5 5
 	"github.com/labstack/echo"
6
+	"github.com/pkg/errors"
7
+	"go.rls.moe/webapps/microblog/model"
8
+	"go.rls.moe/webapps/microblog/util"
6 9
 	"net/http"
10
+	"net/url"
11
+	"strings"
7 12
 )
8 13
 
9
-func OAuthLogin(c echo.Context) error {
14
+type oAuthAuthRequest struct {
15
+	Me           string `form:"me" query:"me"`
16
+	ClientID     string `form:"client_id" query:"client_id"`
17
+	RedirectURI  string `form:"redirect_uri" query:"redirect_uri"`
18
+	State        string `form:"state" query:"state"`
19
+	Scope        string `form:"scope" query:"scope"`
20
+	ResponseType string `form:"response_type" query:"response_type"`
21
+	Accepted     bool   `form:"accepted" query:"-"`
22
+}
23
+
24
+func (o *oAuthAuthRequest) ParseScope() ([]model.Scope, error) {
25
+	scopes := strings.Split(o.Scope, " ")
26
+	out := []model.Scope{}
27
+	for i := range scopes {
28
+		switch model.Scope(scopes[i]) {
29
+		case model.ScopeCreate:
30
+			out = append(out, model.ScopeCreate)
31
+		case model.ScopeUpdate:
32
+			out = append(out, model.ScopeUpdate)
33
+		case model.ScopeDelete:
34
+			out = append(out, model.ScopeDelete)
35
+		case model.ScopeUndelete:
36
+			out = append(out, model.ScopeUndelete)
37
+		case model.ScopeMedia:
38
+			out = append(out, model.ScopeMedia)
39
+		default:
40
+			return nil, errors.New("Unknown scope: " + scopes[i])
41
+		}
42
+	}
43
+	return out, nil
44
+}
45
+
46
+func OAuthAuthEndpoint(c echo.Context) error {
47
+	authRequest := new(oAuthAuthRequest)
48
+	if err := c.Bind(authRequest); err != nil {
49
+		return err
50
+	}
10 51
 	cc := c.(*Context)
11
-	resp := cc.OSINServer.NewResponse()
12
-	defer resp.Close()
13
-	if ar := cc.OSINServer.HandleAuthorizeRequest(resp, c.Request()); ar != nil {
14
-		if c.Request().Method != "POST" {
15
-			return c.Render(http.StatusOK, "login.html", map[string]interface{}{
16
-				"scope": ar.Scope,
17
-			})
18
-			ar.Authorized = true
19
-			cc.OSINServer.FinishAuthorizeRequest(resp, c.Request(), ar)
52
+	if cc.HasSession {
53
+		if authRequest.Me != cc.Session.User.GetURL(c) {
54
+			return errors.New("User Profile and Requested Login Identity do not match")
20 55
 		}
56
+		if authRequest.Accepted {
57
+			client := model.OAuthClient{}
58
+			client.ClientID = authRequest.ClientID
59
+			if err := client.VerifyRedirectURL(authRequest.RedirectURI); err != nil {
60
+				return err
61
+			}
62
+			if err := cc.DB.Where(&client).First(&client).Error; err != nil {
63
+				return err
64
+			}
65
+
66
+			code := model.OAuthCode{}
67
+			code.Scope = authRequest.Scope
68
+			code.ClientID = client.ClientID
69
+			code.User = cc.Session.User
70
+			if authCode, err := util.MakeRandomString(32); err != nil {
71
+				return err
72
+			} else {
73
+				code.Code = authCode
74
+			}
75
+
76
+			if err := cc.DB.Create(&code).Error; err != nil {
77
+				return err
78
+			}
79
+
80
+			redirectUrl, err := url.Parse(client.RedirectURI)
81
+			if err != nil {
82
+				return err
83
+			}
84
+			q := redirectUrl.Query()
85
+			q.Set("me", cc.Session.User.GetURL(cc))
86
+			q.Set("code", code.Code)
87
+			q.Set("state", authRequest.State)
88
+			redirectUrl.RawQuery = q.Encode()
89
+			return c.Redirect(http.StatusSeeOther, redirectUrl.String())
90
+		} else {
91
+			client := model.OAuthClient{}
92
+			client.ClientID = authRequest.ClientID
93
+
94
+			if err := cc.DB.Where(&client).First(&client).Error; err != nil && err != gorm.ErrRecordNotFound {
95
+				return err
96
+			} else if err == gorm.ErrRecordNotFound {
97
+				client.RedirectURI = authRequest.RedirectURI
98
+				if err := cc.DB.Create(&client).Error; err != nil {
99
+					return err
100
+				}
101
+			}
102
+
103
+			if err := client.VerifyClientID(); err != nil {
104
+				return err
105
+			}
106
+
107
+			if scopes, err := authRequest.ParseScope(); err != nil {
108
+				return err
109
+			} else {
110
+				return c.Render(200, "oauth_yesno.html", map[string]interface{}{
111
+					"auth":    authRequest,
112
+					"client":  client,
113
+					"user":    cc.Session.User,
114
+					"scopes":  scopes,
115
+					"request": authRequest,
116
+				})
117
+			}
118
+		}
119
+	} else {
120
+		loginUrl := c.Request().URL
121
+		q := loginUrl.Query()
122
+		q.Set("goto", "oauth")
123
+		loginUrl.RawQuery = q.Encode()
124
+		loginUrl.Path = "/login"
125
+		c.Redirect(http.StatusSeeOther, loginUrl.String())
21 126
 	}
22
-	return osin.OutputJSON(resp, c.Response().Writer, c.Request())
127
+	return nil
128
+}
129
+
130
+type oauthAccessRequest struct {
131
+	GrantType   string `form:"grant_type"`
132
+	Me          string `form:"me"`
133
+	Code        string `form:"code"`
134
+	RedirectURI string `form:"redirect_uri"`
135
+	ClientID    string `form:"client_id"`
23 136
 }
24 137
 
25
-func OAuthToken(c echo.Context) error {
138
+func OAuthAccess(c echo.Context) error {
26 139
 	cc := c.(*Context)
27
-	resp := cc.OSINServer.NewResponse()
28
-	defer resp.Close()
29
-	if ar := cc.OSINServer.HandleAccessRequest(resp, c.Request()); ar != nil {
30
-		ar.Authorized = true
31
-		cc.OSINServer.FinishAccessRequest(resp, c.Request(), ar)
140
+	if authHeader := c.Request().Header.Get("Authorization"); authHeader != "" {
141
+		if !strings.HasPrefix(authHeader, "Bearer ") {
142
+			return errors.New("Authentication only allows Bearer Tokens")
143
+		}
144
+		authHeader = strings.TrimPrefix(authHeader, "Bearer ")
145
+		accessToken := model.OAuthAccess{AccessToken: authHeader}
146
+		if err := cc.DB.Where(&accessToken).First(&accessToken).Error; err != nil {
147
+			return err
148
+		}
149
+		dat, err := accessToken.GetJSON(c)
150
+		if err != nil {
151
+			return err
152
+		}
153
+		return cc.JSON(200, dat)
154
+	}
155
+	accessRequest := oauthAccessRequest{}
156
+	if err := c.Bind(&accessRequest); err != nil {
157
+		return err
158
+	}
159
+	client := model.OAuthClient{ClientID: accessRequest.ClientID}
160
+	if err := cc.DB.Where(&client).First(&client).Error; err != nil {
161
+		return err
162
+	}
163
+	if err := client.VerifyRedirectURL(accessRequest.RedirectURI); err != nil {
164
+		return err
165
+	}
166
+	code := model.OAuthCode{Code: accessRequest.Code}
167
+	if err := cc.DB.Where(&code).First(&code).Error; err != nil {
168
+		return err
169
+	}
170
+	access := &model.OAuthAccess{
171
+		Scope: code.Scope,
172
+	}
173
+	user := model.User{}
174
+	user.ID = code.UserID
175
+	if err := cc.DB.Where(&user).First(&user).Error; err != nil {
176
+		return err
177
+	}
178
+
179
+	if user.GetURL(cc) != accessRequest.Me {
180
+		return errors.New("Identity and Me do not match")
181
+	}
182
+
183
+	access.User = user
184
+
185
+	if newAccessToken, err := util.MakeRandomString(32); err != nil {
186
+		return err
187
+	} else {
188
+		access.AccessToken = newAccessToken
189
+	}
190
+
191
+	if err := cc.DB.Create(access).Error; err != nil {
192
+		return err
193
+	}
194
+
195
+	if dat, err := access.GetJSON(cc); err != nil {
196
+		return err
197
+	} else {
198
+		return c.JSONBlob(http.StatusOK, dat)
32 199
 	}
33
-	return osin.OutputJSON(resp, c.Response().Writer, c.Request())
34 200
 }

+ 62
- 10
router/tmpl.go View File

@@ -2,17 +2,23 @@ package router
2 2
 
3 3
 import (
4 4
 	"bytes"
5
-	"github.com/labstack/echo"
6
-	"go.rls.moe/webapps/microblog/config"
7 5
 	"html/template"
8 6
 	"io"
9 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"
10 15
 )
11 16
 
12 17
 type TemplateRenderer struct {
13 18
 	templates    *template.Template
14 19
 	glob         string
15 20
 	disableCache bool
21
+	enableMinify bool
16 22
 }
17 23
 
18 24
 func NewTemplateRenderer(glob string, config *config.Config) (*TemplateRenderer, error) {
@@ -20,6 +26,7 @@ func NewTemplateRenderer(glob string, config *config.Config) (*TemplateRenderer,
20 26
 		templates:    nil,
21 27
 		glob:         glob,
22 28
 		disableCache: config.NoCache,
29
+		enableMinify: !config.NoMinify,
23 30
 	}
24 31
 	err := t.init()
25 32
 	return t, err
@@ -27,6 +34,11 @@ func NewTemplateRenderer(glob string, config *config.Config) (*TemplateRenderer,
27 34
 
28 35
 func (t *TemplateRenderer) init() error {
29 36
 	t.templates = template.New("root")
37
+	t.templates.Funcs(map[string]interface{}{
38
+		"formatTime": intFormatTime,
39
+		"notEmpty":   intNotEmpty,
40
+		"safeHtml":   intSafeHtml,
41
+	})
30 42
 	_, err := t.templates.ParseGlob(t.glob)
31 43
 	if err != nil {
32 44
 		return err
@@ -35,6 +47,9 @@ func (t *TemplateRenderer) init() error {
35 47
 }
36 48
 
37 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
+	}
38 53
 	if t.disableCache {
39 54
 		err := t.init()
40 55
 		if err != nil {
@@ -43,16 +58,53 @@ func (t *TemplateRenderer) Render(w io.Writer, name string, data interface{}, c
43 58
 	}
44 59
 	if viewContext, isMap := data.(map[string]interface{}); isMap {
45 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
+		})
46 69
 	}
47
-	return t.templates.ExecuteTemplate(w, name, data)
48 70
 }
49 71
 
50 72
 func (t *TemplateRenderer) ErrRender(err error, c echo.Context) {
51
-	var out = bytes.NewBuffer([]byte{})
52
-	t.templates.ExecuteTemplate(out, "error.html",
53
-		map[string]interface{}{
54
-			"err": err,
55
-		},
56
-	)
57
-	c.HTML(http.StatusOK, out.String())
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))
58 110
 }

+ 5
- 2
router/user.go View File

@@ -15,19 +15,22 @@ func UserProfile(c echo.Context) error {
15 15
 		if cc.HasSession {
16 16
 			profileUser = cc.Session.User
17 17
 		} else {
18
-
19 18
 			return c.Redirect(http.StatusSeeOther, "/login")
20 19
 		}
21 20
 	} else {
22 21
 		profileUser.Username = userId
23
-		if err := cc.DB.First(&profileUser).GetErrors(); err != nil {
22
+		if err := cc.DB.Where(&profileUser).First(&profileUser).Error; err != nil {
24 23
 			return c.Render(http.StatusNotFound, "notfound.html", map[string]interface{}{
25 24
 				"path":    c.Request().URL.Path,
26 25
 				"message": fmt.Sprintf("User %s does not exist", userId),
27 26
 			})
28 27
 		}
29 28
 	}
29
+	posts := []model.Post{}
30
+	cc.DB.Find(&posts, model.Post{User: profileUser})
30 31
 	return c.Render(http.StatusOK, "user.html", map[string]interface{}{
31 32
 		"user": profileUser,
33
+		"cc":   cc,
34
+		"posts": posts,
32 35
 	})
33 36
 }

+ 27
- 0
util/minifier/minify.go View File

@@ -0,0 +1,27 @@
1
+package minifier
2
+
3
+import (
4
+	"io"
5
+
6
+	"github.com/tdewolff/minify"
7
+	"github.com/tdewolff/minify/css"
8
+	"github.com/tdewolff/minify/html"
9
+	"github.com/tdewolff/minify/js"
10
+)
11
+
12
+var minifier *minify.M
13
+
14
+func init() {
15
+	minifier = minify.New()
16
+	minifier.Add("text/html", html.DefaultMinifier)
17
+	minifier.Add("text/css", css.DefaultMinifier)
18
+	minifier.Add("text/javascript", js.DefaultMinifier)
19
+}
20
+
21
+func MinifyHTML(out io.Writer, in io.Reader) error {
22
+	return minifier.Minify("text/html", out, in)
23
+}
24
+
25
+func MinifyHTMLWriter(out io.Writer) io.Writer {
26
+	return minifier.Writer("text/html", out)
27
+}

+ 47
- 0
util/util.go View File

@@ -0,0 +1,47 @@
1
+package util
2
+
3
+import (
4
+	"crypto/rand"
5
+	"encoding/base32"
6
+	"net/url"
7
+
8
+	"github.com/labstack/echo"
9
+	"golang.org/x/text/transform"
10
+	"golang.org/x/text/unicode/norm"
11
+	"golang.org/x/text/runes"
12
+	"strings"
13
+	"github.com/microcosm-cc/bluemonday"
14
+)
15
+
16
+func MakeRandomString(bytes int) (string, error) {
17
+	buf := make([]byte, bytes)
18
+	_, err := rand.Read(buf)
19
+	if err != nil {
20
+		return "", err
21
+	}
22
+	return strings.ToLower(base32.HexEncoding.EncodeToString(buf)[:bytes]), nil
23
+}
24
+
25
+func GetBaseURL(c echo.Context) string {
26
+	baseUrl := new(url.URL)
27
+	baseUrl.Host = c.Request().Host
28
+	baseUrl.Scheme = "https"
29
+	return baseUrl.String()
30
+}
31
+
32
+func StripNonAscii(str string) (string, error) {
33
+	set := runes.Predicate(func (r rune) bool {
34
+		return (r < 97 || r > 122) && r != 45
35
+	})
36
+	t := transform.Chain(norm.NFKD, runes.Remove(set))
37
+	str, _, err := transform.String(t, str)
38
+	return str, err
39
+}
40
+
41
+func StrictHTML(s string) string {
42
+	return bluemonday.StrictPolicy().Sanitize(s)
43
+}
44
+
45
+func NormalHTML(s string) string {
46
+	return bluemonday.UGCPolicy().Sanitize(s)
47
+}

+ 16
- 0
util/util_test.go View File

@@ -0,0 +1,16 @@
1
+package util
2
+
3
+import "testing"
4
+
5
+func TestMakeRandomString(t *testing.T) {
6
+	for i := 10; i < 1024; i++ {
7
+		str, err := MakeRandomString(i)
8
+		if err != nil {
9
+			t.Fatalf("Error at length %d: %s", i, err)
10
+			return
11
+		}
12
+		if len(str) != i {
13
+			t.Fatalf("At length %d, mismatch: got %d", i, len(str))
14
+		}
15
+	}
16
+}

Loading…
Cancel
Save