diff --git a/css/application.css b/css/application.css index 32ab32c..49f0a48 100644 --- a/css/application.css +++ b/css/application.css @@ -24,6 +24,17 @@ body { height: 100%; } +div.register { + flex-direction: column; + align-items: inherit; +} +div.login p { + margin: 15px auto; +} +div.login a.login.btn { + margin: 0; +} + .form-inline input[type="url"] { width: 480px; max-width: 100%; diff --git a/src/adapters/web/middleware.go b/src/adapters/web/middleware.go index 1d8a9be..e266032 100644 --- a/src/adapters/web/middleware.go +++ b/src/adapters/web/middleware.go @@ -14,7 +14,7 @@ func (web *Web) authenticate(h http.HandlerFunc) http.HandlerFunc { userID, ok := session.Values["user_id"].(uint) if ok { user, err := web.engine.FindUser(userID) - if err != nil || user.GoogleToken == "" { + if err != nil || user.Status != domain.UserStatusValid { log.Println("Failed to find user: " + err.Error()) ok = false } else { diff --git a/src/adapters/web/page_handlers.go b/src/adapters/web/page_handlers.go index d94605c..ffb5317 100644 --- a/src/adapters/web/page_handlers.go +++ b/src/adapters/web/page_handlers.go @@ -23,17 +23,17 @@ func (web *Web) pagesHandler(w http.ResponseWriter, r *http.Request) { if bytes != nil { json.Unmarshal(bytes, &resp) } - user, ok := context.Get(r, "user").(*domain.User) - if ok { - resp["User"] = user - } var req engine.FindPagesRequest err := form.NewDecoder().Decode(&req, r.URL.Query()) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - req.UserID = user.ID + user, ok := context.Get(r, "user").(*domain.User) + if ok { + resp["User"] = user + req.UserID = user.ID + } pages, count, err := web.engine.FindPages(&req) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/src/adapters/web/templates.go b/src/adapters/web/templates.go index 7838d51..b439154 100644 --- a/src/adapters/web/templates.go +++ b/src/adapters/web/templates.go @@ -32,21 +32,21 @@ func renderTemplate(w http.ResponseWriter, r *http.Request, path string, data ma data["MainHost"] = scheme + os.Getenv("MAIN_DOMAIN") data["FileHost"] = scheme + os.Getenv("FILE_DOMAIN") data["ImageHost"] = scheme + os.Getenv("IMAGE_DOMAIN") - tpl := findTemplate("/layouts.tpl", path) + tpl := findTemplate("/layouts.tpl", "/login.tpl", path) tpl.ExecuteTemplate(w, "base", data) } // cache templates so that it doesn't parse files every time in production -func findTemplate(basePath, path string) (tpl *template.Template) { +func findTemplate(basePath, loginPath, path string) (tpl *template.Template) { if Digest == "" { - tpl = template.Must(template.ParseFiles(baseTplPath+basePath, baseTplPath+path)) + tpl = template.Must(template.ParseFiles(baseTplPath+basePath, baseTplPath+loginPath, baseTplPath+path)) return } tpl, ok := templates[path] if ok { return } - tpl = template.Must(template.ParseFiles(baseTplPath+basePath, baseTplPath+path)) + tpl = template.Must(template.ParseFiles(baseTplPath+basePath, baseTplPath+loginPath, baseTplPath+path)) templates[path] = tpl return } diff --git a/src/adapters/web/user_handlers.go b/src/adapters/web/user_handlers.go new file mode 100644 index 0000000..e71fd31 --- /dev/null +++ b/src/adapters/web/user_handlers.go @@ -0,0 +1,55 @@ +package web + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/scoville/scvl/src/engine" +) + +func (web *Web) userRegistrationHandler(w http.ResponseWriter, r *http.Request) { + user, err := web.engine.UserRegister(&engine.RegistrationRequest{ + Hash: r.FormValue("hash"), + Name: r.FormValue("name"), + Password: r.FormValue("password"), + }) + if err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + session, _ := web.store.Get(r, "scvl") + session.Values["user_id"] = user.ID + session.Save(r, w) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (web *Web) userRegistrationPageHandler(w http.ResponseWriter, r *http.Request) { + hash := mux.Vars(r)["hash"] + invitation, err := web.engine.FindInvitation(&engine.FindInvitationRequest{ + Hash: hash, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + resp := map[string]interface{}{ + "Email": invitation.ToUser.Email, + "Hash": hash, + } + renderTemplate(w, r, "/register.tpl", resp) +} + +func (web *Web) loginHandler(w http.ResponseWriter, r *http.Request) { + session, _ := web.store.Get(r, "scvl") + user, err := web.engine.LoginUser(&engine.LoginUserRequest{ + Email: r.FormValue("email"), + Password: r.FormValue("password"), + }) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + session.Values["user_id"] = user.ID + session.Save(r, w) + http.Redirect(w, r, "/", http.StatusSeeOther) +} diff --git a/src/adapters/web/user_invitation_handlers.go b/src/adapters/web/user_invitation_handlers.go new file mode 100644 index 0000000..6284338 --- /dev/null +++ b/src/adapters/web/user_invitation_handlers.go @@ -0,0 +1,40 @@ +package web + +import ( + "net/http" + + "github.com/gorilla/context" + "github.com/scoville/scvl/src/domain" + "github.com/scoville/scvl/src/engine" +) + +func (web *Web) invitationCreateHandler(w http.ResponseWriter, r *http.Request) { + user, ok := context.Get(r, "user").(*domain.User) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + invitation, err := web.engine.InviteUser(&engine.InviteRequest{ + FromUserID: uint(user.ID), + Email: r.FormValue("email"), + }) + if err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + registerPath := "/register/" + invitation.Hash + resp := map[string]interface{}{ + "RegisterPath": registerPath, + } + renderTemplate(w, r, "/invitation.tpl", resp) +} + +func (web *Web) invitationsHandler(w http.ResponseWriter, r *http.Request) { + _, ok := context.Get(r, "user").(*domain.User) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + resp := map[string]interface{}{} + renderTemplate(w, r, "/invitations.tpl", resp) +} diff --git a/src/adapters/web/web.go b/src/adapters/web/web.go index f484f0a..57fca13 100644 --- a/src/adapters/web/web.go +++ b/src/adapters/web/web.go @@ -45,6 +45,11 @@ func (web *Web) Start(port string) error { r.HandleFunc("/files/{slug}", web.authenticate(web.updateFileHandler)).Methods(http.MethodPost, http.MethodPut, http.MethodPatch) r.Handle("/images", web.authenticate(web.imagesHandler)).Methods(http.MethodGet) r.Handle("/images", web.authenticate(web.imageUploadHandler)).Methods(http.MethodPost) + r.Handle("/invites", web.authenticate(web.invitationsHandler)).Methods(http.MethodGet) + r.Handle("/invites", web.authenticate(web.invitationCreateHandler)).Methods(http.MethodPost) + r.HandleFunc("/register/{hash}", web.userRegistrationPageHandler).Methods(http.MethodGet) + r.HandleFunc("/register", web.userRegistrationHandler).Methods(http.MethodPost) + r.HandleFunc("/login", web.loginHandler).Methods(http.MethodPost) r.HandleFunc("/{slug}/qr.png", web.qrHandler).Methods(http.MethodGet) r.Handle("/{slug}/edit", web.authenticate(web.editHandler)).Methods(http.MethodGet) diff --git a/src/domain/user.go b/src/domain/user.go index 91cf361..61c0d6a 100644 --- a/src/domain/user.go +++ b/src/domain/user.go @@ -1,6 +1,9 @@ package domain -import "time" +import ( + "fmt" + "time" +) // User is a user type User struct { @@ -9,9 +12,35 @@ type User struct { UpdatedAt time.Time DeletedAt *time.Time `sql:"index"` Name string `json:"name"` - Email string `json:"email" gorm:"type:varchar(100);unique_index"` + Email string `json:"email" gorm:"type:varchar(100);unique_index; not null"` - Files []*File `json:"files"` - Images []*Image `json:"images"` - GoogleToken string `json:"google_token"` + Files []*File `json:"files"` + Images []*Image `json:"images"` + GoogleToken string `json:"google_token"` + EncryptedPassword string `json:"-"` + Status string `json:"status"` +} + +// Status for User +const ( + UserStatusTemp = "temp" + UserStatusValid = "valid" + UserStatusDeleted = "deleted" +) + +// SetPassword sets the password +func (w *User) SetPassword(pass string) error { + if len(pass) < 6 { + return fmt.Errorf("password should be greater or equal than 6 characters") + } + w.EncryptedPassword = Encrypt(pass) + return nil +} + +// BeforeSave is called before it is saved to the database +func (w *User) BeforeSave() error { + if w.GoogleToken == "" && w.EncryptedPassword == "" { + return fmt.Errorf("password is required") + } + return nil } diff --git a/src/domain/user_invitation.go b/src/domain/user_invitation.go new file mode 100644 index 0000000..3662608 --- /dev/null +++ b/src/domain/user_invitation.go @@ -0,0 +1,46 @@ +package domain + +import ( + "fmt" + "time" +) + +// UserInvitation is the struct for user_invitation. +type UserInvitation struct { + ID uint `gorm:"primary_key"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `sql:"index"` + Hash string `json:"hash" gorm:"unique_index; not null"` + Status string `json:"-" gorm:"not null" valid:"required,in(sent|used|deleted)"` + FromUserID uint `json:"-" gorm:"type:integer REFERENCES users(id) ON DELETE CASCADE; not null" valid:"required"` + ToUserID uint `json:"to_user_id" gorm:"type:integer REFERENCES users(id) ON DELETE CASCADE"` + + ToUser *User `json:"to_user,omitempty" gorm:"association_autupdate:false;association_autcreate:false"` +} + +// Invitation statuses +const ( + InvitationStatusSent = "sent" + InvitationStatusUsed = "used" + InvitationStatusDeleted = "deleted" +) + +// BeforeCreate generates a unique hash for the invitation. +func (i *UserInvitation) BeforeCreate() error { + i.Hash = GenerateSlug(64) + i.Status = InvitationStatusSent + i.ToUser.SetPassword(GenerateSlug(12)) + return nil +} + +// Valid returns Error if the invitation is not valid +func (i *UserInvitation) Valid() error { + if i.Status == InvitationStatusUsed { + return fmt.Errorf("the invitation is already used") + } + if time.Now().Sub(i.CreatedAt) > time.Hour*24 { + return fmt.Errorf("the invitation is expired") + } + return nil +} diff --git a/src/engine/page.go b/src/engine/page.go index fbae630..d9504d3 100644 --- a/src/engine/page.go +++ b/src/engine/page.go @@ -211,7 +211,7 @@ func (e *Engine) UpdatePage(req *UpdatePageRequest) (page *domain.Page, err erro } func (e *Engine) fetchTitle(userID int, url string) (title string, err error) { - user, err := e.sqlClient.FindUser(uint(userID)) + user, err := e.sqlClient.FindUser(domain.User{ID: uint(userID)}) if err != nil { return } diff --git a/src/engine/sql.go b/src/engine/sql.go index 6e70e38..4d4df8d 100644 --- a/src/engine/sql.go +++ b/src/engine/sql.go @@ -6,9 +6,14 @@ import "github.com/scoville/scvl/src/domain" type SQLClient interface { Close() error - FindUser(uint) (*domain.User, error) + FindUser(domain.User) (*domain.User, error) FindOrCreateUser(domain.User) (*domain.User, error) + CreateInvitation(*domain.UserInvitation) error + FindInvitation(domain.UserInvitation) (*domain.UserInvitation, error) + UpdateInvitation(*domain.UserInvitation, *domain.UserInvitation) error + UserRegister(*domain.User, *domain.User) (*domain.User, error) + FindPages(params *FindPagesRequest) (pages []*domain.Page, count int, err error) FindPageBySlug(string) (*domain.Page, error) CreatePage(*domain.Page) error diff --git a/src/engine/user.go b/src/engine/user.go index 5fe0382..e924dd8 100644 --- a/src/engine/user.go +++ b/src/engine/user.go @@ -5,11 +5,12 @@ import ( "strings" "github.com/scoville/scvl/src/domain" + "golang.org/x/crypto/bcrypt" ) // FindUser finds and returns the user func (e *Engine) FindUser(userID uint) (*domain.User, error) { - return e.sqlClient.FindUser(userID) + return e.sqlClient.FindUser(domain.User{ID: userID}) } // FindOrCreateUserByGoogleCode finds or creates the user @@ -21,5 +22,61 @@ func (e *Engine) FindOrCreateUserByGoogleCode(code string) (*domain.User, error) if e.allowedDomain != "" && !strings.HasSuffix(u.Email, "@"+e.allowedDomain) { return nil, fmt.Errorf("only %s can allowed to use this service", e.allowedDomain) } + u.Status = domain.UserStatusValid return e.sqlClient.FindOrCreateUser(u) } + +// RegistrationRequest is the request +type RegistrationRequest struct { + Name string + Hash string + Password string +} + +// UserRegister creates the user who is invited to the system. +func (e *Engine) UserRegister(req *RegistrationRequest) (*domain.User, error) { + invitation, err := e.sqlClient.FindInvitation(domain.UserInvitation{Hash: req.Hash}) + if err != nil { + return nil, err + } + if err := invitation.Valid(); err != nil { + return nil, err + } + user, err := e.sqlClient.FindUser(domain.User{ + Email: invitation.ToUser.Email, + Status: domain.UserStatusTemp, + }) + if err != nil { + return nil, err + } + if err := user.SetPassword(req.Password); err != nil { + return nil, err + } + user.Name = req.Name + user.Status = domain.UserStatusValid + err = e.sqlClient.UpdateInvitation(invitation, &domain.UserInvitation{ + Status: domain.InvitationStatusUsed, + ToUser: user, + }) + return invitation.ToUser, err +} + +// LoginUserRequest is the Reqeust +type LoginUserRequest struct { + Email string + Password string +} + +// LoginUser is login request +func (e *Engine) LoginUser(req *LoginUserRequest) (*domain.User, error) { + user, err := e.sqlClient.FindUser(domain.User{ + Email: req.Email, + }) + if err != nil { + return nil, err + } + if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(req.Password)); err != nil { + return nil, err + } + return user, nil +} diff --git a/src/engine/user_invitation.go b/src/engine/user_invitation.go new file mode 100644 index 0000000..0da2fa7 --- /dev/null +++ b/src/engine/user_invitation.go @@ -0,0 +1,61 @@ +package engine + +import ( + "github.com/scoville/scvl/src/domain" +) + +// FindInvitationRequest is the Request +type FindInvitationRequest struct { + Hash string +} + +// FindInvitation find the user invitation by hash +func (e *Engine) FindInvitation(req *FindInvitationRequest) (*domain.UserInvitation, error) { + invitation, err := e.sqlClient.FindInvitation(domain.UserInvitation{Hash: req.Hash}) + if err != nil { + return nil, err + } + err = invitation.Valid() + return invitation, err +} + +// InviteRequest is the request +type InviteRequest struct { + FromUserID uint + Email string +} + +// InviteUser deals new user which is invited by existing user +func (e *Engine) InviteUser(req *InviteRequest) (*domain.UserInvitation, error) { + if _, err := e.sqlClient.FindUser(domain.User{ID: req.FromUserID}); err != nil { + return nil, err + } + user, err := e.sqlClient.FindUser(domain.User{ + Email: req.Email, + Status: domain.UserStatusTemp, + }) + if err == nil && user != nil { + invitation, err := e.sqlClient.FindInvitation(domain.UserInvitation{ + FromUserID: req.FromUserID, + ToUserID: user.ID, + Status: domain.InvitationStatusSent, + }) + if err != nil { + return nil, err + } + params := &domain.UserInvitation{Hash: domain.GenerateSlug(64)} + err = e.sqlClient.UpdateInvitation(invitation, params) + return invitation, err + } + + invitation := &domain.UserInvitation{ + Status: domain.InvitationStatusSent, + FromUserID: req.FromUserID, + ToUser: &domain.User{ + Status: domain.UserStatusTemp, + Email: req.Email, + }, + } + err = e.sqlClient.CreateInvitation(invitation) + return invitation, err +} diff --git a/src/providers/sql/sql.go b/src/providers/sql/sql.go index af3417e..69bc02c 100644 --- a/src/providers/sql/sql.go +++ b/src/providers/sql/sql.go @@ -28,6 +28,7 @@ func NewClient(dbURL string) (engine.SQLClient, error) { &domain.PageView{}, &domain.Page{}, &domain.User{}, + &domain.UserInvitation{}, ) return &client{db}, nil } diff --git a/src/providers/sql/user_invitations.go b/src/providers/sql/user_invitations.go new file mode 100644 index 0000000..969c48b --- /dev/null +++ b/src/providers/sql/user_invitations.go @@ -0,0 +1,28 @@ +package sql + +import ( + "github.com/jinzhu/gorm" + "github.com/scoville/scvl/src/domain" +) + +const tblUserInvitations = "user_invitations" + +func (c *client) FindInvitation(cond domain.UserInvitation) (*domain.UserInvitation, error) { + invitation := &domain.UserInvitation{} + err := c.db.Table(tblUserInvitations). + Preload("ToUser", func(db *gorm.DB) *gorm.DB { + return db + }).First(invitation, cond).Error + return invitation, err +} + +func (c *client) UpdateInvitation(invitation, params *domain.UserInvitation) (err error) { + err = c.db.Table(tblUserInvitations). + Model(invitation).Updates(params).Error + return err +} + +func (c *client) CreateInvitation(params *domain.UserInvitation) (err error) { + err = c.db.Create(params).Error + return +} diff --git a/src/providers/sql/users.go b/src/providers/sql/users.go index f03a93e..6c10e57 100644 --- a/src/providers/sql/users.go +++ b/src/providers/sql/users.go @@ -7,7 +7,7 @@ import ( const tblUsers = "users" -func (c *client) FindUser(id uint) (user *domain.User, err error) { +func (c *client) FindUser(cond domain.User) (user *domain.User, err error) { user = &domain.User{} err = c.db.Table(tblUsers). @@ -17,7 +17,7 @@ func (c *client) FindUser(id uint) (user *domain.User, err error) { Preload("Images", func(db *gorm.DB) *gorm.DB { return db.Order("images.created_at DESC") }). - First(user, id).Error + First(user, cond).Error if err != nil { return } @@ -32,7 +32,15 @@ func (c *client) FindOrCreateUser(params domain.User) (user *domain.User, err er user = &domain.User{} err = c.db. Where(domain.User{Email: params.Email}). - Assign(domain.User{Name: params.Name, GoogleToken: params.GoogleToken}). + Assign(domain.User{ + Name: params.Name, + GoogleToken: params.GoogleToken, + Status: params.Status}). FirstOrCreate(user).Error return } + +func (c *client) UserRegister(user, params *domain.User) (*domain.User, error) { + err := c.db.Model(user).Update(params).Error + return user, err +} diff --git a/templates/files.tpl b/templates/files.tpl index 420f376..56c6adb 100644 --- a/templates/files.tpl +++ b/templates/files.tpl @@ -2,7 +2,7 @@

SCVL File Uploader

{{if .LoginURL}} - ログイン + {{template "login" .}} {{else}}

ファイルのアップロードができます。

diff --git a/templates/images.tpl b/templates/images.tpl index 4a45593..a81b901 100644 --- a/templates/images.tpl +++ b/templates/images.tpl @@ -2,7 +2,7 @@

SCVL Image Uploader

{{if .LoginURL}} - + {{template "login" .}} {{else}}

画像のアップロードができます。

diff --git a/templates/index.tpl b/templates/index.tpl index 3381534..9a37943 100644 --- a/templates/index.tpl +++ b/templates/index.tpl @@ -12,5 +12,10 @@ Image Uploader +
{{end}} diff --git a/templates/invitation.tpl b/templates/invitation.tpl new file mode 100644 index 0000000..4d3fb21 --- /dev/null +++ b/templates/invitation.tpl @@ -0,0 +1,20 @@ +{{define "body"}} +
+
+
+

ユーザーを招待しました。以下のURLを共有し、24時間以内にユーザー登録を完了してください。

+
+
+

登録URL:

+

+ + + content_copy + コピーする + +

+
+
+ +
+{{end}} diff --git a/templates/invitations.tpl b/templates/invitations.tpl new file mode 100644 index 0000000..9b38502 --- /dev/null +++ b/templates/invitations.tpl @@ -0,0 +1,11 @@ +{{define "body"}} +
+

ユーザーを招待する

+ +
+ + +
+ +
+{{end}} diff --git a/templates/login.tpl b/templates/login.tpl new file mode 100644 index 0000000..9e4a206 --- /dev/null +++ b/templates/login.tpl @@ -0,0 +1,14 @@ +{{define "login"}} +
+

ログインしてください。

+
+
+ + + +
+
+

または、

+ +
+{{end}} \ No newline at end of file diff --git a/templates/pages.tpl b/templates/pages.tpl index b4fbe95..061c63f 100644 --- a/templates/pages.tpl +++ b/templates/pages.tpl @@ -2,7 +2,7 @@

SCVL URL Shortener

{{if .LoginURL}} - + {{template "login" .}} {{else}}

URLの短縮ができます。

diff --git a/templates/register.tpl b/templates/register.tpl new file mode 100644 index 0000000..e1f8b55 --- /dev/null +++ b/templates/register.tpl @@ -0,0 +1,14 @@ +{{define "body"}} +
+

ユーザー登録

+ +

メールアドレス: {{.Email}}

+
+

パスワード:

+

名前:   

+ +

+
+ +
+{{end}}