From 41ada2298c107f3fada73ddcd08a457fbdf95446 Mon Sep 17 00:00:00 2001 From: Sung Won Cho Date: Thu, 7 Nov 2019 22:47:25 -0800 Subject: [PATCH] Implement note sharing (#300) * Implement publicity control for notes * Implement server side rendering for note sharing * Implement UI * Modularize * Remove autofocus * Fix test * Document --- CHANGELOG.md | 4 +- Vagrantfile | 4 +- pkg/server/.gitignore | 8 + pkg/server/api/.gitignore | 7 - pkg/server/api/README.md | 8 - pkg/server/api/handlers/notes_test.go | 336 ---------------- pkg/server/api/scripts/build.sh | 7 - pkg/server/api/scripts/setup.sh | 9 - pkg/server/{api => }/crypt/crypt.go | 0 pkg/server/database/notes.go | 28 ++ pkg/server/{api => }/handlers/auth.go | 30 +- pkg/server/{api => }/handlers/auth_test.go | 0 pkg/server/{api => }/handlers/classic.go | 40 +- pkg/server/{api => }/handlers/classic_test.go | 0 pkg/server/{api => }/handlers/health.go | 0 pkg/server/{api => }/handlers/health_test.go | 0 pkg/server/{api => }/handlers/helpers.go | 21 +- pkg/server/{api => }/handlers/limit.go | 0 pkg/server/{api => }/handlers/notes.go | 49 +-- pkg/server/handlers/notes_test.go | 363 ++++++++++++++++++ .../{api => }/handlers/repetition_rules.go | 36 +- .../handlers/repetition_rules_test.go | 2 +- pkg/server/{api => }/handlers/routes.go | 92 ++--- pkg/server/{api => }/handlers/routes_test.go | 4 +- pkg/server/{api => }/handlers/semver.go | 0 pkg/server/{api => }/handlers/subscription.go | 62 +-- pkg/server/{api => }/handlers/testutils.go | 0 pkg/server/{api => }/handlers/user.go | 60 +-- pkg/server/{api => }/handlers/user_test.go | 2 +- pkg/server/{api => }/handlers/v3_auth.go | 20 +- pkg/server/{api => }/handlers/v3_auth_test.go | 0 pkg/server/{api => }/handlers/v3_books.go | 34 +- .../{api => }/handlers/v3_books_test.go | 2 +- pkg/server/{api => }/handlers/v3_notes.go | 43 ++- .../{api => }/handlers/v3_notes_test.go | 89 ++++- pkg/server/{api => }/handlers/v3_sync.go | 10 +- pkg/server/{api => }/handlers/v3_sync_test.go | 0 pkg/server/{api => }/helpers/const.go | 0 pkg/server/{api => }/helpers/helpers.go | 0 pkg/server/mailer/mailer.go | 2 +- pkg/server/main.go | 64 +-- pkg/server/{api => }/operations/books.go | 2 +- pkg/server/{api => }/operations/books_test.go | 0 pkg/server/{api => }/operations/helpers.go | 0 .../{api => }/operations/helpers_test.go | 0 pkg/server/{api => }/operations/notes.go | 78 +++- pkg/server/{api => }/operations/notes_test.go | 170 ++++++-- pkg/server/{api => }/operations/sessions.go | 4 +- .../{api => }/operations/subscriptions.go | 0 pkg/server/{api => }/operations/users.go | 2 +- pkg/server/permissions/permissions.go | 38 ++ pkg/server/permissions/permissions_test.go | 93 +++++ pkg/server/{api => }/presenters/book.go | 0 pkg/server/{api => }/presenters/digest.go | 0 .../{api => }/presenters/email_preference.go | 0 pkg/server/{api => }/presenters/helpers.go | 0 pkg/server/{api => }/presenters/note.go | 0 .../{api => }/presenters/repetition_rule.go | 0 .../presenters/repetition_rule_test.go | 0 .../{api => }/scripts/makeDemoDigests/main.go | 2 +- pkg/server/testutils/main.go | 17 +- pkg/server/tmpl/app.go | 101 +++++ pkg/server/tmpl/app_test.go | 92 +++++ pkg/server/tmpl/data.go | 141 +++++++ pkg/server/tmpl/data_test.go | 63 +++ pkg/server/tmpl/tmpl.go | 29 ++ pkg/server/watcher/main.go | 18 + pkg/server/web/handlers.go | 83 ++++ scripts/vagrant/install_utils.sh | 2 +- web/assets/index.html | 4 +- web/package-lock.json | 18 +- web/scripts/dev.sh | 1 + web/scripts/webpack-dev.sh | 2 +- web/src/components/App/App.scss | 4 + web/src/components/App/_buttons.scss | 10 +- web/src/components/App/index.tsx | 22 +- web/src/components/Common/Button/index.tsx | 19 +- web/src/components/Common/Toggle.scss | 79 ++++ web/src/components/Common/Toggle.tsx | 68 ++++ web/src/components/Common/Tooltip/Overlay.tsx | 11 +- .../components/Common/Tooltip/Tooltip.scss | 3 + web/src/components/Common/Tooltip/index.tsx | 7 +- web/src/components/Header/Note/Guest.scss | 23 ++ web/src/components/Header/Note/Guest.tsx | 11 +- web/src/components/Icons/Globe.tsx | 10 +- .../Note/{NoteContent.scss => Content.scss} | 6 + .../Note/{NoteContent.tsx => Content.tsx} | 76 +++- ...{DeleteNoteModal.scss => DeleteModal.scss} | 0 .../{DeleteNoteModal.tsx => DeleteModal.tsx} | 6 +- web/src/components/Note/HeaderData.tsx | 40 +- web/src/components/Note/Placeholder.tsx | 2 +- .../components/Note/ShareModal/CopyButton.tsx | 54 +++ .../Note/ShareModal/ShareModal.scss | 54 +++ web/src/components/Note/ShareModal/index.tsx | 202 ++++++++++ web/src/components/Note/index.tsx | 23 +- web/src/libs/dom.ts | 18 + web/src/store/note/actions.ts | 4 +- 97 files changed, 2326 insertions(+), 822 deletions(-) delete mode 100644 pkg/server/api/.gitignore delete mode 100644 pkg/server/api/README.md delete mode 100644 pkg/server/api/handlers/notes_test.go delete mode 100755 pkg/server/api/scripts/build.sh delete mode 100755 pkg/server/api/scripts/setup.sh rename pkg/server/{api => }/crypt/crypt.go (100%) create mode 100644 pkg/server/database/notes.go rename pkg/server/{api => }/handlers/auth.go (85%) rename pkg/server/{api => }/handlers/auth_test.go (100%) rename pkg/server/{api => }/handlers/classic.go (84%) rename pkg/server/{api => }/handlers/classic_test.go (100%) rename pkg/server/{api => }/handlers/health.go (100%) rename pkg/server/{api => }/handlers/health_test.go (100%) rename pkg/server/{api => }/handlers/helpers.go (83%) rename pkg/server/{api => }/handlers/limit.go (100%) rename pkg/server/{api => }/handlers/notes.go (84%) create mode 100644 pkg/server/handlers/notes_test.go rename pkg/server/{api => }/handlers/repetition_rules.go (90%) rename pkg/server/{api => }/handlers/repetition_rules_test.go (99%) rename pkg/server/{api => }/handlers/routes.go (87%) rename pkg/server/{api => }/handlers/routes_test.go (99%) rename pkg/server/{api => }/handlers/semver.go (100%) rename pkg/server/{api => }/handlers/subscription.go (86%) rename pkg/server/{api => }/handlers/testutils.go (100%) rename pkg/server/{api => }/handlers/user.go (83%) rename pkg/server/{api => }/handlers/user_test.go (99%) rename pkg/server/{api => }/handlers/v3_auth.go (90%) rename pkg/server/{api => }/handlers/v3_auth_test.go (100%) rename pkg/server/{api => }/handlers/v3_books.go (85%) rename pkg/server/{api => }/handlers/v3_books_test.go (99%) rename pkg/server/{api => }/handlers/v3_notes.go (78%) rename pkg/server/{api => }/handlers/v3_notes_test.go (77%) rename pkg/server/{api => }/handlers/v3_sync.go (96%) rename pkg/server/{api => }/handlers/v3_sync_test.go (100%) rename pkg/server/{api => }/helpers/const.go (100%) rename pkg/server/{api => }/helpers/helpers.go (100%) rename pkg/server/{api => }/operations/books.go (98%) rename pkg/server/{api => }/operations/books_test.go (100%) rename pkg/server/{api => }/operations/helpers.go (100%) rename pkg/server/{api => }/operations/helpers_test.go (100%) rename pkg/server/{api => }/operations/notes.go (65%) rename pkg/server/{api => }/operations/notes_test.go (65%) rename pkg/server/{api => }/operations/sessions.go (93%) rename pkg/server/{api => }/operations/subscriptions.go (100%) rename pkg/server/{api => }/operations/users.go (98%) create mode 100644 pkg/server/permissions/permissions.go create mode 100644 pkg/server/permissions/permissions_test.go rename pkg/server/{api => }/presenters/book.go (100%) rename pkg/server/{api => }/presenters/digest.go (100%) rename pkg/server/{api => }/presenters/email_preference.go (100%) rename pkg/server/{api => }/presenters/helpers.go (100%) rename pkg/server/{api => }/presenters/note.go (100%) rename pkg/server/{api => }/presenters/repetition_rule.go (100%) rename pkg/server/{api => }/presenters/repetition_rule_test.go (100%) rename pkg/server/{api => }/scripts/makeDemoDigests/main.go (99%) create mode 100644 pkg/server/tmpl/app.go create mode 100644 pkg/server/tmpl/app_test.go create mode 100644 pkg/server/tmpl/data.go create mode 100644 pkg/server/tmpl/data_test.go create mode 100644 pkg/server/tmpl/tmpl.go create mode 100644 pkg/server/web/handlers.go create mode 100644 web/src/components/Common/Toggle.scss create mode 100644 web/src/components/Common/Toggle.tsx rename web/src/components/Note/{NoteContent.scss => Content.scss} (95%) rename web/src/components/Note/{NoteContent.tsx => Content.tsx} (68%) rename web/src/components/Note/{DeleteNoteModal.scss => DeleteModal.scss} (100%) rename web/src/components/Note/{DeleteNoteModal.tsx => DeleteModal.tsx} (96%) create mode 100644 web/src/components/Note/ShareModal/CopyButton.tsx create mode 100644 web/src/components/Note/ShareModal/ShareModal.scss create mode 100644 web/src/components/Note/ShareModal/index.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 851a4f8c..84be0649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,9 @@ The following log documentes the history of the server project. ### [Unreleased] -N/A +#### Added + +- Share notes (#300) ### 0.2.1 - 2019-11-04 diff --git a/Vagrantfile b/Vagrantfile index 67ce9536..08f6a390 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -2,7 +2,9 @@ Vagrant.configure("2") do |config| config.vm.box = "ubuntu/bionic64" - config.vm.synced_folder '.', '/go/src/github.com/dnote/dnote', type: "rsync", create: true, rsync__args: ["--verbose", "--archive", "--delete", "-z"] + config.vm.synced_folder '.', '/go/src/github.com/dnote/dnote', type: "rsync", create: true, + rsync__args: ["--verbose", "--archive", "--delete", "-z"], + rsync__exclude: [".git/", ".vagrant/", "web/public"] config.vm.network "forwarded_port", guest: 3000, host: 3000 config.vm.network "forwarded_port", guest: 8080, host: 8080 config.vm.network "forwarded_port", guest: 5432, host: 5433 diff --git a/pkg/server/.gitignore b/pkg/server/.gitignore index fa49c96a..0494e979 100644 --- a/pkg/server/.gitignore +++ b/pkg/server/.gitignore @@ -6,3 +6,11 @@ test-dnote /dist /build server + +# Elastic Beanstalk Files +/tmp +application.zip +test-api +/dump +api +/build diff --git a/pkg/server/api/.gitignore b/pkg/server/api/.gitignore deleted file mode 100644 index 78d0d862..00000000 --- a/pkg/server/api/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Elastic Beanstalk Files -/tmp -application.zip -test-api -/dump -api -/build diff --git a/pkg/server/api/README.md b/pkg/server/api/README.md deleted file mode 100644 index 44ae40ac..00000000 --- a/pkg/server/api/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# API - -Dnote API. - -## Development - -* Ensure `timezone = 'UTC'` in postgres setting (`postgresql.conf`) - diff --git a/pkg/server/api/handlers/notes_test.go b/pkg/server/api/handlers/notes_test.go deleted file mode 100644 index 0f18a6e8..00000000 --- a/pkg/server/api/handlers/notes_test.go +++ /dev/null @@ -1,336 +0,0 @@ -/* 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 ( - "encoding/json" - "fmt" - "net/http" - "testing" - "time" - - "github.com/dnote/dnote/pkg/assert" - "github.com/dnote/dnote/pkg/clock" - "github.com/dnote/dnote/pkg/server/api/presenters" - "github.com/dnote/dnote/pkg/server/database" - "github.com/dnote/dnote/pkg/server/testutils" - "github.com/pkg/errors" -) - -func init() { - testutils.InitTestDB() -} - -func TestGetNotes(t *testing.T) { - defer testutils.ClearData() - db := database.DBConn - - // Setup - server := MustNewServer(t, &App{ - Clock: clock.NewMock(), - }) - defer server.Close() - - user := testutils.SetupUserData() - anotherUser := testutils.SetupUserData() - - b1 := database.Book{ - UserID: user.ID, - Label: "js", - } - testutils.MustExec(t, db.Save(&b1), "preparing b1") - b2 := database.Book{ - UserID: user.ID, - Label: "css", - } - testutils.MustExec(t, db.Save(&b2), "preparing b2") - b3 := database.Book{ - UserID: anotherUser.ID, - Label: "css", - } - testutils.MustExec(t, db.Save(&b3), "preparing b3") - - n1 := database.Note{ - UserID: user.ID, - BookUUID: b1.UUID, - Body: "n1 content", - USN: 11, - Deleted: false, - AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(), - } - testutils.MustExec(t, db.Save(&n1), "preparing n1") - n2 := database.Note{ - UserID: user.ID, - BookUUID: b1.UUID, - Body: "n2 content", - USN: 14, - Deleted: false, - AddedOn: time.Date(2018, time.August, 11, 22, 0, 0, 0, time.UTC).UnixNano(), - } - testutils.MustExec(t, db.Save(&n2), "preparing n2") - n3 := database.Note{ - UserID: user.ID, - BookUUID: b1.UUID, - Body: "n3 content", - USN: 17, - Deleted: false, - AddedOn: time.Date(2017, time.January, 10, 23, 0, 0, 0, time.UTC).UnixNano(), - } - testutils.MustExec(t, db.Save(&n3), "preparing n3") - n4 := database.Note{ - UserID: user.ID, - BookUUID: b2.UUID, - Body: "n4 content", - USN: 18, - Deleted: false, - AddedOn: time.Date(2018, time.September, 10, 23, 0, 0, 0, time.UTC).UnixNano(), - } - testutils.MustExec(t, db.Save(&n4), "preparing n4") - n5 := database.Note{ - UserID: anotherUser.ID, - BookUUID: b3.UUID, - Body: "n5 content", - USN: 19, - Deleted: false, - AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(), - } - testutils.MustExec(t, db.Save(&n5), "preparing n5") - n6 := database.Note{ - UserID: user.ID, - BookUUID: b1.UUID, - Body: "", - USN: 11, - Deleted: true, - AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(), - } - testutils.MustExec(t, db.Save(&n6), "preparing n6") - - // Execute - req := testutils.MakeReq(server, "GET", "/notes?year=2018&month=8", "") - res := testutils.HTTPAuthDo(t, req, user) - - // Test - assert.StatusCodeEquals(t, res, http.StatusOK, "") - - var payload GetNotesResponse - if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { - t.Fatal(errors.Wrap(err, "decoding payload")) - } - - var n2Record, n1Record database.Note - testutils.MustExec(t, db.Where("uuid = ?", n2.UUID).First(&n2Record), "finding n2Record") - testutils.MustExec(t, db.Where("uuid = ?", n1.UUID).First(&n1Record), "finding n1Record") - - expected := GetNotesResponse{ - Notes: []presenters.Note{ - { - CreatedAt: n2Record.CreatedAt, - UpdatedAt: n2Record.UpdatedAt, - UUID: n2Record.UUID, - Body: n2Record.Body, - AddedOn: n2Record.AddedOn, - USN: n2Record.USN, - Book: presenters.NoteBook{ - UUID: b1.UUID, - Label: b1.Label, - }, - User: presenters.NoteUser{ - Name: user.Name, - UUID: user.UUID, - }, - }, - { - CreatedAt: n1Record.CreatedAt, - UpdatedAt: n1Record.UpdatedAt, - UUID: n1Record.UUID, - Body: n1Record.Body, - AddedOn: n1Record.AddedOn, - USN: n1Record.USN, - Book: presenters.NoteBook{ - UUID: b1.UUID, - Label: b1.Label, - }, - User: presenters.NoteUser{ - Name: user.Name, - UUID: user.UUID, - }, - }, - }, - Total: 2, - } - - assert.DeepEqual(t,payload, expected, "payload mismatch") -} - -func TestGetNote(t *testing.T) { - defer testutils.ClearData() - db := database.DBConn - - // Setup - server := MustNewServer(t, &App{ - Clock: clock.NewMock(), - }) - defer server.Close() - - user := testutils.SetupUserData() - - b1 := database.Book{ - UserID: user.ID, - Label: "js", - } - testutils.MustExec(t, db.Save(&b1), "preparing b1") - b2 := database.Book{ - UserID: user.ID, - Label: "css", - } - testutils.MustExec(t, db.Save(&b2), "preparing b2") - - n1 := database.Note{ - UserID: user.ID, - BookUUID: b1.UUID, - Body: "n1 content", - USN: 1123, - AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(), - Public: true, - } - testutils.MustExec(t, db.Save(&n1), "preparing n1") - n2 := database.Note{ - UserID: user.ID, - BookUUID: b1.UUID, - Body: "n2 content", - USN: 1888, - AddedOn: time.Date(2018, time.August, 11, 22, 0, 0, 0, time.UTC).UnixNano(), - } - testutils.MustExec(t, db.Save(&n2), "preparing n2") - - // Execute - url := fmt.Sprintf("/notes/%s", n1.UUID) - req := testutils.MakeReq(server, "GET", url, "") - res := testutils.HTTPAuthDo(t, req, user) - - // Test - assert.StatusCodeEquals(t, res, http.StatusOK, "") - - var payload presenters.Note - if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { - t.Fatal(errors.Wrap(err, "decoding payload")) - } - - var n1Record database.Note - testutils.MustExec(t, db.Where("uuid = ?", n1.UUID).First(&n1Record), "finding n1Record") - - expected := presenters.Note{ - UUID: n1Record.UUID, - CreatedAt: n1Record.CreatedAt, - UpdatedAt: n1Record.UpdatedAt, - Body: n1Record.Body, - AddedOn: n1Record.AddedOn, - Public: n1Record.Public, - USN: n1Record.USN, - Book: presenters.NoteBook{ - UUID: b1.UUID, - Label: b1.Label, - }, - User: presenters.NoteUser{ - Name: user.Name, - UUID: user.UUID, - }, - } - - assert.DeepEqual(t,payload, expected, "payload mismatch") -} - -// TODO: finish the test after implementing note sharing -// func TestGetNote_guestAccessPrivate(t *testing.T) { -// defer testutils.ClearData() -// db := database.DBConn -// -// // Setup -// server := httptest.NewServer(NewRouter(&App{ -// Clock: clock.NewMock(), -// })) -// defer server.Close() -// -// user := testutils.SetupUserData() -// -// b1 := database.Book{ -// UUID: "b1-uuid", -// UserID: user.ID, -// Label: "js", -// } -// testutils.MustExec(t, db.Save(&b1), "preparing b1") -// -// n1 := database.Note{ -// UserID: user.ID, -// BookUUID: b1.UUID, -// Body: "n1 content", -// AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(), -// Public: false, -// } -// testutils.MustExec(t, db.Save(&n1), "preparing n1") -// -// // Execute -// url := fmt.Sprintf("/notes/%s", n1.UUID) -// req := testutils.MakeReq(server, "GET", url, "") -// -// res := testutils.HTTPDo(t, req) -// -// // Test -// assert.StatusCodeEquals(t, res, http.StatusNotFound, "") -// } - -// func TestGetNote_nonOwnerAccessPrivate(t *testing.T) { -// defer testutils.ClearData() -// db := database.DBConn -// -// // Setup -// server := httptest.NewServer(NewRouter(&App{ -// Clock: clock.NewMock(), -// })) -// defer server.Close() -// -// owner := testutils.SetupUserData() -// -// nonOwner := testutils.SetupUserData() -// testutils.MustExec(t, db.Model(&nonOwner).Update("api_key", "non-owner-api-key"), "preparing user max_usn") -// -// b1 := database.Book{ -// UUID: "b1-uuid", -// UserID: owner.ID, -// Label: "js", -// } -// testutils.MustExec(t, db.Save(&b1), "preparing b1") -// -// n1 := database.Note{ -// UserID: owner.ID, -// BookUUID: b1.UUID, -// Body: "n1 content", -// AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(), -// Public: false, -// } -// testutils.MustExec(t, db.Save(&n1), "preparing n1") -// -// // Execute -// url := fmt.Sprintf("/notes/%s", n1.UUID) -// req := testutils.MakeReq(server, "GET", url, "") -// res := testutils.HTTPAuthDo(t, req, nonOwner) -// -// // Test -// assert.StatusCodeEquals(t, res, http.StatusNotFound, "") -// } diff --git a/pkg/server/api/scripts/build.sh b/pkg/server/api/scripts/build.sh deleted file mode 100755 index 709e44ac..00000000 --- a/pkg/server/api/scripts/build.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -set -eux - -basePath="$GOPATH/src/github.com/dnote/dnote/pkg/server/api" - -cd "$basePath" -GOOS=linux GOARCH=amd64 go build -o "$basePath/build/api" main.go diff --git a/pkg/server/api/scripts/setup.sh b/pkg/server/api/scripts/setup.sh deleted file mode 100755 index a1230d71..00000000 --- a/pkg/server/api/scripts/setup.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -# setup.sh installs programs and depedencies necessary to run the project locally -# usage: ./setup.sh -set -eux - -curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh -go get github.com/githubnemo/CompileDaemon -dep ensure - diff --git a/pkg/server/api/crypt/crypt.go b/pkg/server/crypt/crypt.go similarity index 100% rename from pkg/server/api/crypt/crypt.go rename to pkg/server/crypt/crypt.go diff --git a/pkg/server/database/notes.go b/pkg/server/database/notes.go new file mode 100644 index 00000000..fa3cb6a7 --- /dev/null +++ b/pkg/server/database/notes.go @@ -0,0 +1,28 @@ +/* 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 database + +import ( + "github.com/jinzhu/gorm" +) + +// PreloadNote preloads the associations for a notes for the given query +func PreloadNote(conn *gorm.DB) *gorm.DB { + return conn.Preload("Book").Preload("User") +} diff --git a/pkg/server/api/handlers/auth.go b/pkg/server/handlers/auth.go similarity index 85% rename from pkg/server/api/handlers/auth.go rename to pkg/server/handlers/auth.go index f2397fb2..32ed6da4 100644 --- a/pkg/server/api/handlers/auth.go +++ b/pkg/server/handlers/auth.go @@ -24,8 +24,8 @@ import ( "net/http" "time" - "github.com/dnote/dnote/pkg/server/api/helpers" - "github.com/dnote/dnote/pkg/server/api/operations" + "github.com/dnote/dnote/pkg/server/helpers" + "github.com/dnote/dnote/pkg/server/operations" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/mailer" "github.com/pkg/errors" @@ -56,7 +56,7 @@ func makeSession(user database.User, account database.Account) Session { func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } @@ -64,7 +64,7 @@ func (a *App) getMe(w http.ResponseWriter, r *http.Request) { var account database.Account if err := db.Where("user_id = ?", user.ID).First(&account).Error; err != nil { - handleError(w, "finding account", err, http.StatusInternalServerError) + HandleError(w, "finding account", err, http.StatusInternalServerError) return } @@ -106,7 +106,7 @@ func (a *App) createResetToken(w http.ResponseWriter, r *http.Request) { return } if err := conn.Error; err != nil { - handleError(w, errors.Wrap(err, "finding account").Error(), nil, http.StatusInternalServerError) + HandleError(w, errors.Wrap(err, "finding account").Error(), nil, http.StatusInternalServerError) return } @@ -117,7 +117,7 @@ func (a *App) createResetToken(w http.ResponseWriter, r *http.Request) { resetToken, err := generateResetToken() if err != nil { - handleError(w, errors.Wrap(err, "generating token").Error(), nil, http.StatusInternalServerError) + HandleError(w, errors.Wrap(err, "generating token").Error(), nil, http.StatusInternalServerError) return } @@ -128,7 +128,7 @@ func (a *App) createResetToken(w http.ResponseWriter, r *http.Request) { } if err := db.Save(&token).Error; err != nil { - handleError(w, errors.Wrap(err, "saving token").Error(), nil, http.StatusInternalServerError) + HandleError(w, errors.Wrap(err, "saving token").Error(), nil, http.StatusInternalServerError) return } @@ -140,12 +140,12 @@ func (a *App) createResetToken(w http.ResponseWriter, r *http.Request) { } email := mailer.NewEmail("noreply@getdnote.com", []string{params.Email}, subject) if err := email.ParseTemplate(mailer.EmailTypeResetPassword, data); err != nil { - handleError(w, errors.Wrap(err, "parsing template").Error(), nil, http.StatusInternalServerError) + HandleError(w, errors.Wrap(err, "parsing template").Error(), nil, http.StatusInternalServerError) return } if err := email.Send(); err != nil { - handleError(w, errors.Wrap(err, "sending email").Error(), nil, http.StatusInternalServerError) + HandleError(w, errors.Wrap(err, "sending email").Error(), nil, http.StatusInternalServerError) return } } @@ -171,7 +171,7 @@ func (a *App) resetPassword(w http.ResponseWriter, r *http.Request) { return } if err := conn.Error; err != nil { - handleError(w, errors.Wrap(err, "finding token").Error(), nil, http.StatusInternalServerError) + HandleError(w, errors.Wrap(err, "finding token").Error(), nil, http.StatusInternalServerError) return } @@ -191,25 +191,25 @@ func (a *App) resetPassword(w http.ResponseWriter, r *http.Request) { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(params.Password), bcrypt.DefaultCost) if err != nil { tx.Rollback() - handleError(w, errors.Wrap(err, "hashing password").Error(), nil, http.StatusInternalServerError) + HandleError(w, errors.Wrap(err, "hashing password").Error(), nil, http.StatusInternalServerError) return } var account database.Account if err := db.Where("user_id = ?", token.UserID).First(&account).Error; err != nil { tx.Rollback() - handleError(w, errors.Wrap(err, "finding user").Error(), nil, http.StatusInternalServerError) + HandleError(w, errors.Wrap(err, "finding user").Error(), nil, http.StatusInternalServerError) return } if err := tx.Model(&account).Update("password", string(hashedPassword)).Error; err != nil { tx.Rollback() - handleError(w, errors.Wrap(err, "updating password").Error(), nil, http.StatusInternalServerError) + HandleError(w, errors.Wrap(err, "updating password").Error(), nil, http.StatusInternalServerError) return } if err := tx.Model(&token).Update("used_at", time.Now()).Error; err != nil { tx.Rollback() - handleError(w, errors.Wrap(err, "updating password reset token").Error(), nil, http.StatusInternalServerError) + HandleError(w, errors.Wrap(err, "updating password reset token").Error(), nil, http.StatusInternalServerError) return } @@ -217,7 +217,7 @@ func (a *App) resetPassword(w http.ResponseWriter, r *http.Request) { var user database.User if err := db.Where("id = ?", account.UserID).First(&user).Error; err != nil { - handleError(w, errors.Wrap(err, "finding user").Error(), nil, http.StatusInternalServerError) + HandleError(w, errors.Wrap(err, "finding user").Error(), nil, http.StatusInternalServerError) return } diff --git a/pkg/server/api/handlers/auth_test.go b/pkg/server/handlers/auth_test.go similarity index 100% rename from pkg/server/api/handlers/auth_test.go rename to pkg/server/handlers/auth_test.go diff --git a/pkg/server/api/handlers/classic.go b/pkg/server/handlers/classic.go similarity index 84% rename from pkg/server/api/handlers/classic.go rename to pkg/server/handlers/classic.go index ed85a766..25aaaaa7 100644 --- a/pkg/server/api/handlers/classic.go +++ b/pkg/server/handlers/classic.go @@ -22,10 +22,10 @@ import ( "encoding/json" "net/http" - "github.com/dnote/dnote/pkg/server/api/crypt" - "github.com/dnote/dnote/pkg/server/api/helpers" - "github.com/dnote/dnote/pkg/server/api/operations" - "github.com/dnote/dnote/pkg/server/api/presenters" + "github.com/dnote/dnote/pkg/server/crypt" + "github.com/dnote/dnote/pkg/server/helpers" + "github.com/dnote/dnote/pkg/server/operations" + "github.com/dnote/dnote/pkg/server/presenters" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/log" "github.com/pkg/errors" @@ -35,7 +35,7 @@ import ( func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } @@ -43,7 +43,7 @@ func (a *App) classicMigrate(w http.ResponseWriter, r *http.Request) { var account database.Account if err := db.Where("user_id = ?", user.ID).First(&account).Error; err != nil { - handleError(w, "finding account", err, http.StatusInternalServerError) + HandleError(w, "finding account", err, http.StatusInternalServerError) return } @@ -55,7 +55,7 @@ func (a *App) classicMigrate(w http.ResponseWriter, r *http.Request) { "client_kdf_iteration": 0, "server_kdf_iteration": 0, }).Error; err != nil { - handleError(w, "updating account", err, http.StatusInternalServerError) + HandleError(w, "updating account", err, http.StatusInternalServerError) return } } @@ -78,7 +78,7 @@ func (a *App) classicPresignin(w http.ResponseWriter, r *http.Request) { var account database.Account conn := db.Where("email = ?", email).First(&account) if !conn.RecordNotFound() && conn.Error != nil { - handleError(w, "getting user", conn.Error, http.StatusInternalServerError) + HandleError(w, "getting user", conn.Error, http.StatusInternalServerError) return } @@ -95,7 +95,7 @@ func (a *App) classicPresignin(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(response); err != nil { - handleError(w, "encoding response", nil, http.StatusInternalServerError) + HandleError(w, "encoding response", nil, http.StatusInternalServerError) return } } @@ -110,7 +110,7 @@ func (a *App) 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) + HandleError(w, "decoding payload", err, http.StatusInternalServerError) return } @@ -125,7 +125,7 @@ func (a *App) classicSignin(w http.ResponseWriter, r *http.Request) { http.Error(w, ErrLoginFailure.Error(), http.StatusUnauthorized) return } else if err := conn.Error; err != nil { - handleError(w, "getting user", err, http.StatusInternalServerError) + HandleError(w, "getting user", err, http.StatusInternalServerError) return } @@ -140,7 +140,7 @@ func (a *App) classicSignin(w http.ResponseWriter, r *http.Request) { session, err := operations.CreateSession(db, account.UserID) if err != nil { - handleError(w, "creating session", nil, http.StatusBadRequest) + HandleError(w, "creating session", nil, http.StatusBadRequest) return } @@ -157,7 +157,7 @@ func (a *App) classicSignin(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(response); err != nil { - handleError(w, "encoding response", err, http.StatusInternalServerError) + HandleError(w, "encoding response", err, http.StatusInternalServerError) return } } @@ -165,7 +165,7 @@ func (a *App) classicSignin(w http.ResponseWriter, r *http.Request) { func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } @@ -173,7 +173,7 @@ func (a *App) classicGetMe(w http.ResponseWriter, r *http.Request) { var account database.Account if err := db.Where("user_id = ?", user.ID).First(&account).Error; err != nil { - handleError(w, "finding account", err, http.StatusInternalServerError) + HandleError(w, "finding account", err, http.StatusInternalServerError) return } @@ -225,7 +225,7 @@ type classicSetPasswordPayload struct { func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } @@ -233,13 +233,13 @@ func (a *App) classicSetPassword(w http.ResponseWriter, r *http.Request) { var params classicSetPasswordPayload if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { - handleError(w, "decoding payload", err, http.StatusInternalServerError) + HandleError(w, "decoding payload", err, http.StatusInternalServerError) return } var account database.Account if err := db.Where("user_id = ?", user.ID).First(&account).Error; err != nil { - handleError(w, "getting user", nil, http.StatusInternalServerError) + HandleError(w, "getting user", nil, http.StatusInternalServerError) return } @@ -260,14 +260,14 @@ func (a *App) classicSetPassword(w http.ResponseWriter, r *http.Request) { func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } var notes []database.Note db := database.DBConn if err := db.Where("user_id = ? AND encrypted = true", user.ID).Find(¬es).Error; err != nil { - handleError(w, "finding notes", err, http.StatusInternalServerError) + HandleError(w, "finding notes", err, http.StatusInternalServerError) return } diff --git a/pkg/server/api/handlers/classic_test.go b/pkg/server/handlers/classic_test.go similarity index 100% rename from pkg/server/api/handlers/classic_test.go rename to pkg/server/handlers/classic_test.go diff --git a/pkg/server/api/handlers/health.go b/pkg/server/handlers/health.go similarity index 100% rename from pkg/server/api/handlers/health.go rename to pkg/server/handlers/health.go diff --git a/pkg/server/api/handlers/health_test.go b/pkg/server/handlers/health_test.go similarity index 100% rename from pkg/server/api/handlers/health_test.go rename to pkg/server/handlers/health_test.go diff --git a/pkg/server/api/handlers/helpers.go b/pkg/server/handlers/helpers.go similarity index 83% rename from pkg/server/api/handlers/helpers.go rename to pkg/server/handlers/helpers.go index b097f9f6..1fd4dcb7 100644 --- a/pkg/server/api/handlers/helpers.go +++ b/pkg/server/handlers/helpers.go @@ -108,8 +108,8 @@ func getClientType(origin string) string { return "web" } -// handleError logs the error and responds with the given status code with a generic status text -func handleError(w http.ResponseWriter, msg string, err error, statusCode int) { +// HandleError logs the error and responds with the given status code with a generic status text +func HandleError(w http.ResponseWriter, msg string, err error, statusCode int) { var message string if err == nil { message = msg @@ -131,7 +131,7 @@ func respondJSON(w http.ResponseWriter, statusCode int, payload interface{}) { w.WriteHeader(statusCode) if err := json.NewEncoder(w).Encode(payload); err != nil { - handleError(w, "encoding response", err, http.StatusInternalServerError) + HandleError(w, "encoding response", err, http.StatusInternalServerError) } } @@ -140,3 +140,18 @@ func (a *App) notSupported(w http.ResponseWriter, r *http.Request) { http.Error(w, "API version is not supported. Please upgrade your client.", http.StatusGone) return } + +func respondForbidden(w http.ResponseWriter) { + http.Error(w, "forbidden", http.StatusForbidden) +} + +func respondUnauthorized(w http.ResponseWriter) { + unsetSessionCookie(w) + w.Header().Add("WWW-Authenticate", `Bearer realm="Dnote Pro", charset="UTF-8"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) +} + +// RespondNotFound responds with not found +func RespondNotFound(w http.ResponseWriter) { + http.Error(w, "not found", http.StatusNotFound) +} diff --git a/pkg/server/api/handlers/limit.go b/pkg/server/handlers/limit.go similarity index 100% rename from pkg/server/api/handlers/limit.go rename to pkg/server/handlers/limit.go diff --git a/pkg/server/api/handlers/notes.go b/pkg/server/handlers/notes.go similarity index 84% rename from pkg/server/api/handlers/notes.go rename to pkg/server/handlers/notes.go index 9f946829..c8ac1335 100644 --- a/pkg/server/api/handlers/notes.go +++ b/pkg/server/handlers/notes.go @@ -26,9 +26,10 @@ import ( "strings" "time" - "github.com/dnote/dnote/pkg/server/api/helpers" - "github.com/dnote/dnote/pkg/server/api/presenters" "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" "github.com/pkg/errors" @@ -85,7 +86,7 @@ func parseSearchQuery(q url.Values) string { return escapeSearchQuery(searchStr) } -func getNoteBaseQuery(noteUUID string, userID int, search string) *gorm.DB { +func getNoteBaseQuery(noteUUID string, search string) *gorm.DB { db := database.DBConn var conn *gorm.DB @@ -95,32 +96,28 @@ func getNoteBaseQuery(noteUUID string, userID int, search string) *gorm.DB { conn = db } - conn = conn.Where("notes.uuid = ? AND notes.user_id = ?", noteUUID, userID) + conn = conn.Where("notes.uuid = ? AND deleted = ?", noteUUID, false) return conn } func (a *App) getNote(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) + user, _, err := AuthWithSession(r, nil) + if err != nil { + HandleError(w, "authenticating", err, http.StatusInternalServerError) return } vars := mux.Vars(r) noteUUID := vars["noteUUID"] - search := parseSearchQuery(r.URL.Query()) - var note database.Note - conn := getNoteBaseQuery(noteUUID, user.ID, search) - conn = preloadNote(conn) - conn.Find(¬e) - - if conn.RecordNotFound() { - http.Error(w, "not found", http.StatusNotFound) + note, ok, err := operations.GetNote(noteUUID, user) + if !ok { + RespondNotFound(w) return - } else if err := conn.Error; err != nil { - handleError(w, "finding note", err, http.StatusInternalServerError) + } + if err != nil { + HandleError(w, "finding note", err, http.StatusInternalServerError) return } @@ -143,7 +140,7 @@ type dateRange struct { func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } query := r.URL.Query() @@ -162,18 +159,18 @@ func respondGetNotes(userID int, query url.Values, w http.ResponseWriter) { var total int if err := conn.Model(database.Note{}).Count(&total).Error; err != nil { - handleError(w, "counting total", err, http.StatusInternalServerError) + HandleError(w, "counting total", err, http.StatusInternalServerError) return } notes := []database.Note{} if total != 0 { conn = orderGetNotes(conn) - conn = preloadNote(conn) + conn = database.PreloadNote(conn) conn = paginate(conn, q.Page) if err := conn.Find(¬es).Error; err != nil { - handleError(w, "finding notes", err, http.StatusInternalServerError) + HandleError(w, "finding notes", err, http.StatusInternalServerError) return } } @@ -280,7 +277,7 @@ func getDateBounds(year, month int) (int64, int64) { func getNotesBaseQuery(userID int, q getNotesQuery) *gorm.DB { db := database.DBConn - conn := db.Debug().Where( + conn := db.Where( "notes.user_id = ? AND notes.deleted = ? AND notes.encrypted = ?", userID, false, q.Encrypted, ) @@ -307,10 +304,6 @@ func orderGetNotes(conn *gorm.DB) *gorm.DB { return conn.Order("notes.added_on DESC, notes.id DESC") } -func preloadNote(conn *gorm.DB) *gorm.DB { - return conn.Preload("Book").Preload("User") -} - // escapeSearchQuery escapes the query for full text search func escapeSearchQuery(searchQuery string) string { return strings.Join(strings.Fields(searchQuery), "&") @@ -319,14 +312,14 @@ func escapeSearchQuery(searchQuery string) string { func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } var notes []database.Note db := database.DBConn if err := db.Where("user_id = ? AND encrypted = true", user.ID).Find(¬es).Error; err != nil { - handleError(w, "finding notes", err, http.StatusInternalServerError) + HandleError(w, "finding notes", err, http.StatusInternalServerError) return } diff --git a/pkg/server/handlers/notes_test.go b/pkg/server/handlers/notes_test.go new file mode 100644 index 00000000..ee9cc6ea --- /dev/null +++ b/pkg/server/handlers/notes_test.go @@ -0,0 +1,363 @@ +/* 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 ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "testing" + "time" + + "github.com/dnote/dnote/pkg/assert" + "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/presenters" + "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/testutils" + "github.com/pkg/errors" +) + +func init() { + testutils.InitTestDB() +} + +func getExpectedNotePayload(n database.Note, b database.Book, u database.User) presenters.Note { + return presenters.Note{ + UUID: n.UUID, + CreatedAt: n.CreatedAt, + UpdatedAt: n.UpdatedAt, + Body: n.Body, + AddedOn: n.AddedOn, + Public: n.Public, + USN: n.USN, + Book: presenters.NoteBook{ + UUID: b.UUID, + Label: b.Label, + }, + User: presenters.NoteUser{ + Name: u.Name, + UUID: u.UUID, + }, + } +} + +func TestGetNotes(t *testing.T) { + defer testutils.ClearData() + db := database.DBConn + + // Setup + server := MustNewServer(t, &App{ + Clock: clock.NewMock(), + }) + defer server.Close() + + user := testutils.SetupUserData() + anotherUser := testutils.SetupUserData() + + b1 := database.Book{ + UserID: user.ID, + Label: "js", + } + testutils.MustExec(t, db.Save(&b1), "preparing b1") + b2 := database.Book{ + UserID: user.ID, + Label: "css", + } + testutils.MustExec(t, db.Save(&b2), "preparing b2") + b3 := database.Book{ + UserID: anotherUser.ID, + Label: "css", + } + testutils.MustExec(t, db.Save(&b3), "preparing b3") + + n1 := database.Note{ + UserID: user.ID, + BookUUID: b1.UUID, + Body: "n1 content", + USN: 11, + Deleted: false, + AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(), + } + testutils.MustExec(t, db.Save(&n1), "preparing n1") + n2 := database.Note{ + UserID: user.ID, + BookUUID: b1.UUID, + Body: "n2 content", + USN: 14, + Deleted: false, + AddedOn: time.Date(2018, time.August, 11, 22, 0, 0, 0, time.UTC).UnixNano(), + } + testutils.MustExec(t, db.Save(&n2), "preparing n2") + n3 := database.Note{ + UserID: user.ID, + BookUUID: b1.UUID, + Body: "n3 content", + USN: 17, + Deleted: false, + AddedOn: time.Date(2017, time.January, 10, 23, 0, 0, 0, time.UTC).UnixNano(), + } + testutils.MustExec(t, db.Save(&n3), "preparing n3") + n4 := database.Note{ + UserID: user.ID, + BookUUID: b2.UUID, + Body: "n4 content", + USN: 18, + Deleted: false, + AddedOn: time.Date(2018, time.September, 10, 23, 0, 0, 0, time.UTC).UnixNano(), + } + testutils.MustExec(t, db.Save(&n4), "preparing n4") + n5 := database.Note{ + UserID: anotherUser.ID, + BookUUID: b3.UUID, + Body: "n5 content", + USN: 19, + Deleted: false, + AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(), + } + testutils.MustExec(t, db.Save(&n5), "preparing n5") + n6 := database.Note{ + UserID: user.ID, + BookUUID: b1.UUID, + Body: "", + USN: 11, + Deleted: true, + AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(), + } + testutils.MustExec(t, db.Save(&n6), "preparing n6") + + // Execute + req := testutils.MakeReq(server, "GET", "/notes?year=2018&month=8", "") + res := testutils.HTTPAuthDo(t, req, user) + + // Test + assert.StatusCodeEquals(t, res, http.StatusOK, "") + + var payload GetNotesResponse + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { + t.Fatal(errors.Wrap(err, "decoding payload")) + } + + var n2Record, n1Record database.Note + testutils.MustExec(t, db.Where("uuid = ?", n2.UUID).First(&n2Record), "finding n2Record") + testutils.MustExec(t, db.Where("uuid = ?", n1.UUID).First(&n1Record), "finding n1Record") + + expected := GetNotesResponse{ + Notes: []presenters.Note{ + getExpectedNotePayload(n2Record, b1, user), + getExpectedNotePayload(n1Record, b1, user), + }, + Total: 2, + } + + assert.DeepEqual(t, payload, expected, "payload mismatch") +} + +func TestGetNote(t *testing.T) { + defer testutils.ClearData() + db := database.DBConn + + // Setup + server := MustNewServer(t, &App{ + Clock: clock.NewMock(), + }) + defer server.Close() + + user := testutils.SetupUserData() + anotherUser := testutils.SetupUserData() + + b1 := database.Book{ + UserID: user.ID, + Label: "js", + } + testutils.MustExec(t, db.Save(&b1), "preparing b1") + + privateNote := database.Note{ + UserID: user.ID, + BookUUID: b1.UUID, + Body: "privateNote content", + Public: false, + } + testutils.MustExec(t, db.Save(&privateNote), "preparing privateNote") + publicNote := database.Note{ + UserID: user.ID, + BookUUID: b1.UUID, + Body: "publicNote content", + Public: true, + } + testutils.MustExec(t, db.Save(&publicNote), "preparing publicNote") + deletedNote := database.Note{ + UserID: user.ID, + BookUUID: b1.UUID, + Deleted: true, + } + testutils.MustExec(t, db.Save(&deletedNote), "preparing publicNote") + + t.Run("owner accessing private note", func(t *testing.T) { + // Execute + url := fmt.Sprintf("/notes/%s", privateNote.UUID) + req := testutils.MakeReq(server, "GET", url, "") + res := testutils.HTTPAuthDo(t, req, user) + + // Test + assert.StatusCodeEquals(t, res, http.StatusOK, "") + + var payload presenters.Note + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { + t.Fatal(errors.Wrap(err, "decoding payload")) + } + + var n1Record database.Note + testutils.MustExec(t, db.Where("uuid = ?", privateNote.UUID).First(&n1Record), "finding n1Record") + + expected := getExpectedNotePayload(n1Record, b1, user) + assert.DeepEqual(t, payload, expected, "payload mismatch") + }) + + t.Run("owner accessing public note", func(t *testing.T) { + // Execute + url := fmt.Sprintf("/notes/%s", publicNote.UUID) + req := testutils.MakeReq(server, "GET", url, "") + res := testutils.HTTPAuthDo(t, req, user) + + // Test + assert.StatusCodeEquals(t, res, http.StatusOK, "") + + var payload presenters.Note + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { + t.Fatal(errors.Wrap(err, "decoding payload")) + } + + var n2Record database.Note + testutils.MustExec(t, db.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record") + + expected := getExpectedNotePayload(n2Record, b1, user) + assert.DeepEqual(t, payload, expected, "payload mismatch") + }) + + t.Run("non-owner accessing public note", func(t *testing.T) { + // Execute + url := fmt.Sprintf("/notes/%s", publicNote.UUID) + req := testutils.MakeReq(server, "GET", url, "") + res := testutils.HTTPAuthDo(t, req, anotherUser) + + // Test + assert.StatusCodeEquals(t, res, http.StatusOK, "") + + var payload presenters.Note + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { + t.Fatal(errors.Wrap(err, "decoding payload")) + } + + var n2Record database.Note + testutils.MustExec(t, db.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record") + + expected := getExpectedNotePayload(n2Record, b1, user) + assert.DeepEqual(t, payload, expected, "payload mismatch") + }) + + t.Run("non-owner accessing private note", func(t *testing.T) { + // Execute + url := fmt.Sprintf("/notes/%s", privateNote.UUID) + req := testutils.MakeReq(server, "GET", url, "") + res := testutils.HTTPAuthDo(t, req, anotherUser) + + // Test + assert.StatusCodeEquals(t, res, http.StatusNotFound, "") + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(errors.Wrap(err, "reading body")) + } + + assert.DeepEqual(t, string(body), "not found\n", "payload mismatch") + }) + + t.Run("guest accessing public note", func(t *testing.T) { + // Execute + url := fmt.Sprintf("/notes/%s", publicNote.UUID) + req := testutils.MakeReq(server, "GET", url, "") + res := testutils.HTTPDo(t, req) + + // Test + assert.StatusCodeEquals(t, res, http.StatusOK, "") + + var payload presenters.Note + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { + t.Fatal(errors.Wrap(err, "decoding payload")) + } + + var n2Record database.Note + testutils.MustExec(t, db.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record") + + expected := getExpectedNotePayload(n2Record, b1, user) + assert.DeepEqual(t, payload, expected, "payload mismatch") + }) + + t.Run("guest accessing private note", func(t *testing.T) { + // Execute + url := fmt.Sprintf("/notes/%s", privateNote.UUID) + req := testutils.MakeReq(server, "GET", url, "") + res := testutils.HTTPDo(t, req) + + // Test + assert.StatusCodeEquals(t, res, http.StatusNotFound, "") + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(errors.Wrap(err, "reading body")) + } + + assert.DeepEqual(t, string(body), "not found\n", "payload mismatch") + }) + + t.Run("nonexistent", func(t *testing.T) { + // Execute + url := fmt.Sprintf("/notes/%s", "someRandomString") + req := testutils.MakeReq(server, "GET", url, "") + res := testutils.HTTPAuthDo(t, req, user) + + // Test + assert.StatusCodeEquals(t, res, http.StatusNotFound, "") + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(errors.Wrap(err, "reading body")) + } + + assert.DeepEqual(t, string(body), "not found\n", "payload mismatch") + }) + + t.Run("deleted", func(t *testing.T) { + // Execute + url := fmt.Sprintf("/notes/%s", deletedNote.UUID) + req := testutils.MakeReq(server, "GET", url, "") + res := testutils.HTTPAuthDo(t, req, user) + + // Test + assert.StatusCodeEquals(t, res, http.StatusNotFound, "") + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Fatal(errors.Wrap(err, "reading body")) + } + + assert.DeepEqual(t, string(body), "not found\n", "payload mismatch") + }) +} diff --git a/pkg/server/api/handlers/repetition_rules.go b/pkg/server/handlers/repetition_rules.go similarity index 90% rename from pkg/server/api/handlers/repetition_rules.go rename to pkg/server/handlers/repetition_rules.go index c66d8b5b..cc305cb1 100644 --- a/pkg/server/api/handlers/repetition_rules.go +++ b/pkg/server/handlers/repetition_rules.go @@ -23,8 +23,8 @@ import ( "net/http" "time" - "github.com/dnote/dnote/pkg/server/api/helpers" - "github.com/dnote/dnote/pkg/server/api/presenters" + "github.com/dnote/dnote/pkg/server/helpers" + "github.com/dnote/dnote/pkg/server/presenters" "github.com/dnote/dnote/pkg/server/database" "github.com/gorilla/mux" "github.com/pkg/errors" @@ -33,7 +33,7 @@ import ( func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } @@ -48,7 +48,7 @@ func (a *App) getRepetitionRule(w http.ResponseWriter, r *http.Request) { db := database.DBConn var repetitionRule database.RepetitionRule if err := db.Where("user_id = ? AND uuid = ?", user.ID, repetitionRuleUUID).Preload("Books").Find(&repetitionRule).Error; err != nil { - handleError(w, "getting repetition rules", err, http.StatusInternalServerError) + HandleError(w, "getting repetition rules", err, http.StatusInternalServerError) return } @@ -59,14 +59,14 @@ func (a *App) getRepetitionRule(w http.ResponseWriter, r *http.Request) { func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } db := database.DBConn var repetitionRules []database.RepetitionRule if err := 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) + HandleError(w, "getting repetition rules", err, http.StatusInternalServerError) return } @@ -278,7 +278,7 @@ func calcNextActive(now time.Time, p calcNextActiveParams) int64 { func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } @@ -291,7 +291,7 @@ func (a *App) createRepetitionRule(w http.ResponseWriter, r *http.Request) { db := database.DBConn var books []database.Book if err := db.Where("user_id = ? AND uuid IN (?)", user.ID, params.GetBookUUIDs()).Find(&books).Error; err != nil { - handleError(w, "finding books", nil, http.StatusInternalServerError) + HandleError(w, "finding books", nil, http.StatusInternalServerError) return } @@ -314,7 +314,7 @@ func (a *App) createRepetitionRule(w http.ResponseWriter, r *http.Request) { Enabled: params.GetEnabled(), } if err := db.Create(&record).Error; err != nil { - handleError(w, "creating a repetition rule", err, http.StatusInternalServerError) + HandleError(w, "creating a repetition rule", err, http.StatusInternalServerError) return } @@ -339,7 +339,7 @@ func parseUpdateDigestParams(r *http.Request) (repetitionRuleParams, error) { func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } @@ -355,12 +355,12 @@ func (a *App) deleteRepetitionRule(w http.ResponseWriter, r *http.Request) { http.Error(w, "Not found", http.StatusNotFound) return } else if err := conn.Error; err != nil { - handleError(w, "finding the repetition rule", err, http.StatusInternalServerError) + HandleError(w, "finding the repetition rule", err, http.StatusInternalServerError) return } if err := db.Exec("DELETE from repetition_rules WHERE uuid = ?", rule.UUID).Error; err != nil { - handleError(w, "deleting the repetition rule", err, http.StatusInternalServerError) + HandleError(w, "deleting the repetition rule", err, http.StatusInternalServerError) } w.WriteHeader(http.StatusOK) @@ -369,7 +369,7 @@ func (a *App) deleteRepetitionRule(w http.ResponseWriter, r *http.Request) { func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } @@ -387,7 +387,7 @@ func (a *App) updateRepetitionRule(w http.ResponseWriter, r *http.Request) { var repetitionRule database.RepetitionRule if err := tx.Where("user_id = ? AND uuid = ?", user.ID, repetitionRuleUUID).Preload("Books").First(&repetitionRule).Error; err != nil { - handleError(w, "finding record", nil, http.StatusInternalServerError) + HandleError(w, "finding record", nil, http.StatusInternalServerError) return } @@ -433,26 +433,26 @@ func (a *App) updateRepetitionRule(w http.ResponseWriter, r *http.Request) { if params.BookUUIDs != nil { var books []database.Book if err := tx.Where("user_id = ? AND uuid IN (?)", user.ID, *params.BookUUIDs).Find(&books).Error; err != nil { - handleError(w, "finding books", err, http.StatusInternalServerError) + HandleError(w, "finding books", err, http.StatusInternalServerError) return } if err := tx.Model(&repetitionRule).Association("Books").Replace(books).Error; err != nil { tx.Rollback() - handleError(w, "updating books association for a repetitionRule", err, http.StatusInternalServerError) + HandleError(w, "updating books association for a repetitionRule", err, http.StatusInternalServerError) return } } if err := tx.Save(&repetitionRule).Error; err != nil { tx.Rollback() - handleError(w, "creating a repetition rule", err, http.StatusInternalServerError) + HandleError(w, "creating a repetition rule", err, http.StatusInternalServerError) return } if err := tx.Commit().Error; err != nil { tx.Rollback() - handleError(w, "committing a transaction", err, http.StatusInternalServerError) + HandleError(w, "committing a transaction", err, http.StatusInternalServerError) } resp := presenters.PresentRepetitionRule(repetitionRule) diff --git a/pkg/server/api/handlers/repetition_rules_test.go b/pkg/server/handlers/repetition_rules_test.go similarity index 99% rename from pkg/server/api/handlers/repetition_rules_test.go rename to pkg/server/handlers/repetition_rules_test.go index 993dcf3d..3015d41c 100644 --- a/pkg/server/api/handlers/repetition_rules_test.go +++ b/pkg/server/handlers/repetition_rules_test.go @@ -27,7 +27,7 @@ import ( "github.com/dnote/dnote/pkg/assert" "github.com/dnote/dnote/pkg/clock" - "github.com/dnote/dnote/pkg/server/api/presenters" + "github.com/dnote/dnote/pkg/server/presenters" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/testutils" "github.com/pkg/errors" diff --git a/pkg/server/api/handlers/routes.go b/pkg/server/handlers/routes.go similarity index 87% rename from pkg/server/api/handlers/routes.go rename to pkg/server/handlers/routes.go index d92a9a64..42a8b849 100644 --- a/pkg/server/api/handlers/routes.go +++ b/pkg/server/handlers/routes.go @@ -27,20 +27,14 @@ import ( "time" "github.com/dnote/dnote/pkg/clock" - "github.com/dnote/dnote/pkg/server/api/helpers" "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/helpers" "github.com/dnote/dnote/pkg/server/log" "github.com/gorilla/mux" "github.com/pkg/errors" "github.com/stripe/stripe-go" ) -// ErrInvalidAuthHeader is an error for invalid format of Authorization HTTP header -var ErrInvalidAuthHeader = errors.New("Invalid authorization header") - -// ErrForbidden is an error for forbidden requests -var ErrForbidden = errors.New("forbidden") - // Route represents a single route type Route struct { Method string @@ -58,7 +52,7 @@ func parseAuthHeader(h string) (authHeader, error) { parts := strings.Split(h, " ") if len(parts) != 2 { - return authHeader{}, ErrInvalidAuthHeader + return authHeader{}, errors.New("Invalid authorization header") } parsed := authHeader{ @@ -69,16 +63,6 @@ func parseAuthHeader(h string) (authHeader, error) { return parsed, nil } -func respondForbidden(w http.ResponseWriter) { - http.Error(w, "forbidden", http.StatusForbidden) -} - -func respondUnauthorized(w http.ResponseWriter) { - unsetSessionCookie(w) - w.Header().Add("WWW-Authenticate", `Bearer realm="Dnote Pro", charset="UTF-8"`) - http.Error(w, "unauthorized", http.StatusUnauthorized) -} - func legacyAuth(next http.HandlerFunc) http.HandlerFunc { db := database.DBConn @@ -153,7 +137,8 @@ func getCredential(r *http.Request) (string, error) { return ret, nil } -func authWithSession(r *http.Request, p *authMiddlewareParams) (database.User, bool, error) { +// AuthWithSession performs user authentication with session +func AuthWithSession(r *http.Request, p *AuthMiddlewareParams) (database.User, bool, error) { db := database.DBConn var user database.User @@ -161,7 +146,6 @@ func authWithSession(r *http.Request, p *authMiddlewareParams) (database.User, b if err != nil { return user, false, errors.Wrap(err, "getting credential") } - if sessionKey == "" { return user, false, nil } @@ -187,16 +171,10 @@ func authWithSession(r *http.Request, p *authMiddlewareParams) (database.User, b return user, false, errors.Wrap(err, "finding user from token") } - if p != nil && p.ProOnly { - if !user.Cloud { - return user, false, ErrForbidden - } - } - return user, true, nil } -func authWithToken(r *http.Request, tokenType string, p *authMiddlewareParams) (database.User, database.Token, bool, error) { +func authWithToken(r *http.Request, tokenType string, p *AuthMiddlewareParams) (database.User, database.Token, bool, error) { db := database.DBConn var user database.User var token database.Token @@ -222,45 +200,41 @@ func authWithToken(r *http.Request, tokenType string, p *authMiddlewareParams) ( return user, token, false, errors.Wrap(err, "finding user") } - if p != nil && p.ProOnly { - if !user.Cloud { - return user, token, false, ErrForbidden - } - } - return user, token, true, nil } -type authMiddlewareParams struct { +// AuthMiddlewareParams is the params for the authentication middleware +type AuthMiddlewareParams struct { ProOnly bool } -func auth(next http.HandlerFunc, p *authMiddlewareParams) http.HandlerFunc { +func auth(next http.HandlerFunc, p *AuthMiddlewareParams) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, ok, err := authWithSession(r, p) - if !ok || err != nil { - if err == ErrForbidden { - http.Error(w, "forbidden", http.StatusForbidden) - } else { - respondUnauthorized(w) - } + user, ok, err := AuthWithSession(r, p) + if !ok { + respondUnauthorized(w) return } + if err != nil { + HandleError(w, "authenticating with session", err, http.StatusInternalServerError) + return + } + + if p != nil && p.ProOnly { + if !user.Cloud { + respondForbidden(w) + } + } ctx := context.WithValue(r.Context(), helpers.KeyUser, user) next.ServeHTTP(w, r.WithContext(ctx)) }) } -func tokenAuth(next http.HandlerFunc, tokenType string, p *authMiddlewareParams) http.HandlerFunc { +func 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(r, tokenType, p) if err != nil { - if err == ErrForbidden { - respondForbidden(w) - return - } - // log the error and continue log.ErrorWrap(err, "authenticating with token") } @@ -271,22 +245,24 @@ func tokenAuth(next http.HandlerFunc, tokenType string, p *authMiddlewareParams) ctx = context.WithValue(ctx, helpers.KeyToken, token) } else { // If token-based auth fails, fall back to session-based auth - user, ok, err = authWithSession(r, p) + user, ok, err = AuthWithSession(r, p) if err != nil { - // log the error and continue - log.ErrorWrap(err, "authenticating with session") + HandleError(w, "authenticating with session", err, http.StatusInternalServerError) + return } if !ok { - if err == ErrForbidden { - respondForbidden(w) - } else { - respondUnauthorized(w) - } + respondUnauthorized(w) return } } + if p != nil && p.ProOnly { + if !user.Cloud { + respondForbidden(w) + } + } + ctx = context.WithValue(ctx, helpers.KeyUser, user) next.ServeHTTP(w, r.WithContext(ctx)) }) @@ -383,7 +359,7 @@ func NewRouter(app *App) (*mux.Router, error) { return nil, errors.Wrap(err, "initializing app") } - proOnly := authMiddlewareParams{ProOnly: true} + proOnly := AuthMiddlewareParams{ProOnly: true} var routes = []Route{ // internal @@ -404,7 +380,7 @@ func NewRouter(app *App) (*mux.Router, error) { {"GET", "/stripe_source", auth(app.getStripeSource, nil), true}, {"PATCH", "/stripe_source", auth(app.updateStripeSource, nil), true}, {"GET", "/notes", auth(app.getNotes, &proOnly), false}, - {"GET", "/notes/{noteUUID}", auth(app.getNote, &proOnly), true}, + {"GET", "/notes/{noteUUID}", app.getNote, true}, {"GET", "/calendar", auth(app.getCalendar, &proOnly), true}, {"GET", "/repetition_rules", auth(app.getRepetitionRules, &proOnly), true}, {"GET", "/repetition_rules/{repetitionRuleUUID}", tokenAuth(app.getRepetitionRule, database.TokenTypeRepetition, &proOnly), true}, diff --git a/pkg/server/api/handlers/routes_test.go b/pkg/server/handlers/routes_test.go similarity index 99% rename from pkg/server/api/handlers/routes_test.go rename to pkg/server/handlers/routes_test.go index 01683d90..53227e7c 100644 --- a/pkg/server/api/handlers/routes_test.go +++ b/pkg/server/handlers/routes_test.go @@ -317,7 +317,7 @@ func TestAuthMiddleware_ProOnly(t *testing.T) { handler := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } - server := httptest.NewServer(auth(handler, &authMiddlewareParams{ + server := httptest.NewServer(auth(handler, &AuthMiddlewareParams{ ProOnly: true, })) defer server.Close() @@ -544,7 +544,7 @@ func TestTokenAuthMiddleWare_ProOnly(t *testing.T) { handler := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } - server := httptest.NewServer(tokenAuth(handler, database.TokenTypeEmailPreference, &authMiddlewareParams{ + server := httptest.NewServer(tokenAuth(handler, database.TokenTypeEmailPreference, &AuthMiddlewareParams{ ProOnly: true, })) defer server.Close() diff --git a/pkg/server/api/handlers/semver.go b/pkg/server/handlers/semver.go similarity index 100% rename from pkg/server/api/handlers/semver.go rename to pkg/server/handlers/semver.go diff --git a/pkg/server/api/handlers/subscription.go b/pkg/server/handlers/subscription.go similarity index 86% rename from pkg/server/api/handlers/subscription.go rename to pkg/server/handlers/subscription.go index 79476652..1fe700b6 100644 --- a/pkg/server/api/handlers/subscription.go +++ b/pkg/server/handlers/subscription.go @@ -26,8 +26,8 @@ import ( "os" "strings" - "github.com/dnote/dnote/pkg/server/api/helpers" - "github.com/dnote/dnote/pkg/server/api/operations" + "github.com/dnote/dnote/pkg/server/helpers" + "github.com/dnote/dnote/pkg/server/operations" "github.com/dnote/dnote/pkg/server/database" "github.com/jinzhu/gorm" "github.com/pkg/errors" @@ -128,13 +128,13 @@ type createSubPayload struct { func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } var payload createSubPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - handleError(w, "decoding params", err, http.StatusBadRequest) + HandleError(w, "decoding params", err, http.StatusBadRequest) return } @@ -147,31 +147,31 @@ func (a *App) createSub(w http.ResponseWriter, r *http.Request) { "billing_country": payload.Country, }).Error; err != nil { tx.Rollback() - handleError(w, "updating user", err, http.StatusInternalServerError) + HandleError(w, "updating user", err, http.StatusInternalServerError) return } customer, err := getOrCreateStripeCustomer(tx, user) if err != nil { tx.Rollback() - handleError(w, "getting customer", err, http.StatusInternalServerError) + HandleError(w, "getting customer", err, http.StatusInternalServerError) return } if _, err = addCustomerSource(customer.ID, payload.Source.ID); err != nil { tx.Rollback() - handleError(w, "attaching source", err, http.StatusInternalServerError) + HandleError(w, "attaching source", err, http.StatusInternalServerError) return } if _, err := createCustomerSubscription(customer.ID, proPlanID); err != nil { tx.Rollback() - handleError(w, "creating subscription", err, http.StatusInternalServerError) + HandleError(w, "creating subscription", err, http.StatusInternalServerError) return } if err := tx.Commit().Error; err != nil { - handleError(w, "committing a subscription transaction", err, http.StatusInternalServerError) + HandleError(w, "committing a subscription transaction", err, http.StatusInternalServerError) return } @@ -218,21 +218,21 @@ func validateUpdateSubPayload(p updateSubPayload) error { func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } if user.StripeCustomerID == "" { - handleError(w, "Customer does not exist", nil, http.StatusForbidden) + HandleError(w, "Customer does not exist", nil, http.StatusForbidden) return } var payload updateSubPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - handleError(w, "decoding params", err, http.StatusBadRequest) + HandleError(w, "decoding params", err, http.StatusBadRequest) return } if err := validateUpdateSubPayload(payload); err != nil { - handleError(w, "invalid payload", err, http.StatusBadRequest) + HandleError(w, "invalid payload", err, http.StatusBadRequest) return } @@ -251,7 +251,7 @@ func (a *App) updateSub(w http.ResponseWriter, r *http.Request) { statusCode = http.StatusInternalServerError } - handleError(w, fmt.Sprintf("during operation %s", payload.Op), err, statusCode) + HandleError(w, fmt.Sprintf("during operation %s", payload.Op), err, statusCode) return } @@ -281,7 +281,7 @@ func respondWithEmptySub(w http.ResponseWriter) { w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(emptyGetSubResponse); err != nil { - handleError(w, "encoding response", err, http.StatusInternalServerError) + HandleError(w, "encoding response", err, http.StatusInternalServerError) return } } @@ -289,7 +289,7 @@ func respondWithEmptySub(w http.ResponseWriter) { func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } if user.StripeCustomerID == "" { @@ -304,7 +304,7 @@ func (a *App) getSub(w http.ResponseWriter, r *http.Request) { if !i.Next() { if err := i.Err(); err != nil { - handleError(w, "fetching subscription", err, http.StatusInternalServerError) + HandleError(w, "fetching subscription", err, http.StatusInternalServerError) return } @@ -417,13 +417,13 @@ func validateUpdateStripeSourcePayload(p updateStripeSourcePayload) error { func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } var payload updateStripeSourcePayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - handleError(w, "decoding params", err, http.StatusBadRequest) + HandleError(w, "decoding params", err, http.StatusBadRequest) return } if err := validateUpdateStripeSourcePayload(payload); err != nil { @@ -439,32 +439,32 @@ func (a *App) updateStripeSource(w http.ResponseWriter, r *http.Request) { "billing_country": payload.Country, }).Error; err != nil { tx.Rollback() - handleError(w, "updating user", err, http.StatusInternalServerError) + HandleError(w, "updating user", err, http.StatusInternalServerError) return } c, err := customer.Get(user.StripeCustomerID, nil) if err != nil { tx.Rollback() - handleError(w, "retriving customer", err, http.StatusInternalServerError) + HandleError(w, "retriving customer", err, http.StatusInternalServerError) return } if _, err := removeCustomerSource(user.StripeCustomerID, c.DefaultSource.ID); err != nil { tx.Rollback() - handleError(w, "removing source", err, http.StatusInternalServerError) + HandleError(w, "removing source", err, http.StatusInternalServerError) return } if _, err := addCustomerSource(user.StripeCustomerID, payload.Source.ID); err != nil { tx.Rollback() - handleError(w, "attaching source", err, http.StatusInternalServerError) + HandleError(w, "attaching source", err, http.StatusInternalServerError) return } if err := tx.Commit().Error; err != nil { tx.Rollback() - handleError(w, "committing transaction", err, http.StatusInternalServerError) + HandleError(w, "committing transaction", err, http.StatusInternalServerError) return } @@ -474,7 +474,7 @@ func (a *App) updateStripeSource(w http.ResponseWriter, r *http.Request) { func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } if user.StripeCustomerID == "" { @@ -484,7 +484,7 @@ func (a *App) getStripeSource(w http.ResponseWriter, r *http.Request) { c, err := customer.Get(user.StripeCustomerID, nil) if err != nil { - handleError(w, "fetching stripe customer", err, http.StatusInternalServerError) + HandleError(w, "fetching stripe customer", err, http.StatusInternalServerError) return } @@ -495,7 +495,7 @@ func (a *App) getStripeSource(w http.ResponseWriter, r *http.Request) { cd, err := getStripeCard(user.StripeCustomerID, c.DefaultSource.ID) if err != nil { - handleError(w, "fetching stripe source", err, http.StatusInternalServerError) + HandleError(w, "fetching stripe source", err, http.StatusInternalServerError) return } @@ -512,14 +512,14 @@ func (a *App) getStripeSource(w http.ResponseWriter, r *http.Request) { func (a *App) stripeWebhook(w http.ResponseWriter, req *http.Request) { body, err := ioutil.ReadAll(req.Body) if err != nil { - handleError(w, "reading body", err, http.StatusServiceUnavailable) + HandleError(w, "reading body", err, http.StatusServiceUnavailable) return } webhookSecret := os.Getenv("StripeWebhookSecret") event, err := webhook.ConstructEvent(body, req.Header.Get("Stripe-Signature"), webhookSecret) if err != nil { - handleError(w, "verifying stripe webhook signature", err, http.StatusBadRequest) + HandleError(w, "verifying stripe webhook signature", err, http.StatusBadRequest) return } @@ -528,7 +528,7 @@ func (a *App) stripeWebhook(w http.ResponseWriter, req *http.Request) { { var subscription stripe.Subscription if json.Unmarshal(event.Data.Raw, &subscription); err != nil { - handleError(w, "unmarshaling payload", err, http.StatusBadRequest) + HandleError(w, "unmarshaling payload", err, http.StatusBadRequest) return } @@ -537,7 +537,7 @@ func (a *App) stripeWebhook(w http.ResponseWriter, req *http.Request) { default: { msg := fmt.Sprintf("Unsupported webhook event type %s", event.Type) - handleError(w, msg, err, http.StatusBadRequest) + HandleError(w, msg, err, http.StatusBadRequest) return } } diff --git a/pkg/server/api/handlers/testutils.go b/pkg/server/handlers/testutils.go similarity index 100% rename from pkg/server/api/handlers/testutils.go rename to pkg/server/handlers/testutils.go diff --git a/pkg/server/api/handlers/user.go b/pkg/server/handlers/user.go similarity index 83% rename from pkg/server/api/handlers/user.go rename to pkg/server/handlers/user.go index 9160e1ca..e7acc47f 100644 --- a/pkg/server/api/handlers/user.go +++ b/pkg/server/handlers/user.go @@ -23,8 +23,8 @@ import ( "net/http" "time" - "github.com/dnote/dnote/pkg/server/api/helpers" - "github.com/dnote/dnote/pkg/server/api/presenters" + "github.com/dnote/dnote/pkg/server/helpers" + "github.com/dnote/dnote/pkg/server/presenters" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/log" "github.com/dnote/dnote/pkg/server/mailer" @@ -42,7 +42,7 @@ func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } @@ -62,14 +62,14 @@ func (a *App) updateProfile(w http.ResponseWriter, r *http.Request) { var account database.Account err = db.Where("user_id = ?", user.ID).First(&account).Error if err != nil { - handleError(w, "finding account", err, http.StatusInternalServerError) + HandleError(w, "finding account", err, http.StatusInternalServerError) return } tx := db.Begin() if err := tx.Save(&user).Error; err != nil { tx.Rollback() - handleError(w, "saving user", err, http.StatusInternalServerError) + HandleError(w, "saving user", err, http.StatusInternalServerError) return } @@ -81,7 +81,7 @@ func (a *App) updateProfile(w http.ResponseWriter, r *http.Request) { if err := tx.Save(&account).Error; err != nil { tx.Rollback() - handleError(w, "saving account", err, http.StatusInternalServerError) + HandleError(w, "saving account", err, http.StatusInternalServerError) return } @@ -106,7 +106,7 @@ func respondWithCalendar(w http.ResponseWriter, userID int) { Order("added_date DESC").Rows() if err != nil { - handleError(w, "Failed to count lessons", err, http.StatusInternalServerError) + HandleError(w, "Failed to count lessons", err, http.StatusInternalServerError) return } @@ -117,7 +117,7 @@ func respondWithCalendar(w http.ResponseWriter, userID int) { var d time.Time if err := rows.Scan(&count, &d); err != nil { - handleError(w, "counting notes", err, http.StatusInternalServerError) + HandleError(w, "counting notes", err, http.StatusInternalServerError) } payload[d.Format("2006-1-2")] = count } @@ -128,7 +128,7 @@ func respondWithCalendar(w http.ResponseWriter, userID int) { func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } @@ -138,7 +138,7 @@ func (a *App) getCalendar(w http.ResponseWriter, r *http.Request) { func (a *App) getDemoCalendar(w http.ResponseWriter, r *http.Request) { userID, err := helpers.GetDemoUserID() if err != nil { - handleError(w, "finding demo user", err, http.StatusInternalServerError) + HandleError(w, "finding demo user", err, http.StatusInternalServerError) return } @@ -150,14 +150,14 @@ func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } var account database.Account err := db.Where("user_id = ?", user.ID).First(&account).Error if err != nil { - handleError(w, "finding account", err, http.StatusInternalServerError) + HandleError(w, "finding account", err, http.StatusInternalServerError) return } @@ -172,7 +172,7 @@ func (a *App) createVerificationToken(w http.ResponseWriter, r *http.Request) { tokenValue, err := generateVerificationCode() if err != nil { - handleError(w, "generating verification code", err, http.StatusInternalServerError) + HandleError(w, "generating verification code", err, http.StatusInternalServerError) return } @@ -183,7 +183,7 @@ func (a *App) createVerificationToken(w http.ResponseWriter, r *http.Request) { } if err := db.Save(&token).Error; err != nil { - handleError(w, "saving token", err, http.StatusInternalServerError) + HandleError(w, "saving token", err, http.StatusInternalServerError) return } @@ -195,12 +195,12 @@ func (a *App) createVerificationToken(w http.ResponseWriter, r *http.Request) { } email := mailer.NewEmail("noreply@getdnote.com", []string{account.Email.String}, subject) if err := email.ParseTemplate(mailer.EmailTypeEmailVerification, data); err != nil { - handleError(w, "parsing template", err, http.StatusInternalServerError) + HandleError(w, "parsing template", err, http.StatusInternalServerError) return } if err := email.Send(); err != nil { - handleError(w, "sending email", err, http.StatusInternalServerError) + HandleError(w, "sending email", err, http.StatusInternalServerError) return } @@ -216,7 +216,7 @@ func (a *App) 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) + HandleError(w, "decoding payload", err, http.StatusInternalServerError) return } @@ -241,7 +241,7 @@ func (a *App) verifyEmail(w http.ResponseWriter, r *http.Request) { var account database.Account if err := db.Where("user_id = ?", token.UserID).First(&account).Error; err != nil { - handleError(w, "finding account", err, http.StatusInternalServerError) + HandleError(w, "finding account", err, http.StatusInternalServerError) return } if account.EmailVerified { @@ -253,19 +253,19 @@ func (a *App) verifyEmail(w http.ResponseWriter, r *http.Request) { account.EmailVerified = true if err := tx.Save(&account).Error; err != nil { tx.Rollback() - handleError(w, "updating email_verified", err, http.StatusInternalServerError) + HandleError(w, "updating email_verified", err, http.StatusInternalServerError) return } if err := tx.Model(&token).Update("used_at", time.Now()).Error; err != nil { tx.Rollback() - handleError(w, "updating reset token", err, http.StatusInternalServerError) + HandleError(w, "updating reset token", err, http.StatusInternalServerError) return } tx.Commit() var user database.User if err := db.Where("id = ?", token.UserID).First(&user).Error; err != nil { - handleError(w, "finding user", err, http.StatusInternalServerError) + HandleError(w, "finding user", err, http.StatusInternalServerError) return } @@ -282,19 +282,19 @@ func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } var params updateEmailPreferencePayload if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { - handleError(w, "decoding payload", err, http.StatusInternalServerError) + HandleError(w, "decoding payload", err, http.StatusInternalServerError) return } var frequency database.EmailPreference if err := db.Where(database.EmailPreference{UserID: user.ID}).FirstOrCreate(&frequency).Error; err != nil { - handleError(w, "finding frequency", err, http.StatusInternalServerError) + HandleError(w, "finding frequency", err, http.StatusInternalServerError) return } @@ -303,7 +303,7 @@ func (a *App) updateEmailPreference(w http.ResponseWriter, r *http.Request) { frequency.DigestWeekly = params.DigestWeekly if err := tx.Save(&frequency).Error; err != nil { tx.Rollback() - handleError(w, "saving frequency", err, http.StatusInternalServerError) + HandleError(w, "saving frequency", err, http.StatusInternalServerError) return } @@ -312,7 +312,7 @@ func (a *App) updateEmailPreference(w http.ResponseWriter, r *http.Request) { // Use token if the user was authenticated by token if err := tx.Model(&token).Update("used_at", time.Now()).Error; err != nil { tx.Rollback() - handleError(w, "updating reset token", err, http.StatusInternalServerError) + HandleError(w, "updating reset token", err, http.StatusInternalServerError) return } } @@ -327,13 +327,13 @@ func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } var pref database.EmailPreference if err := db.Where(database.EmailPreference{UserID: user.ID}).First(&pref).Error; err != nil { - handleError(w, "finding pref", err, http.StatusInternalServerError) + HandleError(w, "finding pref", err, http.StatusInternalServerError) return } @@ -351,7 +351,7 @@ func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } @@ -367,7 +367,7 @@ func (a *App) updatePassword(w http.ResponseWriter, r *http.Request) { var account database.Account if err := db.Where("user_id = ?", user.ID).First(&account).Error; err != nil { - handleError(w, "getting user", nil, http.StatusInternalServerError) + HandleError(w, "getting user", nil, http.StatusInternalServerError) return } diff --git a/pkg/server/api/handlers/user_test.go b/pkg/server/handlers/user_test.go similarity index 99% rename from pkg/server/api/handlers/user_test.go rename to pkg/server/handlers/user_test.go index efe3dd39..e9e1ac5b 100644 --- a/pkg/server/api/handlers/user_test.go +++ b/pkg/server/handlers/user_test.go @@ -27,7 +27,7 @@ import ( "github.com/dnote/dnote/pkg/assert" "github.com/dnote/dnote/pkg/clock" - "github.com/dnote/dnote/pkg/server/api/presenters" + "github.com/dnote/dnote/pkg/server/presenters" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/mailer" "github.com/dnote/dnote/pkg/server/testutils" diff --git a/pkg/server/api/handlers/v3_auth.go b/pkg/server/handlers/v3_auth.go similarity index 90% rename from pkg/server/api/handlers/v3_auth.go rename to pkg/server/handlers/v3_auth.go index 45af3fdd..18cf194b 100644 --- a/pkg/server/api/handlers/v3_auth.go +++ b/pkg/server/handlers/v3_auth.go @@ -23,7 +23,7 @@ import ( "net/http" "time" - "github.com/dnote/dnote/pkg/server/api/operations" + "github.com/dnote/dnote/pkg/server/operations" "github.com/dnote/dnote/pkg/server/database" "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" @@ -85,7 +85,7 @@ func (a *App) signin(w http.ResponseWriter, r *http.Request) { var params signinPayload err := json.NewDecoder(r.Body).Decode(¶ms) if err != nil { - handleError(w, "decoding payload", err, http.StatusInternalServerError) + HandleError(w, "decoding payload", err, http.StatusInternalServerError) return } if params.Email == "" || params.Password == "" { @@ -99,7 +99,7 @@ func (a *App) signin(w http.ResponseWriter, r *http.Request) { http.Error(w, ErrLoginFailure.Error(), http.StatusUnauthorized) return } else if conn.Error != nil { - handleError(w, "getting user", err, http.StatusInternalServerError) + HandleError(w, "getting user", err, http.StatusInternalServerError) return } @@ -113,7 +113,7 @@ func (a *App) signin(w http.ResponseWriter, r *http.Request) { var user database.User err = db.Where("id = ?", account.UserID).First(&user).Error if err != nil { - handleError(w, "finding user", err, http.StatusInternalServerError) + HandleError(w, "finding user", err, http.StatusInternalServerError) return } @@ -134,7 +134,7 @@ func (a *App) signoutOptions(w http.ResponseWriter, r *http.Request) { func (a *App) signout(w http.ResponseWriter, r *http.Request) { key, err := getCredential(r) if err != nil { - handleError(w, "getting credential", nil, http.StatusInternalServerError) + HandleError(w, "getting credential", nil, http.StatusInternalServerError) return } @@ -145,7 +145,7 @@ func (a *App) signout(w http.ResponseWriter, r *http.Request) { err = operations.DeleteSession(database.DBConn, key) if err != nil { - handleError(w, "deleting session", nil, http.StatusInternalServerError) + HandleError(w, "deleting session", nil, http.StatusInternalServerError) return } @@ -192,7 +192,7 @@ func (a *App) register(w http.ResponseWriter, r *http.Request) { var count int if err := db.Model(database.Account{}).Where("email = ?", params.Email).Count(&count).Error; err != nil { - handleError(w, "checking duplicate user", err, http.StatusInternalServerError) + HandleError(w, "checking duplicate user", err, http.StatusInternalServerError) return } if count > 0 { @@ -202,7 +202,7 @@ func (a *App) register(w http.ResponseWriter, r *http.Request) { user, err := operations.CreateUser(params.Email, params.Password) if err != nil { - handleError(w, "creating user", err, http.StatusInternalServerError) + HandleError(w, "creating user", err, http.StatusInternalServerError) return } @@ -216,7 +216,7 @@ func respondWithSession(w http.ResponseWriter, userID int, statusCode int) { session, err := operations.CreateSession(db, userID) if err != nil { - handleError(w, "creating session", nil, http.StatusBadRequest) + HandleError(w, "creating session", nil, http.StatusBadRequest) return } @@ -230,7 +230,7 @@ func respondWithSession(w http.ResponseWriter, userID int, statusCode int) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) if err := json.NewEncoder(w).Encode(response); err != nil { - handleError(w, "encoding response", err, http.StatusInternalServerError) + HandleError(w, "encoding response", err, http.StatusInternalServerError) return } } diff --git a/pkg/server/api/handlers/v3_auth_test.go b/pkg/server/handlers/v3_auth_test.go similarity index 100% rename from pkg/server/api/handlers/v3_auth_test.go rename to pkg/server/handlers/v3_auth_test.go diff --git a/pkg/server/api/handlers/v3_books.go b/pkg/server/handlers/v3_books.go similarity index 85% rename from pkg/server/api/handlers/v3_books.go rename to pkg/server/handlers/v3_books.go index d18e75cb..7be082d5 100644 --- a/pkg/server/api/handlers/v3_books.go +++ b/pkg/server/handlers/v3_books.go @@ -24,9 +24,9 @@ import ( "net/http" "net/url" - "github.com/dnote/dnote/pkg/server/api/helpers" - "github.com/dnote/dnote/pkg/server/api/operations" - "github.com/dnote/dnote/pkg/server/api/presenters" + "github.com/dnote/dnote/pkg/server/helpers" + "github.com/dnote/dnote/pkg/server/operations" + "github.com/dnote/dnote/pkg/server/presenters" "github.com/dnote/dnote/pkg/server/database" "github.com/gorilla/mux" "github.com/pkg/errors" @@ -59,13 +59,13 @@ func (a *App) CreateBook(w http.ResponseWriter, r *http.Request) { var params createBookPayload err := json.NewDecoder(r.Body).Decode(¶ms) if err != nil { - handleError(w, "decoding payload", err, http.StatusInternalServerError) + HandleError(w, "decoding payload", err, http.StatusInternalServerError) return } err = validateCreateBookPayload(params) if err != nil { - handleError(w, "validating payload", err, http.StatusBadRequest) + HandleError(w, "validating payload", err, http.StatusBadRequest) return } @@ -76,7 +76,7 @@ func (a *App) CreateBook(w http.ResponseWriter, r *http.Request) { Where("user_id = ? AND label = ?", user.ID, params.Name). Count(&bookCount).Error if err != nil { - handleError(w, "checking duplicate", err, http.StatusInternalServerError) + HandleError(w, "checking duplicate", err, http.StatusInternalServerError) return } if bookCount > 0 { @@ -86,7 +86,7 @@ func (a *App) CreateBook(w http.ResponseWriter, r *http.Request) { book, err := operations.CreateBook(user, a.Clock, params.Name) if err != nil { - handleError(w, "inserting book", err, http.StatusInternalServerError) + HandleError(w, "inserting book", err, http.StatusInternalServerError) } resp := CreateBookResp{ Book: presenters.PresentBook(book), @@ -124,7 +124,7 @@ func respondWithBooks(userID int, query url.Values, w http.ResponseWriter) { } if err := conn.Find(&books).Error; err != nil { - handleError(w, "finding books", err, http.StatusInternalServerError) + HandleError(w, "finding books", err, http.StatusInternalServerError) return } @@ -136,7 +136,7 @@ func respondWithBooks(userID int, query url.Values, w http.ResponseWriter) { func (a *App) GetDemoBooks(w http.ResponseWriter, r *http.Request) { demoUserID, err := helpers.GetDemoUserID() if err != nil { - handleError(w, "finding demo user", err, http.StatusInternalServerError) + HandleError(w, "finding demo user", err, http.StatusInternalServerError) return } @@ -177,7 +177,7 @@ func (a *App) GetBook(w http.ResponseWriter, r *http.Request) { return } if err := conn.Error; err != nil { - handleError(w, "finding book", err, http.StatusInternalServerError) + HandleError(w, "finding book", err, http.StatusInternalServerError) return } @@ -209,21 +209,21 @@ func (a *App) UpdateBook(w http.ResponseWriter, r *http.Request) { var book database.Book if err := tx.Where("user_id = ? AND uuid = ?", user.ID, uuid).First(&book).Error; err != nil { - handleError(w, "finding book", err, http.StatusInternalServerError) + HandleError(w, "finding book", err, http.StatusInternalServerError) return } var params updateBookPayload err := json.NewDecoder(r.Body).Decode(¶ms) if err != nil { - handleError(w, "decoding payload", err, http.StatusInternalServerError) + HandleError(w, "decoding payload", err, http.StatusInternalServerError) return } book, err = operations.UpdateBook(tx, a.Clock, user, book, params.Name) if err != nil { tx.Rollback() - handleError(w, "updating a book", err, http.StatusInternalServerError) + HandleError(w, "updating a book", err, http.StatusInternalServerError) } tx.Commit() @@ -255,25 +255,25 @@ func (a *App) DeleteBook(w http.ResponseWriter, r *http.Request) { var book database.Book if err := tx.Where("user_id = ? AND uuid = ?", user.ID, uuid).First(&book).Error; err != nil { - handleError(w, "finding book", err, http.StatusInternalServerError) + HandleError(w, "finding book", err, http.StatusInternalServerError) return } var notes []database.Note if err := tx.Where("book_uuid = ? AND NOT deleted", uuid).Order("usn ASC").Find(¬es).Error; err != nil { - handleError(w, "finding notes", err, http.StatusInternalServerError) + HandleError(w, "finding notes", err, http.StatusInternalServerError) return } for _, note := range notes { if _, err := operations.DeleteNote(tx, user, note); err != nil { - handleError(w, "deleting a note", err, http.StatusInternalServerError) + HandleError(w, "deleting a note", err, http.StatusInternalServerError) return } } b, err := operations.DeleteBook(tx, user, book) if err != nil { - handleError(w, "deleting book", err, http.StatusInternalServerError) + HandleError(w, "deleting book", err, http.StatusInternalServerError) return } diff --git a/pkg/server/api/handlers/v3_books_test.go b/pkg/server/handlers/v3_books_test.go similarity index 99% rename from pkg/server/api/handlers/v3_books_test.go rename to pkg/server/handlers/v3_books_test.go index d80d4d11..ab07c7a6 100644 --- a/pkg/server/api/handlers/v3_books_test.go +++ b/pkg/server/handlers/v3_books_test.go @@ -26,7 +26,7 @@ import ( "github.com/dnote/dnote/pkg/assert" "github.com/dnote/dnote/pkg/clock" - "github.com/dnote/dnote/pkg/server/api/presenters" + "github.com/dnote/dnote/pkg/server/presenters" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/testutils" "github.com/pkg/errors" diff --git a/pkg/server/api/handlers/v3_notes.go b/pkg/server/handlers/v3_notes.go similarity index 78% rename from pkg/server/api/handlers/v3_notes.go rename to pkg/server/handlers/v3_notes.go index cb3e7612..e6e62c78 100644 --- a/pkg/server/api/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/api/helpers" - "github.com/dnote/dnote/pkg/server/api/operations" - "github.com/dnote/dnote/pkg/server/api/presenters" + "github.com/dnote/dnote/pkg/server/helpers" + "github.com/dnote/dnote/pkg/server/operations" + "github.com/dnote/dnote/pkg/server/presenters" "github.com/dnote/dnote/pkg/server/database" "github.com/gorilla/mux" "github.com/pkg/errors" @@ -34,6 +34,7 @@ import ( type updateNotePayload struct { BookUUID *string `json:"book_uuid"` Content *string `json:"content"` + Public *bool `json:"public"` } type updateNoteResp struct { @@ -42,7 +43,7 @@ type updateNoteResp struct { } func validateUpdateNotePayload(p updateNotePayload) bool { - return p.BookUUID != nil || p.Content != nil + return p.BookUUID != nil || p.Content != nil || p.Public != nil } // UpdateNote updates note @@ -53,41 +54,45 @@ func (a *App) UpdateNote(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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } var params updateNotePayload err := json.NewDecoder(r.Body).Decode(¶ms) if err != nil { - handleError(w, "decoding params", err, http.StatusInternalServerError) + HandleError(w, "decoding params", err, http.StatusInternalServerError) return } if ok := validateUpdateNotePayload(params); !ok { - handleError(w, "Invalid payload", nil, http.StatusBadRequest) + HandleError(w, "Invalid payload", nil, http.StatusBadRequest) return } var note database.Note if err := db.Where("uuid = ? AND user_id = ?", noteUUID, user.ID).First(¬e).Error; err != nil { - handleError(w, "finding note", err, http.StatusInternalServerError) + HandleError(w, "finding note", err, http.StatusInternalServerError) return } tx := db.Begin() - note, err = operations.UpdateNote(tx, user, a.Clock, note, params.BookUUID, params.Content) + note, err = operations.UpdateNote(tx, user, a.Clock, note, &operations.UpdateNoteParams{ + BookUUID: params.BookUUID, + Content: params.Content, + Public: params.Public, + }) if err != nil { tx.Rollback() - handleError(w, "updating note", err, http.StatusInternalServerError) + HandleError(w, "updating note", err, http.StatusInternalServerError) return } var book database.Book if err := tx.Where("uuid = ? AND user_id = ?", note.BookUUID, user.ID).First(&book).Error; err != nil { tx.Rollback() - handleError(w, fmt.Sprintf("finding book %s to preload", note.BookUUID), err, http.StatusInternalServerError) + HandleError(w, fmt.Sprintf("finding book %s to preload", note.BookUUID), err, http.StatusInternalServerError) return } @@ -118,13 +123,13 @@ func (a *App) DeleteNote(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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } var note database.Note if err := db.Where("uuid = ? AND user_id = ?", noteUUID, user.ID).Preload("Book").First(¬e).Error; err != nil { - handleError(w, "finding note", err, http.StatusInternalServerError) + HandleError(w, "finding note", err, http.StatusInternalServerError) return } @@ -133,7 +138,7 @@ func (a *App) DeleteNote(w http.ResponseWriter, r *http.Request) { n, err := operations.DeleteNote(tx, user, note) if err != nil { tx.Rollback() - handleError(w, "deleting note", err, http.StatusInternalServerError) + HandleError(w, "deleting note", err, http.StatusInternalServerError) return } @@ -170,33 +175,33 @@ type CreateNoteResp struct { func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } var params createNotePayload err := json.NewDecoder(r.Body).Decode(¶ms) if err != nil { - handleError(w, "decoding payload", err, http.StatusInternalServerError) + HandleError(w, "decoding payload", err, http.StatusInternalServerError) return } err = validateCreateNotePayload(params) if err != nil { - handleError(w, "validating payload", err, http.StatusBadRequest) + HandleError(w, "validating payload", err, http.StatusBadRequest) return } var book database.Book db := database.DBConn if err := db.Where("uuid = ? AND user_id = ?", params.BookUUID, user.ID).First(&book).Error; err != nil { - handleError(w, "finding book", err, http.StatusInternalServerError) + HandleError(w, "finding book", err, http.StatusInternalServerError) return } note, err := operations.CreateNote(user, a.Clock, params.BookUUID, params.Content, params.AddedOn, params.EditedOn, false) if err != nil { - handleError(w, "creating note", err, http.StatusInternalServerError) + HandleError(w, "creating note", err, http.StatusInternalServerError) return } diff --git a/pkg/server/api/handlers/v3_notes_test.go b/pkg/server/handlers/v3_notes_test.go similarity index 77% rename from pkg/server/api/handlers/v3_notes_test.go rename to pkg/server/handlers/v3_notes_test.go index a1b05c99..9b2f221b 100644 --- a/pkg/server/api/handlers/v3_notes_test.go +++ b/pkg/server/handlers/v3_notes_test.go @@ -96,10 +96,12 @@ func TestUpdateNote(t *testing.T) { noteUUID string noteBookUUID string noteBody string + notePublic bool noteDeleted bool expectedNoteBody string expectedNoteBookName string expectedNoteBookUUID string + expectedNotePublic bool }{ { payload: fmt.Sprintf(`{ @@ -107,11 +109,13 @@ func TestUpdateNote(t *testing.T) { }`, updatedBody), noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053", noteBookUUID: b1UUID, + notePublic: false, noteBody: "original content", noteDeleted: false, expectedNoteBookUUID: b1UUID, expectedNoteBody: "some updated content", expectedNoteBookName: "css", + expectedNotePublic: false, }, { payload: fmt.Sprintf(`{ @@ -119,11 +123,13 @@ func TestUpdateNote(t *testing.T) { }`, b1UUID), noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053", noteBookUUID: b1UUID, + notePublic: false, noteBody: "original content", noteDeleted: false, expectedNoteBookUUID: b1UUID, expectedNoteBody: "original content", expectedNoteBookName: "css", + expectedNotePublic: false, }, { payload: fmt.Sprintf(`{ @@ -131,11 +137,13 @@ func TestUpdateNote(t *testing.T) { }`, b2UUID), noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053", noteBookUUID: b1UUID, + notePublic: false, noteBody: "original content", noteDeleted: false, expectedNoteBookUUID: b2UUID, expectedNoteBody: "original content", expectedNoteBookName: "js", + expectedNotePublic: false, }, { payload: fmt.Sprintf(`{ @@ -144,11 +152,13 @@ func TestUpdateNote(t *testing.T) { }`, b2UUID, updatedBody), noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053", noteBookUUID: b1UUID, + notePublic: false, noteBody: "original content", noteDeleted: false, expectedNoteBookUUID: b2UUID, expectedNoteBody: "some updated content", expectedNoteBookName: "js", + expectedNotePublic: false, }, { payload: fmt.Sprintf(`{ @@ -157,16 +167,77 @@ func TestUpdateNote(t *testing.T) { }`, b1UUID, updatedBody), noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053", noteBookUUID: b1UUID, + notePublic: false, noteBody: "", noteDeleted: true, expectedNoteBookUUID: b1UUID, expectedNoteBody: updatedBody, expectedNoteBookName: "js", + expectedNotePublic: false, + }, + { + payload: fmt.Sprintf(`{ + "public": %t + }`, true), + noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053", + noteBookUUID: b1UUID, + notePublic: false, + noteBody: "original content", + noteDeleted: false, + expectedNoteBookUUID: b1UUID, + expectedNoteBody: "original content", + expectedNoteBookName: "css", + expectedNotePublic: true, + }, + { + payload: fmt.Sprintf(`{ + "public": %t + }`, false), + noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053", + noteBookUUID: b1UUID, + notePublic: true, + noteBody: "original content", + noteDeleted: false, + expectedNoteBookUUID: b1UUID, + expectedNoteBody: "original content", + expectedNoteBookName: "css", + expectedNotePublic: false, + }, + { + payload: fmt.Sprintf(`{ + "content": "%s", + "public": %t + }`, updatedBody, false), + noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053", + noteBookUUID: b1UUID, + notePublic: true, + noteBody: "original content", + noteDeleted: false, + expectedNoteBookUUID: b1UUID, + expectedNoteBody: updatedBody, + expectedNoteBookName: "css", + expectedNotePublic: false, + }, + { + payload: fmt.Sprintf(`{ + "book_uuid": "%s", + "content": "%s", + "public": %t + }`, b2UUID, updatedBody, true), + noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053", + noteBookUUID: b1UUID, + notePublic: false, + noteBody: "original content", + noteDeleted: false, + expectedNoteBookUUID: b2UUID, + expectedNoteBody: updatedBody, + expectedNoteBookName: "js", + expectedNotePublic: true, }, } for idx, tc := range testCases { - func() { + t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) { defer testutils.ClearData() db := database.DBConn @@ -198,6 +269,7 @@ func TestUpdateNote(t *testing.T) { BookUUID: tc.noteBookUUID, Body: tc.noteBody, Deleted: tc.noteDeleted, + Public: tc.notePublic, } testutils.MustExec(t, db.Save(¬e), "preparing note") @@ -207,7 +279,7 @@ func TestUpdateNote(t *testing.T) { res := testutils.HTTPAuthDo(t, req, user) // Test - assert.StatusCodeEquals(t, res, http.StatusOK, fmt.Sprintf("status code mismatch for test case %d", idx)) + assert.StatusCodeEquals(t, res, http.StatusOK, "status code mismatch for test case") var bookRecord database.Book var noteRecord database.Note @@ -222,13 +294,14 @@ func TestUpdateNote(t *testing.T) { assert.Equalf(t, bookCount, 2, "book count mismatch") assert.Equalf(t, noteCount, 1, "note count mismatch") - assert.Equal(t, noteRecord.UUID, tc.noteUUID, fmt.Sprintf("note uuid mismatch for test case %d", idx)) - assert.Equal(t, noteRecord.Body, tc.expectedNoteBody, fmt.Sprintf("note content mismatch for test case %d", idx)) - assert.Equal(t, noteRecord.BookUUID, tc.expectedNoteBookUUID, fmt.Sprintf("note book_uuid mismatch for test case %d", idx)) - assert.Equal(t, noteRecord.USN, 102, fmt.Sprintf("note usn mismatch for test case %d", idx)) + assert.Equal(t, noteRecord.UUID, tc.noteUUID, "note uuid mismatch for test case") + assert.Equal(t, noteRecord.Body, tc.expectedNoteBody, "note content mismatch for test case") + assert.Equal(t, noteRecord.BookUUID, tc.expectedNoteBookUUID, "note book_uuid mismatch for test case") + assert.Equal(t, noteRecord.Public, tc.expectedNotePublic, "note public mismatch for test case") + assert.Equal(t, noteRecord.USN, 102, "note usn mismatch for test case") - assert.Equal(t, userRecord.MaxUSN, 102, fmt.Sprintf("user max_usn mismatch for test case %d", idx)) - }() + assert.Equal(t, userRecord.MaxUSN, 102, "user max_usn mismatch for test case") + }) } } diff --git a/pkg/server/api/handlers/v3_sync.go b/pkg/server/handlers/v3_sync.go similarity index 96% rename from pkg/server/api/handlers/v3_sync.go rename to pkg/server/handlers/v3_sync.go index 197a2d39..0994fa94 100644 --- a/pkg/server/api/handlers/v3_sync.go +++ b/pkg/server/handlers/v3_sync.go @@ -26,7 +26,7 @@ import ( "strconv" "time" - "github.com/dnote/dnote/pkg/server/api/helpers" + "github.com/dnote/dnote/pkg/server/helpers" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/log" "github.com/pkg/errors" @@ -252,19 +252,19 @@ type GetSyncFragmentResp struct { func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } afterUSN, limit, err := parseGetSyncFragmentQuery(r.URL.Query()) if err != nil { - handleError(w, "parsing query params", err, http.StatusInternalServerError) + HandleError(w, "parsing query params", err, http.StatusInternalServerError) return } fragment, err := a.newFragment(user.ID, user.MaxUSN, afterUSN, limit) if err != nil { - handleError(w, "getting fragment", err, http.StatusInternalServerError) + HandleError(w, "getting fragment", err, http.StatusInternalServerError) return } @@ -285,7 +285,7 @@ type GetSyncStateResp struct { func (a *App) 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) + HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } diff --git a/pkg/server/api/handlers/v3_sync_test.go b/pkg/server/handlers/v3_sync_test.go similarity index 100% rename from pkg/server/api/handlers/v3_sync_test.go rename to pkg/server/handlers/v3_sync_test.go diff --git a/pkg/server/api/helpers/const.go b/pkg/server/helpers/const.go similarity index 100% rename from pkg/server/api/helpers/const.go rename to pkg/server/helpers/const.go diff --git a/pkg/server/api/helpers/helpers.go b/pkg/server/helpers/helpers.go similarity index 100% rename from pkg/server/api/helpers/helpers.go rename to pkg/server/helpers/helpers.go diff --git a/pkg/server/mailer/mailer.go b/pkg/server/mailer/mailer.go index 410e97bb..55177c37 100644 --- a/pkg/server/mailer/mailer.go +++ b/pkg/server/mailer/mailer.go @@ -155,7 +155,7 @@ func (e *Email) Send() error { if os.Getenv("GO_ENV") != "PRODUCTION" { fmt.Println("Not sending email because not production") fmt.Println(e.subject, e.to, e.from) - fmt.Println("Body", e.Body) + // fmt.Println("Body", e.Body) return nil } diff --git a/pkg/server/main.go b/pkg/server/main.go index 211a649c..9615a653 100644 --- a/pkg/server/main.go +++ b/pkg/server/main.go @@ -26,13 +26,13 @@ import ( "os" "github.com/dnote/dnote/pkg/clock" - "github.com/dnote/dnote/pkg/server/api/handlers" "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/handlers" "github.com/dnote/dnote/pkg/server/job" "github.com/dnote/dnote/pkg/server/mailer" + "github.com/dnote/dnote/pkg/server/web" "github.com/gobuffalo/packr/v2" - "github.com/gorilla/mux" "github.com/pkg/errors" ) @@ -53,44 +53,18 @@ func mustFind(box *packr.Box, path string) []byte { return b } -func getStaticHandler() http.Handler { - box := packr.New("static", "../../web/public/static") +func initContext() web.Context { + staticBox := packr.New("static", "../../web/public/static") - return http.StripPrefix("/static/", http.FileServer(box)) -} - -// getRootHandler returns an HTTP handler that serves the app shell -func getRootHandler() http.HandlerFunc { - b := mustFind(rootBox, "index.html") - - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", "no-cache") - w.Write(b) + return web.Context{ + IndexHTML: mustFind(rootBox, "index.html"), + RobotsTxt: mustFind(rootBox, "robots.txt"), + ServiceWorkerJs: mustFind(rootBox, "service-worker.js"), + StaticFileSystem: staticBox, } } -func getRobotsHandler() http.HandlerFunc { - b := mustFind(rootBox, "robots.txt") - - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", "no-cache") - w.Write(b) - } -} - -func getSWHandler() http.HandlerFunc { - b := mustFind(rootBox, "service-worker.js") - - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Content-Type", "application/javascript") - w.Write(b) - } -} - -func initServer() (*mux.Router, error) { - srv := mux.NewRouter() - +func initServer() (*http.ServeMux, error) { apiRouter, err := handlers.NewRouter(&handlers.App{ Clock: clock.New(), StripeAPIBackend: nil, @@ -100,15 +74,16 @@ func initServer() (*mux.Router, error) { return nil, errors.Wrap(err, "initializing router") } - srv.PathPrefix("/api").Handler(http.StripPrefix("/api", apiRouter)) - srv.PathPrefix("/static").Handler(getStaticHandler()) - srv.Handle("/service-worker.js", getSWHandler()) - srv.Handle("/robots.txt", getRobotsHandler()) + ctx := initContext() - // For all other requests, serve the index.html file - srv.PathPrefix("/").Handler(getRootHandler()) + mux := http.NewServeMux() + mux.Handle("/api/", http.StripPrefix("/api", apiRouter)) + mux.Handle("/static/", web.GetStaticHandler(ctx.StaticFileSystem)) + mux.HandleFunc("/service-worker.js", web.GetSWHandler(ctx.ServiceWorkerJs)) + mux.HandleFunc("/robots.txt", web.GetRobotsHandler(ctx.RobotsTxt)) + mux.HandleFunc("/", web.GetRootHandler(ctx.IndexHTML)) - return srv, nil + return mux, nil } func startCmd() { @@ -137,8 +112,7 @@ func startCmd() { } log.Printf("Dnote version %s is running on port %s", versionTag, *port) - addr := fmt.Sprintf(":%s", *port) - log.Fatalln(http.ListenAndServe(addr, srv)) + log.Fatalln(http.ListenAndServe(":"+*port, srv)) } func versionCmd() { diff --git a/pkg/server/api/operations/books.go b/pkg/server/operations/books.go similarity index 98% rename from pkg/server/api/operations/books.go rename to pkg/server/operations/books.go index dcaadc62..cb3bc77d 100644 --- a/pkg/server/api/operations/books.go +++ b/pkg/server/operations/books.go @@ -20,7 +20,7 @@ package operations import ( "github.com/dnote/dnote/pkg/clock" - "github.com/dnote/dnote/pkg/server/api/helpers" + "github.com/dnote/dnote/pkg/server/helpers" "github.com/dnote/dnote/pkg/server/database" "github.com/jinzhu/gorm" "github.com/pkg/errors" diff --git a/pkg/server/api/operations/books_test.go b/pkg/server/operations/books_test.go similarity index 100% rename from pkg/server/api/operations/books_test.go rename to pkg/server/operations/books_test.go diff --git a/pkg/server/api/operations/helpers.go b/pkg/server/operations/helpers.go similarity index 100% rename from pkg/server/api/operations/helpers.go rename to pkg/server/operations/helpers.go diff --git a/pkg/server/api/operations/helpers_test.go b/pkg/server/operations/helpers_test.go similarity index 100% rename from pkg/server/api/operations/helpers_test.go rename to pkg/server/operations/helpers_test.go diff --git a/pkg/server/api/operations/notes.go b/pkg/server/operations/notes.go similarity index 65% rename from pkg/server/api/operations/notes.go rename to pkg/server/operations/notes.go index 297124aa..0395c6d3 100644 --- a/pkg/server/api/operations/notes.go +++ b/pkg/server/operations/notes.go @@ -20,8 +20,9 @@ package operations import ( "github.com/dnote/dnote/pkg/clock" - "github.com/dnote/dnote/pkg/server/api/helpers" "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/helpers" + "github.com/dnote/dnote/pkg/server/permissions" "github.com/jinzhu/gorm" "github.com/pkg/errors" ) @@ -78,18 +79,55 @@ func CreateNote(user database.User, clock clock.Clock, bookUUID, content string, return note, nil } +// UpdateNoteParams is the parameters for updating a note +type UpdateNoteParams struct { + BookUUID *string + Content *string + Public *bool +} + +// GetBookUUID gets the bookUUID from the UpdateNoteParams +func (r UpdateNoteParams) GetBookUUID() string { + if r.BookUUID == nil { + return "" + } + + return *r.BookUUID +} + +// GetContent gets the content from the UpdateNoteParams +func (r UpdateNoteParams) GetContent() string { + if r.Content == nil { + return "" + } + + return *r.Content +} + +// GetPublic gets the public field from the UpdateNoteParams +func (r UpdateNoteParams) GetPublic() bool { + if r.Public == nil { + return false + } + + return *r.Public +} + // 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, bookUUID, content *string) (database.Note, error) { +func UpdateNote(tx *gorm.DB, user database.User, clock clock.Clock, 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") } - if bookUUID != nil { - note.BookUUID = *bookUUID + if p.BookUUID != nil { + note.BookUUID = p.GetBookUUID() } - if content != nil { - note.Body = *content + if p.Content != nil { + note.Body = p.GetContent() + } + if p.Public != nil { + note.Public = p.GetPublic() } note.USN = nextUSN @@ -123,3 +161,31 @@ func DeleteNote(tx *gorm.DB, user database.User, note database.Note) (database.N return note, nil } + +// GetNote retrieves a note for the given user +func GetNote(uuid string, user database.User) (database.Note, bool, error) { + zeroNote := database.Note{} + if !helpers.ValidateUUID(uuid) { + return zeroNote, false, nil + } + + db := database.DBConn + + conn := db.Where("notes.uuid = ? AND deleted = ?", uuid, false) + conn = database.PreloadNote(conn) + + var note database.Note + conn = conn.Find(¬e) + + if conn.RecordNotFound() { + return zeroNote, false, nil + } else if err := conn.Error; err != nil { + return zeroNote, false, errors.Wrap(err, "finding note") + } + + if ok := permissions.ViewNote(&user, note); !ok { + return zeroNote, false, nil + } + + return note, true, nil +} diff --git a/pkg/server/api/operations/notes_test.go b/pkg/server/operations/notes_test.go similarity index 65% rename from pkg/server/api/operations/notes_test.go rename to pkg/server/operations/notes_test.go index 53c1a391..17da24c6 100644 --- a/pkg/server/api/operations/notes_test.go +++ b/pkg/server/operations/notes_test.go @@ -123,45 +123,45 @@ func TestCreateNote(t *testing.T) { func TestUpdateNote(t *testing.T) { testCases := []struct { - userUSN int - expectedUSN int + userUSN int }{ { - userUSN: 8, - expectedUSN: 9, + userUSN: 8, }, { - userUSN: 102229, - expectedUSN: 102230, + userUSN: 102229, }, { - userUSN: 8099, - expectedUSN: 8100, + userUSN: 8099, }, } for idx, tc := range testCases { - func() { + t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) { defer testutils.ClearData() db := database.DBConn user := testutils.SetupUserData() - testutils.MustExec(t, db.Model(&user).Update("max_usn", tc.userUSN), fmt.Sprintf("preparing user max_usn for test case %d", idx)) + testutils.MustExec(t, db.Model(&user).Update("max_usn", tc.userUSN), "preparing user max_usn for test case") anotherUser := testutils.SetupUserData() - testutils.MustExec(t, db.Model(&anotherUser).Update("max_usn", 55), fmt.Sprintf("preparing user max_usn for test case %d", idx)) + testutils.MustExec(t, db.Model(&anotherUser).Update("max_usn", 55), "preparing user max_usn for test case") b1 := database.Book{UserID: user.ID, Label: "js", Deleted: false} - testutils.MustExec(t, db.Save(&b1), fmt.Sprintf("preparing b1 for test case %d", idx)) + testutils.MustExec(t, db.Save(&b1), "preparing b1 for test case") note := database.Note{UserID: user.ID, Deleted: false, Body: "test content", BookUUID: b1.UUID} - testutils.MustExec(t, db.Save(¬e), fmt.Sprintf("preparing note for test case %d", idx)) + testutils.MustExec(t, db.Save(¬e), "preparing note for test case") c := clock.NewMock() content := "updated test content" + public := true tx := db.Begin() - if _, err := UpdateNote(tx, user, c, note, nil, &content); err != nil { + if _, err := UpdateNote(tx, user, c, note, &UpdateNoteParams{ + Content: &content, + Public: &public, + }); err != nil { tx.Rollback() t.Fatal(errors.Wrap(err, "deleting note")) } @@ -171,20 +171,21 @@ func TestUpdateNote(t *testing.T) { var noteRecord database.Note var userRecord database.User - testutils.MustExec(t, db.Model(&database.Book{}).Count(&bookCount), fmt.Sprintf("counting book for test case %d", idx)) - testutils.MustExec(t, db.Model(&database.Note{}).Count(¬eCount), fmt.Sprintf("counting notes for test case %d", idx)) - testutils.MustExec(t, db.First(¬eRecord), fmt.Sprintf("finding note for test case %d", idx)) - testutils.MustExec(t, db.Where("id = ?", user.ID).First(&userRecord), fmt.Sprintf("finding user for test case %d", idx)) + testutils.MustExec(t, db.Model(&database.Book{}).Count(&bookCount), "counting book for test case") + testutils.MustExec(t, db.Model(&database.Note{}).Count(¬eCount), "counting notes for test case") + testutils.MustExec(t, db.First(¬eRecord), "finding note for test case") + testutils.MustExec(t, db.Where("id = ?", user.ID).First(&userRecord), "finding user for test case") + expectedUSN := tc.userUSN + 1 assert.Equal(t, bookCount, 1, "book count mismatch") assert.Equal(t, noteCount, 1, "note count mismatch") assert.Equal(t, noteRecord.UserID, user.ID, "note UserID mismatch") assert.Equal(t, noteRecord.Body, content, "note Body mismatch") + assert.Equal(t, noteRecord.Public, public, "note Public mismatch") assert.Equal(t, noteRecord.Deleted, false, "note Deleted mismatch") - assert.Equal(t, noteRecord.USN, tc.expectedUSN, "note USN mismatch") - - assert.Equal(t, userRecord.MaxUSN, tc.expectedUSN, "user MaxUSN mismatch") - }() + assert.Equal(t, noteRecord.USN, expectedUSN, "note USN mismatch") + assert.Equal(t, userRecord.MaxUSN, expectedUSN, "user MaxUSN mismatch") + }) } } @@ -255,3 +256,128 @@ func TestDeleteNote(t *testing.T) { }() } } + +func TestGetNote(t *testing.T) { + user := testutils.SetupUserData() + anotherUser := testutils.SetupUserData() + + db := database.DBConn + defer testutils.ClearData() + + b1 := database.Book{ + UserID: user.ID, + Label: "js", + } + testutils.MustExec(t, db.Save(&b1), "preparing b1") + + privateNote := database.Note{ + UserID: user.ID, + BookUUID: b1.UUID, + Body: "privateNote content", + Deleted: false, + Public: false, + } + testutils.MustExec(t, db.Save(&privateNote), "preparing privateNote") + + publicNote := database.Note{ + UserID: user.ID, + BookUUID: b1.UUID, + Body: "privateNote content", + Deleted: false, + Public: true, + } + testutils.MustExec(t, db.Save(&publicNote), "preparing privateNote") + + var privateNoteRecord, publicNoteRecord database.Note + testutils.MustExec(t, db.Where("uuid = ?", privateNote.UUID).Preload("Book").Preload("User").First(&privateNoteRecord), "finding privateNote") + testutils.MustExec(t, db.Where("uuid = ?", publicNote.UUID).Preload("Book").Preload("User").First(&publicNoteRecord), "finding publicNote") + + testCases := []struct { + name string + user database.User + note database.Note + expectedOK bool + expectedNote database.Note + }{ + { + name: "owner accessing private note", + user: user, + note: privateNote, + expectedOK: true, + expectedNote: privateNoteRecord, + }, + { + name: "non-owner accessing private note", + user: anotherUser, + note: privateNote, + expectedOK: false, + expectedNote: database.Note{}, + }, + { + name: "non-owner accessing public note", + user: anotherUser, + note: publicNote, + expectedOK: true, + expectedNote: publicNoteRecord, + }, + { + name: "guest accessing private note", + user: database.User{}, + note: privateNote, + expectedOK: false, + expectedNote: database.Note{}, + }, + { + name: "guest accessing public note", + user: database.User{}, + note: publicNote, + expectedOK: true, + expectedNote: publicNoteRecord, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + note, ok, err := GetNote(tc.note.UUID, tc.user) + if err != nil { + t.Fatal(errors.Wrap(err, "executing")) + } + + assert.Equal(t, ok, tc.expectedOK, "ok mismatch") + assert.DeepEqual(t, note, tc.expectedNote, "note mismatch") + }) + } +} + +func TestGetNote_nonexistent(t *testing.T) { + user := testutils.SetupUserData() + + db := database.DBConn + defer testutils.ClearData() + + b1 := database.Book{ + UserID: user.ID, + Label: "js", + } + testutils.MustExec(t, db.Save(&b1), "preparing b1") + + n1UUID := "4fd19336-671e-4ff3-8f22-662b80e22edc" + n1 := database.Note{ + UUID: n1UUID, + UserID: user.ID, + BookUUID: b1.UUID, + Body: "n1 content", + Deleted: false, + Public: false, + } + testutils.MustExec(t, db.Save(&n1), "preparing n1") + + nonexistentUUID := "4fd19336-671e-4ff3-8f22-662b80e22edd" + note, ok, err := GetNote(nonexistentUUID, user) + if err != nil { + t.Fatal(errors.Wrap(err, "executing")) + } + + assert.Equal(t, ok, false, "ok mismatch") + assert.DeepEqual(t, note, database.Note{}, "note mismatch") +} diff --git a/pkg/server/api/operations/sessions.go b/pkg/server/operations/sessions.go similarity index 93% rename from pkg/server/api/operations/sessions.go rename to pkg/server/operations/sessions.go index ea261db5..980d5c7c 100644 --- a/pkg/server/api/operations/sessions.go +++ b/pkg/server/operations/sessions.go @@ -21,7 +21,7 @@ package operations import ( "time" - "github.com/dnote/dnote/pkg/server/api/crypt" + "github.com/dnote/dnote/pkg/server/crypt" "github.com/dnote/dnote/pkg/server/database" "github.com/jinzhu/gorm" "github.com/pkg/errors" @@ -60,7 +60,7 @@ 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.Debug().Where("key = ?", sessionKey).Delete(&database.Session{}).Error; err != nil { + if err := db.Where("key = ?", sessionKey).Delete(&database.Session{}).Error; err != nil { return errors.Wrap(err, "deleting the session") } diff --git a/pkg/server/api/operations/subscriptions.go b/pkg/server/operations/subscriptions.go similarity index 100% rename from pkg/server/api/operations/subscriptions.go rename to pkg/server/operations/subscriptions.go diff --git a/pkg/server/api/operations/users.go b/pkg/server/operations/users.go similarity index 98% rename from pkg/server/api/operations/users.go rename to pkg/server/operations/users.go index f55a6437..fd88dd18 100644 --- a/pkg/server/api/operations/users.go +++ b/pkg/server/operations/users.go @@ -21,7 +21,7 @@ package operations import ( "time" - "github.com/dnote/dnote/pkg/server/api/crypt" + "github.com/dnote/dnote/pkg/server/crypt" "github.com/dnote/dnote/pkg/server/database" "github.com/jinzhu/gorm" "github.com/pkg/errors" diff --git a/pkg/server/permissions/permissions.go b/pkg/server/permissions/permissions.go new file mode 100644 index 00000000..c2004547 --- /dev/null +++ b/pkg/server/permissions/permissions.go @@ -0,0 +1,38 @@ +/* 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 permissions + +import ( + "github.com/dnote/dnote/pkg/server/database" +) + +// ViewNote checks if the given user can view the given note +func ViewNote(user *database.User, note database.Note) bool { + if note.Public { + return true + } + if user == nil { + return false + } + if note.UserID == 0 { + return false + } + + return note.UserID == user.ID +} diff --git a/pkg/server/permissions/permissions_test.go b/pkg/server/permissions/permissions_test.go new file mode 100644 index 00000000..a273d4df --- /dev/null +++ b/pkg/server/permissions/permissions_test.go @@ -0,0 +1,93 @@ +/* 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 permissions + +import ( + "testing" + + "github.com/dnote/dnote/pkg/assert" + "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/testutils" +) + +func init() { + testutils.InitTestDB() +} + +func TestViewNote(t *testing.T) { + user := testutils.SetupUserData() + anotherUser := testutils.SetupUserData() + + db := database.DBConn + defer testutils.ClearData() + + b1 := database.Book{ + UserID: user.ID, + Label: "js", + } + testutils.MustExec(t, db.Save(&b1), "preparing b1") + + privateNote := database.Note{ + UserID: user.ID, + BookUUID: b1.UUID, + Body: "privateNote content", + Deleted: false, + Public: false, + } + testutils.MustExec(t, db.Save(&privateNote), "preparing privateNote") + + publicNote := database.Note{ + UserID: user.ID, + BookUUID: b1.UUID, + Body: "privateNote content", + Deleted: false, + Public: true, + } + testutils.MustExec(t, db.Save(&publicNote), "preparing privateNote") + + t.Run("owner accessing private note", func(t *testing.T) { + result := ViewNote(&user, privateNote) + assert.Equal(t, result, true, "result mismatch") + }) + + t.Run("owner accessing public note", func(t *testing.T) { + result := ViewNote(&user, publicNote) + assert.Equal(t, result, true, "result mismatch") + }) + + t.Run("non-owner accessing private note", func(t *testing.T) { + result := ViewNote(&anotherUser, privateNote) + assert.Equal(t, result, false, "result mismatch") + }) + + t.Run("non-owner accessing public note", func(t *testing.T) { + result := ViewNote(&anotherUser, publicNote) + assert.Equal(t, result, true, "result mismatch") + }) + + t.Run("guest accessing private note", func(t *testing.T) { + result := ViewNote(nil, privateNote) + assert.Equal(t, result, false, "result mismatch") + }) + + t.Run("guest accessing public note", func(t *testing.T) { + result := ViewNote(nil, publicNote) + assert.Equal(t, result, true, "result mismatch") + }) +} diff --git a/pkg/server/api/presenters/book.go b/pkg/server/presenters/book.go similarity index 100% rename from pkg/server/api/presenters/book.go rename to pkg/server/presenters/book.go diff --git a/pkg/server/api/presenters/digest.go b/pkg/server/presenters/digest.go similarity index 100% rename from pkg/server/api/presenters/digest.go rename to pkg/server/presenters/digest.go diff --git a/pkg/server/api/presenters/email_preference.go b/pkg/server/presenters/email_preference.go similarity index 100% rename from pkg/server/api/presenters/email_preference.go rename to pkg/server/presenters/email_preference.go diff --git a/pkg/server/api/presenters/helpers.go b/pkg/server/presenters/helpers.go similarity index 100% rename from pkg/server/api/presenters/helpers.go rename to pkg/server/presenters/helpers.go diff --git a/pkg/server/api/presenters/note.go b/pkg/server/presenters/note.go similarity index 100% rename from pkg/server/api/presenters/note.go rename to pkg/server/presenters/note.go diff --git a/pkg/server/api/presenters/repetition_rule.go b/pkg/server/presenters/repetition_rule.go similarity index 100% rename from pkg/server/api/presenters/repetition_rule.go rename to pkg/server/presenters/repetition_rule.go diff --git a/pkg/server/api/presenters/repetition_rule_test.go b/pkg/server/presenters/repetition_rule_test.go similarity index 100% rename from pkg/server/api/presenters/repetition_rule_test.go rename to pkg/server/presenters/repetition_rule_test.go diff --git a/pkg/server/api/scripts/makeDemoDigests/main.go b/pkg/server/scripts/makeDemoDigests/main.go similarity index 99% rename from pkg/server/api/scripts/makeDemoDigests/main.go rename to pkg/server/scripts/makeDemoDigests/main.go index b41d39cb..b878a41a 100644 --- a/pkg/server/api/scripts/makeDemoDigests/main.go +++ b/pkg/server/scripts/makeDemoDigests/main.go @@ -19,7 +19,7 @@ package main import ( - "github.com/dnote/dnote/pkg/server/api/helpers" + "github.com/dnote/dnote/pkg/server/helpers" "github.com/dnote/dnote/pkg/server/database" "os" "time" diff --git a/pkg/server/testutils/main.go b/pkg/server/testutils/main.go index cacd6f1a..2621ba2d 100644 --- a/pkg/server/testutils/main.go +++ b/pkg/server/testutils/main.go @@ -20,8 +20,10 @@ package testutils import ( + "encoding/base64" "encoding/json" "fmt" + "math/rand" "net/http" "net/http/httptest" "os" @@ -36,6 +38,10 @@ import ( "golang.org/x/crypto/bcrypt" ) +func init() { + rand.Seed(time.Now().UnixNano()) +} + // InitTestDB establishes connection pool with the test database specified by // the environment variable configuration and initalizes a new schema func InitTestDB() { @@ -209,8 +215,13 @@ func HTTPDo(t *testing.T, req *http.Request) *http.Response { func HTTPAuthDo(t *testing.T, req *http.Request, user database.User) *http.Response { db := database.DBConn + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + t.Fatal(errors.Wrap(err, "reading random bits")) + } + session := database.Session{ - Key: "Vvgm3eBXfXGEFWERI7faiRJ3DAzJw+7DdT9J1LEyNfI=", + Key: base64.StdEncoding.EncodeToString(b), UserID: user.ID, ExpiresAt: time.Now().Add(time.Hour * 10 * 24), } @@ -225,8 +236,8 @@ func HTTPAuthDo(t *testing.T, req *http.Request, user database.User) *http.Respo } // MakeReq makes an HTTP request and returns a response -func MakeReq(server *httptest.Server, method, url, data string) *http.Request { - endpoint := fmt.Sprintf("%s%s", server.URL, url) +func MakeReq(server *httptest.Server, method, path, data string) *http.Request { + endpoint := fmt.Sprintf("%s%s", server.URL, path) req, err := http.NewRequest(method, endpoint, strings.NewReader(data)) if err != nil { diff --git a/pkg/server/tmpl/app.go b/pkg/server/tmpl/app.go new file mode 100644 index 00000000..abbc53b7 --- /dev/null +++ b/pkg/server/tmpl/app.go @@ -0,0 +1,101 @@ +/* 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 ( + "bytes" + "html/template" + "net/http" + "regexp" + + "github.com/pkg/errors" +) + +// routes +var notesPathRegex = regexp.MustCompile("^/notes/([^/]+)$") + +// template names +var templateIndex = "index" +var templateNoteMetaTags = "note_metatags" + +// AppShell represents the application in HTML +type AppShell struct { + 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) { + t, err := template.New(templateIndex).Parse(string(content)) + if err != nil { + return AppShell{}, errors.Wrap(err, "parsing the index template") + } + + _, err = t.New(templateNoteMetaTags).Parse(noteMetaTags) + if err != nil { + return AppShell{}, errors.Wrap(err, "parsing the note meta tags template") + } + + return AppShell{t}, nil +} + +// Execute executes the index template +func (a AppShell) Execute(r *http.Request) ([]byte, error) { + data, err := a.getData(r) + if err != nil { + return nil, errors.Wrap(err, "getting data") + } + + var buf bytes.Buffer + if err := a.T.ExecuteTemplate(&buf, templateIndex, data); err != nil { + return nil, errors.Wrap(err, "executing template") + } + + return buf.Bytes(), nil +} + +func (a AppShell) getData(r *http.Request) (tmplData, error) { + path := r.URL.Path + + if ok, params := matchPath(path, notesPathRegex); ok { + p, err := a.newNotePage(r, params[0]) + if err != nil { + return tmplData{}, errors.Wrap(err, "instantiating note page") + } + + return p.getData() + } + + p := defaultPage{} + return p.getData(), nil +} + +// matchPath checks if the given path matches the given regular expressions +// and returns a boolean as well as any parameters from regex capture groups. +func matchPath(p string, reg *regexp.Regexp) (bool, []string) { + match := notesPathRegex.FindStringSubmatch(p) + + if len(match) > 0 { + return true, match[1:] + } + + return false, nil +} diff --git a/pkg/server/tmpl/app_test.go b/pkg/server/tmpl/app_test.go new file mode 100644 index 00000000..6431eb57 --- /dev/null +++ b/pkg/server/tmpl/app_test.go @@ -0,0 +1,92 @@ +/* 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 ( + "fmt" + "net/http" + "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 init() { + testutils.InitTestDB() +} + +func TestAppShellExecute(t *testing.T) { + t.Run("home", func(t *testing.T) { + a, err := NewAppShell([]byte("{{ .Title }}{{ .MetaTags }}")) + if err != nil { + t.Fatal(errors.Wrap(err, "preparing app shell")) + } + + r, err := http.NewRequest("GET", "http://mock.url/", nil) + if err != nil { + t.Fatal(errors.Wrap(err, "preparing request")) + } + + b, err := a.Execute(r) + if err != nil { + t.Fatal(errors.Wrap(err, "executing")) + } + + assert.Equal(t, string(b), "Dnote", "result mismatch") + }) + + t.Run("note", func(t *testing.T) { + defer testutils.ClearData() + db := database.DBConn + + user := testutils.SetupUserData() + b1 := database.Book{ + UserID: user.ID, + Label: "js", + } + testutils.MustExec(t, db.Save(&b1), "preparing b1") + n1 := database.Note{ + UserID: user.ID, + BookUUID: b1.UUID, + Public: true, + Body: "n1 content", + } + testutils.MustExec(t, db.Save(&n1), "preparing note") + + a, err := NewAppShell([]byte("{{ .MetaTags }}")) + if err != nil { + t.Fatal(errors.Wrap(err, "preparing app shell")) + } + + endpoint := fmt.Sprintf("http://mock.url/notes/%s", n1.UUID) + r, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + t.Fatal(errors.Wrap(err, "preparing request")) + } + + b, err := a.Execute(r) + if err != nil { + t.Fatal(errors.Wrap(err, "executing")) + } + + assert.NotEqual(t, string(b), "", "result should not be empty") + }) +} diff --git a/pkg/server/tmpl/data.go b/pkg/server/tmpl/data.go new file mode 100644 index 00000000..b0c9790d --- /dev/null +++ b/pkg/server/tmpl/data.go @@ -0,0 +1,141 @@ +/* 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 ( + "bytes" + "fmt" + "html/template" + "net/http" + "regexp" + "strings" + "time" + + "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/handlers" + "github.com/dnote/dnote/pkg/server/operations" + "github.com/pkg/errors" +) + +var newlineRegexp = regexp.MustCompile(`\r?\n`) + +// tmplData is the data to be passed to the app shell template +type tmplData struct { + Title string + MetaTags template.HTML +} + +type noteMetaTagsData struct { + Title string + Description string +} + +type notePage struct { + Note database.Note + T *template.Template +} + +func (a AppShell) newNotePage(r *http.Request, noteUUID string) (notePage, error) { + user, _, err := handlers.AuthWithSession(r, nil) + if err != nil { + return notePage{}, errors.Wrap(err, "authenticating with session") + } + + note, ok, err := operations.GetNote(noteUUID, user) + + if !ok { + return notePage{}, ErrNotFound + } + if err != nil { + return notePage{}, errors.Wrap(err, "getting note") + } + + return notePage{note, a.T}, nil +} + +func (p notePage) getTitle() string { + note := p.Note + date := time.Unix(0, note.AddedOn).Format("Jan 2 2006") + + return fmt.Sprintf("Note: %s (%s)", note.Book.Label, date) +} + +func excerpt(s string, maxLen int) string { + if len(s) > maxLen { + + var lastIdx int + if maxLen > 3 { + lastIdx = maxLen - 3 + } else { + lastIdx = maxLen + } + + return s[:lastIdx] + "..." + } + + return s +} + +func formatMetaDescContent(s string) string { + desc := excerpt(s, 200) + desc = strings.Trim(desc, " ") + + return newlineRegexp.ReplaceAllString(desc, " ") +} + +func (p notePage) getMetaTags() (template.HTML, error) { + title := p.getTitle() + desc := formatMetaDescContent(p.Note.Body) + + data := noteMetaTagsData{ + Title: title, + Description: desc, + } + + var buf bytes.Buffer + if err := p.T.ExecuteTemplate(&buf, templateNoteMetaTags, data); err != nil { + return "", errors.Wrap(err, "executing template") + } + + return template.HTML(buf.String()), nil +} + +func (p notePage) getData() (tmplData, error) { + mt, err := p.getMetaTags() + if err != nil { + return tmplData{}, errors.Wrap(err, "getting meta tags") + } + + dat := tmplData{ + Title: p.getTitle(), + MetaTags: mt, + } + + return dat, nil +} + +type defaultPage struct { +} + +func (p defaultPage) getData() tmplData { + return tmplData{ + Title: "Dnote", + MetaTags: "", + } +} diff --git a/pkg/server/tmpl/data_test.go b/pkg/server/tmpl/data_test.go new file mode 100644 index 00000000..d0709ec5 --- /dev/null +++ b/pkg/server/tmpl/data_test.go @@ -0,0 +1,63 @@ +/* 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 ( + "html/template" + "testing" + "time" + + "github.com/dnote/dnote/pkg/assert" + "github.com/dnote/dnote/pkg/server/database" + "github.com/pkg/errors" +) + +func TestDefaultPageGetData(t *testing.T) { + p := defaultPage{} + + result := p.getData() + + assert.Equal(t, result.MetaTags, template.HTML(""), "MetaTags mismatch") + assert.Equal(t, result.Title, "Dnote", "Title mismatch") +} + +func TestNotePageGetData(t *testing.T) { + a, err := NewAppShell(nil) + if err != nil { + t.Fatal(errors.Wrap(err, "preparing app shell")) + } + + p := notePage{ + Note: database.Note{ + Book: database.Book{ + Label: "vocabulary", + }, + AddedOn: time.Date(2019, time.January, 2, 0, 0, 0, 0, time.UTC).UnixNano(), + }, + T: a.T, + } + + result, err := p.getData() + if err != nil { + t.Fatal(errors.Wrap(err, "executing")) + } + + assert.NotEqual(t, result.MetaTags, template.HTML(""), "MetaTags should not be empty") + assert.Equal(t, result.Title, "Note: vocabulary (Jan 2 2019)", "Title mismatch") +} diff --git a/pkg/server/tmpl/tmpl.go b/pkg/server/tmpl/tmpl.go new file mode 100644 index 00000000..1409300d --- /dev/null +++ b/pkg/server/tmpl/tmpl.go @@ -0,0 +1,29 @@ +/* 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 + +var noteMetaTags = ` + + + + + + + +` diff --git a/pkg/server/watcher/main.go b/pkg/server/watcher/main.go index 3ff3c75f..b99c4486 100644 --- a/pkg/server/watcher/main.go +++ b/pkg/server/watcher/main.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 main import ( diff --git a/pkg/server/web/handlers.go b/pkg/server/web/handlers.go new file mode 100644 index 00000000..45814b52 --- /dev/null +++ b/pkg/server/web/handlers.go @@ -0,0 +1,83 @@ +/* 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 provides handlers for the web application +package web + +import ( + "net/http" + + "github.com/dnote/dnote/pkg/server/handlers" + "github.com/dnote/dnote/pkg/server/tmpl" + "github.com/pkg/errors" +) + +// Context contains contents of web assets +type Context struct { + IndexHTML []byte + RobotsTxt []byte + ServiceWorkerJs []byte + StaticFileSystem http.FileSystem +} + +// GetRootHandler returns an HTTP handler that serves the app shell +func GetRootHandler(b []byte) http.HandlerFunc { + appShell, err := tmpl.NewAppShell(b) + if err != nil { + panic(errors.Wrap(err, "initializing app shell")) + } + + return func(w http.ResponseWriter, r *http.Request) { + // index.html must not be cached + w.Header().Set("Cache-Control", "no-cache") + + buf, err := appShell.Execute(r) + if err != nil { + if errors.Cause(err) == tmpl.ErrNotFound { + handlers.RespondNotFound(w) + } else { + handlers.HandleError(w, "executing app shell", err, http.StatusInternalServerError) + } + return + } + + w.Write(buf) + } +} + +// GetRobotsHandler returns an HTTP handler that serves robots.txt +func GetRobotsHandler(b []byte) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache") + w.Write(b) + } +} + +// GetSWHandler returns an HTTP handler that serves service worker +func GetSWHandler(b []byte) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Content-Type", "application/javascript") + w.Write(b) + } +} + +// GetStaticHandler returns an HTTP handler that serves static files from a filesystem +func GetStaticHandler(root http.FileSystem) http.Handler { + return http.StripPrefix("/static/", http.FileServer(root)) +} diff --git a/scripts/vagrant/install_utils.sh b/scripts/vagrant/install_utils.sh index abd29340..1c06d413 100755 --- a/scripts/vagrant/install_utils.sh +++ b/scripts/vagrant/install_utils.sh @@ -2,7 +2,7 @@ set -eux sudo apt-get update -sudo apt-get install -y htop git wget build-essential +sudo apt-get install -y htop git wget build-essential inotify-tools # Install Chrome wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add diff --git a/web/assets/index.html b/web/assets/index.html index e87032ee..99e92b2a 100644 --- a/web/assets/index.html +++ b/web/assets/index.html @@ -3,7 +3,7 @@ - Dnote + {{ .Title }}