From 891be61031ce9602bcb63a38be01216ede25b443 Mon Sep 17 00:00:00 2001 From: Sung Won Cho Date: Sat, 14 Dec 2019 12:10:48 +0700 Subject: [PATCH] Improve email and signup for self-hosting users (#355) * Add app abstraction * Abstract out email sending * Get sender * Test * Move operations to app * Test * Test * Add license * Fix lint --- pkg/server/app/app.go | 72 ++++++++ pkg/server/app/app_test.go | 113 ++++++++++++ pkg/server/{operations => app}/books.go | 15 +- pkg/server/{operations => app}/books_test.go | 21 ++- pkg/server/app/email.go | 137 +++++++++++++++ pkg/server/app/email_test.go | 132 ++++++++++++++ pkg/server/{operations => app}/helpers.go | 2 +- .../{operations => app}/helpers_test.go | 2 +- pkg/server/app/main_test.go | 35 ++++ pkg/server/{operations => app}/notes.go | 19 +- pkg/server/{operations => app}/notes_test.go | 24 ++- pkg/server/{operations => app}/sessions.go | 15 +- .../{operations => app}/subscriptions.go | 13 +- pkg/server/app/testutils.go | 64 +++++++ pkg/server/{operations => app}/users.go | 22 ++- pkg/server/app/users_test.go | 66 +++++++ pkg/server/dbconn/dbconn.go | 18 ++ pkg/server/handlers/auth.go | 44 +++-- pkg/server/handlers/auth_test.go | 17 +- pkg/server/handlers/classic.go | 31 ++-- pkg/server/handlers/classic_test.go | 9 +- pkg/server/handlers/health.go | 2 +- pkg/server/handlers/health_test.go | 3 +- pkg/server/handlers/helpers.go | 6 +- pkg/server/handlers/main_test.go | 18 ++ pkg/server/handlers/notes.go | 15 +- pkg/server/handlers/notes_test.go | 5 +- pkg/server/handlers/repetition_rules.go | 30 ++-- pkg/server/handlers/repetition_rules_test.go | 55 +++--- pkg/server/handlers/routes.go | 163 +++++++----------- pkg/server/handlers/routes_test.go | 46 ++--- pkg/server/handlers/subscription.go | 26 +-- pkg/server/handlers/testutils.go | 26 ++- pkg/server/handlers/user.go | 61 ++++--- pkg/server/handlers/user_test.go | 45 ++--- pkg/server/handlers/v3_auth.go | 47 ++--- pkg/server/handlers/v3_auth_test.go | 58 ++++--- pkg/server/handlers/v3_books.go | 31 ++-- pkg/server/handlers/v3_books_test.go | 49 +++--- pkg/server/handlers/v3_notes.go | 26 +-- pkg/server/handlers/v3_notes_test.go | 25 +-- pkg/server/handlers/v3_sync.go | 14 +- pkg/server/job/job_test.go | 18 ++ pkg/server/job/repetition/main_test.go | 18 ++ pkg/server/mailer/backend.go | 48 +++++- pkg/server/main.go | 18 +- pkg/server/operations/main_test.go | 17 -- pkg/server/tmpl/app.go | 17 +- pkg/server/tmpl/app_test.go | 11 +- pkg/server/tmpl/data.go | 8 +- pkg/server/tmpl/data_test.go | 4 +- pkg/server/tmpl/main_test.go | 18 ++ pkg/server/web/handlers.go | 14 +- pkg/server/web/handlers_test.go | 38 +++- pkg/server/web/main_test.go | 35 ++++ pkg/watcher/main.go | 6 +- scripts/license.sh | 11 +- web/jest.config.js | 18 ++ web/src/components/Repetition/Content.tsx | 18 ++ web/src/components/Subscription/Footer.tsx | 18 ++ .../components/Subscription/Plan/ProCTA.tsx | 18 ++ 61 files changed, 1408 insertions(+), 567 deletions(-) create mode 100644 pkg/server/app/app.go create mode 100644 pkg/server/app/app_test.go rename pkg/server/{operations => app}/books.go (84%) rename pkg/server/{operations => app}/books_test.go (96%) create mode 100644 pkg/server/app/email.go create mode 100644 pkg/server/app/email_test.go rename pkg/server/{operations => app}/helpers.go (98%) rename pkg/server/{operations => app}/helpers_test.go (99%) create mode 100644 pkg/server/app/main_test.go rename pkg/server/{operations => app}/notes.go (85%) rename pkg/server/{operations => app}/notes_test.go (96%) rename pkg/server/{operations => app}/sessions.go (77%) rename pkg/server/{operations => app}/subscriptions.go (83%) create mode 100644 pkg/server/app/testutils.go rename pkg/server/{operations => app}/users.go (90%) create mode 100644 pkg/server/app/users_test.go delete mode 100644 pkg/server/operations/main_test.go create mode 100644 pkg/server/web/main_test.go diff --git a/pkg/server/app/app.go b/pkg/server/app/app.go new file mode 100644 index 00000000..5b725079 --- /dev/null +++ b/pkg/server/app/app.go @@ -0,0 +1,72 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package app + +import ( + "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/mailer" + "github.com/jinzhu/gorm" + "github.com/pkg/errors" + "github.com/stripe/stripe-go" +) + +var ( + // ErrEmptyDB is an error for missing database connection in the app configuration + ErrEmptyDB = errors.New("No database connection was provided") + // ErrEmptyClock is an error for missing clock in the app configuration + ErrEmptyClock = errors.New("No clock was provided") + // ErrEmptyWebURL is an error for missing WebURL content in the app configuration + ErrEmptyWebURL = errors.New("No WebURL was provided") + // ErrEmptyEmailTemplates is an error for missing EmailTemplates content in the app configuration + ErrEmptyEmailTemplates = errors.New("No EmailTemplate store was provided") + // ErrEmptyEmailBackend is an error for missing EmailBackend content in the app configuration + ErrEmptyEmailBackend = errors.New("No EmailBackend was provided") +) + +// App is an application configuration +type App struct { + DB *gorm.DB + Clock clock.Clock + StripeAPIBackend stripe.Backend + EmailTemplates mailer.Templates + EmailBackend mailer.Backend + WebURL string + OnPremise bool +} + +// Validate validates the app configuration +func (a *App) Validate() error { + if a.WebURL == "" { + return ErrEmptyWebURL + } + if a.Clock == nil { + return ErrEmptyClock + } + if a.EmailTemplates == nil { + return ErrEmptyEmailTemplates + } + if a.EmailBackend == nil { + return ErrEmptyEmailBackend + } + if a.DB == nil { + return ErrEmptyDB + } + + return nil +} diff --git a/pkg/server/app/app_test.go b/pkg/server/app/app_test.go new file mode 100644 index 00000000..5a2936f1 --- /dev/null +++ b/pkg/server/app/app_test.go @@ -0,0 +1,113 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package app + +import ( + "fmt" + "testing" + + "github.com/dnote/dnote/pkg/assert" + "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/mailer" + "github.com/dnote/dnote/pkg/server/testutils" + "github.com/jinzhu/gorm" + "github.com/pkg/errors" +) + +func TestValidate(t *testing.T) { + testCases := []struct { + app App + expectedErr error + }{ + { + app: App{ + DB: &gorm.DB{}, + Clock: clock.NewMock(), + StripeAPIBackend: nil, + EmailTemplates: mailer.Templates{}, + EmailBackend: &testutils.MockEmailbackendImplementation{}, + WebURL: "http://mock.url", + }, + expectedErr: nil, + }, + { + app: App{ + DB: nil, + Clock: clock.NewMock(), + StripeAPIBackend: nil, + EmailTemplates: mailer.Templates{}, + EmailBackend: &testutils.MockEmailbackendImplementation{}, + WebURL: "http://mock.url", + }, + expectedErr: ErrEmptyDB, + }, + { + app: App{ + DB: &gorm.DB{}, + Clock: nil, + StripeAPIBackend: nil, + EmailTemplates: mailer.Templates{}, + EmailBackend: &testutils.MockEmailbackendImplementation{}, + WebURL: "http://mock.url", + }, + expectedErr: ErrEmptyClock, + }, + { + app: App{ + DB: &gorm.DB{}, + Clock: clock.NewMock(), + StripeAPIBackend: nil, + EmailTemplates: nil, + EmailBackend: &testutils.MockEmailbackendImplementation{}, + WebURL: "http://mock.url", + }, + expectedErr: ErrEmptyEmailTemplates, + }, + { + app: App{ + DB: &gorm.DB{}, + Clock: clock.NewMock(), + StripeAPIBackend: nil, + EmailTemplates: mailer.Templates{}, + EmailBackend: nil, + WebURL: "http://mock.url", + }, + expectedErr: ErrEmptyEmailBackend, + }, + { + app: App{ + DB: &gorm.DB{}, + Clock: clock.NewMock(), + StripeAPIBackend: nil, + EmailTemplates: mailer.Templates{}, + EmailBackend: &testutils.MockEmailbackendImplementation{}, + WebURL: "", + }, + expectedErr: ErrEmptyWebURL, + }, + } + + for idx, tc := range testCases { + t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) { + err := tc.app.Validate() + + assert.Equal(t, errors.Cause(err), tc.expectedErr, "error mismatch") + }) + } +} diff --git a/pkg/server/operations/books.go b/pkg/server/app/books.go similarity index 84% rename from pkg/server/operations/books.go rename to pkg/server/app/books.go index 6408368b..8462960a 100644 --- a/pkg/server/operations/books.go +++ b/pkg/server/app/books.go @@ -16,10 +16,9 @@ * along with Dnote. If not, see . */ -package operations +package app import ( - "github.com/dnote/dnote/pkg/clock" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/helpers" "github.com/jinzhu/gorm" @@ -27,8 +26,8 @@ import ( ) // CreateBook creates a book with the next usn and updates the user's max_usn -func CreateBook(db *gorm.DB, user database.User, clock clock.Clock, name string) (database.Book, error) { - tx := db.Begin() +func (a *App) CreateBook(user database.User, name string) (database.Book, error) { + tx := a.DB.Begin() nextUSN, err := incrementUserUSN(tx, user.ID) if err != nil { @@ -45,7 +44,7 @@ func CreateBook(db *gorm.DB, user database.User, clock clock.Clock, name string) UUID: uuid, UserID: user.ID, Label: name, - AddedOn: clock.Now().UnixNano(), + AddedOn: a.Clock.Now().UnixNano(), USN: nextUSN, Encrypted: false, } @@ -60,7 +59,7 @@ func CreateBook(db *gorm.DB, user database.User, clock clock.Clock, name string) } // DeleteBook marks a book deleted with the next usn and updates the user's max_usn -func DeleteBook(tx *gorm.DB, user database.User, book database.Book) (database.Book, error) { +func (a *App) DeleteBook(tx *gorm.DB, user database.User, book database.Book) (database.Book, error) { if user.ID != book.UserID { return book, errors.New("Not allowed") } @@ -83,7 +82,7 @@ func DeleteBook(tx *gorm.DB, user database.User, book database.Book) (database.B } // UpdateBook updaates the book, the usn and the user's max_usn -func UpdateBook(tx *gorm.DB, c clock.Clock, user database.User, book database.Book, label *string) (database.Book, error) { +func (a *App) UpdateBook(tx *gorm.DB, user database.User, book database.Book, label *string) (database.Book, error) { if user.ID != book.UserID { return book, errors.New("Not allowed") } @@ -98,7 +97,7 @@ func UpdateBook(tx *gorm.DB, c clock.Clock, user database.User, book database.Bo } book.USN = nextUSN - book.EditedOn = c.Now().UnixNano() + book.EditedOn = a.Clock.Now().UnixNano() book.Deleted = false // TODO: remove after all users have been migrated book.Encrypted = false diff --git a/pkg/server/operations/books_test.go b/pkg/server/app/books_test.go similarity index 96% rename from pkg/server/operations/books_test.go rename to pkg/server/app/books_test.go index fba28276..ac8d739c 100644 --- a/pkg/server/operations/books_test.go +++ b/pkg/server/app/books_test.go @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -package operations +package app import ( "fmt" @@ -62,9 +62,11 @@ func TestCreateBook(t *testing.T) { anotherUser := testutils.SetupUserData() testutils.MustExec(t, testutils.DB.Model(&anotherUser).Update("max_usn", 55), fmt.Sprintf("preparing user max_usn for test case %d", idx)) - c := clock.NewMock() + a := NewTest(&App{ + Clock: clock.NewMock(), + }) - book, err := CreateBook(testutils.DB, user, c, tc.label) + book, err := a.CreateBook(user, tc.label) if err != nil { t.Fatal(errors.Wrap(err, "creating book")) } @@ -130,7 +132,8 @@ func TestDeleteBook(t *testing.T) { testutils.MustExec(t, testutils.DB.Save(&book), fmt.Sprintf("preparing book for test case %d", idx)) tx := testutils.DB.Begin() - ret, err := DeleteBook(tx, user, book) + a := NewTest(nil) + ret, err := a.DeleteBook(tx, user, book) if err != nil { tx.Rollback() t.Fatal(errors.Wrap(err, "deleting book")) @@ -203,14 +206,16 @@ func TestUpdateBook(t *testing.T) { anotherUser := testutils.SetupUserData() testutils.MustExec(t, testutils.DB.Model(&anotherUser).Update("max_usn", 55), fmt.Sprintf("preparing user max_usn for test case %d", idx)) - c := clock.NewMock() - b := database.Book{UserID: user.ID, Deleted: false, Label: tc.expectedLabel} testutils.MustExec(t, testutils.DB.Save(&b), fmt.Sprintf("preparing book for test case %d", idx)) - tx := testutils.DB.Begin() + c := clock.NewMock() + a := NewTest(&App{ + Clock: c, + }) - book, err := UpdateBook(tx, c, user, b, tc.payloadLabel) + tx := testutils.DB.Begin() + book, err := a.UpdateBook(tx, user, b, tc.payloadLabel) if err != nil { tx.Rollback() t.Fatal(errors.Wrap(err, "updating book")) diff --git a/pkg/server/app/email.go b/pkg/server/app/email.go new file mode 100644 index 00000000..e043e5ea --- /dev/null +++ b/pkg/server/app/email.go @@ -0,0 +1,137 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package app + +import ( + "fmt" + "net/url" + "strings" + + "github.com/dnote/dnote/pkg/server/mailer" + "github.com/pkg/errors" +) + +var defaultSender = "sung@getdnote.com" + +// getSenderEmail returns the sender email +func (a *App) getSenderEmail(want string) (string, error) { + if !a.OnPremise { + return want, nil + } + + addr, err := a.getNoreplySender() + if err != nil { + return "", errors.Wrap(err, "getting sender email address") + } + + return addr, nil +} + +func getDomainFromURL(rawURL string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", errors.Wrap(err, "parsing url") + } + + host := u.Hostname() + parts := strings.Split(host, ".") + if len(parts) < 2 { + return host, nil + } + domain := parts[len(parts)-2] + "." + parts[len(parts)-1] + + return domain, nil +} + +func (a *App) getNoreplySender() (string, error) { + domain, err := getDomainFromURL(a.WebURL) + if err != nil { + return "", errors.Wrap(err, "parsing web url") + } + + addr := fmt.Sprintf("noreply@%s", domain) + return addr, nil +} + +// SendVerificationEmail sends verification email +func (a *App) SendVerificationEmail(email, tokenValue string) error { + body, err := a.EmailTemplates.Execute(mailer.EmailTypeEmailVerification, mailer.EmailKindText, mailer.EmailVerificationTmplData{ + Token: tokenValue, + WebURL: a.WebURL, + }) + if err != nil { + return errors.Wrapf(err, "executing reset verification template for %s", email) + } + + from, err := a.getSenderEmail(defaultSender) + if err != nil { + return errors.Wrap(err, "getting the sender email") + } + + if err := a.EmailBackend.Queue("Verify your Dnote email address", from, []string{email}, "text/plain", body); err != nil { + return errors.Wrapf(err, "queueing email for %s", email) + } + + return nil +} + +// SendWelcomeEmail sends welcome email +func (a *App) SendWelcomeEmail(email string) error { + body, err := a.EmailTemplates.Execute(mailer.EmailTypeWelcome, mailer.EmailKindText, mailer.WelcomeTmplData{ + AccountEmail: email, + WebURL: a.WebURL, + }) + if err != nil { + return errors.Wrapf(err, "executing reset verification template for %s", email) + } + + from, err := a.getSenderEmail(defaultSender) + if err != nil { + return errors.Wrap(err, "getting the sender email") + } + + if err := a.EmailBackend.Queue("Welcome to Dnote!", from, []string{email}, "text/plain", body); err != nil { + return errors.Wrapf(err, "queueing email for %s", email) + } + + return nil +} + +// SendPasswordResetEmail sends verification email +func (a *App) SendPasswordResetEmail(email, tokenValue string) error { + body, err := a.EmailTemplates.Execute(mailer.EmailTypeResetPassword, mailer.EmailKindText, mailer.EmailResetPasswordTmplData{ + AccountEmail: email, + Token: tokenValue, + WebURL: a.WebURL, + }) + if err != nil { + return errors.Wrapf(err, "executing reset verification template for %s", email) + } + + from, err := a.getSenderEmail(defaultSender) + if err != nil { + return errors.Wrap(err, "getting the sender email") + } + + if err := a.EmailBackend.Queue("Reset your password", from, []string{email}, "text/plain", body); err != nil { + return errors.Wrapf(err, "queueing email for %s", email) + } + + return nil +} diff --git a/pkg/server/app/email_test.go b/pkg/server/app/email_test.go new file mode 100644 index 00000000..b6de47df --- /dev/null +++ b/pkg/server/app/email_test.go @@ -0,0 +1,132 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package app + +import ( + "fmt" + "testing" + + "github.com/dnote/dnote/pkg/assert" + "github.com/dnote/dnote/pkg/server/testutils" +) + +func TestSendVerificationEmail(t *testing.T) { + testCases := []struct { + onPremise bool + expectedSender string + }{ + { + onPremise: false, + expectedSender: "sung@getdnote.com", + }, + { + onPremise: true, + expectedSender: "noreply@example.com", + }, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("self hosted %t", tc.onPremise), func(t *testing.T) { + emailBackend := testutils.MockEmailbackendImplementation{} + a := NewTest(&App{ + OnPremise: tc.onPremise, + WebURL: "http://example.com", + EmailBackend: &emailBackend, + }) + + if err := a.SendVerificationEmail("alice@example.com", "mockTokenValue"); err != nil { + t.Fatal(err, "failed to perform") + } + + assert.Equalf(t, len(emailBackend.Emails), 1, "email queue count mismatch") + assert.Equal(t, emailBackend.Emails[0].From, tc.expectedSender, "email sender mismatch") + assert.DeepEqual(t, emailBackend.Emails[0].To, []string{"alice@example.com"}, "email sender mismatch") + }) + } +} + +func TestSendWelcomeEmail(t *testing.T) { + testCases := []struct { + onPremise bool + expectedSender string + }{ + { + onPremise: false, + expectedSender: "sung@getdnote.com", + }, + { + onPremise: true, + expectedSender: "noreply@example.com", + }, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("self hosted %t", tc.onPremise), func(t *testing.T) { + emailBackend := testutils.MockEmailbackendImplementation{} + a := NewTest(&App{ + OnPremise: tc.onPremise, + WebURL: "http://example.com", + EmailBackend: &emailBackend, + }) + + if err := a.SendWelcomeEmail("alice@example.com"); err != nil { + t.Fatal(err, "failed to perform") + } + + assert.Equalf(t, len(emailBackend.Emails), 1, "email queue count mismatch") + assert.Equal(t, emailBackend.Emails[0].From, tc.expectedSender, "email sender mismatch") + assert.DeepEqual(t, emailBackend.Emails[0].To, []string{"alice@example.com"}, "email sender mismatch") + }) + } +} + +func TestSendPasswordResetEmail(t *testing.T) { + testCases := []struct { + onPremise bool + expectedSender string + }{ + { + onPremise: false, + expectedSender: "sung@getdnote.com", + }, + { + onPremise: true, + expectedSender: "noreply@example.com", + }, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("self hosted %t", tc.onPremise), func(t *testing.T) { + emailBackend := testutils.MockEmailbackendImplementation{} + a := NewTest(&App{ + OnPremise: tc.onPremise, + WebURL: "http://example.com", + EmailBackend: &emailBackend, + }) + + if err := a.SendPasswordResetEmail("alice@example.com", "mockTokenValue"); err != nil { + t.Fatal(err, "failed to perform") + } + + assert.Equalf(t, len(emailBackend.Emails), 1, "email queue count mismatch") + assert.Equal(t, emailBackend.Emails[0].From, tc.expectedSender, "email sender mismatch") + assert.DeepEqual(t, emailBackend.Emails[0].To, []string{"alice@example.com"}, "email sender mismatch") + }) + } +} diff --git a/pkg/server/operations/helpers.go b/pkg/server/app/helpers.go similarity index 98% rename from pkg/server/operations/helpers.go rename to pkg/server/app/helpers.go index 7f748191..d39c48bc 100644 --- a/pkg/server/operations/helpers.go +++ b/pkg/server/app/helpers.go @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -package operations +package app import ( "github.com/dnote/dnote/pkg/server/database" diff --git a/pkg/server/operations/helpers_test.go b/pkg/server/app/helpers_test.go similarity index 99% rename from pkg/server/operations/helpers_test.go rename to pkg/server/app/helpers_test.go index 68e137e6..c2274744 100644 --- a/pkg/server/operations/helpers_test.go +++ b/pkg/server/app/helpers_test.go @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -package operations +package app import ( "fmt" diff --git a/pkg/server/app/main_test.go b/pkg/server/app/main_test.go new file mode 100644 index 00000000..5bd96a61 --- /dev/null +++ b/pkg/server/app/main_test.go @@ -0,0 +1,35 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package app + +import ( + "os" + "testing" + + "github.com/dnote/dnote/pkg/server/testutils" +) + +func TestMain(m *testing.M) { + testutils.InitTestDB() + + code := m.Run() + testutils.ClearData() + + os.Exit(code) +} diff --git a/pkg/server/operations/notes.go b/pkg/server/app/notes.go similarity index 85% rename from pkg/server/operations/notes.go rename to pkg/server/app/notes.go index ba1a9a39..00b683fd 100644 --- a/pkg/server/operations/notes.go +++ b/pkg/server/app/notes.go @@ -16,10 +16,9 @@ * along with Dnote. If not, see . */ -package operations +package app import ( - "github.com/dnote/dnote/pkg/clock" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/helpers" "github.com/dnote/dnote/pkg/server/permissions" @@ -29,8 +28,8 @@ import ( // CreateNote creates a note with the next usn and updates the user's max_usn. // It returns the created note. -func CreateNote(db *gorm.DB, user database.User, clock clock.Clock, bookUUID, content string, addedOn *int64, editedOn *int64, public bool) (database.Note, error) { - tx := db.Begin() +func (a *App) CreateNote(user database.User, bookUUID, content string, addedOn *int64, editedOn *int64, public bool) (database.Note, error) { + tx := a.DB.Begin() nextUSN, err := incrementUserUSN(tx, user.ID) if err != nil { @@ -40,7 +39,7 @@ func CreateNote(db *gorm.DB, user database.User, clock clock.Clock, bookUUID, co var noteAddedOn int64 if addedOn == nil { - noteAddedOn = clock.Now().UnixNano() + noteAddedOn = a.Clock.Now().UnixNano() } else { noteAddedOn = *addedOn } @@ -113,7 +112,7 @@ func (r UpdateNoteParams) GetPublic() bool { } // UpdateNote creates a note with the next usn and updates the user's max_usn -func UpdateNote(tx *gorm.DB, user database.User, clock clock.Clock, note database.Note, p *UpdateNoteParams) (database.Note, error) { +func (a *App) UpdateNote(tx *gorm.DB, user database.User, note database.Note, p *UpdateNoteParams) (database.Note, error) { nextUSN, err := incrementUserUSN(tx, user.ID) if err != nil { return note, errors.Wrap(err, "incrementing user max_usn") @@ -130,7 +129,7 @@ func UpdateNote(tx *gorm.DB, user database.User, clock clock.Clock, note databas } note.USN = nextUSN - note.EditedOn = clock.Now().UnixNano() + note.EditedOn = a.Clock.Now().UnixNano() note.Deleted = false // TODO: remove after all users are migrated note.Encrypted = false @@ -143,7 +142,7 @@ func UpdateNote(tx *gorm.DB, user database.User, clock clock.Clock, note databas } // DeleteNote marks a note deleted with the next usn and updates the user's max_usn -func DeleteNote(tx *gorm.DB, user database.User, note database.Note) (database.Note, error) { +func (a *App) DeleteNote(tx *gorm.DB, user database.User, note database.Note) (database.Note, error) { nextUSN, err := incrementUserUSN(tx, user.ID) if err != nil { return note, errors.Wrap(err, "incrementing user max_usn") @@ -162,13 +161,13 @@ func DeleteNote(tx *gorm.DB, user database.User, note database.Note) (database.N } // GetNote retrieves a note for the given user -func GetNote(db *gorm.DB, uuid string, user database.User) (database.Note, bool, error) { +func (a *App) GetNote(uuid string, user database.User) (database.Note, bool, error) { zeroNote := database.Note{} if !helpers.ValidateUUID(uuid) { return zeroNote, false, nil } - conn := db.Where("notes.uuid = ? AND deleted = ?", uuid, false) + conn := a.DB.Where("notes.uuid = ? AND deleted = ?", uuid, false) conn = database.PreloadNote(conn) var note database.Note diff --git a/pkg/server/operations/notes_test.go b/pkg/server/app/notes_test.go similarity index 96% rename from pkg/server/operations/notes_test.go rename to pkg/server/app/notes_test.go index f30aba96..e8af2e7c 100644 --- a/pkg/server/operations/notes_test.go +++ b/pkg/server/app/notes_test.go @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -package operations +package app import ( "fmt" @@ -85,8 +85,12 @@ func TestCreateNote(t *testing.T) { b1 := database.Book{UserID: user.ID, Label: "js", Deleted: false} testutils.MustExec(t, testutils.DB.Save(&b1), fmt.Sprintf("preparing b1 for test case %d", idx)) + a := NewTest(&App{ + Clock: mockClock, + }) + tx := testutils.DB.Begin() - if _, err := CreateNote(testutils.DB, user, mockClock, b1.UUID, "note content", tc.addedOn, tc.editedOn, false); err != nil { + if _, err := a.CreateNote(user, b1.UUID, "note content", tc.addedOn, tc.editedOn, false); err != nil { tx.Rollback() t.Fatal(errors.Wrap(err, "deleting note")) } @@ -151,8 +155,12 @@ func TestUpdateNote(t *testing.T) { content := "updated test content" public := true + a := NewTest(&App{ + Clock: c, + }) + tx := testutils.DB.Begin() - if _, err := UpdateNote(tx, user, c, note, &UpdateNoteParams{ + if _, err := a.UpdateNote(tx, user, note, &UpdateNoteParams{ Content: &content, Public: &public, }); err != nil { @@ -218,8 +226,10 @@ func TestDeleteNote(t *testing.T) { note := database.Note{UserID: user.ID, Deleted: false, Body: "test content", BookUUID: b1.UUID} testutils.MustExec(t, testutils.DB.Save(¬e), fmt.Sprintf("preparing note for test case %d", idx)) + a := NewTest(nil) + tx := testutils.DB.Begin() - ret, err := DeleteNote(tx, user, note) + ret, err := a.DeleteNote(tx, user, note) if err != nil { tx.Rollback() t.Fatal(errors.Wrap(err, "deleting note")) @@ -330,7 +340,8 @@ func TestGetNote(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - note, ok, err := GetNote(testutils.DB, tc.note.UUID, tc.user) + a := NewTest(nil) + note, ok, err := a.GetNote(tc.note.UUID, tc.user) if err != nil { t.Fatal(errors.Wrap(err, "executing")) } @@ -363,8 +374,9 @@ func TestGetNote_nonexistent(t *testing.T) { } testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1") + a := NewTest(nil) nonexistentUUID := "4fd19336-671e-4ff3-8f22-662b80e22edd" - note, ok, err := GetNote(testutils.DB, nonexistentUUID, user) + note, ok, err := a.GetNote(nonexistentUUID, user) if err != nil { t.Fatal(errors.Wrap(err, "executing")) } diff --git a/pkg/server/operations/sessions.go b/pkg/server/app/sessions.go similarity index 77% rename from pkg/server/operations/sessions.go rename to pkg/server/app/sessions.go index 980d5c7c..0fc909e0 100644 --- a/pkg/server/operations/sessions.go +++ b/pkg/server/app/sessions.go @@ -16,19 +16,18 @@ * along with Dnote. If not, see . */ -package operations +package app import ( "time" "github.com/dnote/dnote/pkg/server/crypt" "github.com/dnote/dnote/pkg/server/database" - "github.com/jinzhu/gorm" "github.com/pkg/errors" ) // CreateSession returns a new session for the user of the given id -func CreateSession(db *gorm.DB, userID int) (database.Session, error) { +func (a *App) CreateSession(userID int) (database.Session, error) { key, err := crypt.GetRandomStr(32) if err != nil { return database.Session{}, errors.Wrap(err, "generating key") @@ -41,7 +40,7 @@ func CreateSession(db *gorm.DB, userID int) (database.Session, error) { ExpiresAt: time.Now().Add(24 * 100 * time.Hour), } - if err := db.Save(&session).Error; err != nil { + if err := a.DB.Save(&session).Error; err != nil { return database.Session{}, errors.Wrap(err, "saving session") } @@ -50,8 +49,8 @@ func CreateSession(db *gorm.DB, userID int) (database.Session, error) { // DeleteUserSessions deletes all existing sessions for the given user. It effectively // invalidates all existing sessions. -func DeleteUserSessions(db *gorm.DB, userID int) error { - if err := db.Where("user_id = ?", userID).Delete(&database.Session{}).Error; err != nil { +func (a *App) DeleteUserSessions(userID int) error { + if err := a.DB.Where("user_id = ?", userID).Delete(&database.Session{}).Error; err != nil { return errors.Wrap(err, "deleting sessions") } @@ -59,8 +58,8 @@ func DeleteUserSessions(db *gorm.DB, userID int) error { } // DeleteSession deletes the session that match the given info -func DeleteSession(db *gorm.DB, sessionKey string) error { - if err := db.Where("key = ?", sessionKey).Delete(&database.Session{}).Error; err != nil { +func (a *App) DeleteSession(sessionKey string) error { + if err := a.DB.Where("key = ?", sessionKey).Delete(&database.Session{}).Error; err != nil { return errors.Wrap(err, "deleting the session") } diff --git a/pkg/server/operations/subscriptions.go b/pkg/server/app/subscriptions.go similarity index 83% rename from pkg/server/operations/subscriptions.go rename to pkg/server/app/subscriptions.go index 6d51db2e..0da08239 100644 --- a/pkg/server/operations/subscriptions.go +++ b/pkg/server/app/subscriptions.go @@ -16,13 +16,12 @@ * along with Dnote. If not, see . */ -package operations +package app import ( "github.com/dnote/dnote/pkg/server/database" "github.com/pkg/errors" - "github.com/jinzhu/gorm" "github.com/stripe/stripe-go" "github.com/stripe/stripe-go/sub" ) @@ -32,7 +31,7 @@ import ( var ErrSubscriptionActive = errors.New("The subscription is currently active") // CancelSub cancels the subscription of the given user -func CancelSub(subscriptionID string, user database.User) error { +func (a *App) CancelSub(subscriptionID string, user database.User) error { updateParams := &stripe.SubscriptionParams{ CancelAtPeriodEnd: stripe.Bool(true), } @@ -46,7 +45,7 @@ func CancelSub(subscriptionID string, user database.User) error { } // ReactivateSub reactivates the subscription of the given user -func ReactivateSub(subscriptionID string, user database.User) error { +func (a *App) ReactivateSub(subscriptionID string, user database.User) error { s, err := sub.Get(subscriptionID, nil) if err != nil { return errors.Wrap(err, "fetching subscription") @@ -67,13 +66,13 @@ func ReactivateSub(subscriptionID string, user database.User) error { } // MarkUnsubscribed marks the user unsubscribed -func MarkUnsubscribed(db *gorm.DB, stripeCustomerID string) error { +func (a *App) MarkUnsubscribed(stripeCustomerID string) error { var user database.User - if err := db.Where("stripe_customer_id = ?", stripeCustomerID).First(&user).Error; err != nil { + if err := a.DB.Where("stripe_customer_id = ?", stripeCustomerID).First(&user).Error; err != nil { return errors.Wrap(err, "finding user") } - if err := db.Model(&user).Update("cloud", false).Error; err != nil { + if err := a.DB.Model(&user).Update("cloud", false).Error; err != nil { return errors.Wrap(err, "updating user") } diff --git a/pkg/server/app/testutils.go b/pkg/server/app/testutils.go new file mode 100644 index 00000000..55078180 --- /dev/null +++ b/pkg/server/app/testutils.go @@ -0,0 +1,64 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package app + +import ( + "os" + + "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/mailer" + "github.com/dnote/dnote/pkg/server/testutils" +) + +// NewTest returns an app for a testing environment +func NewTest(appParams *App) App { + emailTmplDir := os.Getenv("DNOTE_TEST_EMAIL_TEMPLATE_DIR") + + a := App{ + DB: testutils.DB, + WebURL: os.Getenv("WebURL"), + Clock: clock.NewMock(), + EmailTemplates: mailer.NewTemplates(&emailTmplDir), + EmailBackend: &testutils.MockEmailbackendImplementation{}, + StripeAPIBackend: nil, + OnPremise: false, + } + + // Allow to override with appParams + if appParams != nil && appParams.EmailBackend != nil { + a.EmailBackend = appParams.EmailBackend + } + if appParams != nil && appParams.Clock != nil { + a.Clock = appParams.Clock + } + if appParams != nil && appParams.EmailTemplates != nil { + a.EmailTemplates = appParams.EmailTemplates + } + if appParams != nil && appParams.StripeAPIBackend != nil { + a.StripeAPIBackend = appParams.StripeAPIBackend + } + if appParams != nil && appParams.OnPremise { + a.OnPremise = appParams.OnPremise + } + if appParams != nil && appParams.WebURL != "" { + a.WebURL = appParams.WebURL + } + + return a +} diff --git a/pkg/server/operations/users.go b/pkg/server/app/users.go similarity index 90% rename from pkg/server/operations/users.go rename to pkg/server/app/users.go index b616d524..081ec2fe 100644 --- a/pkg/server/operations/users.go +++ b/pkg/server/app/users.go @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -package operations +package app import ( "time" @@ -47,7 +47,7 @@ func generateVerificationCode() (string, error) { } // TouchLastLoginAt updates the last login timestamp -func TouchLastLoginAt(user database.User, tx *gorm.DB) error { +func (a *App) TouchLastLoginAt(user database.User, tx *gorm.DB) error { t := time.Now() if err := tx.Model(&user).Update(database.User{LastLoginAt: &t}).Error; err != nil { return errors.Wrap(err, "updating last_login_at") @@ -104,8 +104,8 @@ func createDefaultRepetitionRule(user database.User, tx *gorm.DB) error { } // CreateUser creates a user -func CreateUser(db *gorm.DB, email, password string) (database.User, error) { - tx := db.Begin() +func (a *App) CreateUser(email, password string) (database.User, error) { + tx := a.DB.Begin() hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { @@ -113,7 +113,17 @@ func CreateUser(db *gorm.DB, email, password string) (database.User, error) { return database.User{}, errors.Wrap(err, "hashing password") } - user := database.User{} + // Grant all privileges if self-hosting + var pro bool + if a.OnPremise { + pro = true + } else { + pro = false + } + + user := database.User{ + Cloud: pro, + } if err = tx.Save(&user).Error; err != nil { tx.Rollback() return database.User{}, errors.Wrap(err, "saving user") @@ -140,7 +150,7 @@ func CreateUser(db *gorm.DB, email, password string) (database.User, error) { tx.Rollback() return database.User{}, errors.Wrap(err, "creating default repetition rule") } - if err := TouchLastLoginAt(user, tx); err != nil { + if err := a.TouchLastLoginAt(user, tx); err != nil { tx.Rollback() return database.User{}, errors.Wrap(err, "updating last login") } diff --git a/pkg/server/app/users_test.go b/pkg/server/app/users_test.go new file mode 100644 index 00000000..4a75c571 --- /dev/null +++ b/pkg/server/app/users_test.go @@ -0,0 +1,66 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package app + +import ( + "fmt" + "testing" + + "github.com/dnote/dnote/pkg/assert" + "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/testutils" + "github.com/pkg/errors" +) + +func TestCreateUser(t *testing.T) { + testCases := []struct { + onPremise bool + expectedPro bool + }{ + { + onPremise: true, + expectedPro: true, + }, + { + onPremise: false, + expectedPro: false, + }, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("self hosting %t", tc.onPremise), func(t *testing.T) { + defer testutils.ClearData() + + a := NewTest(&App{ + OnPremise: tc.onPremise, + }) + if _, err := a.CreateUser("alice@example.com", "pass1234"); err != nil { + t.Fatal(errors.Wrap(err, "executing")) + } + + var userCount int + var userRecord database.User + testutils.MustExec(t, testutils.DB.Model(&database.User{}).Count(&userCount), "counting user") + testutils.MustExec(t, testutils.DB.First(&userRecord), "finding user") + + assert.Equal(t, userCount, 1, "book count mismatch") + assert.Equal(t, userRecord.Cloud, tc.expectedPro, "user pro mismatch") + }) + } +} diff --git a/pkg/server/dbconn/dbconn.go b/pkg/server/dbconn/dbconn.go index d55d1400..85f3194e 100644 --- a/pkg/server/dbconn/dbconn.go +++ b/pkg/server/dbconn/dbconn.go @@ -1,3 +1,21 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + package dbconn import ( diff --git a/pkg/server/handlers/auth.go b/pkg/server/handlers/auth.go index d6b00a7b..965d0fcd 100644 --- a/pkg/server/handlers/auth.go +++ b/pkg/server/handlers/auth.go @@ -27,7 +27,6 @@ import ( "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/helpers" "github.com/dnote/dnote/pkg/server/mailer" - "github.com/dnote/dnote/pkg/server/operations" "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" ) @@ -53,7 +52,7 @@ func makeSession(user database.User, account database.Account) Session { } } -func (a *App) getMe(w http.ResponseWriter, r *http.Request) { +func (a *API) getMe(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -61,7 +60,7 @@ func (a *App) getMe(w http.ResponseWriter, r *http.Request) { } var account database.Account - if err := a.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil { + if err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil { HandleError(w, "finding account", err, http.StatusInternalServerError) return } @@ -74,8 +73,8 @@ func (a *App) getMe(w http.ResponseWriter, r *http.Request) { User: session, } - tx := a.DB.Begin() - if err := operations.TouchLastLoginAt(user, tx); err != nil { + tx := a.App.DB.Begin() + if err := a.App.TouchLastLoginAt(user, tx); err != nil { tx.Rollback() // In case of an error, gracefully continue to avoid disturbing the service log.Println("error touching last_login_at", err.Error()) @@ -89,7 +88,7 @@ type createResetTokenPayload struct { Email string `json:"email"` } -func (a *App) createResetToken(w http.ResponseWriter, r *http.Request) { +func (a *API) createResetToken(w http.ResponseWriter, r *http.Request) { var params createResetTokenPayload if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { http.Error(w, "invalid payload", http.StatusBadRequest) @@ -97,7 +96,7 @@ func (a *App) createResetToken(w http.ResponseWriter, r *http.Request) { } var account database.Account - conn := a.DB.Where("email = ?", params.Email).First(&account) + conn := a.App.DB.Where("email = ?", params.Email).First(&account) if conn.RecordNotFound() { return } @@ -123,22 +122,19 @@ func (a *App) createResetToken(w http.ResponseWriter, r *http.Request) { Type: database.TokenTypeResetPassword, } - if err := a.DB.Save(&token).Error; err != nil { + if err := a.App.DB.Save(&token).Error; err != nil { HandleError(w, errors.Wrap(err, "saving token").Error(), nil, http.StatusInternalServerError) return } - body, err := a.EmailTemplates.Execute(mailer.EmailTypeResetPassword, mailer.EmailKindText, mailer.EmailResetPasswordTmplData{ - AccountEmail: account.Email.String, - Token: resetToken, - WebURL: a.WebURL, - }) - if err != nil { - HandleError(w, errors.Wrap(err, "executing reset password email template").Error(), nil, http.StatusInternalServerError) - } + if err := a.App.SendPasswordResetEmail(account.Email.String, resetToken); err != nil { + if errors.Cause(err) == mailer.ErrSMTPNotConfigured { + respondInvalidSMTPConfig(w) + } else { + HandleError(w, errors.Wrap(err, "sending password reset email").Error(), nil, http.StatusInternalServerError) + } - if err := a.EmailBackend.Queue("Reset your password", "sung@getdnote.com", []string{params.Email}, "text/plain", body); err != nil { - HandleError(w, errors.Wrap(err, "queueing email").Error(), nil, http.StatusInternalServerError) + return } } @@ -147,7 +143,7 @@ type resetPasswordPayload struct { Token string `json:"token"` } -func (a *App) resetPassword(w http.ResponseWriter, r *http.Request) { +func (a *API) resetPassword(w http.ResponseWriter, r *http.Request) { var params resetPasswordPayload if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { http.Error(w, "invalid payload", http.StatusBadRequest) @@ -155,7 +151,7 @@ func (a *App) resetPassword(w http.ResponseWriter, r *http.Request) { } var token database.Token - conn := a.DB.Where("value = ? AND type =? AND used_at IS NULL", params.Token, database.TokenTypeResetPassword).First(&token) + conn := a.App.DB.Where("value = ? AND type =? AND used_at IS NULL", params.Token, database.TokenTypeResetPassword).First(&token) if conn.RecordNotFound() { http.Error(w, "invalid token", http.StatusBadRequest) return @@ -176,7 +172,7 @@ func (a *App) resetPassword(w http.ResponseWriter, r *http.Request) { return } - tx := a.DB.Begin() + tx := a.App.DB.Begin() hashedPassword, err := bcrypt.GenerateFromPassword([]byte(params.Password), bcrypt.DefaultCost) if err != nil { @@ -186,7 +182,7 @@ func (a *App) resetPassword(w http.ResponseWriter, r *http.Request) { } var account database.Account - if err := a.DB.Where("user_id = ?", token.UserID).First(&account).Error; err != nil { + if err := a.App.DB.Where("user_id = ?", token.UserID).First(&account).Error; err != nil { tx.Rollback() HandleError(w, errors.Wrap(err, "finding user").Error(), nil, http.StatusInternalServerError) return @@ -206,10 +202,10 @@ func (a *App) resetPassword(w http.ResponseWriter, r *http.Request) { tx.Commit() var user database.User - if err := a.DB.Where("id = ?", account.UserID).First(&user).Error; err != nil { + if err := a.App.DB.Where("id = ?", account.UserID).First(&user).Error; err != nil { HandleError(w, errors.Wrap(err, "finding user").Error(), nil, http.StatusInternalServerError) return } - respondWithSession(a.DB, w, user.ID, http.StatusOK) + a.respondWithSession(a.App.DB, w, user.ID, http.StatusOK) } diff --git a/pkg/server/handlers/auth_test.go b/pkg/server/handlers/auth_test.go index ff3d8784..dcdc6b79 100644 --- a/pkg/server/handlers/auth_test.go +++ b/pkg/server/handlers/auth_test.go @@ -25,6 +25,7 @@ import ( "github.com/dnote/dnote/pkg/assert" "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/testutils" "golang.org/x/crypto/bcrypt" @@ -35,7 +36,7 @@ func TestGetMe(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) defer server.Close() @@ -62,7 +63,7 @@ func TestCreateResetToken(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -96,7 +97,7 @@ func TestCreateResetToken(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -126,7 +127,7 @@ func TestResetPassword(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -173,7 +174,7 @@ func TestResetPassword(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -212,7 +213,7 @@ func TestResetPassword(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -250,7 +251,7 @@ func TestResetPassword(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -299,7 +300,7 @@ func TestResetPassword(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) diff --git a/pkg/server/handlers/classic.go b/pkg/server/handlers/classic.go index 0f1b0c98..aaf8ffb4 100644 --- a/pkg/server/handlers/classic.go +++ b/pkg/server/handlers/classic.go @@ -26,13 +26,12 @@ import ( "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/helpers" "github.com/dnote/dnote/pkg/server/log" - "github.com/dnote/dnote/pkg/server/operations" "github.com/dnote/dnote/pkg/server/presenters" "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" ) -func (a *App) classicMigrate(w http.ResponseWriter, r *http.Request) { +func (a *API) classicMigrate(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -40,12 +39,12 @@ func (a *App) classicMigrate(w http.ResponseWriter, r *http.Request) { } var account database.Account - if err := a.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil { + if err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil { HandleError(w, "finding account", err, http.StatusInternalServerError) return } - if err := a.DB.Model(&account). + if err := a.App.DB.Model(&account). Update(map[string]interface{}{ "salt": "", "auth_key_hash": "", @@ -63,7 +62,7 @@ type PresigninResponse struct { Iteration int `json:"iteration"` } -func (a *App) classicPresignin(w http.ResponseWriter, r *http.Request) { +func (a *API) classicPresignin(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() email := q.Get("email") if email == "" { @@ -72,7 +71,7 @@ func (a *App) classicPresignin(w http.ResponseWriter, r *http.Request) { } var account database.Account - conn := a.DB.Where("email = ?", email).First(&account) + conn := a.App.DB.Where("email = ?", email).First(&account) if !conn.RecordNotFound() && conn.Error != nil { HandleError(w, "getting user", conn.Error, http.StatusInternalServerError) return @@ -101,7 +100,7 @@ type classicSigninPayload struct { AuthKey string `json:"auth_key"` } -func (a *App) classicSignin(w http.ResponseWriter, r *http.Request) { +func (a *API) classicSignin(w http.ResponseWriter, r *http.Request) { var params classicSigninPayload if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { HandleError(w, "decoding payload", err, http.StatusInternalServerError) @@ -114,7 +113,7 @@ func (a *App) classicSignin(w http.ResponseWriter, r *http.Request) { } var account database.Account - conn := a.DB.Where("email = ?", params.Email).First(&account) + conn := a.App.DB.Where("email = ?", params.Email).First(&account) if conn.RecordNotFound() { http.Error(w, ErrLoginFailure.Error(), http.StatusUnauthorized) return @@ -132,7 +131,7 @@ func (a *App) classicSignin(w http.ResponseWriter, r *http.Request) { return } - session, err := operations.CreateSession(a.DB, account.UserID) + session, err := a.App.CreateSession(account.UserID) if err != nil { HandleError(w, "creating session", nil, http.StatusBadRequest) return @@ -156,7 +155,7 @@ func (a *App) classicSignin(w http.ResponseWriter, r *http.Request) { } } -func (a *App) classicGetMe(w http.ResponseWriter, r *http.Request) { +func (a *API) classicGetMe(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -164,7 +163,7 @@ func (a *App) classicGetMe(w http.ResponseWriter, r *http.Request) { } var account database.Account - if err := a.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil { + if err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil { HandleError(w, "finding account", err, http.StatusInternalServerError) return } @@ -214,7 +213,7 @@ type classicSetPasswordPayload struct { Password string } -func (a *App) classicSetPassword(w http.ResponseWriter, r *http.Request) { +func (a *API) classicSetPassword(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -228,7 +227,7 @@ func (a *App) classicSetPassword(w http.ResponseWriter, r *http.Request) { } var account database.Account - if err := a.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil { + if err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil { HandleError(w, "getting user", nil, http.StatusInternalServerError) return } @@ -239,7 +238,7 @@ func (a *App) classicSetPassword(w http.ResponseWriter, r *http.Request) { return } - if err := a.DB.Model(&account).Update("password", string(hashedNewPassword)).Error; err != nil { + if err := a.App.DB.Model(&account).Update("password", string(hashedNewPassword)).Error; err != nil { http.Error(w, errors.Wrap(err, "updating password").Error(), http.StatusInternalServerError) return } @@ -247,7 +246,7 @@ func (a *App) classicSetPassword(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } -func (a *App) classicGetNotes(w http.ResponseWriter, r *http.Request) { +func (a *API) classicGetNotes(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -255,7 +254,7 @@ func (a *App) classicGetNotes(w http.ResponseWriter, r *http.Request) { } var notes []database.Note - if err := a.DB.Where("user_id = ? AND encrypted = true", user.ID).Find(¬es).Error; err != nil { + if err := a.App.DB.Where("user_id = ? AND encrypted = true", user.ID).Find(¬es).Error; err != nil { HandleError(w, "finding notes", err, http.StatusInternalServerError) return } diff --git a/pkg/server/handlers/classic_test.go b/pkg/server/handlers/classic_test.go index 0a01fc59..4c92712f 100644 --- a/pkg/server/handlers/classic_test.go +++ b/pkg/server/handlers/classic_test.go @@ -26,6 +26,7 @@ import ( "github.com/dnote/dnote/pkg/assert" "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/testutils" "github.com/pkg/errors" @@ -68,7 +69,7 @@ func TestClassicPresignin(t *testing.T) { t.Run(fmt.Sprintf("presignin %s", tc.email), func(t *testing.T) { // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) defer server.Close() @@ -96,7 +97,7 @@ func TestClassicPresignin_MissingParams(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) defer server.Close() @@ -118,7 +119,7 @@ func TestClassicSignin(t *testing.T) { testutils.MustExec(t, testutils.DB.Save(&alice), "saving alice") // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) defer server.Close() @@ -216,7 +217,7 @@ func TestClassicSignin_Failure(t *testing.T) { t.Run(fmt.Sprintf("signin %s %s", tc.email, tc.authKey), func(t *testing.T) { // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) defer server.Close() diff --git a/pkg/server/handlers/health.go b/pkg/server/handlers/health.go index 1cdc03ef..29e12020 100644 --- a/pkg/server/handlers/health.go +++ b/pkg/server/handlers/health.go @@ -22,7 +22,7 @@ import ( "net/http" ) -func (a *App) checkHealth(w http.ResponseWriter, r *http.Request) { +func (a *API) checkHealth(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) } diff --git a/pkg/server/handlers/health_test.go b/pkg/server/handlers/health_test.go index 78067e82..c2f07027 100644 --- a/pkg/server/handlers/health_test.go +++ b/pkg/server/handlers/health_test.go @@ -24,13 +24,14 @@ import ( "github.com/dnote/dnote/pkg/assert" "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/testutils" "github.com/jinzhu/gorm" ) func TestCheckHealth(t *testing.T) { // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ DB: &gorm.DB{}, Clock: clock.NewMock(), }) diff --git a/pkg/server/handlers/helpers.go b/pkg/server/handlers/helpers.go index 1fd4dcb7..87ddfabe 100644 --- a/pkg/server/handlers/helpers.go +++ b/pkg/server/handlers/helpers.go @@ -136,7 +136,7 @@ func respondJSON(w http.ResponseWriter, statusCode int, payload interface{}) { } // notSupported is the handler for the route that is no longer supported -func (a *App) notSupported(w http.ResponseWriter, r *http.Request) { +func (a *API) notSupported(w http.ResponseWriter, r *http.Request) { http.Error(w, "API version is not supported. Please upgrade your client.", http.StatusGone) return } @@ -155,3 +155,7 @@ func respondUnauthorized(w http.ResponseWriter) { func RespondNotFound(w http.ResponseWriter) { http.Error(w, "not found", http.StatusNotFound) } + +func respondInvalidSMTPConfig(w http.ResponseWriter) { + http.Error(w, "SMTP is not configured", http.StatusInternalServerError) +} diff --git a/pkg/server/handlers/main_test.go b/pkg/server/handlers/main_test.go index c090f138..a3d68bcc 100644 --- a/pkg/server/handlers/main_test.go +++ b/pkg/server/handlers/main_test.go @@ -1,3 +1,21 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + package handlers import ( diff --git a/pkg/server/handlers/notes.go b/pkg/server/handlers/notes.go index a850a82d..753718b6 100644 --- a/pkg/server/handlers/notes.go +++ b/pkg/server/handlers/notes.go @@ -28,7 +28,6 @@ import ( "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/helpers" - "github.com/dnote/dnote/pkg/server/operations" "github.com/dnote/dnote/pkg/server/presenters" "github.com/gorilla/mux" "github.com/jinzhu/gorm" @@ -99,8 +98,8 @@ func getNoteBaseQuery(db *gorm.DB, noteUUID string, search string) *gorm.DB { return conn } -func (a *App) getNote(w http.ResponseWriter, r *http.Request) { - user, _, err := AuthWithSession(a.DB, r, nil) +func (a *API) getNote(w http.ResponseWriter, r *http.Request) { + user, _, err := AuthWithSession(a.App.DB, r, nil) if err != nil { HandleError(w, "authenticating", err, http.StatusInternalServerError) return @@ -109,7 +108,7 @@ func (a *App) getNote(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) noteUUID := vars["noteUUID"] - note, ok, err := operations.GetNote(a.DB, noteUUID, user) + note, ok, err := a.App.GetNote(noteUUID, user) if !ok { RespondNotFound(w) return @@ -135,7 +134,7 @@ type dateRange struct { upper int64 } -func (a *App) getNotes(w http.ResponseWriter, r *http.Request) { +func (a *API) getNotes(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -143,7 +142,7 @@ func (a *App) getNotes(w http.ResponseWriter, r *http.Request) { } query := r.URL.Query() - respondGetNotes(a.DB, user.ID, query, w) + respondGetNotes(a.App.DB, user.ID, query, w) } func respondGetNotes(db *gorm.DB, userID int, query url.Values, w http.ResponseWriter) { @@ -305,7 +304,7 @@ func escapeSearchQuery(searchQuery string) string { return strings.Join(strings.Fields(searchQuery), "&") } -func (a *App) legacyGetNotes(w http.ResponseWriter, r *http.Request) { +func (a *API) legacyGetNotes(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -313,7 +312,7 @@ func (a *App) legacyGetNotes(w http.ResponseWriter, r *http.Request) { } var notes []database.Note - if err := a.DB.Where("user_id = ? AND encrypted = true", user.ID).Find(¬es).Error; err != nil { + if err := a.App.DB.Where("user_id = ? AND encrypted = true", user.ID).Find(¬es).Error; err != nil { HandleError(w, "finding notes", err, http.StatusInternalServerError) return } diff --git a/pkg/server/handlers/notes_test.go b/pkg/server/handlers/notes_test.go index 22ec8918..0cfca30d 100644 --- a/pkg/server/handlers/notes_test.go +++ b/pkg/server/handlers/notes_test.go @@ -28,6 +28,7 @@ import ( "github.com/dnote/dnote/pkg/assert" "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/presenters" "github.com/dnote/dnote/pkg/server/testutils" @@ -59,7 +60,7 @@ func TestGetNotes(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -171,7 +172,7 @@ func TestGetNote(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) diff --git a/pkg/server/handlers/repetition_rules.go b/pkg/server/handlers/repetition_rules.go index f363f557..72535bfd 100644 --- a/pkg/server/handlers/repetition_rules.go +++ b/pkg/server/handlers/repetition_rules.go @@ -30,7 +30,7 @@ import ( "github.com/pkg/errors" ) -func (a *App) getRepetitionRule(w http.ResponseWriter, r *http.Request) { +func (a *API) getRepetitionRule(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -46,7 +46,7 @@ func (a *App) getRepetitionRule(w http.ResponseWriter, r *http.Request) { } var repetitionRule database.RepetitionRule - if err := a.DB.Where("user_id = ? AND uuid = ?", user.ID, repetitionRuleUUID).Preload("Books").Find(&repetitionRule).Error; err != nil { + if err := a.App.DB.Where("user_id = ? AND uuid = ?", user.ID, repetitionRuleUUID).Preload("Books").Find(&repetitionRule).Error; err != nil { HandleError(w, "getting repetition rules", err, http.StatusInternalServerError) return } @@ -55,7 +55,7 @@ func (a *App) getRepetitionRule(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, resp) } -func (a *App) getRepetitionRules(w http.ResponseWriter, r *http.Request) { +func (a *API) getRepetitionRules(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -63,7 +63,7 @@ func (a *App) getRepetitionRules(w http.ResponseWriter, r *http.Request) { } var repetitionRules []database.RepetitionRule - if err := a.DB.Where("user_id = ?", user.ID).Preload("Books").Order("last_active DESC").Find(&repetitionRules).Error; err != nil { + if err := a.App.DB.Where("user_id = ?", user.ID).Preload("Books").Order("last_active DESC").Find(&repetitionRules).Error; err != nil { HandleError(w, "getting repetition rules", err, http.StatusInternalServerError) return } @@ -273,7 +273,7 @@ func calcNextActive(now time.Time, p calcNextActiveParams) int64 { return t0 + p.Frequency } -func (a *App) createRepetitionRule(w http.ResponseWriter, r *http.Request) { +func (a *API) createRepetitionRule(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -287,12 +287,12 @@ func (a *App) createRepetitionRule(w http.ResponseWriter, r *http.Request) { } var books []database.Book - if err := a.DB.Where("user_id = ? AND uuid IN (?)", user.ID, params.GetBookUUIDs()).Find(&books).Error; err != nil { + if err := a.App.DB.Where("user_id = ? AND uuid IN (?)", user.ID, params.GetBookUUIDs()).Find(&books).Error; err != nil { HandleError(w, "finding books", nil, http.StatusInternalServerError) return } - nextActive := calcNextActive(a.Clock.Now(), calcNextActiveParams{ + nextActive := calcNextActive(a.App.Clock.Now(), calcNextActiveParams{ Hour: params.GetHour(), Minute: params.GetMinute(), Frequency: params.GetFrequency(), @@ -310,7 +310,7 @@ func (a *App) createRepetitionRule(w http.ResponseWriter, r *http.Request) { NoteCount: params.GetNoteCount(), Enabled: params.GetEnabled(), } - if err := a.DB.Create(&record).Error; err != nil { + if err := a.App.DB.Create(&record).Error; err != nil { HandleError(w, "creating a repetition rule", err, http.StatusInternalServerError) return } @@ -333,7 +333,7 @@ func parseUpdateDigestParams(r *http.Request) (repetitionRuleParams, error) { return ret, nil } -func (a *App) deleteRepetitionRule(w http.ResponseWriter, r *http.Request) { +func (a *API) deleteRepetitionRule(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -344,7 +344,7 @@ func (a *App) deleteRepetitionRule(w http.ResponseWriter, r *http.Request) { repetitionRuleUUID := vars["repetitionRuleUUID"] var rule database.RepetitionRule - conn := a.DB.Where("uuid = ? AND user_id = ?", repetitionRuleUUID, user.ID).First(&rule) + conn := a.App.DB.Where("uuid = ? AND user_id = ?", repetitionRuleUUID, user.ID).First(&rule) if conn.RecordNotFound() { http.Error(w, "Not found", http.StatusNotFound) @@ -354,14 +354,14 @@ func (a *App) deleteRepetitionRule(w http.ResponseWriter, r *http.Request) { return } - if err := a.DB.Exec("DELETE from repetition_rules WHERE uuid = ?", rule.UUID).Error; err != nil { + if err := a.App.DB.Exec("DELETE from repetition_rules WHERE uuid = ?", rule.UUID).Error; err != nil { HandleError(w, "deleting the repetition rule", err, http.StatusInternalServerError) } w.WriteHeader(http.StatusOK) } -func (a *App) updateRepetitionRule(w http.ResponseWriter, r *http.Request) { +func (a *API) updateRepetitionRule(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -377,7 +377,7 @@ func (a *App) updateRepetitionRule(w http.ResponseWriter, r *http.Request) { return } - tx := a.DB.Begin() + tx := a.App.DB.Begin() var repetitionRule database.RepetitionRule if err := tx.Where("user_id = ? AND uuid = ?", user.ID, repetitionRuleUUID).Preload("Books").First(&repetitionRule).Error; err != nil { @@ -393,7 +393,7 @@ func (a *App) updateRepetitionRule(w http.ResponseWriter, r *http.Request) { repetitionRule.Enabled = enabled if enabled && !repetitionRule.Enabled { - repetitionRule.NextActive = calcNextActive(a.Clock.Now(), calcNextActiveParams{ + repetitionRule.NextActive = calcNextActive(a.App.Clock.Now(), calcNextActiveParams{ Hour: repetitionRule.Hour, Minute: repetitionRule.Minute, Frequency: repetitionRule.Frequency, @@ -412,7 +412,7 @@ func (a *App) updateRepetitionRule(w http.ResponseWriter, r *http.Request) { frequency := params.GetFrequency() repetitionRule.Frequency = frequency - repetitionRule.NextActive = calcNextActive(a.Clock.Now(), calcNextActiveParams{ + repetitionRule.NextActive = calcNextActive(a.App.Clock.Now(), calcNextActiveParams{ Hour: repetitionRule.Hour, Minute: repetitionRule.Minute, Frequency: frequency, diff --git a/pkg/server/handlers/repetition_rules_test.go b/pkg/server/handlers/repetition_rules_test.go index 97c9eadd..b977a453 100644 --- a/pkg/server/handlers/repetition_rules_test.go +++ b/pkg/server/handlers/repetition_rules_test.go @@ -27,6 +27,7 @@ import ( "github.com/dnote/dnote/pkg/assert" "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/presenters" "github.com/dnote/dnote/pkg/server/testutils" @@ -34,12 +35,12 @@ import ( ) func TestGetRepetitionRule(t *testing.T) { - + defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - + server := MustNewServer(t, &app.App{ + Clock: clock.NewMock(), }) defer server.Close() @@ -109,12 +110,12 @@ func TestGetRepetitionRule(t *testing.T) { } func TestGetRepetitionRules(t *testing.T) { - + defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - + server := MustNewServer(t, &app.App{ + Clock: clock.NewMock(), }) defer server.Close() @@ -215,7 +216,7 @@ func TestGetRepetitionRules(t *testing.T) { func TestCreateRepetitionRules(t *testing.T) { t.Run("all books", func(t *testing.T) { - + defer testutils.ClearData() // Setup @@ -223,8 +224,8 @@ func TestCreateRepetitionRules(t *testing.T) { t0 := time.Date(2009, time.November, 1, 2, 3, 4, 5, time.UTC) c.SetNow(t0) - server := MustNewServer(t, &App{ - + server := MustNewServer(t, &app.App{ + Clock: c, }) defer server.Close() @@ -274,7 +275,7 @@ func TestCreateRepetitionRules(t *testing.T) { } for _, tc := range bookDomainTestCases { t.Run(tc, func(t *testing.T) { - + defer testutils.ClearData() // Setup @@ -282,8 +283,8 @@ func TestCreateRepetitionRules(t *testing.T) { t0 := time.Date(2009, time.November, 1, 2, 3, 4, 5, time.UTC) c.SetNow(t0) - server := MustNewServer(t, &App{ - + server := MustNewServer(t, &app.App{ + Clock: c, }) defer server.Close() @@ -339,15 +340,15 @@ func TestCreateRepetitionRules(t *testing.T) { } func TestUpdateRepetitionRules(t *testing.T) { - + defer testutils.ClearData() // Setup c := clock.NewMock() t0 := time.Date(2009, time.November, 1, 2, 3, 4, 5, time.UTC) c.SetNow(t0) - server := MustNewServer(t, &App{ - + server := MustNewServer(t, &app.App{ + Clock: c, }) defer server.Close() @@ -417,12 +418,12 @@ func TestUpdateRepetitionRules(t *testing.T) { } func TestDeleteRepetitionRules(t *testing.T) { - + defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - + server := MustNewServer(t, &app.App{ + Clock: clock.NewMock(), }) defer server.Close() @@ -543,12 +544,12 @@ func TestCreateUpdateRepetitionRules_BadRequest(t *testing.T) { for idx, tc := range testCases { t.Run(fmt.Sprintf("test case - create %d", idx), func(t *testing.T) { - + defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - + server := MustNewServer(t, &app.App{ + Clock: clock.NewMock(), }) defer server.Close() @@ -568,7 +569,7 @@ func TestCreateUpdateRepetitionRules_BadRequest(t *testing.T) { }) t.Run(fmt.Sprintf("test case %d - update", idx), func(t *testing.T) { - + defer testutils.ClearData() // Setup @@ -592,8 +593,8 @@ func TestCreateUpdateRepetitionRules_BadRequest(t *testing.T) { } testutils.MustExec(t, testutils.DB.Save(&b1), "preparing book1") - server := MustNewServer(t, &App{ - + server := MustNewServer(t, &app.App{ + Clock: clock.NewMock(), }) defer server.Close() @@ -628,12 +629,12 @@ func TestCreateRepetitionRules_BadRequest(t *testing.T) { for idx, tc := range testCases { t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) { - + defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - + server := MustNewServer(t, &app.App{ + Clock: clock.NewMock(), }) defer server.Close() diff --git a/pkg/server/handlers/routes.go b/pkg/server/handlers/routes.go index 5981cc0a..1417677c 100644 --- a/pkg/server/handlers/routes.go +++ b/pkg/server/handlers/routes.go @@ -26,30 +26,16 @@ import ( "strings" "time" - "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/helpers" "github.com/dnote/dnote/pkg/server/log" - "github.com/dnote/dnote/pkg/server/mailer" "github.com/gorilla/mux" "github.com/jinzhu/gorm" "github.com/pkg/errors" "github.com/stripe/stripe-go" ) -var ( - // ErrEmptyDB is an error for missing database connection in the app configuration - ErrEmptyDB = errors.New("No database connection was provided") - // ErrEmptyClock is an error for missing clock in the app configuration - ErrEmptyClock = errors.New("No clock was provided") - // ErrEmptyWebURL is an error for missing WebURL content in the app configuration - ErrEmptyWebURL = errors.New("No WebURL was provided") - // ErrEmptyEmailTemplates is an error for missing EmailTemplates content in the app configuration - ErrEmptyEmailTemplates = errors.New("No EmailTemplate store was provided") - // ErrEmptyEmailBackend is an error for missing EmailBackend content in the app configuration - ErrEmptyEmailBackend = errors.New("No EmailBackend was provided") -) - // Route represents a single route type Route struct { Method string @@ -199,9 +185,9 @@ type AuthMiddlewareParams struct { ProOnly bool } -func (a *App) auth(next http.HandlerFunc, p *AuthMiddlewareParams) http.HandlerFunc { +func (a *API) auth(next http.HandlerFunc, p *AuthMiddlewareParams) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, ok, err := AuthWithSession(a.DB, r, p) + user, ok, err := AuthWithSession(a.App.DB, r, p) if !ok { respondUnauthorized(w) return @@ -223,9 +209,9 @@ func (a *App) auth(next http.HandlerFunc, p *AuthMiddlewareParams) http.HandlerF }) } -func (a *App) tokenAuth(next http.HandlerFunc, tokenType string, p *AuthMiddlewareParams) http.HandlerFunc { +func (a *API) tokenAuth(next http.HandlerFunc, tokenType string, p *AuthMiddlewareParams) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, token, ok, err := authWithToken(a.DB, r, tokenType, p) + user, token, ok, err := authWithToken(a.App.DB, r, tokenType, p) if err != nil { // log the error and continue log.ErrorWrap(err, "authenticating with token") @@ -237,7 +223,7 @@ func (a *App) tokenAuth(next http.HandlerFunc, tokenType string, p *AuthMiddlewa ctx = context.WithValue(ctx, helpers.KeyToken, token) } else { // If token-based auth fails, fall back to session-based auth - user, ok, err = AuthWithSession(a.DB, r, p) + user, ok, err = AuthWithSession(a.App.DB, r, p) if err != nil { HandleError(w, "authenticating with session", err, http.StatusInternalServerError) return @@ -316,54 +302,29 @@ func applyMiddleware(h http.HandlerFunc, rateLimit bool) http.Handler { return ret } -// App is an application configuration -type App struct { - DB *gorm.DB - Clock clock.Clock - StripeAPIBackend stripe.Backend - EmailTemplates mailer.Templates - EmailBackend mailer.Backend - WebURL string -} - -func (a *App) validate() error { - if a.WebURL == "" { - return ErrEmptyWebURL - } - if a.Clock == nil { - return ErrEmptyClock - } - if a.EmailTemplates == nil { - return ErrEmptyEmailTemplates - } - if a.EmailBackend == nil { - return ErrEmptyEmailBackend - } - if a.DB == nil { - return ErrEmptyDB - } - - return nil +// API is a web API configuration +type API struct { + App *app.App } // init sets up the application based on the configuration -func (a *App) init() error { - if err := a.validate(); err != nil { +func (a *API) init() error { + if err := a.App.Validate(); err != nil { return errors.Wrap(err, "validating the app parameters") } stripe.Key = os.Getenv("StripeSecretKey") - if a.StripeAPIBackend != nil { - stripe.SetBackend(stripe.APIBackend, a.StripeAPIBackend) + if a.App.StripeAPIBackend != nil { + stripe.SetBackend(stripe.APIBackend, a.App.StripeAPIBackend) } return nil } // NewRouter creates and returns a new router -func NewRouter(app *App) (*mux.Router, error) { - if err := app.init(); err != nil { +func (a *API) NewRouter() (*mux.Router, error) { + if err := a.init(); err != nil { return nil, errors.Wrap(err, "initializing app") } @@ -371,61 +332,61 @@ func NewRouter(app *App) (*mux.Router, error) { var routes = []Route{ // internal - {"GET", "/health", app.checkHealth, false}, - {"GET", "/me", app.auth(app.getMe, nil), true}, - {"POST", "/verification-token", app.auth(app.createVerificationToken, nil), true}, - {"PATCH", "/verify-email", app.verifyEmail, true}, - {"POST", "/reset-token", app.createResetToken, true}, - {"PATCH", "/reset-password", app.resetPassword, true}, - {"PATCH", "/account/profile", app.auth(app.updateProfile, nil), true}, - {"PATCH", "/account/password", app.auth(app.updatePassword, nil), true}, - {"GET", "/account/email-preference", app.tokenAuth(app.getEmailPreference, database.TokenTypeEmailPreference, nil), true}, - {"PATCH", "/account/email-preference", app.tokenAuth(app.updateEmailPreference, database.TokenTypeEmailPreference, nil), true}, - {"POST", "/subscriptions", app.auth(app.createSub, nil), true}, - {"PATCH", "/subscriptions", app.auth(app.updateSub, nil), true}, - {"POST", "/webhooks/stripe", app.stripeWebhook, true}, - {"GET", "/subscriptions", app.auth(app.getSub, nil), true}, - {"GET", "/stripe_source", app.auth(app.getStripeSource, nil), true}, - {"PATCH", "/stripe_source", app.auth(app.updateStripeSource, nil), true}, - {"GET", "/notes", app.auth(app.getNotes, nil), false}, - {"GET", "/notes/{noteUUID}", app.getNote, true}, - {"GET", "/calendar", app.auth(app.getCalendar, nil), true}, - {"GET", "/repetition_rules", app.auth(app.getRepetitionRules, nil), true}, - {"GET", "/repetition_rules/{repetitionRuleUUID}", app.tokenAuth(app.getRepetitionRule, database.TokenTypeRepetition, &proOnly), true}, - {"POST", "/repetition_rules", app.auth(app.createRepetitionRule, &proOnly), true}, - {"PATCH", "/repetition_rules/{repetitionRuleUUID}", app.tokenAuth(app.updateRepetitionRule, database.TokenTypeRepetition, &proOnly), true}, - {"DELETE", "/repetition_rules/{repetitionRuleUUID}", app.auth(app.deleteRepetitionRule, &proOnly), true}, + {"GET", "/health", a.checkHealth, false}, + {"GET", "/me", a.auth(a.getMe, nil), true}, + {"POST", "/verification-token", a.auth(a.createVerificationToken, nil), true}, + {"PATCH", "/verify-email", a.verifyEmail, true}, + {"POST", "/reset-token", a.createResetToken, true}, + {"PATCH", "/reset-password", a.resetPassword, true}, + {"PATCH", "/account/profile", a.auth(a.updateProfile, nil), true}, + {"PATCH", "/account/password", a.auth(a.updatePassword, nil), true}, + {"GET", "/account/email-preference", a.tokenAuth(a.getEmailPreference, database.TokenTypeEmailPreference, nil), true}, + {"PATCH", "/account/email-preference", a.tokenAuth(a.updateEmailPreference, database.TokenTypeEmailPreference, nil), true}, + {"POST", "/subscriptions", a.auth(a.createSub, nil), true}, + {"PATCH", "/subscriptions", a.auth(a.updateSub, nil), true}, + {"POST", "/webhooks/stripe", a.stripeWebhook, true}, + {"GET", "/subscriptions", a.auth(a.getSub, nil), true}, + {"GET", "/stripe_source", a.auth(a.getStripeSource, nil), true}, + {"PATCH", "/stripe_source", a.auth(a.updateStripeSource, nil), true}, + {"GET", "/notes", a.auth(a.getNotes, nil), false}, + {"GET", "/notes/{noteUUID}", a.getNote, true}, + {"GET", "/calendar", a.auth(a.getCalendar, nil), true}, + {"GET", "/repetition_rules", a.auth(a.getRepetitionRules, nil), true}, + {"GET", "/repetition_rules/{repetitionRuleUUID}", a.tokenAuth(a.getRepetitionRule, database.TokenTypeRepetition, &proOnly), true}, + {"POST", "/repetition_rules", a.auth(a.createRepetitionRule, &proOnly), true}, + {"PATCH", "/repetition_rules/{repetitionRuleUUID}", a.tokenAuth(a.updateRepetitionRule, database.TokenTypeRepetition, &proOnly), true}, + {"DELETE", "/repetition_rules/{repetitionRuleUUID}", a.auth(a.deleteRepetitionRule, &proOnly), true}, // migration of classic users - {"GET", "/classic/presignin", cors(app.classicPresignin), true}, - {"POST", "/classic/signin", cors(app.classicSignin), true}, - {"PATCH", "/classic/migrate", app.auth(app.classicMigrate, &proOnly), true}, - {"GET", "/classic/notes", app.auth(app.classicGetNotes, nil), true}, - {"PATCH", "/classic/set-password", app.auth(app.classicSetPassword, nil), true}, + {"GET", "/classic/presignin", cors(a.classicPresignin), true}, + {"POST", "/classic/signin", cors(a.classicSignin), true}, + {"PATCH", "/classic/migrate", a.auth(a.classicMigrate, &proOnly), true}, + {"GET", "/classic/notes", a.auth(a.classicGetNotes, nil), true}, + {"PATCH", "/classic/set-password", a.auth(a.classicSetPassword, nil), true}, // v3 - {"GET", "/v3/sync/fragment", cors(app.auth(app.GetSyncFragment, nil)), false}, - {"GET", "/v3/sync/state", cors(app.auth(app.GetSyncState, nil)), false}, - {"OPTIONS", "/v3/books", cors(app.BooksOptions), true}, - {"GET", "/v3/books", cors(app.auth(app.GetBooks, nil)), true}, - {"GET", "/v3/books/{bookUUID}", cors(app.auth(app.GetBook, nil)), true}, - {"POST", "/v3/books", cors(app.auth(app.CreateBook, nil)), false}, - {"PATCH", "/v3/books/{bookUUID}", cors(app.auth(app.UpdateBook, nil)), false}, - {"DELETE", "/v3/books/{bookUUID}", cors(app.auth(app.DeleteBook, nil)), false}, - {"OPTIONS", "/v3/notes", cors(app.NotesOptions), true}, - {"POST", "/v3/notes", cors(app.auth(app.CreateNote, nil)), false}, - {"PATCH", "/v3/notes/{noteUUID}", app.auth(app.UpdateNote, nil), false}, - {"DELETE", "/v3/notes/{noteUUID}", app.auth(app.DeleteNote, nil), false}, - {"POST", "/v3/signin", cors(app.signin), true}, - {"OPTIONS", "/v3/signout", cors(app.signoutOptions), true}, - {"POST", "/v3/signout", cors(app.signout), true}, - {"POST", "/v3/register", app.register, true}, + {"GET", "/v3/sync/fragment", cors(a.auth(a.GetSyncFragment, nil)), false}, + {"GET", "/v3/sync/state", cors(a.auth(a.GetSyncState, nil)), false}, + {"OPTIONS", "/v3/books", cors(a.BooksOptions), true}, + {"GET", "/v3/books", cors(a.auth(a.GetBooks, nil)), true}, + {"GET", "/v3/books/{bookUUID}", cors(a.auth(a.GetBook, nil)), true}, + {"POST", "/v3/books", cors(a.auth(a.CreateBook, nil)), false}, + {"PATCH", "/v3/books/{bookUUID}", cors(a.auth(a.UpdateBook, nil)), false}, + {"DELETE", "/v3/books/{bookUUID}", cors(a.auth(a.DeleteBook, nil)), false}, + {"OPTIONS", "/v3/notes", cors(a.NotesOptions), true}, + {"POST", "/v3/notes", cors(a.auth(a.CreateNote, nil)), false}, + {"PATCH", "/v3/notes/{noteUUID}", a.auth(a.UpdateNote, nil), false}, + {"DELETE", "/v3/notes/{noteUUID}", a.auth(a.DeleteNote, nil), false}, + {"POST", "/v3/signin", cors(a.signin), true}, + {"OPTIONS", "/v3/signout", cors(a.signoutOptions), true}, + {"POST", "/v3/signout", cors(a.signout), true}, + {"POST", "/v3/register", a.register, true}, } router := mux.NewRouter().StrictSlash(true) - router.PathPrefix("/v1").Handler(applyMiddleware(app.notSupported, true)) - router.PathPrefix("/v2").Handler(applyMiddleware(app.notSupported, true)) + router.PathPrefix("/v1").Handler(applyMiddleware(a.notSupported, true)) + router.PathPrefix("/v2").Handler(applyMiddleware(a.notSupported, true)) for _, route := range routes { handler := route.HandlerFunc diff --git a/pkg/server/handlers/routes_test.go b/pkg/server/handlers/routes_test.go index 15e2da71..f1e252a4 100644 --- a/pkg/server/handlers/routes_test.go +++ b/pkg/server/handlers/routes_test.go @@ -27,6 +27,7 @@ import ( "github.com/dnote/dnote/pkg/assert" "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/mailer" "github.com/dnote/dnote/pkg/server/testutils" @@ -202,8 +203,8 @@ func TestAuthMiddleware(t *testing.T) { handler := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } - app := App{DB: testutils.DB} - server := httptest.NewServer(app.auth(handler, nil)) + api := API{App: &app.App{DB: testutils.DB}} + server := httptest.NewServer(api.auth(handler, nil)) defer server.Close() t.Run("with header", func(t *testing.T) { @@ -310,8 +311,8 @@ func TestAuthMiddleware_ProOnly(t *testing.T) { handler := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } - app := App{DB: testutils.DB} - server := httptest.NewServer(app.auth(handler, &AuthMiddlewareParams{ + api := API{App: &app.App{DB: testutils.DB}} + server := httptest.NewServer(api.auth(handler, &AuthMiddlewareParams{ ProOnly: true, })) defer server.Close() @@ -403,8 +404,8 @@ func TestTokenAuthMiddleWare(t *testing.T) { handler := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } - app := App{DB: testutils.DB} - server := httptest.NewServer(app.tokenAuth(handler, database.TokenTypeEmailPreference, nil)) + api := API{App: &app.App{DB: testutils.DB}} + server := httptest.NewServer(api.tokenAuth(handler, database.TokenTypeEmailPreference, nil)) defer server.Close() t.Run("with token", func(t *testing.T) { @@ -533,8 +534,8 @@ func TestTokenAuthMiddleWare_ProOnly(t *testing.T) { handler := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } - app := App{DB: testutils.DB} - server := httptest.NewServer(app.tokenAuth(handler, database.TokenTypeEmailPreference, &AuthMiddlewareParams{ + api := API{App: &app.App{DB: testutils.DB}} + server := httptest.NewServer(api.tokenAuth(handler, database.TokenTypeEmailPreference, &AuthMiddlewareParams{ ProOnly: true, })) defer server.Close() @@ -671,7 +672,7 @@ func TestNotSupportedVersions(t *testing.T) { } // setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ DB: &gorm.DB{}, Clock: clock.NewMock(), }) @@ -691,11 +692,11 @@ func TestNotSupportedVersions(t *testing.T) { func TestNewRouter_AppValidate(t *testing.T) { testCases := []struct { - app App + app app.App expectedErr error }{ { - app: App{ + app: app.App{ DB: &gorm.DB{}, Clock: clock.NewMock(), StripeAPIBackend: nil, @@ -706,7 +707,7 @@ func TestNewRouter_AppValidate(t *testing.T) { expectedErr: nil, }, { - app: App{ + app: app.App{ DB: nil, Clock: clock.NewMock(), StripeAPIBackend: nil, @@ -714,10 +715,10 @@ func TestNewRouter_AppValidate(t *testing.T) { EmailBackend: &testutils.MockEmailbackendImplementation{}, WebURL: "http://mock.url", }, - expectedErr: ErrEmptyDB, + expectedErr: app.ErrEmptyDB, }, { - app: App{ + app: app.App{ DB: &gorm.DB{}, Clock: nil, StripeAPIBackend: nil, @@ -725,10 +726,10 @@ func TestNewRouter_AppValidate(t *testing.T) { EmailBackend: &testutils.MockEmailbackendImplementation{}, WebURL: "http://mock.url", }, - expectedErr: ErrEmptyClock, + expectedErr: app.ErrEmptyClock, }, { - app: App{ + app: app.App{ DB: &gorm.DB{}, Clock: clock.NewMock(), StripeAPIBackend: nil, @@ -736,10 +737,10 @@ func TestNewRouter_AppValidate(t *testing.T) { EmailBackend: &testutils.MockEmailbackendImplementation{}, WebURL: "http://mock.url", }, - expectedErr: ErrEmptyEmailTemplates, + expectedErr: app.ErrEmptyEmailTemplates, }, { - app: App{ + app: app.App{ DB: &gorm.DB{}, Clock: clock.NewMock(), StripeAPIBackend: nil, @@ -747,10 +748,10 @@ func TestNewRouter_AppValidate(t *testing.T) { EmailBackend: nil, WebURL: "http://mock.url", }, - expectedErr: ErrEmptyEmailBackend, + expectedErr: app.ErrEmptyEmailBackend, }, { - app: App{ + app: app.App{ DB: &gorm.DB{}, Clock: clock.NewMock(), StripeAPIBackend: nil, @@ -758,13 +759,14 @@ func TestNewRouter_AppValidate(t *testing.T) { EmailBackend: &testutils.MockEmailbackendImplementation{}, WebURL: "", }, - expectedErr: ErrEmptyWebURL, + expectedErr: app.ErrEmptyWebURL, }, } for idx, tc := range testCases { t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) { - _, err := NewRouter(&tc.app) + api := API{App: &tc.app} + _, err := api.NewRouter() assert.Equal(t, errors.Cause(err), tc.expectedErr, "error mismatch") }) diff --git a/pkg/server/handlers/subscription.go b/pkg/server/handlers/subscription.go index 1f727ca8..5024070b 100644 --- a/pkg/server/handlers/subscription.go +++ b/pkg/server/handlers/subscription.go @@ -26,9 +26,9 @@ import ( "os" "strings" + "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/helpers" - "github.com/dnote/dnote/pkg/server/operations" "github.com/jinzhu/gorm" "github.com/pkg/errors" "github.com/stripe/stripe-go" @@ -125,7 +125,7 @@ type createSubPayload struct { } // createSub creates a subscription for a the current user -func (a *App) createSub(w http.ResponseWriter, r *http.Request) { +func (a *API) createSub(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -138,7 +138,7 @@ func (a *App) createSub(w http.ResponseWriter, r *http.Request) { return } - tx := a.DB.Begin() + tx := a.App.DB.Begin() if err := tx.Model(&user). Update(map[string]interface{}{ @@ -214,7 +214,7 @@ func validateUpdateSubPayload(p updateSubPayload) error { return nil } -func (a *App) updateSub(w http.ResponseWriter, r *http.Request) { +func (a *API) updateSub(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -237,14 +237,14 @@ func (a *App) updateSub(w http.ResponseWriter, r *http.Request) { var err error if payload.Op == updateSubOpCancel { - err = operations.CancelSub(payload.StripeSubcriptionID, user) + err = a.App.CancelSub(payload.StripeSubcriptionID, user) } else if payload.Op == updateSubOpReactivate { - err = operations.ReactivateSub(payload.StripeSubcriptionID, user) + err = a.App.ReactivateSub(payload.StripeSubcriptionID, user) } if err != nil { var statusCode int - if err == operations.ErrSubscriptionActive { + if err == app.ErrSubscriptionActive { statusCode = http.StatusBadRequest } else { statusCode = http.StatusInternalServerError @@ -285,7 +285,7 @@ func respondWithEmptySub(w http.ResponseWriter) { } } -func (a *App) getSub(w http.ResponseWriter, r *http.Request) { +func (a *API) getSub(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -413,7 +413,7 @@ func validateUpdateStripeSourcePayload(p updateStripeSourcePayload) error { return nil } -func (a *App) updateStripeSource(w http.ResponseWriter, r *http.Request) { +func (a *API) updateStripeSource(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -430,7 +430,7 @@ func (a *App) updateStripeSource(w http.ResponseWriter, r *http.Request) { return } - tx := a.DB.Begin() + tx := a.App.DB.Begin() if err := tx.Model(&user). Update(map[string]interface{}{ @@ -469,7 +469,7 @@ func (a *App) updateStripeSource(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } -func (a *App) getStripeSource(w http.ResponseWriter, r *http.Request) { +func (a *API) getStripeSource(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -507,7 +507,7 @@ func (a *App) getStripeSource(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, resp) } -func (a *App) stripeWebhook(w http.ResponseWriter, req *http.Request) { +func (a *API) stripeWebhook(w http.ResponseWriter, req *http.Request) { body, err := ioutil.ReadAll(req.Body) if err != nil { HandleError(w, "reading body", err, http.StatusServiceUnavailable) @@ -530,7 +530,7 @@ func (a *App) stripeWebhook(w http.ResponseWriter, req *http.Request) { return } - operations.MarkUnsubscribed(a.DB, subscription.Customer.ID) + a.App.MarkUnsubscribed(subscription.Customer.ID) } default: { diff --git a/pkg/server/handlers/testutils.go b/pkg/server/handlers/testutils.go index d0cb8e21..9bcd71cc 100644 --- a/pkg/server/handlers/testutils.go +++ b/pkg/server/handlers/testutils.go @@ -20,29 +20,18 @@ package handlers import ( "net/http/httptest" - "os" "testing" - "github.com/dnote/dnote/pkg/server/mailer" - "github.com/dnote/dnote/pkg/server/testutils" + "github.com/dnote/dnote/pkg/server/app" "github.com/pkg/errors" ) // MustNewServer is a test utility function to initialize a new server // with the given app paratmers -func MustNewServer(t *testing.T, app *App) *httptest.Server { - app.WebURL = os.Getenv("WebURL") - app.DB = testutils.DB +func MustNewServer(t *testing.T, appParams *app.App) *httptest.Server { + api := NewTestAPI(appParams) - // If email backend was not provided, use the default mock backend - if app.EmailBackend == nil { - app.EmailBackend = &testutils.MockEmailbackendImplementation{} - } - - emailTmplDir := os.Getenv("DNOTE_TEST_EMAIL_TEMPLATE_DIR") - app.EmailTemplates = mailer.NewTemplates(&emailTmplDir) - - r, err := NewRouter(app) + r, err := api.NewRouter() if err != nil { t.Fatal(errors.Wrap(err, "initializing server")) } @@ -51,3 +40,10 @@ func MustNewServer(t *testing.T, app *App) *httptest.Server { return server } + +// NewTestAPI returns a new API for test +func NewTestAPI(appParams *app.App) API { + a := app.NewTest(appParams) + + return API{App: &a} +} diff --git a/pkg/server/handlers/user.go b/pkg/server/handlers/user.go index 95684163..f32f23a7 100644 --- a/pkg/server/handlers/user.go +++ b/pkg/server/handlers/user.go @@ -38,7 +38,7 @@ type updateProfilePayload struct { } // updateProfile updates user -func (a *App) updateProfile(w http.ResponseWriter, r *http.Request) { +func (a *API) updateProfile(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -59,13 +59,13 @@ func (a *App) updateProfile(w http.ResponseWriter, r *http.Request) { } var account database.Account - err = a.DB.Where("user_id = ?", user.ID).First(&account).Error + err = a.App.DB.Where("user_id = ?", user.ID).First(&account).Error if err != nil { HandleError(w, "finding account", err, http.StatusInternalServerError) return } - tx := a.DB.Begin() + tx := a.App.DB.Begin() if err := tx.Save(&user).Error; err != nil { tx.Rollback() HandleError(w, "saving user", err, http.StatusInternalServerError) @@ -86,7 +86,7 @@ func (a *App) updateProfile(w http.ResponseWriter, r *http.Request) { tx.Commit() - respondWithSession(a.DB, w, user.ID, http.StatusOK) + a.respondWithSession(a.App.DB, w, user.ID, http.StatusOK) } type updateEmailPayload struct { @@ -122,17 +122,17 @@ func respondWithCalendar(db *gorm.DB, w http.ResponseWriter, userID int) { respondJSON(w, http.StatusOK, payload) } -func (a *App) getCalendar(w http.ResponseWriter, r *http.Request) { +func (a *API) getCalendar(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } - respondWithCalendar(a.DB, w, user.ID) + respondWithCalendar(a.App.DB, w, user.ID) } -func (a *App) createVerificationToken(w http.ResponseWriter, r *http.Request) { +func (a *API) createVerificationToken(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -140,7 +140,7 @@ func (a *App) createVerificationToken(w http.ResponseWriter, r *http.Request) { } var account database.Account - err := a.DB.Where("user_id = ?", user.ID).First(&account).Error + err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error if err != nil { HandleError(w, "finding account", err, http.StatusInternalServerError) return @@ -167,20 +167,19 @@ func (a *App) createVerificationToken(w http.ResponseWriter, r *http.Request) { Type: database.TokenTypeEmailVerification, } - if err := a.DB.Save(&token).Error; err != nil { + if err := a.App.DB.Save(&token).Error; err != nil { HandleError(w, "saving token", err, http.StatusInternalServerError) return } - body, err := a.EmailTemplates.Execute(mailer.EmailTypeEmailVerification, mailer.EmailKindText, mailer.EmailVerificationTmplData{ - Token: tokenValue, - WebURL: a.WebURL, - }) - if err != nil { - HandleError(w, errors.Wrap(err, "executing reset verification template").Error(), nil, http.StatusInternalServerError) - } - if err := a.EmailBackend.Queue("Verify your Dnote email address", "sung@getdnote.com", []string{account.Email.String}, "text/plain", body); err != nil { - HandleError(w, errors.Wrap(err, "queueing email").Error(), nil, http.StatusInternalServerError) + if err := a.App.SendVerificationEmail(account.Email.String, tokenValue); err != nil { + if errors.Cause(err) == mailer.ErrSMTPNotConfigured { + respondInvalidSMTPConfig(w) + } else { + HandleError(w, errors.Wrap(err, "sending verification email").Error(), nil, http.StatusInternalServerError) + } + + return } w.WriteHeader(http.StatusCreated) @@ -190,7 +189,7 @@ type verifyEmailPayload struct { Token string `json:"token"` } -func (a *App) verifyEmail(w http.ResponseWriter, r *http.Request) { +func (a *API) verifyEmail(w http.ResponseWriter, r *http.Request) { var params verifyEmailPayload if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { HandleError(w, "decoding payload", err, http.StatusInternalServerError) @@ -198,7 +197,7 @@ func (a *App) verifyEmail(w http.ResponseWriter, r *http.Request) { } var token database.Token - if err := a.DB. + if err := a.App.DB. Where("value = ? AND type = ?", params.Token, database.TokenTypeEmailVerification). First(&token).Error; err != nil { http.Error(w, "invalid token", http.StatusBadRequest) @@ -217,7 +216,7 @@ func (a *App) verifyEmail(w http.ResponseWriter, r *http.Request) { } var account database.Account - if err := a.DB.Where("user_id = ?", token.UserID).First(&account).Error; err != nil { + if err := a.App.DB.Where("user_id = ?", token.UserID).First(&account).Error; err != nil { HandleError(w, "finding account", err, http.StatusInternalServerError) return } @@ -226,7 +225,7 @@ func (a *App) verifyEmail(w http.ResponseWriter, r *http.Request) { return } - tx := a.DB.Begin() + tx := a.App.DB.Begin() account.EmailVerified = true if err := tx.Save(&account).Error; err != nil { tx.Rollback() @@ -241,7 +240,7 @@ func (a *App) verifyEmail(w http.ResponseWriter, r *http.Request) { tx.Commit() var user database.User - if err := a.DB.Where("id = ?", token.UserID).First(&user).Error; err != nil { + if err := a.App.DB.Where("id = ?", token.UserID).First(&user).Error; err != nil { HandleError(w, "finding user", err, http.StatusInternalServerError) return } @@ -254,7 +253,7 @@ type updateEmailPreferencePayload struct { DigestWeekly bool `json:"digest_weekly"` } -func (a *App) updateEmailPreference(w http.ResponseWriter, r *http.Request) { +func (a *API) updateEmailPreference(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -268,12 +267,12 @@ func (a *App) updateEmailPreference(w http.ResponseWriter, r *http.Request) { } var frequency database.EmailPreference - if err := a.DB.Where(database.EmailPreference{UserID: user.ID}).FirstOrCreate(&frequency).Error; err != nil { + if err := a.App.DB.Where(database.EmailPreference{UserID: user.ID}).FirstOrCreate(&frequency).Error; err != nil { HandleError(w, "finding frequency", err, http.StatusInternalServerError) return } - tx := a.DB.Begin() + tx := a.App.DB.Begin() frequency.DigestWeekly = params.DigestWeekly if err := tx.Save(&frequency).Error; err != nil { @@ -297,7 +296,7 @@ func (a *App) updateEmailPreference(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, frequency) } -func (a *App) getEmailPreference(w http.ResponseWriter, r *http.Request) { +func (a *API) getEmailPreference(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -305,7 +304,7 @@ func (a *App) getEmailPreference(w http.ResponseWriter, r *http.Request) { } var pref database.EmailPreference - if err := a.DB.Where(database.EmailPreference{UserID: user.ID}).First(&pref).Error; err != nil { + if err := a.App.DB.Where(database.EmailPreference{UserID: user.ID}).First(&pref).Error; err != nil { HandleError(w, "finding pref", err, http.StatusInternalServerError) return } @@ -319,7 +318,7 @@ type updatePasswordPayload struct { NewPassword string `json:"new_password"` } -func (a *App) updatePassword(w http.ResponseWriter, r *http.Request) { +func (a *API) updatePassword(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -337,7 +336,7 @@ func (a *App) updatePassword(w http.ResponseWriter, r *http.Request) { } var account database.Account - if err := a.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil { + if err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil { HandleError(w, "getting user", nil, http.StatusInternalServerError) return } @@ -362,7 +361,7 @@ func (a *App) updatePassword(w http.ResponseWriter, r *http.Request) { return } - if err := a.DB.Model(&account).Update("password", string(hashedNewPassword)).Error; err != nil { + if err := a.App.DB.Model(&account).Update("password", string(hashedNewPassword)).Error; err != nil { http.Error(w, errors.Wrap(err, "updating password").Error(), http.StatusInternalServerError) return } diff --git a/pkg/server/handlers/user_test.go b/pkg/server/handlers/user_test.go index e802b0ef..ea37f8b3 100644 --- a/pkg/server/handlers/user_test.go +++ b/pkg/server/handlers/user_test.go @@ -27,6 +27,7 @@ import ( "github.com/dnote/dnote/pkg/assert" "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/presenters" "github.com/dnote/dnote/pkg/server/testutils" @@ -40,8 +41,7 @@ func TestUpdatePassword(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) defer server.Close() @@ -69,7 +69,7 @@ func TestUpdatePassword(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -96,7 +96,7 @@ func TestUpdatePassword(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -125,7 +125,7 @@ func TestCreateVerificationToken(t *testing.T) { // Setup emailBackend := testutils.MockEmailbackendImplementation{} - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), EmailBackend: &emailBackend, }) @@ -160,7 +160,7 @@ func TestCreateVerificationToken(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -194,8 +194,7 @@ func TestVerifyEmail(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) defer server.Close() @@ -236,8 +235,7 @@ func TestVerifyEmail(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) defer server.Close() @@ -281,8 +279,7 @@ func TestVerifyEmail(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) defer server.Close() @@ -324,8 +321,7 @@ func TestVerifyEmail(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) defer server.Close() @@ -370,8 +366,7 @@ func TestUpdateEmail(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) defer server.Close() @@ -405,8 +400,7 @@ func TestUpdateEmailPreference(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) defer server.Close() @@ -432,7 +426,7 @@ func TestUpdateEmailPreference(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -473,7 +467,7 @@ func TestUpdateEmailPreference(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -508,7 +502,7 @@ func TestUpdateEmailPreference(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -545,7 +539,7 @@ func TestUpdateEmailPreference(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -582,7 +576,7 @@ func TestUpdateEmailPreference(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -609,7 +603,7 @@ func TestUpdateEmailPreference(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -643,10 +637,9 @@ func TestUpdateEmailPreference(t *testing.T) { } func TestGetEmailPreference(t *testing.T) { - defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) diff --git a/pkg/server/handlers/v3_auth.go b/pkg/server/handlers/v3_auth.go index 30cf363f..9104d1bf 100644 --- a/pkg/server/handlers/v3_auth.go +++ b/pkg/server/handlers/v3_auth.go @@ -25,8 +25,6 @@ import ( "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/log" - "github.com/dnote/dnote/pkg/server/mailer" - "github.com/dnote/dnote/pkg/server/operations" "github.com/jinzhu/gorm" "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" @@ -80,7 +78,7 @@ type signinPayload struct { Password string `json:"password"` } -func (a *App) signin(w http.ResponseWriter, r *http.Request) { +func (a *API) signin(w http.ResponseWriter, r *http.Request) { var params signinPayload err := json.NewDecoder(r.Body).Decode(¶ms) if err != nil { @@ -93,7 +91,7 @@ func (a *App) signin(w http.ResponseWriter, r *http.Request) { } var account database.Account - conn := a.DB.Where("email = ?", params.Email).First(&account) + conn := a.App.DB.Where("email = ?", params.Email).First(&account) if conn.RecordNotFound() { http.Error(w, ErrLoginFailure.Error(), http.StatusUnauthorized) return @@ -110,27 +108,27 @@ func (a *App) signin(w http.ResponseWriter, r *http.Request) { } var user database.User - err = a.DB.Where("id = ?", account.UserID).First(&user).Error + err = a.App.DB.Where("id = ?", account.UserID).First(&user).Error if err != nil { HandleError(w, "finding user", err, http.StatusInternalServerError) return } - err = operations.TouchLastLoginAt(user, a.DB) + err = a.App.TouchLastLoginAt(user, a.App.DB) if err != nil { http.Error(w, errors.Wrap(err, "touching login timestamp").Error(), http.StatusInternalServerError) return } - respondWithSession(a.DB, w, account.UserID, http.StatusOK) + a.respondWithSession(a.App.DB, w, account.UserID, http.StatusOK) } -func (a *App) signoutOptions(w http.ResponseWriter, r *http.Request) { +func (a *API) signoutOptions(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Methods", "POST") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Version") } -func (a *App) signout(w http.ResponseWriter, r *http.Request) { +func (a *API) signout(w http.ResponseWriter, r *http.Request) { key, err := getCredential(r) if err != nil { HandleError(w, "getting credential", nil, http.StatusInternalServerError) @@ -142,7 +140,7 @@ func (a *App) signout(w http.ResponseWriter, r *http.Request) { return } - err = operations.DeleteSession(a.DB, key) + err = a.App.DeleteSession(key) if err != nil { HandleError(w, "deleting session", nil, http.StatusInternalServerError) return @@ -177,7 +175,7 @@ func parseRegisterPaylaod(r *http.Request) (registerPayload, error) { return ret, nil } -func (a *App) register(w http.ResponseWriter, r *http.Request) { +func (a *API) register(w http.ResponseWriter, r *http.Request) { params, err := parseRegisterPaylaod(r) if err != nil { http.Error(w, "invalid payload", http.StatusBadRequest) @@ -189,7 +187,7 @@ func (a *App) register(w http.ResponseWriter, r *http.Request) { } var count int - if err := a.DB.Model(database.Account{}).Where("email = ?", params.Email).Count(&count).Error; err != nil { + if err := a.App.DB.Model(database.Account{}).Where("email = ?", params.Email).Count(&count).Error; err != nil { HandleError(w, "checking duplicate user", err, http.StatusInternalServerError) return } @@ -198,36 +196,23 @@ func (a *App) register(w http.ResponseWriter, r *http.Request) { return } - user, err := operations.CreateUser(a.DB, params.Email, params.Password) + user, err := a.App.CreateUser(params.Email, params.Password) if err != nil { HandleError(w, "creating user", err, http.StatusInternalServerError) return } - respondWithSession(a.DB, w, user.ID, http.StatusCreated) + a.respondWithSession(a.App.DB, w, user.ID, http.StatusCreated) - // send welcome email - body, err := a.EmailTemplates.Execute(mailer.EmailTypeWelcome, mailer.EmailKindText, mailer.WelcomeTmplData{ - AccountEmail: params.Email, - WebURL: a.WebURL, - }) - if err != nil { - log.WithFields(log.Fields{ - "email": params.Email, - }).ErrorWrap(err, "executing welcome email template") - return - } - if err := a.EmailBackend.Queue("Welcome to Dnote!", "sung@getdnote.com", []string{params.Email}, "text/plain", body); err != nil { - log.WithFields(log.Fields{ - "email": params.Email, - }).ErrorWrap(err, "queueing email") + if err := a.App.SendWelcomeEmail(params.Email); err != nil { + log.ErrorWrap(err, "sending welcome email") } } // respondWithSession makes a HTTP response with the session from the user with the given userID. // It sets the HTTP-Only cookie for browser clients and also sends a JSON response for non-browser clients. -func respondWithSession(db *gorm.DB, w http.ResponseWriter, userID int, statusCode int) { - session, err := operations.CreateSession(db, userID) +func (a *API) respondWithSession(db *gorm.DB, w http.ResponseWriter, userID int, statusCode int) { + session, err := a.App.CreateSession(userID) if err != nil { HandleError(w, "creating session", nil, http.StatusBadRequest) return diff --git a/pkg/server/handlers/v3_auth_test.go b/pkg/server/handlers/v3_auth_test.go index f691e9d5..5632cf3a 100644 --- a/pkg/server/handlers/v3_auth_test.go +++ b/pkg/server/handlers/v3_auth_test.go @@ -27,6 +27,7 @@ import ( "github.com/dnote/dnote/pkg/assert" "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/testutils" "github.com/pkg/errors" @@ -58,20 +59,35 @@ func assertSessionResp(t *testing.T, res *http.Response) { func TestRegister(t *testing.T) { testCases := []struct { - email string - password string + email string + password string + onPremise bool + expectedPro bool }{ { - email: "alice@example.com", - password: "pass1234", + email: "alice@example.com", + password: "pass1234", + onPremise: false, + expectedPro: false, }, { - email: "bob@example.com", - password: "Y9EwmjH@Jq6y5a64MSACUoM4w7SAhzvY", + email: "bob@example.com", + password: "Y9EwmjH@Jq6y5a64MSACUoM4w7SAhzvY", + onPremise: false, + expectedPro: false, }, { - email: "chuck@example.com", - password: "e*H@kJi^vXbWEcD9T5^Am!Y@7#Po2@PC", + email: "chuck@example.com", + password: "e*H@kJi^vXbWEcD9T5^Am!Y@7#Po2@PC", + onPremise: false, + expectedPro: false, + }, + // on premise + { + email: "dan@example.com", + password: "e*H@kJi^vXbWEcD9T5^Am!Y@7#Po2@PC", + onPremise: true, + expectedPro: true, }, } @@ -81,9 +97,10 @@ func TestRegister(t *testing.T) { // Setup emailBackend := testutils.MockEmailbackendImplementation{} - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), EmailBackend: &emailBackend, + OnPremise: tc.onPremise, }) defer server.Close() @@ -105,7 +122,7 @@ func TestRegister(t *testing.T) { var user database.User testutils.MustExec(t, testutils.DB.Where("id = ?", account.UserID).First(&user), "finding user") - assert.Equal(t, user.Cloud, false, "Cloud mismatch") + assert.Equal(t, user.Cloud, tc.expectedPro, "Cloud mismatch") assert.Equal(t, user.StripeCustomerID, "", "StripeCustomerID mismatch") assert.Equal(t, user.MaxUSN, 0, "MaxUSN mismatch") @@ -125,11 +142,10 @@ func TestRegister(t *testing.T) { func TestRegisterMissingParams(t *testing.T) { t.Run("missing email", func(t *testing.T) { - defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -157,7 +173,7 @@ func TestRegisterMissingParams(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -185,7 +201,7 @@ func TestRegisterDuplicateEmail(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -219,11 +235,10 @@ func TestRegisterDuplicateEmail(t *testing.T) { func TestSignIn(t *testing.T) { t.Run("success", func(t *testing.T) { - defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -250,11 +265,10 @@ func TestSignIn(t *testing.T) { }) t.Run("wrong password", func(t *testing.T) { - defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -286,7 +300,7 @@ func TestSignIn(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -318,7 +332,7 @@ func TestSignIn(t *testing.T) { defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -362,7 +376,7 @@ func TestSignout(t *testing.T) { testutils.MustExec(t, testutils.DB.Save(&session2), "preparing session2") // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) @@ -414,7 +428,7 @@ func TestSignout(t *testing.T) { testutils.MustExec(t, testutils.DB.Save(&session2), "preparing session2") // Setup - server := MustNewServer(t, &App{ + server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), }) diff --git a/pkg/server/handlers/v3_books.go b/pkg/server/handlers/v3_books.go index 2cac6d61..0e6d2c53 100644 --- a/pkg/server/handlers/v3_books.go +++ b/pkg/server/handlers/v3_books.go @@ -26,7 +26,6 @@ import ( "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/helpers" - "github.com/dnote/dnote/pkg/server/operations" "github.com/dnote/dnote/pkg/server/presenters" "github.com/gorilla/mux" "github.com/jinzhu/gorm" @@ -51,7 +50,7 @@ func validateCreateBookPayload(p createBookPayload) error { } // CreateBook creates a new book -func (a *App) CreateBook(w http.ResponseWriter, r *http.Request) { +func (a *API) CreateBook(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { return @@ -71,7 +70,7 @@ func (a *App) CreateBook(w http.ResponseWriter, r *http.Request) { } var bookCount int - err = a.DB.Model(database.Book{}). + err = a.App.DB.Model(database.Book{}). Where("user_id = ? AND label = ?", user.ID, params.Name). Count(&bookCount).Error if err != nil { @@ -83,7 +82,7 @@ func (a *App) CreateBook(w http.ResponseWriter, r *http.Request) { return } - book, err := operations.CreateBook(a.DB, user, a.Clock, params.Name) + book, err := a.App.CreateBook(user, params.Name) if err != nil { HandleError(w, "inserting book", err, http.StatusInternalServerError) } @@ -94,7 +93,7 @@ func (a *App) CreateBook(w http.ResponseWriter, r *http.Request) { } // BooksOptions is a handler for OPTIONS endpoint for notes -func (a *App) BooksOptions(w http.ResponseWriter, r *http.Request) { +func (a *API) BooksOptions(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Methods", "GET, POST") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Version") } @@ -130,7 +129,7 @@ func respondWithBooks(db *gorm.DB, userID int, query url.Values, w http.Response } // GetBooks returns books for the user -func (a *App) GetBooks(w http.ResponseWriter, r *http.Request) { +func (a *API) GetBooks(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { return @@ -138,11 +137,11 @@ func (a *App) GetBooks(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() - respondWithBooks(a.DB, user.ID, query, w) + respondWithBooks(a.App.DB, user.ID, query, w) } // GetBook returns a book for the user -func (a *App) GetBook(w http.ResponseWriter, r *http.Request) { +func (a *API) GetBook(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { return @@ -152,7 +151,7 @@ func (a *App) GetBook(w http.ResponseWriter, r *http.Request) { bookUUID := vars["bookUUID"] var book database.Book - conn := a.DB.Where("uuid = ? AND user_id = ?", bookUUID, user.ID).First(&book) + conn := a.App.DB.Where("uuid = ? AND user_id = ?", bookUUID, user.ID).First(&book) if conn.RecordNotFound() { w.WriteHeader(http.StatusNotFound) @@ -177,7 +176,7 @@ type UpdateBookResp struct { } // UpdateBook updates a book -func (a *App) UpdateBook(w http.ResponseWriter, r *http.Request) { +func (a *API) UpdateBook(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { return @@ -186,7 +185,7 @@ func (a *App) UpdateBook(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) uuid := vars["bookUUID"] - tx := a.DB.Begin() + tx := a.App.DB.Begin() var book database.Book if err := tx.Where("user_id = ? AND uuid = ?", user.ID, uuid).First(&book).Error; err != nil { @@ -201,7 +200,7 @@ func (a *App) UpdateBook(w http.ResponseWriter, r *http.Request) { return } - book, err = operations.UpdateBook(tx, a.Clock, user, book, params.Name) + book, err = a.App.UpdateBook(tx, user, book, params.Name) if err != nil { tx.Rollback() HandleError(w, "updating a book", err, http.StatusInternalServerError) @@ -222,7 +221,7 @@ type DeleteBookResp struct { } // DeleteBook removes a book -func (a *App) DeleteBook(w http.ResponseWriter, r *http.Request) { +func (a *API) DeleteBook(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { return @@ -231,7 +230,7 @@ func (a *App) DeleteBook(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) uuid := vars["bookUUID"] - tx := a.DB.Begin() + tx := a.App.DB.Begin() var book database.Book if err := tx.Where("user_id = ? AND uuid = ?", user.ID, uuid).First(&book).Error; err != nil { @@ -246,12 +245,12 @@ func (a *App) DeleteBook(w http.ResponseWriter, r *http.Request) { } for _, note := range notes { - if _, err := operations.DeleteNote(tx, user, note); err != nil { + if _, err := a.App.DeleteNote(tx, user, note); err != nil { HandleError(w, "deleting a note", err, http.StatusInternalServerError) return } } - b, err := operations.DeleteBook(tx, user, book) + b, err := a.App.DeleteBook(tx, user, book) if err != nil { HandleError(w, "deleting book", err, http.StatusInternalServerError) return diff --git a/pkg/server/handlers/v3_books_test.go b/pkg/server/handlers/v3_books_test.go index 375275fc..43c79b0b 100644 --- a/pkg/server/handlers/v3_books_test.go +++ b/pkg/server/handlers/v3_books_test.go @@ -26,6 +26,7 @@ import ( "github.com/dnote/dnote/pkg/assert" "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/presenters" "github.com/dnote/dnote/pkg/server/testutils" @@ -33,13 +34,13 @@ import ( ) func TestGetBooks(t *testing.T) { - + defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - -Clock: clock.NewMock(), + server := MustNewServer(t, &app.App{ + + Clock: clock.NewMock(), }) defer server.Close() @@ -113,13 +114,13 @@ Clock: clock.NewMock(), } func TestGetBooksByName(t *testing.T) { - + defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - -Clock: clock.NewMock(), + server := MustNewServer(t, &app.App{ + + Clock: clock.NewMock(), }) defer server.Close() @@ -199,13 +200,13 @@ func TestDeleteBook(t *testing.T) { for _, tc := range testCases { t.Run(fmt.Sprintf("originally deleted %t", tc.deleted), func(t *testing.T) { - + defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - -Clock: clock.NewMock(), + server := MustNewServer(t, &app.App{ + + Clock: clock.NewMock(), }) defer server.Close() @@ -350,13 +351,13 @@ Clock: clock.NewMock(), } func TestCreateBook(t *testing.T) { - + defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - -Clock: clock.NewMock(), + server := MustNewServer(t, &app.App{ + + Clock: clock.NewMock(), }) defer server.Close() @@ -410,13 +411,13 @@ Clock: clock.NewMock(), } func TestCreateBookDuplicate(t *testing.T) { - + defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - -Clock: clock.NewMock(), + server := MustNewServer(t, &app.App{ + + Clock: clock.NewMock(), }) defer server.Close() @@ -490,13 +491,13 @@ func TestUpdateBook(t *testing.T) { for idx, tc := range testCases { func() { - + defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - -Clock: clock.NewMock(), + server := MustNewServer(t, &app.App{ + + Clock: clock.NewMock(), }) defer server.Close() diff --git a/pkg/server/handlers/v3_notes.go b/pkg/server/handlers/v3_notes.go index 65dc9f17..74cd0bab 100644 --- a/pkg/server/handlers/v3_notes.go +++ b/pkg/server/handlers/v3_notes.go @@ -23,9 +23,9 @@ import ( "fmt" "net/http" + "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/helpers" - "github.com/dnote/dnote/pkg/server/operations" "github.com/dnote/dnote/pkg/server/presenters" "github.com/gorilla/mux" "github.com/pkg/errors" @@ -47,7 +47,7 @@ func validateUpdateNotePayload(p updateNotePayload) bool { } // UpdateNote updates note -func (a *App) UpdateNote(w http.ResponseWriter, r *http.Request) { +func (a *API) UpdateNote(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) noteUUID := vars["noteUUID"] @@ -70,14 +70,14 @@ func (a *App) UpdateNote(w http.ResponseWriter, r *http.Request) { } var note database.Note - if err := a.DB.Where("uuid = ? AND user_id = ?", noteUUID, user.ID).First(¬e).Error; err != nil { + if err := a.App.DB.Where("uuid = ? AND user_id = ?", noteUUID, user.ID).First(¬e).Error; err != nil { HandleError(w, "finding note", err, http.StatusInternalServerError) return } - tx := a.DB.Begin() + tx := a.App.DB.Begin() - note, err = operations.UpdateNote(tx, user, a.Clock, note, &operations.UpdateNoteParams{ + note, err = a.App.UpdateNote(tx, user, note, &app.UpdateNoteParams{ BookUUID: params.BookUUID, Content: params.Content, Public: params.Public, @@ -114,7 +114,7 @@ type deleteNoteResp struct { } // DeleteNote removes note -func (a *App) DeleteNote(w http.ResponseWriter, r *http.Request) { +func (a *API) DeleteNote(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) noteUUID := vars["noteUUID"] @@ -125,14 +125,14 @@ func (a *App) DeleteNote(w http.ResponseWriter, r *http.Request) { } var note database.Note - if err := a.DB.Where("uuid = ? AND user_id = ?", noteUUID, user.ID).Preload("Book").First(¬e).Error; err != nil { + if err := a.App.DB.Where("uuid = ? AND user_id = ?", noteUUID, user.ID).Preload("Book").First(¬e).Error; err != nil { HandleError(w, "finding note", err, http.StatusInternalServerError) return } - tx := a.DB.Begin() + tx := a.App.DB.Begin() - n, err := operations.DeleteNote(tx, user, note) + n, err := a.App.DeleteNote(tx, user, note) if err != nil { tx.Rollback() HandleError(w, "deleting note", err, http.StatusInternalServerError) @@ -169,7 +169,7 @@ type CreateNoteResp struct { } // CreateNote creates a note -func (a *App) CreateNote(w http.ResponseWriter, r *http.Request) { +func (a *API) CreateNote(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -190,12 +190,12 @@ func (a *App) CreateNote(w http.ResponseWriter, r *http.Request) { } var book database.Book - if err := a.DB.Where("uuid = ? AND user_id = ?", params.BookUUID, user.ID).First(&book).Error; err != nil { + if err := a.App.DB.Where("uuid = ? AND user_id = ?", params.BookUUID, user.ID).First(&book).Error; err != nil { HandleError(w, "finding book", err, http.StatusInternalServerError) return } - note, err := operations.CreateNote(a.DB, user, a.Clock, params.BookUUID, params.Content, params.AddedOn, params.EditedOn, false) + note, err := a.App.CreateNote(user, params.BookUUID, params.Content, params.AddedOn, params.EditedOn, false) if err != nil { HandleError(w, "creating note", err, http.StatusInternalServerError) return @@ -212,7 +212,7 @@ func (a *App) CreateNote(w http.ResponseWriter, r *http.Request) { } // NotesOptions is a handler for OPTIONS endpoint for notes -func (a *App) NotesOptions(w http.ResponseWriter, r *http.Request) { +func (a *API) NotesOptions(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Methods", "POST") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Version") } diff --git a/pkg/server/handlers/v3_notes_test.go b/pkg/server/handlers/v3_notes_test.go index 524018e1..a2dff836 100644 --- a/pkg/server/handlers/v3_notes_test.go +++ b/pkg/server/handlers/v3_notes_test.go @@ -25,18 +25,19 @@ import ( "github.com/dnote/dnote/pkg/assert" "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/testutils" ) func TestCreateNote(t *testing.T) { - + defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - -Clock: clock.NewMock(), + server := MustNewServer(t, &app.App{ + + Clock: clock.NewMock(), }) defer server.Close() @@ -235,13 +236,13 @@ func TestUpdateNote(t *testing.T) { for idx, tc := range testCases { t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) { - + defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - -Clock: clock.NewMock(), + server := MustNewServer(t, &app.App{ + + Clock: clock.NewMock(), }) defer server.Close() @@ -331,13 +332,13 @@ func TestDeleteNote(t *testing.T) { for _, tc := range testCases { t.Run(fmt.Sprintf("originally deleted %t", tc.deleted), func(t *testing.T) { - + defer testutils.ClearData() // Setup - server := MustNewServer(t, &App{ - -Clock: clock.NewMock(), + server := MustNewServer(t, &app.App{ + + Clock: clock.NewMock(), }) defer server.Close() diff --git a/pkg/server/handlers/v3_sync.go b/pkg/server/handlers/v3_sync.go index 71468cc4..69d4cad6 100644 --- a/pkg/server/handlers/v3_sync.go +++ b/pkg/server/handlers/v3_sync.go @@ -120,13 +120,13 @@ func (e *queryParamError) Error() string { return fmt.Sprintf("invalid query param %s=%s. %s", e.key, e.value, e.message) } -func (a *App) newFragment(userID, userMaxUSN, afterUSN, limit int) (SyncFragment, error) { +func (a *API) newFragment(userID, userMaxUSN, afterUSN, limit int) (SyncFragment, error) { var notes []database.Note - if err := a.DB.Where("user_id = ? AND usn > ? AND usn <= ?", userID, afterUSN, userMaxUSN).Order("usn ASC").Limit(limit).Find(¬es).Error; err != nil { + if err := a.App.DB.Where("user_id = ? AND usn > ? AND usn <= ?", userID, afterUSN, userMaxUSN).Order("usn ASC").Limit(limit).Find(¬es).Error; err != nil { return SyncFragment{}, nil } var books []database.Book - if err := a.DB.Where("user_id = ? AND usn > ? AND usn <= ?", userID, afterUSN, userMaxUSN).Order("usn ASC").Limit(limit).Find(&books).Error; err != nil { + if err := a.App.DB.Where("user_id = ? AND usn > ? AND usn <= ?", userID, afterUSN, userMaxUSN).Order("usn ASC").Limit(limit).Find(&books).Error; err != nil { return SyncFragment{}, nil } @@ -191,7 +191,7 @@ func (a *App) newFragment(userID, userMaxUSN, afterUSN, limit int) (SyncFragment ret := SyncFragment{ FragMaxUSN: fragMaxUSN, UserMaxUSN: userMaxUSN, - CurrentTime: a.Clock.Now().Unix(), + CurrentTime: a.App.Clock.Now().Unix(), Notes: fragNotes, Books: fragBooks, ExpungedNotes: fragExpungedNotes, @@ -247,7 +247,7 @@ type GetSyncFragmentResp struct { } // GetSyncFragment responds with a sync fragment -func (a *App) GetSyncFragment(w http.ResponseWriter, r *http.Request) { +func (a *API) GetSyncFragment(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -280,7 +280,7 @@ type GetSyncStateResp struct { } // GetSyncState responds with a sync fragment -func (a *App) GetSyncState(w http.ResponseWriter, r *http.Request) { +func (a *API) GetSyncState(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(helpers.KeyUser).(database.User) if !ok { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) @@ -291,7 +291,7 @@ func (a *App) GetSyncState(w http.ResponseWriter, r *http.Request) { FullSyncBefore: fullSyncBefore, MaxUSN: user.MaxUSN, // TODO: exposing server time means we probably shouldn't seed random generator with time? - CurrentTime: a.Clock.Now().Unix(), + CurrentTime: a.App.Clock.Now().Unix(), } log.WithFields(log.Fields{ diff --git a/pkg/server/job/job_test.go b/pkg/server/job/job_test.go index 43c049e6..ee5bc61e 100644 --- a/pkg/server/job/job_test.go +++ b/pkg/server/job/job_test.go @@ -1,3 +1,21 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + package job import ( diff --git a/pkg/server/job/repetition/main_test.go b/pkg/server/job/repetition/main_test.go index 6555ee67..358100a9 100644 --- a/pkg/server/job/repetition/main_test.go +++ b/pkg/server/job/repetition/main_test.go @@ -1,3 +1,21 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + package repetition import ( diff --git a/pkg/server/mailer/backend.go b/pkg/server/mailer/backend.go index 11fa4ab4..fbc6755a 100644 --- a/pkg/server/mailer/backend.go +++ b/pkg/server/mailer/backend.go @@ -1,3 +1,21 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + package mailer import ( @@ -10,6 +28,9 @@ import ( "gopkg.in/gomail.v2" ) +// ErrSMTPNotConfigured is an error indicating that SMTP is not configured +var ErrSMTPNotConfigured = errors.New("SMTP is not configured") + // Backend is an interface for sending emails. type Backend interface { Queue(subject, from string, to []string, contentType, body string) error @@ -27,18 +48,35 @@ type dialerParams struct { Password string } +func validateSMTPConfig() bool { + port := os.Getenv("SmtpPort") + host := os.Getenv("SmtpHost") + username := os.Getenv("SmtpUsername") + password := os.Getenv("SmtpPassword") + + return port != "" && host != "" && username != "" && password != "" +} + func getSMTPParams() (*dialerParams, error) { - portStr := os.Getenv("SmtpPort") - port, err := strconv.Atoi(portStr) + portEnv := os.Getenv("SmtpPort") + hostEnv := os.Getenv("SmtpHost") + usernameEnv := os.Getenv("SmtpUsername") + passwordEnv := os.Getenv("SmtpPassword") + + if portEnv != "" && hostEnv != "" && usernameEnv != "" && passwordEnv != "" { + return nil, ErrSMTPNotConfigured + } + + port, err := strconv.Atoi(portEnv) if err != nil { return nil, errors.Wrap(err, "parsing SMTP port") } p := &dialerParams{ - Host: os.Getenv("SmtpHost"), + Host: hostEnv, Port: port, - Username: os.Getenv("SmtpUsername"), - Password: os.Getenv("SmtpPassword"), + Username: usernameEnv, + Password: passwordEnv, } return p, nil diff --git a/pkg/server/main.go b/pkg/server/main.go index d85bba6a..1a7128c6 100644 --- a/pkg/server/main.go +++ b/pkg/server/main.go @@ -26,6 +26,7 @@ import ( "os" "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/dbconn" "github.com/dnote/dnote/pkg/server/handlers" @@ -55,11 +56,11 @@ func mustFind(box *packr.Box, path string) []byte { return b } -func initContext(db *gorm.DB) web.Context { +func initContext(a *app.App) web.Context { staticBox := packr.New("static", "../../web/public/static") return web.Context{ - DB: db, + App: a, IndexHTML: mustFind(rootBox, "index.html"), RobotsTxt: mustFind(rootBox, "robots.txt"), ServiceWorkerJs: mustFind(rootBox, "service-worker.js"), @@ -67,13 +68,14 @@ func initContext(db *gorm.DB) web.Context { } } -func initServer(app handlers.App) (*http.ServeMux, error) { - apiRouter, err := handlers.NewRouter(&app) +func initServer(a app.App) (*http.ServeMux, error) { + api := handlers.API{App: &a} + apiRouter, err := api.NewRouter() if err != nil { return nil, errors.Wrap(err, "initializing router") } - webCtx := initContext(app.DB) + webCtx := initContext(&a) webHandlers, err := web.Init(webCtx) if err != nil { return nil, errors.Wrap(err, "initializing web handlers") @@ -110,10 +112,10 @@ func initDB() *gorm.DB { return db } -func initApp() handlers.App { +func initApp() app.App { db := initDB() - return handlers.App{ + return app.App{ DB: db, Clock: clock.New(), StripeAPIBackend: nil, @@ -123,7 +125,7 @@ func initApp() handlers.App { } } -func runJob(a handlers.App) error { +func runJob(a app.App) error { jobRunner, err := job.NewRunner(a.DB, a.Clock, a.EmailTemplates, a.EmailBackend, a.WebURL) if err != nil { return errors.Wrap(err, "getting a job runner") diff --git a/pkg/server/operations/main_test.go b/pkg/server/operations/main_test.go deleted file mode 100644 index b2e7fab8..00000000 --- a/pkg/server/operations/main_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package operations - -import ( - "os" - "testing" - - "github.com/dnote/dnote/pkg/server/testutils" -) - -func TestMain(m *testing.M) { - testutils.InitTestDB() - - code := m.Run() - testutils.ClearData() - - os.Exit(code) -} diff --git a/pkg/server/tmpl/app.go b/pkg/server/tmpl/app.go index 770ef6c4..dfd6b64a 100644 --- a/pkg/server/tmpl/app.go +++ b/pkg/server/tmpl/app.go @@ -24,7 +24,7 @@ import ( "net/http" "regexp" - "github.com/jinzhu/gorm" + "github.com/dnote/dnote/pkg/server/app" "github.com/pkg/errors" ) @@ -37,14 +37,15 @@ var templateNoteMetaTags = "note_metatags" // AppShell represents the application in HTML type AppShell struct { - T *template.Template + App *app.App + T *template.Template } // ErrNotFound is an error indicating that a resource was not found var ErrNotFound = errors.New("not found") // NewAppShell parses the templates for the application -func NewAppShell(content []byte) (AppShell, error) { +func NewAppShell(a *app.App, content []byte) (AppShell, error) { t, err := template.New(templateIndex).Parse(string(content)) if err != nil { return AppShell{}, errors.Wrap(err, "parsing the index template") @@ -55,12 +56,12 @@ func NewAppShell(content []byte) (AppShell, error) { return AppShell{}, errors.Wrap(err, "parsing the note meta tags template") } - return AppShell{t}, nil + return AppShell{App: a, T: t}, nil } // Execute executes the index template -func (a AppShell) Execute(r *http.Request, db *gorm.DB) ([]byte, error) { - data, err := a.getData(db, r) +func (a AppShell) Execute(r *http.Request) ([]byte, error) { + data, err := a.getData(r) if err != nil { return nil, errors.Wrap(err, "getting data") } @@ -73,11 +74,11 @@ func (a AppShell) Execute(r *http.Request, db *gorm.DB) ([]byte, error) { return buf.Bytes(), nil } -func (a AppShell) getData(db *gorm.DB, r *http.Request) (tmplData, error) { +func (a AppShell) getData(r *http.Request) (tmplData, error) { path := r.URL.Path if ok, params := matchPath(path, notesPathRegex); ok { - p, err := a.newNotePage(db, r, params[0]) + p, err := a.newNotePage(r, params[0]) if err != nil { return tmplData{}, errors.Wrap(err, "instantiating note page") } diff --git a/pkg/server/tmpl/app_test.go b/pkg/server/tmpl/app_test.go index d0ea83b0..664c4393 100644 --- a/pkg/server/tmpl/app_test.go +++ b/pkg/server/tmpl/app_test.go @@ -24,14 +24,17 @@ import ( "testing" "github.com/dnote/dnote/pkg/assert" + "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/testutils" "github.com/pkg/errors" ) func TestAppShellExecute(t *testing.T) { + testApp := app.NewTest(nil) + t.Run("home", func(t *testing.T) { - a, err := NewAppShell([]byte("{{ .Title }}{{ .MetaTags }}")) + a, err := NewAppShell(&testApp, []byte("{{ .Title }}{{ .MetaTags }}")) if err != nil { t.Fatal(errors.Wrap(err, "preparing app shell")) } @@ -41,7 +44,7 @@ func TestAppShellExecute(t *testing.T) { t.Fatal(errors.Wrap(err, "preparing request")) } - b, err := a.Execute(r, testutils.DB) + b, err := a.Execute(r) if err != nil { t.Fatal(errors.Wrap(err, "executing")) } @@ -66,7 +69,7 @@ func TestAppShellExecute(t *testing.T) { } testutils.MustExec(t, testutils.DB.Save(&n1), "preparing note") - a, err := NewAppShell([]byte("{{ .MetaTags }}")) + a, err := NewAppShell(&testApp, []byte("{{ .MetaTags }}")) if err != nil { t.Fatal(errors.Wrap(err, "preparing app shell")) } @@ -77,7 +80,7 @@ func TestAppShellExecute(t *testing.T) { t.Fatal(errors.Wrap(err, "preparing request")) } - b, err := a.Execute(r, testutils.DB) + b, err := a.Execute(r) if err != nil { t.Fatal(errors.Wrap(err, "executing")) } diff --git a/pkg/server/tmpl/data.go b/pkg/server/tmpl/data.go index fcfce9b0..c40b8bb4 100644 --- a/pkg/server/tmpl/data.go +++ b/pkg/server/tmpl/data.go @@ -29,8 +29,6 @@ import ( "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/handlers" - "github.com/dnote/dnote/pkg/server/operations" - "github.com/jinzhu/gorm" "github.com/pkg/errors" ) @@ -52,13 +50,13 @@ type notePage struct { T *template.Template } -func (a AppShell) newNotePage(db *gorm.DB, r *http.Request, noteUUID string) (notePage, error) { - user, _, err := handlers.AuthWithSession(db, r, nil) +func (a AppShell) newNotePage(r *http.Request, noteUUID string) (notePage, error) { + user, _, err := handlers.AuthWithSession(a.App.DB, r, nil) if err != nil { return notePage{}, errors.Wrap(err, "authenticating with session") } - note, ok, err := operations.GetNote(db, noteUUID, user) + note, ok, err := a.App.GetNote(noteUUID, user) if !ok { return notePage{}, ErrNotFound diff --git a/pkg/server/tmpl/data_test.go b/pkg/server/tmpl/data_test.go index d0709ec5..c315bc2d 100644 --- a/pkg/server/tmpl/data_test.go +++ b/pkg/server/tmpl/data_test.go @@ -24,6 +24,7 @@ import ( "time" "github.com/dnote/dnote/pkg/assert" + "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" "github.com/pkg/errors" ) @@ -38,7 +39,8 @@ func TestDefaultPageGetData(t *testing.T) { } func TestNotePageGetData(t *testing.T) { - a, err := NewAppShell(nil) + testApp := app.NewTest(nil) + a, err := NewAppShell(&testApp, nil) if err != nil { t.Fatal(errors.Wrap(err, "preparing app shell")) } diff --git a/pkg/server/tmpl/main_test.go b/pkg/server/tmpl/main_test.go index 81f2b954..77b7935f 100644 --- a/pkg/server/tmpl/main_test.go +++ b/pkg/server/tmpl/main_test.go @@ -1,3 +1,21 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + package tmpl import ( diff --git a/pkg/server/web/handlers.go b/pkg/server/web/handlers.go index 415baf02..36316b9a 100644 --- a/pkg/server/web/handlers.go +++ b/pkg/server/web/handlers.go @@ -22,15 +22,13 @@ package web import ( "net/http" + "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/handlers" "github.com/dnote/dnote/pkg/server/tmpl" - "github.com/jinzhu/gorm" "github.com/pkg/errors" ) var ( - // ErrEmptyDB is an error for missing database connection in the context - ErrEmptyDB = errors.New("No database connection was provided") // ErrEmptyIndexHTML is an error for missing index.html content in the context ErrEmptyIndexHTML = errors.New("No index.html content was provided") // ErrEmptyRobotsTxt is an error for missing robots.txt content in the context @@ -43,7 +41,7 @@ var ( // Context contains contents of web assets type Context struct { - DB *gorm.DB + App *app.App IndexHTML []byte RobotsTxt []byte ServiceWorkerJs []byte @@ -59,8 +57,8 @@ type Handlers struct { } func validateContext(c Context) error { - if c.DB == nil { - return ErrEmptyDB + if err := c.App.Validate(); err != nil { + return errors.Wrap(err, "validating app") } if c.IndexHTML == nil { return ErrEmptyIndexHTML @@ -94,7 +92,7 @@ func Init(c Context) (Handlers, error) { // getRootHandler returns an HTTP handler that serves the app shell func getRootHandler(c Context) http.HandlerFunc { - appShell, err := tmpl.NewAppShell(c.IndexHTML) + appShell, err := tmpl.NewAppShell(c.App, c.IndexHTML) if err != nil { panic(errors.Wrap(err, "initializing app shell")) } @@ -103,7 +101,7 @@ func getRootHandler(c Context) http.HandlerFunc { // index.html must not be cached w.Header().Set("Cache-Control", "no-cache") - buf, err := appShell.Execute(r, c.DB) + buf, err := appShell.Execute(r) if err != nil { if errors.Cause(err) == tmpl.ErrNotFound { handlers.RespondNotFound(w) diff --git a/pkg/server/web/handlers_test.go b/pkg/server/web/handlers_test.go index 508a024f..01e339bd 100644 --- a/pkg/server/web/handlers_test.go +++ b/pkg/server/web/handlers_test.go @@ -1,3 +1,21 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + package web import ( @@ -6,7 +24,7 @@ import ( "testing" "github.com/dnote/dnote/pkg/assert" - "github.com/jinzhu/gorm" + "github.com/dnote/dnote/pkg/server/app" "github.com/pkg/errors" ) @@ -16,13 +34,17 @@ func TestInit(t *testing.T) { mockServiceWorkerJs := []byte("function() {}") mockStaticFileSystem := http.Dir(".") + testApp := app.NewTest(nil) + testAppNoDB := app.NewTest(nil) + testAppNoDB.DB = nil + testCases := []struct { ctx Context expectedErr error }{ { ctx: Context{ - DB: &gorm.DB{}, + App: &testApp, IndexHTML: mockIndexHTML, RobotsTxt: mockRobotsTxt, ServiceWorkerJs: mockServiceWorkerJs, @@ -32,17 +54,17 @@ func TestInit(t *testing.T) { }, { ctx: Context{ - DB: nil, + App: &testAppNoDB, IndexHTML: mockIndexHTML, RobotsTxt: mockRobotsTxt, ServiceWorkerJs: mockServiceWorkerJs, StaticFileSystem: mockStaticFileSystem, }, - expectedErr: ErrEmptyDB, + expectedErr: app.ErrEmptyDB, }, { ctx: Context{ - DB: &gorm.DB{}, + App: &testApp, IndexHTML: nil, RobotsTxt: mockRobotsTxt, ServiceWorkerJs: mockServiceWorkerJs, @@ -52,7 +74,7 @@ func TestInit(t *testing.T) { }, { ctx: Context{ - DB: &gorm.DB{}, + App: &testApp, IndexHTML: mockIndexHTML, RobotsTxt: nil, ServiceWorkerJs: mockServiceWorkerJs, @@ -62,7 +84,7 @@ func TestInit(t *testing.T) { }, { ctx: Context{ - DB: &gorm.DB{}, + App: &testApp, IndexHTML: mockIndexHTML, RobotsTxt: mockRobotsTxt, ServiceWorkerJs: nil, @@ -72,7 +94,7 @@ func TestInit(t *testing.T) { }, { ctx: Context{ - DB: &gorm.DB{}, + App: &testApp, IndexHTML: mockIndexHTML, RobotsTxt: mockRobotsTxt, ServiceWorkerJs: mockServiceWorkerJs, diff --git a/pkg/server/web/main_test.go b/pkg/server/web/main_test.go new file mode 100644 index 00000000..0611f9ca --- /dev/null +++ b/pkg/server/web/main_test.go @@ -0,0 +1,35 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package web + +import ( + "os" + "testing" + + "github.com/dnote/dnote/pkg/server/testutils" +) + +func TestMain(m *testing.M) { + testutils.InitTestDB() + + code := m.Run() + testutils.ClearData() + + os.Exit(code) +} diff --git a/pkg/watcher/main.go b/pkg/watcher/main.go index 87604fd7..69bca81b 100644 --- a/pkg/watcher/main.go +++ b/pkg/watcher/main.go @@ -3,16 +3,16 @@ * This file is part of Dnote. * * Dnote is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by + * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Dnote is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. + * GNU General Public License for more details. * - * You should have received a copy of the GNU Affero General Public License + * You should have received a copy of the GNU General Public License * along with Dnote. If not, see . */ diff --git a/scripts/license.sh b/scripts/license.sh index 64be9622..ea9612ad 100755 --- a/scripts/license.sh +++ b/scripts/license.sh @@ -56,9 +56,10 @@ agpl="/* Copyright (C) 2019 Monomax Software Pty Ltd */" dir=$(dirname "${BASH_SOURCE[0]}") -pkgPath="$dir/pkg" -serverPath="$dir/pkg/server" -browserPath="$dir/browser" +basedir="$dir/.." +pkgPath="$basedir/pkg" +serverPath="$basedir/pkg/server" +browserPath="$basedir/browser" gplFiles=$(find "$pkgPath" "$browserPath" -type f \( -name "*.go" -o -name "*.js" -o -name "*.ts" -o -name "*.tsx" -o -name "*.scss" -o -name "*.css" \) ! -path "**/vendor/*" ! -path "**/node_modules/*" ! -path "$serverPath/*") @@ -67,8 +68,8 @@ for file in $gplFiles; do add_notice "$file" "$gpl" done -webPath="$dir/web" -jslibPath="$dir/jslib/src" +webPath="$basedir/web" +jslibPath="$basedir/jslib/src" agplFiles=$(find "$serverPath" "$webPath" "$jslibPath" -type f \( -name "*.go" -o -name "*.js" -o -name "*.ts" -o -name "*.tsx" -o -name "*.scss" -o -name "*.css" \) ! -path "**/vendor/*" ! -path "**/node_modules/*" ! -path "**/dist/*") for file in $agplFiles; do diff --git a/web/jest.config.js b/web/jest.config.js index 99ab01ad..cfbb1ab5 100644 --- a/web/jest.config.js +++ b/web/jest.config.js @@ -1,3 +1,21 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + module.exports = { preset: 'ts-jest', collectCoverageFrom: ['./src/**/*.ts'] diff --git a/web/src/components/Repetition/Content.tsx b/web/src/components/Repetition/Content.tsx index af6736e1..461768f0 100644 --- a/web/src/components/Repetition/Content.tsx +++ b/web/src/components/Repetition/Content.tsx @@ -1,3 +1,21 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + import React, { Fragment, useState, useEffect } from 'react'; import classnames from 'classnames'; diff --git a/web/src/components/Subscription/Footer.tsx b/web/src/components/Subscription/Footer.tsx index 8e884b4e..b1bd8a04 100644 --- a/web/src/components/Subscription/Footer.tsx +++ b/web/src/components/Subscription/Footer.tsx @@ -1,3 +1,21 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + import React from 'react'; import styles from './Subscription.scss'; diff --git a/web/src/components/Subscription/Plan/ProCTA.tsx b/web/src/components/Subscription/Plan/ProCTA.tsx index aaa4708c..7d76f12e 100644 --- a/web/src/components/Subscription/Plan/ProCTA.tsx +++ b/web/src/components/Subscription/Plan/ProCTA.tsx @@ -1,3 +1,21 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + import React from 'react'; import { Link } from 'react-router-dom'; import classnames from 'classnames';