Implement note sharing (#300)

* Implement publicity control for notes

* Implement server side rendering for note sharing

* Implement UI

* Modularize

* Remove autofocus

* Fix test

* Document
This commit is contained in:
Sung Won Cho 2019-11-07 22:47:25 -08:00 committed by GitHub
commit 41ada2298c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
97 changed files with 2327 additions and 823 deletions

View file

@ -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

4
Vagrantfile vendored
View file

@ -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

View file

@ -6,3 +6,11 @@ test-dnote
/dist
/build
server
# Elastic Beanstalk Files
/tmp
application.zip
test-api
/dump
api
/build

View file

@ -1,7 +0,0 @@
# Elastic Beanstalk Files
/tmp
application.zip
test-api
/dump
api
/build

View file

@ -1,8 +0,0 @@
# API
Dnote API.
## Development
* Ensure `timezone = 'UTC'` in postgres setting (`postgresql.conf`)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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, "")
// }

View file

@ -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

View file

@ -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

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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")
}

View file

@ -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
}

View file

@ -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(&params); 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(&params); 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(&notes).Error; err != nil {
handleError(w, "finding notes", err, http.StatusInternalServerError)
HandleError(w, "finding notes", err, http.StatusInternalServerError)
return
}

View file

@ -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)
}

View file

@ -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(&note)
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(&notes).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(&notes).Error; err != nil {
handleError(w, "finding notes", err, http.StatusInternalServerError)
HandleError(w, "finding notes", err, http.StatusInternalServerError)
return
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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")
})
}

View file

@ -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)

View file

@ -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"

View file

@ -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},

View file

@ -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()

View file

@ -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
}
}

View file

@ -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(&params); 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(&params); 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
}

View file

@ -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"

View file

@ -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(&params)
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
}
}

View file

@ -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(&params)
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(&params)
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(&notes).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
}

View file

@ -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"

View file

@ -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(&params)
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(&note).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(&note).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(&params)
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
}

View file

@ -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(&note), "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")
})
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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() {

View file

@ -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"

View file

@ -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(&note)
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
}

View file

@ -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(&note), fmt.Sprintf("preparing note for test case %d", idx))
testutils.MustExec(t, db.Save(&note), "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(&noteCount), fmt.Sprintf("counting notes for test case %d", idx))
testutils.MustExec(t, db.First(&noteRecord), 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(&noteCount), "counting notes for test case")
testutils.MustExec(t, db.First(&noteRecord), "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")
}

View file

@ -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")
}

View file

@ -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"

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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")
})
}

View file

@ -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"

View file

@ -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 {

101
pkg/server/tmpl/app.go Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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("<head><title>{{ .Title }}</title>{{ .MetaTags }}</head>"))
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), "<head><title>Dnote</title></head>", "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")
})
}

141
pkg/server/tmpl/data.go Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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: "",
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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")
}

29
pkg/server/tmpl/tmpl.go Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package tmpl
var noteMetaTags = `
<meta name="description" content="{{ .Description }}" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="{{ .Title }}" />
<meta name="twitter:description" content="{{ .Description }}" />
<meta name="twitter:image" content="https://dnote-asset.s3.amazonaws.com/images/logo-text-vertical.png" />
<meta name="og:image" content="https://dnote-asset.s3.amazonaws.com/images/logo-text-vertical.png" />
<meta name="og:title" content="{{ .Title }}" />
<meta name="og:description" content="{{ .Description }}" />`

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package main
import (

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
// 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))
}

View file

@ -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

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,minimum-scale=1" />
<title>Dnote</title>
<title>{{ .Title }}</title>
<style>
body {
margin: 0;
@ -26,6 +26,8 @@
<meta name="msapplication-TileImage" content="<!--ASSET_BASE_PLACEHOLDER-->/ms-icon-144x144.png" />
<meta name="theme-color" content="#ffffff" />
{{ .MetaTags }}
<!--CSS_BUNDLE_PLACEHOLDER-->
</head>
<body>

18
web/package-lock.json generated
View file

@ -12023,7 +12023,8 @@
},
"ansi-regex": {
"version": "2.1.1",
"bundled": true
"bundled": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
@ -12058,7 +12059,8 @@
},
"code-point-at": {
"version": "1.1.0",
"bundled": true
"bundled": true,
"optional": true
},
"concat-map": {
"version": "0.0.1",
@ -12066,7 +12068,8 @@
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true
"bundled": true,
"optional": true
},
"core-util-is": {
"version": "1.0.2",
@ -12169,7 +12172,8 @@
},
"inherits": {
"version": "2.0.3",
"bundled": true
"bundled": true,
"optional": true
},
"ini": {
"version": "1.3.5",
@ -12179,6 +12183,7 @@
"is-fullwidth-code-point": {
"version": "1.0.0",
"bundled": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -12290,7 +12295,8 @@
},
"number-is-nan": {
"version": "1.0.1",
"bundled": true
"bundled": true,
"optional": true
},
"object-assign": {
"version": "4.1.1",
@ -12405,6 +12411,7 @@
"string-width": {
"version": "1.0.2",
"bundled": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@ -12422,6 +12429,7 @@
"strip-ansi": {
"version": "3.0.1",
"bundled": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}

View file

@ -32,6 +32,7 @@ set +a
COMPILED_PATH="$basePath/web/compiled" \
IS_TEST=true \
VERSION="$VERSION" \
WEBPACK_HOST="0.0.0.0" \
"$appPath"/scripts/webpack-dev.sh
) &
devServerPID=$!

View file

@ -22,6 +22,6 @@ appPath="$basePath"/web
VERSION="$VERSION" \
"$appPath"/node_modules/.bin/webpack-dev-server\
--env.isTest="$IS_TEST" \
--host 0.0.0.0 \
--host "$WEBPACK_HOST" \
--config "$appPath"/webpack/dev.config.js
)

View file

@ -28,6 +28,10 @@
min-height: calc(100vh - #{$header-height} - #{$footer-height});
margin-bottom: $footer-height;
&.nofooter {
margin-bottom: 0;
}
&.noheader:not(.nofooter) {
min-height: calc(100vh - #{$footer-height});
}

View file

@ -55,7 +55,10 @@
user-select: none;
border-image: initial;
border-radius: 0.25rem;
transition: all 0.2s ease-in-out;
transition-property: color, box-shadow;
transition-duration: 0.2s;
transition-timing-function: ease-in-out;
text-decoration: none;
border-radius: 5px;
cursor: pointer;
@ -85,6 +88,11 @@ button:disabled {
opacity: 0.6;
}
.button-small {
@include font-size('small');
padding: rem(4px) rem(12px);
}
.button-normal {
@include font-size('small');
padding: rem(8px) rem(16px);

View file

@ -30,7 +30,8 @@ import {
noHeaderPaths,
subscriptionPaths,
noFooterPaths,
checkCurrentPathIn
checkCurrentPathIn,
checkCurrentPath
} from 'web/libs/paths';
import { getFiltersFromSearchStr } from 'jslib/helpers/filters';
import Splash from '../Splash';
@ -120,6 +121,16 @@ function useMobileMenuState(
return [isMobileMenuOpen, setMobileMenuOpen];
}
function checkNoFooter(location: Location, loggedIn: boolean): boolean {
if (checkCurrentPath(location, notePathDef)) {
if (!loggedIn) {
return true;
}
}
return checkCurrentPathIn(location, noFooterPaths);
}
const App: React.FunctionComponent<Props> = ({ location }) => {
useFetchData();
useSavePrevLocation(location);
@ -137,8 +148,9 @@ const App: React.FunctionComponent<Props> = ({ location }) => {
return <Splash />;
}
const loggedIn = user.data.uuid !== '';
const noHeader = checkCurrentPathIn(location, noHeaderPaths);
const noFooter = checkCurrentPathIn(location, noFooterPaths);
const noFooter = checkNoFooter(location, loggedIn);
return (
<Fragment>
@ -166,7 +178,11 @@ const App: React.FunctionComponent<Props> = ({ location }) => {
<Route path={noFooterPaths} exact component={null} />
<Route
path="/"
render={() => {
render={({ location }) => {
if (noFooter) {
return null;
}
return (
<TabBar
isMobileMenuOpen={isMobileMenuOpen}

View file

@ -57,16 +57,11 @@ const Button: React.FunctionComponent<Props> = ({
<button
id={id}
type={type}
className={classnames(
className,
'button',
`button-${kind}`,
`button-${size}`,
{
[styles.busy]: isBusy,
'button-stretch': stretch
}
)}
className={classnames(className, 'button', `button-${kind}`, {
[styles.busy]: isBusy,
'button-stretch': stretch,
[`button-${size}`]: size
})}
disabled={isBusy || disabled}
onClick={onClick}
tabIndex={tabIndex}
@ -85,8 +80,4 @@ const Button: React.FunctionComponent<Props> = ({
);
};
Button.defaultProps = {
size: 'normal'
};
export default Button;

View file

@ -0,0 +1,79 @@
/* 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 <https://www.gnu.org/licenses/>.
*/
@import '../App/responsive';
@import '../App/rem';
@import '../App/theme';
@import '../App/font';
.label {
display: flex;
align-items: center;
margin-left: rem(12px);
margin-bottom: 0;
input[type='checkbox'] {
position: absolute;
z-index: 2;
opacity: 0;
}
input[type='checkbox']:focus + .toggle {
outline: #5d9dd5 solid 1px;
box-shadow: 0 0 8px #5e9ed6;
}
&.enabled {
color: $green;
.toggle {
background-color: $green;
}
.indicator {
transform: translateX(15px);
}
}
&.disabled {
color: $gray;
.toggle {
background-color: $gray;
}
.indicator {
transform: translateX(3px);
}
}
}
.toggle {
height: 20px;
border-radius: 16px;
width: 32px;
position: relative;
}
.indicator {
position: absolute;
top: 3px;
border-radius: 100%;
background-color: $white;
height: 14px;
width: 14px;
min-width: 14px;
}

View file

@ -0,0 +1,68 @@
/* 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 <https://www.gnu.org/licenses/>.
*/
import React, { useState } from 'react';
import classnames from 'classnames';
import styles from './Toggle.scss';
interface Props {
checked: boolean;
onChange: (boolean) => void;
label: React.ReactNode;
id?: string;
disabled?: boolean;
}
const Toggle: React.FunctionComponent<Props> = ({
id,
checked,
onChange,
disabled,
label
}) => {
return (
<div>
<label
htmlFor={id}
className={classnames(styles.label, {
[styles.enabled]: checked,
[styles.disabled]: !checked
})}
>
<input
id={id}
type="checkbox"
checked={checked}
onChange={e => {
onChange(e.target.checked);
}}
disabled={disabled}
/>
<div className={classnames(styles.toggle, {})}>
<div className={styles.indicator}></div>
</div>
{label}
</label>
</div>
);
};
export default Toggle;

View file

@ -26,10 +26,11 @@ import styles from './Tooltip.scss';
interface Props {
id: string;
isOpen: boolean;
children: React.ReactElement;
children: React.ReactNode;
triggerEl: HTMLElement;
alignment: Alignment;
direction: Direction;
noArrow: boolean;
}
// cumulativeOffset calculates the top and left offsets of the given element
@ -162,7 +163,7 @@ function calcArrowX(
const arrowWidth = arrowRect.width / 2;
if (direction === 'top' || direction === 'bottom') {
return offsetX + triggerRect.width / 2;
return offsetX + triggerRect.width / 2 - arrowWidth;
}
if (direction === 'left') {
return offsetX - arrowWidth;
@ -223,7 +224,8 @@ const Overlay: React.FunctionComponent<Props> = ({
children,
triggerEl,
alignment,
direction
direction,
noArrow
}) => {
const [overlayEl, setOverlayEl] = useState(null);
const [arrowEl, setArrowEl] = useState(null);
@ -249,7 +251,8 @@ const Overlay: React.FunctionComponent<Props> = ({
[styles.top]: direction === 'top',
[styles.bottom]: direction === 'bottom',
[styles.left]: direction === 'left',
[styles.right]: direction === 'right'
[styles.right]: direction === 'right',
[styles.hidden]: noArrow
})}
style={{ top: arrowPos.top, left: arrowPos.left }}
ref={el => {

View file

@ -52,4 +52,7 @@
&.right {
border-right-color: #2a2a2a;
}
&.hidden {
visibility: hidden;
}
}

View file

@ -32,11 +32,12 @@ interface Props {
id: string;
alignment: Alignment;
direction: Direction;
overlay: React.ReactElement;
overlay: React.ReactNode;
children: React.ReactChild;
contentClassName?: string;
wrapperClassName?: string;
triggerClassName?: string;
noArrow?: boolean;
}
const Tooltip: React.FunctionComponent<Props> = ({
@ -45,7 +46,8 @@ const Tooltip: React.FunctionComponent<Props> = ({
direction,
wrapperClassName,
overlay,
children
children,
noArrow = false
}) => {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef(null);
@ -75,6 +77,7 @@ const Tooltip: React.FunctionComponent<Props> = ({
triggerEl={triggerRef.current}
alignment={alignment}
direction={direction}
noArrow={noArrow}
>
{overlay}
</Overlay>

View file

@ -54,3 +54,26 @@
margin-left: rem(12px);
vertical-align: middle;
}
.logo {
margin: 0 auto;
display: none;
@include breakpoint(md) {
display: block;
}
}
.logosm {
@include breakpoint(md) {
display: none;
}
}
.cta {
@include font-size('small', false);
@include breakpoint(lg) {
@include font-size('regular', false);
}
}

View file

@ -19,6 +19,7 @@
import React from 'react';
import { Link } from 'react-router-dom';
import LogoWithText from '../../Icons/LogoWithText';
import Logo from '../../Icons/Logo';
import { getHomePath } from 'web/libs/paths';
import styles from './Guest.scss';
@ -28,15 +29,11 @@ const UserNoteHeader: React.FunctionComponent = () => {
<header className={styles.wrapper}>
<div className={styles.content}>
<Link to={getHomePath({})} className={styles.brand}>
<Logo width={32} height={32} fill="#909090" className="logo" />
<span className={styles['brand-name']}>Dnote</span>
<LogoWithText id="main-logo-text" width={75} fill="#909090" />
</Link>
<Link
to={getHomePath({})}
className="button button-normal button-slim button-first-outline"
>
Go to Dnote
<Link to={getHomePath()} className={styles.cta}>
Go to Dnote &#8250;
</Link>
</div>
</header>

View file

@ -44,12 +44,20 @@ import React from 'react';
import { IconProps } from './types';
const Icon = ({ fill, width, height, className }: IconProps) => {
interface Props extends IconProps {
title?: string;
desc?: string;
ariaLabel?: string;
}
const Icon = ({ fill, width, height, className, ariaLabel }: Props) => {
const h = `${height}px`;
const w = `${width}px`;
return (
<svg
role="img"
aria-label={ariaLabel}
width={w}
height={h}
viewBox="0 0 14 16"

View file

@ -33,9 +33,15 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: rem(12px) rem(16px);
border-bottom: 1px solid $border-color;
}
.header-left,
.header-right {
display: flex;
align-items: center;
}
.book-icon {
vertical-align: middle;

View file

@ -25,12 +25,14 @@ import { Link } from 'react-router-dom';
import { getNoteEditPath, getHomePath } from 'web/libs/paths';
import { tokenize, TokenKind } from 'web/libs/fts/lexer';
import BookIcon from '../Icons/Book';
import GlobeIcon from '../Icons/Globe';
import { parseMarkdown } from '../../helpers/markdown';
import { nanosecToMillisec, getMonthName } from '../../helpers/time';
import formatTime from '../../helpers/time/format';
import { useSelector } from '../../store';
import Time from '../Common/Time';
import styles from './NoteContent.scss';
import Tooltip from '../Common/Tooltip';
import styles from './Content.scss';
function formatAddedOn(ts: number): string {
const ms = nanosecToMillisec(ts);
@ -77,9 +79,13 @@ function formatContent(content: string): string {
interface Props {
onDeleteModalOpen: () => void;
onShareModalOpen: () => void;
}
const Content: React.FunctionComponent<Props> = ({ onDeleteModalOpen }) => {
const Content: React.FunctionComponent<Props> = ({
onDeleteModalOpen,
onShareModalOpen
}) => {
const { note, user } = useSelector(state => {
return {
note: state.note.data,
@ -87,24 +93,47 @@ const Content: React.FunctionComponent<Props> = ({ onDeleteModalOpen }) => {
};
});
const publicTooltip = 'Anyone on the Internet can see this note.';
const isOwner = note.user.uuid === user.uuid;
return (
<article className={styles.frame}>
<header className={styles.header}>
<BookIcon
fill="#000000"
width={20}
height={20}
className={styles['book-icon']}
/>
<div className={styles['header-left']}>
<BookIcon
fill="#000000"
width={20}
height={20}
className={styles['book-icon']}
/>
<h1 className={styles['book-label']}>
<Link
to={getHomePath({ book: note.book.label })}
className={styles['book-label-link']}
>
{note.book.label}
</Link>
</h1>
<h1 className={styles['book-label']}>
<Link
to={getHomePath({ book: note.book.label })}
className={styles['book-label-link']}
>
{note.book.label}
</Link>
</h1>
</div>
<div className={styles['header-right']}>
{isOwner && note.public && (
<Tooltip
id="note-public-indicator"
alignment="right"
direction="bottom"
overlay={publicTooltip}
>
<GlobeIcon
fill="#8c8c8c"
width={16}
height={16}
ariaLabel={publicTooltip}
/>
</Tooltip>
)}
</div>
</header>
<section
@ -124,8 +153,21 @@ const Content: React.FunctionComponent<Props> = ({ onDeleteModalOpen }) => {
tooltipDirection="bottom"
/>
{note.user.uuid === user.uuid && (
{isOwner && (
<div className={styles.actions}>
<button
id="T-share-note-button"
type="button"
className={classnames('button-no-ui', styles.action)}
onClick={e => {
e.preventDefault();
onShareModalOpen();
}}
>
Share
</button>
<button
id="T-delete-note-button"
type="button"

View file

@ -26,7 +26,7 @@ import Flash from '../Common/Flash';
import { setMessage } from '../../store/ui';
import { useDispatch } from '../../store';
import Button from '../Common/Button';
import styles from './DeleteNoteModal.scss';
import styles from './DeleteModal.scss';
interface Props extends RouteComponentProps {
isOpen: boolean;
@ -34,7 +34,7 @@ interface Props extends RouteComponentProps {
noteUUID: string;
}
const DeleteNoteModal: React.FunctionComponent<Props> = ({
const DeleteModal: React.FunctionComponent<Props> = ({
isOpen,
onDismiss,
noteUUID,
@ -136,4 +136,4 @@ const DeleteNoteModal: React.FunctionComponent<Props> = ({
);
};
export default withRouter(DeleteNoteModal);
export default withRouter(DeleteModal);

View file

@ -20,17 +20,14 @@ import React from 'react';
import Helmet from 'react-helmet';
import { NoteState } from '../../store/note';
import { nanosecToMillisec, getMonthName } from '../../helpers/time';
import { nanosecToMillisec } from '../../helpers/time';
import formatTime from '../../helpers/time/format';
function formatAddedOn(ts: number): string {
const ms = nanosecToMillisec(ts);
const d = new Date(ms);
const month = getMonthName(d, true);
const date = d.getDate();
const year = d.getFullYear();
return `${month} ${date} ${year}`;
return formatTime(d, '%MMM %D %YYYY');
}
function getTitle(note: NoteState): string {
@ -38,18 +35,7 @@ function getTitle(note: NoteState): string {
return 'Note';
}
return `Note (${formatAddedOn(note.data.added_on)}) in ${
note.data.book.label
}`;
}
function getDescription(note: NoteState): string {
if (!note.isFetched) {
return 'View microlessons and write your own.';
}
const book = note.data.book;
return `View microlessons in ${book.label} and write your own. Dnote is a home for your everyday learning.`;
return `Note: ${note.data.book.label} (${formatAddedOn(note.data.added_on)})`;
}
interface Props {
@ -58,27 +44,9 @@ interface Props {
const HeaderData: React.FunctionComponent<Props> = ({ note }) => {
const title = getTitle(note);
const description = getDescription(note);
const noteData = note.data;
return (
<Helmet>
<title>{title}</title>
<meta name="description" content={description} />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={noteData.content} />
<meta
name="twitter:image"
content="https://s3.amazonaws.com/dnote-assets/images/bf3fed4fb122e394e26bcf35d63e26f8.png"
/>
<meta
name="og:image"
content="https://s3.amazonaws.com/dnote-assets/images/bf3fed4fb122e394e26bcf35d63e26f8.png"
/>
<meta name="og:title" content={title} />
<meta name="og:description" content={noteData.content} />
</Helmet>
);
};

View file

@ -20,7 +20,7 @@ import React from 'react';
import classnames from 'classnames';
import styles from './Placeholder.scss';
import noteStyles from './NoteContent.scss';
import noteStyles from './Content.scss';
interface Props {}

View file

@ -0,0 +1,54 @@
/* 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 <https://www.gnu.org/licenses/>.
*/
import React from 'react';
import classnames from 'classnames';
import Button from '../../Common/Button';
import styles from './ShareModal.scss';
interface Props {
kind: string;
size?: string;
isHot: boolean;
onClick: () => void;
className?: string;
}
const CopyButton: React.FunctionComponent<Props> = ({
kind,
size,
isHot,
onClick,
className
}) => {
return (
<Button
type="button"
size={size}
kind={kind}
onClick={onClick}
disabled={isHot}
className={className}
>
{isHot ? 'Copied' : 'Copy link'}
</Button>
);
};
export default CopyButton;

View file

@ -0,0 +1,54 @@
/* 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 <https://www.gnu.org/licenses/>.
*/
@import '../../App/rem';
@import '../../App/responsive';
@import '../../App/theme';
@import '../../App/font';
.label-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.label {
margin-bottom: 0;
}
.status {
font-weight: 600;
@include font-size('small');
margin-left: rem(8px);
}
.help {
margin: rem(16px) 0;
@include font-size('small');
color: $gray;
}
.link-input {
margin-top: rem(8px);
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: rem(8px);
}

View file

@ -0,0 +1,202 @@
/* 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 <https://www.gnu.org/licenses/>.
*/
import React, { useState } from 'react';
import classnames from 'classnames';
import { homePathDef, getHomePath, getNotePath } from 'web/libs/paths';
import { copyToClipboard, selectTextInputValue } from 'web/libs/dom';
import { NoteData } from 'jslib/operations/types';
import operations from 'web/libs/operations';
import Modal, { Header, Body } from '../../Common/Modal';
import Flash from '../../Common/Flash';
import { setMessage } from '../../../store/ui';
import { useDispatch } from '../../../store';
import Button from '../../Common/Button';
import Toggle from '../../Common/Toggle';
import { receiveNote } from '../../../store/note';
import CopyButton from './CopyButton';
import styles from './ShareModal.scss';
// getNoteURL returns the absolute URL for the note
function getNoteURL(uuid: string): string {
const loc = getNotePath(uuid);
const path = loc.pathname;
// TODO: maybe get these values from the configuration instead of parsing
// current URL.
const { protocol, host } = window.location;
return `${protocol}//${host}${path}`;
}
function getHelpText(isPublic: boolean): string {
if (isPublic) {
return 'Anyone with this URL can view this note.';
}
return 'This note is private only to you.';
}
interface Props {
isOpen: boolean;
onDismiss: () => void;
note: NoteData;
}
const ShareModal: React.FunctionComponent<Props> = ({
isOpen,
onDismiss,
note
}) => {
const [inProgress, setInProgress] = useState(false);
const [errMessage, setErrMessage] = useState('');
const [copyHot, setCopyHot] = useState(false);
const dispatch = useDispatch();
const labelId = 'share-note-modal-label';
const linkValue = getNoteURL(note.uuid);
function handleToggle(val: boolean) {
setInProgress(true);
operations.notes
.update(note.uuid, { public: val })
.then(resp => {
dispatch(receiveNote(resp));
setInProgress(false);
})
.catch(err => {
console.log('Error sharing note', err);
setInProgress(false);
setErrMessage(err.message);
});
}
function handleCopy() {
copyToClipboard(linkValue);
setCopyHot(true);
window.setTimeout(() => {
setCopyHot(false);
}, 1200);
}
return (
<Modal
modalId="T-share-note-modal"
isOpen={isOpen}
onDismiss={onDismiss}
ariaLabelledBy={labelId}
size="regular"
>
<Header
labelId={labelId}
heading="Share this note"
onDismiss={onDismiss}
/>
<Flash
kind="danger"
onDismiss={() => {
setErrMessage('');
}}
hasBorder={false}
when={Boolean(errMessage)}
noMargin
>
{errMessage}
</Flash>
<Body>
<div className={styles['label-row']}>
<label htmlFor="link-value" className={styles.label}>
Link sharing
</label>
<Toggle
id="T-note-public-toggle"
checked={note.public}
onChange={handleToggle}
disabled={inProgress}
label={
<span className={styles.status}>
{note.public ? 'Enabled' : 'Disabled'}
</span>
}
/>
</div>
<input
id="link-value"
type="text"
disabled={!note.public}
value={linkValue}
onChange={e => {
e.preventDefault();
}}
className={classnames(
'form-control text-input text-input-small',
styles['link-input']
)}
onFocus={e => {
const el = e.target;
selectTextInputValue(el);
}}
onKeyDown={e => {
e.preventDefault();
}}
onMouseUp={e => {
e.preventDefault();
const el = e.target as HTMLInputElement;
selectTextInputValue(el);
}}
/>
<p className={styles.help}>{getHelpText(note.public)} </p>
<div className={styles.actions}>
{note.public && (
<CopyButton
kind="third"
size="normal"
onClick={handleCopy}
isHot={copyHot}
className={classnames('button-normal button-second', styles.copy)}
/>
)}
<Button
id="T-share-note-modal-close"
type="button"
size="normal"
kind="second"
onClick={onDismiss}
disabled={inProgress}
>
Cancel
</Button>
</div>
</Body>
</Modal>
);
};
export default ShareModal;

View file

@ -21,14 +21,15 @@ import { withRouter, RouteComponentProps } from 'react-router-dom';
import { notePathDef } from 'web/libs/paths';
import { parseSearchString } from 'jslib/helpers/url';
import HeaderData from './HeaderData';
import NoteContent from './NoteContent';
import Content from './Content';
import Flash from '../Common/Flash';
import { getNote } from '../../store/note';
import Placeholder from './Placeholder';
import { useDispatch, useSelector, ReduxDispatch } from '../../store';
import { unsetMessage } from '../../store/ui';
import DeleteNoteModal from './DeleteNoteModal';
import DeleteModal from './DeleteModal';
import ShareModal from './ShareModal';
import HeaderData from './HeaderData';
import styles from './index.scss';
interface Match {
@ -72,6 +73,7 @@ const Note: React.FunctionComponent<Props> = ({ match, location }) => {
};
});
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
useFetchData(dispatch, noteUUID, location.search);
useClearMessage(dispatch);
@ -86,23 +88,34 @@ const Note: React.FunctionComponent<Props> = ({ match, location }) => {
<div className="container mobile-nopadding page page-mobile-full">
{note.isFetched ? (
<NoteContent
<Content
onDeleteModalOpen={() => {
setIsDeleteModalOpen(true);
}}
onShareModalOpen={() => {
setIsShareModalOpen(true);
}}
/>
) : (
<Placeholder />
)}
</div>
<DeleteNoteModal
<DeleteModal
isOpen={isDeleteModalOpen}
onDismiss={() => {
setIsDeleteModalOpen(false);
}}
noteUUID={note.data.uuid}
/>
<ShareModal
isOpen={isShareModalOpen}
onDismiss={() => {
setIsShareModalOpen(false);
}}
note={note.data}
/>
</div>
);
};

View file

@ -102,3 +102,21 @@ export function isMobileWidth() {
return width < mdThreshold;
}
// copyToClipboard copies the given string to the user's clipboard
export function copyToClipboard(s: string) {
const el = document.createElement('textarea');
el.value = s;
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
}
// selectTextInputValue visually selects the value of the given text input
export function selectTextInputValue(
el: HTMLInputElement | HTMLTextAreaElement
) {
const len = el.value.length;
el.setSelectionRange(0, len);
}

View file

@ -21,7 +21,7 @@ import { NoteData } from 'jslib/operations/types';
import { RECEIVE, START_FETCHING, ERROR, RESET } from './type';
import { ThunkAction } from '../types';
export function receiveNote(note) {
export function receiveNote(note: NoteData) {
return {
type: RECEIVE,
data: { note }
@ -40,7 +40,7 @@ function startFetchingNote() {
};
}
function receiveNoteError(errorMessage) {
function receiveNoteError(errorMessage: string) {
return {
type: ERROR,
data: { errorMessage }