mirror of
https://github.com/dnote/dnote
synced 2026-03-14 22:45:50 +01:00
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:
parent
cbfafb0a40
commit
41ada2298c
97 changed files with 2327 additions and 823 deletions
|
|
@ -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
4
Vagrantfile
vendored
|
|
@ -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
|
||||
|
|
|
|||
8
pkg/server/.gitignore
vendored
8
pkg/server/.gitignore
vendored
|
|
@ -6,3 +6,11 @@ test-dnote
|
|||
/dist
|
||||
/build
|
||||
server
|
||||
|
||||
# Elastic Beanstalk Files
|
||||
/tmp
|
||||
application.zip
|
||||
test-api
|
||||
/dump
|
||||
api
|
||||
/build
|
||||
|
|
|
|||
7
pkg/server/api/.gitignore
vendored
7
pkg/server/api/.gitignore
vendored
|
|
@ -1,7 +0,0 @@
|
|||
# Elastic Beanstalk Files
|
||||
/tmp
|
||||
application.zip
|
||||
test-api
|
||||
/dump
|
||||
api
|
||||
/build
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
# API
|
||||
|
||||
Dnote API.
|
||||
|
||||
## Development
|
||||
|
||||
* Ensure `timezone = 'UTC'` in postgres setting (`postgresql.conf`)
|
||||
|
||||
|
|
@ -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, "")
|
||||
// }
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
28
pkg/server/database/notes.go
Normal file
28
pkg/server/database/notes.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -22,10 +22,10 @@ import (
|
|||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/api/crypt"
|
||||
"github.com/dnote/dnote/pkg/server/api/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/api/operations"
|
||||
"github.com/dnote/dnote/pkg/server/api/presenters"
|
||||
"github.com/dnote/dnote/pkg/server/crypt"
|
||||
"github.com/dnote/dnote/pkg/server/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/operations"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/log"
|
||||
"github.com/pkg/errors"
|
||||
|
|
@ -35,7 +35,7 @@ import (
|
|||
func (a *App) classicMigrate(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ func (a *App) classicMigrate(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
var account database.Account
|
||||
if err := db.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
|
||||
handleError(w, "finding account", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding account", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ func (a *App) classicMigrate(w http.ResponseWriter, r *http.Request) {
|
|||
"client_kdf_iteration": 0,
|
||||
"server_kdf_iteration": 0,
|
||||
}).Error; err != nil {
|
||||
handleError(w, "updating account", err, http.StatusInternalServerError)
|
||||
HandleError(w, "updating account", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -78,7 +78,7 @@ func (a *App) classicPresignin(w http.ResponseWriter, r *http.Request) {
|
|||
var account database.Account
|
||||
conn := db.Where("email = ?", email).First(&account)
|
||||
if !conn.RecordNotFound() && conn.Error != nil {
|
||||
handleError(w, "getting user", conn.Error, http.StatusInternalServerError)
|
||||
HandleError(w, "getting user", conn.Error, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -95,7 +95,7 @@ func (a *App) classicPresignin(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
handleError(w, "encoding response", nil, http.StatusInternalServerError)
|
||||
HandleError(w, "encoding response", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -110,7 +110,7 @@ func (a *App) classicSignin(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
var params classicSigninPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
|
||||
handleError(w, "decoding payload", err, http.StatusInternalServerError)
|
||||
HandleError(w, "decoding payload", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +125,7 @@ func (a *App) classicSignin(w http.ResponseWriter, r *http.Request) {
|
|||
http.Error(w, ErrLoginFailure.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
} else if err := conn.Error; err != nil {
|
||||
handleError(w, "getting user", err, http.StatusInternalServerError)
|
||||
HandleError(w, "getting user", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ func (a *App) classicSignin(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
session, err := operations.CreateSession(db, account.UserID)
|
||||
if err != nil {
|
||||
handleError(w, "creating session", nil, http.StatusBadRequest)
|
||||
HandleError(w, "creating session", nil, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +157,7 @@ func (a *App) classicSignin(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
handleError(w, "encoding response", err, http.StatusInternalServerError)
|
||||
HandleError(w, "encoding response", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -165,7 +165,7 @@ func (a *App) classicSignin(w http.ResponseWriter, r *http.Request) {
|
|||
func (a *App) classicGetMe(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -173,7 +173,7 @@ func (a *App) classicGetMe(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
var account database.Account
|
||||
if err := db.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
|
||||
handleError(w, "finding account", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding account", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -225,7 +225,7 @@ type classicSetPasswordPayload struct {
|
|||
func (a *App) classicSetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -233,13 +233,13 @@ func (a *App) classicSetPassword(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
var params classicSetPasswordPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
|
||||
handleError(w, "decoding payload", err, http.StatusInternalServerError)
|
||||
HandleError(w, "decoding payload", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var account database.Account
|
||||
if err := db.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
|
||||
handleError(w, "getting user", nil, http.StatusInternalServerError)
|
||||
HandleError(w, "getting user", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -260,14 +260,14 @@ func (a *App) classicSetPassword(w http.ResponseWriter, r *http.Request) {
|
|||
func (a *App) classicGetNotes(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var notes []database.Note
|
||||
db := database.DBConn
|
||||
if err := db.Where("user_id = ? AND encrypted = true", user.ID).Find(¬es).Error; err != nil {
|
||||
handleError(w, "finding notes", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding notes", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -26,9 +26,10 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/api/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/api/presenters"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/operations"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
|
|
@ -85,7 +86,7 @@ func parseSearchQuery(q url.Values) string {
|
|||
return escapeSearchQuery(searchStr)
|
||||
}
|
||||
|
||||
func getNoteBaseQuery(noteUUID string, userID int, search string) *gorm.DB {
|
||||
func getNoteBaseQuery(noteUUID string, search string) *gorm.DB {
|
||||
db := database.DBConn
|
||||
|
||||
var conn *gorm.DB
|
||||
|
|
@ -95,32 +96,28 @@ func getNoteBaseQuery(noteUUID string, userID int, search string) *gorm.DB {
|
|||
conn = db
|
||||
}
|
||||
|
||||
conn = conn.Where("notes.uuid = ? AND notes.user_id = ?", noteUUID, userID)
|
||||
conn = conn.Where("notes.uuid = ? AND deleted = ?", noteUUID, false)
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
func (a *App) getNote(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
user, _, err := AuthWithSession(r, nil)
|
||||
if err != nil {
|
||||
HandleError(w, "authenticating", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
noteUUID := vars["noteUUID"]
|
||||
search := parseSearchQuery(r.URL.Query())
|
||||
|
||||
var note database.Note
|
||||
conn := getNoteBaseQuery(noteUUID, user.ID, search)
|
||||
conn = preloadNote(conn)
|
||||
conn.Find(¬e)
|
||||
|
||||
if conn.RecordNotFound() {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
note, ok, err := operations.GetNote(noteUUID, user)
|
||||
if !ok {
|
||||
RespondNotFound(w)
|
||||
return
|
||||
} else if err := conn.Error; err != nil {
|
||||
handleError(w, "finding note", err, http.StatusInternalServerError)
|
||||
}
|
||||
if err != nil {
|
||||
HandleError(w, "finding note", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +140,7 @@ type dateRange struct {
|
|||
func (a *App) getNotes(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
query := r.URL.Query()
|
||||
|
|
@ -162,18 +159,18 @@ func respondGetNotes(userID int, query url.Values, w http.ResponseWriter) {
|
|||
|
||||
var total int
|
||||
if err := conn.Model(database.Note{}).Count(&total).Error; err != nil {
|
||||
handleError(w, "counting total", err, http.StatusInternalServerError)
|
||||
HandleError(w, "counting total", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
notes := []database.Note{}
|
||||
if total != 0 {
|
||||
conn = orderGetNotes(conn)
|
||||
conn = preloadNote(conn)
|
||||
conn = database.PreloadNote(conn)
|
||||
conn = paginate(conn, q.Page)
|
||||
|
||||
if err := conn.Find(¬es).Error; err != nil {
|
||||
handleError(w, "finding notes", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding notes", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -280,7 +277,7 @@ func getDateBounds(year, month int) (int64, int64) {
|
|||
func getNotesBaseQuery(userID int, q getNotesQuery) *gorm.DB {
|
||||
db := database.DBConn
|
||||
|
||||
conn := db.Debug().Where(
|
||||
conn := db.Where(
|
||||
"notes.user_id = ? AND notes.deleted = ? AND notes.encrypted = ?",
|
||||
userID, false, q.Encrypted,
|
||||
)
|
||||
|
|
@ -307,10 +304,6 @@ func orderGetNotes(conn *gorm.DB) *gorm.DB {
|
|||
return conn.Order("notes.added_on DESC, notes.id DESC")
|
||||
}
|
||||
|
||||
func preloadNote(conn *gorm.DB) *gorm.DB {
|
||||
return conn.Preload("Book").Preload("User")
|
||||
}
|
||||
|
||||
// escapeSearchQuery escapes the query for full text search
|
||||
func escapeSearchQuery(searchQuery string) string {
|
||||
return strings.Join(strings.Fields(searchQuery), "&")
|
||||
|
|
@ -319,14 +312,14 @@ func escapeSearchQuery(searchQuery string) string {
|
|||
func (a *App) legacyGetNotes(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var notes []database.Note
|
||||
db := database.DBConn
|
||||
if err := db.Where("user_id = ? AND encrypted = true", user.ID).Find(¬es).Error; err != nil {
|
||||
handleError(w, "finding notes", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding notes", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
363
pkg/server/handlers/notes_test.go
Normal file
363
pkg/server/handlers/notes_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
|
@ -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},
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -23,8 +23,8 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/api/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/api/presenters"
|
||||
"github.com/dnote/dnote/pkg/server/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/log"
|
||||
"github.com/dnote/dnote/pkg/server/mailer"
|
||||
|
|
@ -42,7 +42,7 @@ func (a *App) updateProfile(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -62,14 +62,14 @@ func (a *App) updateProfile(w http.ResponseWriter, r *http.Request) {
|
|||
var account database.Account
|
||||
err = db.Where("user_id = ?", user.ID).First(&account).Error
|
||||
if err != nil {
|
||||
handleError(w, "finding account", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding account", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tx := db.Begin()
|
||||
if err := tx.Save(&user).Error; err != nil {
|
||||
tx.Rollback()
|
||||
handleError(w, "saving user", err, http.StatusInternalServerError)
|
||||
HandleError(w, "saving user", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -81,7 +81,7 @@ func (a *App) updateProfile(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
if err := tx.Save(&account).Error; err != nil {
|
||||
tx.Rollback()
|
||||
handleError(w, "saving account", err, http.StatusInternalServerError)
|
||||
HandleError(w, "saving account", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ func respondWithCalendar(w http.ResponseWriter, userID int) {
|
|||
Order("added_date DESC").Rows()
|
||||
|
||||
if err != nil {
|
||||
handleError(w, "Failed to count lessons", err, http.StatusInternalServerError)
|
||||
HandleError(w, "Failed to count lessons", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +117,7 @@ func respondWithCalendar(w http.ResponseWriter, userID int) {
|
|||
var d time.Time
|
||||
|
||||
if err := rows.Scan(&count, &d); err != nil {
|
||||
handleError(w, "counting notes", err, http.StatusInternalServerError)
|
||||
HandleError(w, "counting notes", err, http.StatusInternalServerError)
|
||||
}
|
||||
payload[d.Format("2006-1-2")] = count
|
||||
}
|
||||
|
|
@ -128,7 +128,7 @@ func respondWithCalendar(w http.ResponseWriter, userID int) {
|
|||
func (a *App) getCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ func (a *App) getCalendar(w http.ResponseWriter, r *http.Request) {
|
|||
func (a *App) getDemoCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := helpers.GetDemoUserID()
|
||||
if err != nil {
|
||||
handleError(w, "finding demo user", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding demo user", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -150,14 +150,14 @@ func (a *App) createVerificationToken(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var account database.Account
|
||||
err := db.Where("user_id = ?", user.ID).First(&account).Error
|
||||
if err != nil {
|
||||
handleError(w, "finding account", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding account", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +172,7 @@ func (a *App) createVerificationToken(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
tokenValue, err := generateVerificationCode()
|
||||
if err != nil {
|
||||
handleError(w, "generating verification code", err, http.StatusInternalServerError)
|
||||
HandleError(w, "generating verification code", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -183,7 +183,7 @@ func (a *App) createVerificationToken(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
if err := db.Save(&token).Error; err != nil {
|
||||
handleError(w, "saving token", err, http.StatusInternalServerError)
|
||||
HandleError(w, "saving token", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -195,12 +195,12 @@ func (a *App) createVerificationToken(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
email := mailer.NewEmail("noreply@getdnote.com", []string{account.Email.String}, subject)
|
||||
if err := email.ParseTemplate(mailer.EmailTypeEmailVerification, data); err != nil {
|
||||
handleError(w, "parsing template", err, http.StatusInternalServerError)
|
||||
HandleError(w, "parsing template", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := email.Send(); err != nil {
|
||||
handleError(w, "sending email", err, http.StatusInternalServerError)
|
||||
HandleError(w, "sending email", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -216,7 +216,7 @@ func (a *App) verifyEmail(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
var params verifyEmailPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
|
||||
handleError(w, "decoding payload", err, http.StatusInternalServerError)
|
||||
HandleError(w, "decoding payload", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -241,7 +241,7 @@ func (a *App) verifyEmail(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
var account database.Account
|
||||
if err := db.Where("user_id = ?", token.UserID).First(&account).Error; err != nil {
|
||||
handleError(w, "finding account", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding account", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if account.EmailVerified {
|
||||
|
|
@ -253,19 +253,19 @@ func (a *App) verifyEmail(w http.ResponseWriter, r *http.Request) {
|
|||
account.EmailVerified = true
|
||||
if err := tx.Save(&account).Error; err != nil {
|
||||
tx.Rollback()
|
||||
handleError(w, "updating email_verified", err, http.StatusInternalServerError)
|
||||
HandleError(w, "updating email_verified", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := tx.Model(&token).Update("used_at", time.Now()).Error; err != nil {
|
||||
tx.Rollback()
|
||||
handleError(w, "updating reset token", err, http.StatusInternalServerError)
|
||||
HandleError(w, "updating reset token", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tx.Commit()
|
||||
|
||||
var user database.User
|
||||
if err := db.Where("id = ?", token.UserID).First(&user).Error; err != nil {
|
||||
handleError(w, "finding user", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding user", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -282,19 +282,19 @@ func (a *App) updateEmailPreference(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var params updateEmailPreferencePayload
|
||||
if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
|
||||
handleError(w, "decoding payload", err, http.StatusInternalServerError)
|
||||
HandleError(w, "decoding payload", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var frequency database.EmailPreference
|
||||
if err := db.Where(database.EmailPreference{UserID: user.ID}).FirstOrCreate(&frequency).Error; err != nil {
|
||||
handleError(w, "finding frequency", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding frequency", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -303,7 +303,7 @@ func (a *App) updateEmailPreference(w http.ResponseWriter, r *http.Request) {
|
|||
frequency.DigestWeekly = params.DigestWeekly
|
||||
if err := tx.Save(&frequency).Error; err != nil {
|
||||
tx.Rollback()
|
||||
handleError(w, "saving frequency", err, http.StatusInternalServerError)
|
||||
HandleError(w, "saving frequency", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -312,7 +312,7 @@ func (a *App) updateEmailPreference(w http.ResponseWriter, r *http.Request) {
|
|||
// Use token if the user was authenticated by token
|
||||
if err := tx.Model(&token).Update("used_at", time.Now()).Error; err != nil {
|
||||
tx.Rollback()
|
||||
handleError(w, "updating reset token", err, http.StatusInternalServerError)
|
||||
HandleError(w, "updating reset token", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -327,13 +327,13 @@ func (a *App) getEmailPreference(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var pref database.EmailPreference
|
||||
if err := db.Where(database.EmailPreference{UserID: user.ID}).First(&pref).Error; err != nil {
|
||||
handleError(w, "finding pref", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding pref", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -351,7 +351,7 @@ func (a *App) updatePassword(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -367,7 +367,7 @@ func (a *App) updatePassword(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
var account database.Account
|
||||
if err := db.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
|
||||
handleError(w, "getting user", nil, http.StatusInternalServerError)
|
||||
HandleError(w, "getting user", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
|
|
@ -23,7 +23,7 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/api/operations"
|
||||
"github.com/dnote/dnote/pkg/server/operations"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
|
@ -85,7 +85,7 @@ func (a *App) signin(w http.ResponseWriter, r *http.Request) {
|
|||
var params signinPayload
|
||||
err := json.NewDecoder(r.Body).Decode(¶ms)
|
||||
if err != nil {
|
||||
handleError(w, "decoding payload", err, http.StatusInternalServerError)
|
||||
HandleError(w, "decoding payload", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if params.Email == "" || params.Password == "" {
|
||||
|
|
@ -99,7 +99,7 @@ func (a *App) signin(w http.ResponseWriter, r *http.Request) {
|
|||
http.Error(w, ErrLoginFailure.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
} else if conn.Error != nil {
|
||||
handleError(w, "getting user", err, http.StatusInternalServerError)
|
||||
HandleError(w, "getting user", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +113,7 @@ func (a *App) signin(w http.ResponseWriter, r *http.Request) {
|
|||
var user database.User
|
||||
err = db.Where("id = ?", account.UserID).First(&user).Error
|
||||
if err != nil {
|
||||
handleError(w, "finding user", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding user", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +134,7 @@ func (a *App) signoutOptions(w http.ResponseWriter, r *http.Request) {
|
|||
func (a *App) signout(w http.ResponseWriter, r *http.Request) {
|
||||
key, err := getCredential(r)
|
||||
if err != nil {
|
||||
handleError(w, "getting credential", nil, http.StatusInternalServerError)
|
||||
HandleError(w, "getting credential", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -145,7 +145,7 @@ func (a *App) signout(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
err = operations.DeleteSession(database.DBConn, key)
|
||||
if err != nil {
|
||||
handleError(w, "deleting session", nil, http.StatusInternalServerError)
|
||||
HandleError(w, "deleting session", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -192,7 +192,7 @@ func (a *App) register(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
var count int
|
||||
if err := db.Model(database.Account{}).Where("email = ?", params.Email).Count(&count).Error; err != nil {
|
||||
handleError(w, "checking duplicate user", err, http.StatusInternalServerError)
|
||||
HandleError(w, "checking duplicate user", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if count > 0 {
|
||||
|
|
@ -202,7 +202,7 @@ func (a *App) register(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
user, err := operations.CreateUser(params.Email, params.Password)
|
||||
if err != nil {
|
||||
handleError(w, "creating user", err, http.StatusInternalServerError)
|
||||
HandleError(w, "creating user", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -216,7 +216,7 @@ func respondWithSession(w http.ResponseWriter, userID int, statusCode int) {
|
|||
|
||||
session, err := operations.CreateSession(db, userID)
|
||||
if err != nil {
|
||||
handleError(w, "creating session", nil, http.StatusBadRequest)
|
||||
HandleError(w, "creating session", nil, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -230,7 +230,7 @@ func respondWithSession(w http.ResponseWriter, userID int, statusCode int) {
|
|||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
handleError(w, "encoding response", err, http.StatusInternalServerError)
|
||||
HandleError(w, "encoding response", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -24,9 +24,9 @@ import (
|
|||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/api/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/api/operations"
|
||||
"github.com/dnote/dnote/pkg/server/api/presenters"
|
||||
"github.com/dnote/dnote/pkg/server/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/operations"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
|
|
@ -59,13 +59,13 @@ func (a *App) CreateBook(w http.ResponseWriter, r *http.Request) {
|
|||
var params createBookPayload
|
||||
err := json.NewDecoder(r.Body).Decode(¶ms)
|
||||
if err != nil {
|
||||
handleError(w, "decoding payload", err, http.StatusInternalServerError)
|
||||
HandleError(w, "decoding payload", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = validateCreateBookPayload(params)
|
||||
if err != nil {
|
||||
handleError(w, "validating payload", err, http.StatusBadRequest)
|
||||
HandleError(w, "validating payload", err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -76,7 +76,7 @@ func (a *App) CreateBook(w http.ResponseWriter, r *http.Request) {
|
|||
Where("user_id = ? AND label = ?", user.ID, params.Name).
|
||||
Count(&bookCount).Error
|
||||
if err != nil {
|
||||
handleError(w, "checking duplicate", err, http.StatusInternalServerError)
|
||||
HandleError(w, "checking duplicate", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if bookCount > 0 {
|
||||
|
|
@ -86,7 +86,7 @@ func (a *App) CreateBook(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
book, err := operations.CreateBook(user, a.Clock, params.Name)
|
||||
if err != nil {
|
||||
handleError(w, "inserting book", err, http.StatusInternalServerError)
|
||||
HandleError(w, "inserting book", err, http.StatusInternalServerError)
|
||||
}
|
||||
resp := CreateBookResp{
|
||||
Book: presenters.PresentBook(book),
|
||||
|
|
@ -124,7 +124,7 @@ func respondWithBooks(userID int, query url.Values, w http.ResponseWriter) {
|
|||
}
|
||||
|
||||
if err := conn.Find(&books).Error; err != nil {
|
||||
handleError(w, "finding books", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding books", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -136,7 +136,7 @@ func respondWithBooks(userID int, query url.Values, w http.ResponseWriter) {
|
|||
func (a *App) GetDemoBooks(w http.ResponseWriter, r *http.Request) {
|
||||
demoUserID, err := helpers.GetDemoUserID()
|
||||
if err != nil {
|
||||
handleError(w, "finding demo user", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding demo user", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -177,7 +177,7 @@ func (a *App) GetBook(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
if err := conn.Error; err != nil {
|
||||
handleError(w, "finding book", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding book", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -209,21 +209,21 @@ func (a *App) UpdateBook(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
var book database.Book
|
||||
if err := tx.Where("user_id = ? AND uuid = ?", user.ID, uuid).First(&book).Error; err != nil {
|
||||
handleError(w, "finding book", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding book", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var params updateBookPayload
|
||||
err := json.NewDecoder(r.Body).Decode(¶ms)
|
||||
if err != nil {
|
||||
handleError(w, "decoding payload", err, http.StatusInternalServerError)
|
||||
HandleError(w, "decoding payload", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
book, err = operations.UpdateBook(tx, a.Clock, user, book, params.Name)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
handleError(w, "updating a book", err, http.StatusInternalServerError)
|
||||
HandleError(w, "updating a book", err, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
|
@ -255,25 +255,25 @@ func (a *App) DeleteBook(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
var book database.Book
|
||||
if err := tx.Where("user_id = ? AND uuid = ?", user.ID, uuid).First(&book).Error; err != nil {
|
||||
handleError(w, "finding book", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding book", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var notes []database.Note
|
||||
if err := tx.Where("book_uuid = ? AND NOT deleted", uuid).Order("usn ASC").Find(¬es).Error; err != nil {
|
||||
handleError(w, "finding notes", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding notes", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
for _, note := range notes {
|
||||
if _, err := operations.DeleteNote(tx, user, note); err != nil {
|
||||
handleError(w, "deleting a note", err, http.StatusInternalServerError)
|
||||
HandleError(w, "deleting a note", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
b, err := operations.DeleteBook(tx, user, book)
|
||||
if err != nil {
|
||||
handleError(w, "deleting book", err, http.StatusInternalServerError)
|
||||
HandleError(w, "deleting book", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
|
|
@ -23,9 +23,9 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/api/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/api/operations"
|
||||
"github.com/dnote/dnote/pkg/server/api/presenters"
|
||||
"github.com/dnote/dnote/pkg/server/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/operations"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
|
|
@ -34,6 +34,7 @@ import (
|
|||
type updateNotePayload struct {
|
||||
BookUUID *string `json:"book_uuid"`
|
||||
Content *string `json:"content"`
|
||||
Public *bool `json:"public"`
|
||||
}
|
||||
|
||||
type updateNoteResp struct {
|
||||
|
|
@ -42,7 +43,7 @@ type updateNoteResp struct {
|
|||
}
|
||||
|
||||
func validateUpdateNotePayload(p updateNotePayload) bool {
|
||||
return p.BookUUID != nil || p.Content != nil
|
||||
return p.BookUUID != nil || p.Content != nil || p.Public != nil
|
||||
}
|
||||
|
||||
// UpdateNote updates note
|
||||
|
|
@ -53,41 +54,45 @@ func (a *App) UpdateNote(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var params updateNotePayload
|
||||
err := json.NewDecoder(r.Body).Decode(¶ms)
|
||||
if err != nil {
|
||||
handleError(w, "decoding params", err, http.StatusInternalServerError)
|
||||
HandleError(w, "decoding params", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if ok := validateUpdateNotePayload(params); !ok {
|
||||
handleError(w, "Invalid payload", nil, http.StatusBadRequest)
|
||||
HandleError(w, "Invalid payload", nil, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var note database.Note
|
||||
if err := db.Where("uuid = ? AND user_id = ?", noteUUID, user.ID).First(¬e).Error; err != nil {
|
||||
handleError(w, "finding note", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding note", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tx := db.Begin()
|
||||
|
||||
note, err = operations.UpdateNote(tx, user, a.Clock, note, params.BookUUID, params.Content)
|
||||
note, err = operations.UpdateNote(tx, user, a.Clock, note, &operations.UpdateNoteParams{
|
||||
BookUUID: params.BookUUID,
|
||||
Content: params.Content,
|
||||
Public: params.Public,
|
||||
})
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
handleError(w, "updating note", err, http.StatusInternalServerError)
|
||||
HandleError(w, "updating note", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var book database.Book
|
||||
if err := tx.Where("uuid = ? AND user_id = ?", note.BookUUID, user.ID).First(&book).Error; err != nil {
|
||||
tx.Rollback()
|
||||
handleError(w, fmt.Sprintf("finding book %s to preload", note.BookUUID), err, http.StatusInternalServerError)
|
||||
HandleError(w, fmt.Sprintf("finding book %s to preload", note.BookUUID), err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -118,13 +123,13 @@ func (a *App) DeleteNote(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var note database.Note
|
||||
if err := db.Where("uuid = ? AND user_id = ?", noteUUID, user.ID).Preload("Book").First(¬e).Error; err != nil {
|
||||
handleError(w, "finding note", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding note", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -133,7 +138,7 @@ func (a *App) DeleteNote(w http.ResponseWriter, r *http.Request) {
|
|||
n, err := operations.DeleteNote(tx, user, note)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
handleError(w, "deleting note", err, http.StatusInternalServerError)
|
||||
HandleError(w, "deleting note", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -170,33 +175,33 @@ type CreateNoteResp struct {
|
|||
func (a *App) CreateNote(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var params createNotePayload
|
||||
err := json.NewDecoder(r.Body).Decode(¶ms)
|
||||
if err != nil {
|
||||
handleError(w, "decoding payload", err, http.StatusInternalServerError)
|
||||
HandleError(w, "decoding payload", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = validateCreateNotePayload(params)
|
||||
if err != nil {
|
||||
handleError(w, "validating payload", err, http.StatusBadRequest)
|
||||
HandleError(w, "validating payload", err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var book database.Book
|
||||
db := database.DBConn
|
||||
if err := db.Where("uuid = ? AND user_id = ?", params.BookUUID, user.ID).First(&book).Error; err != nil {
|
||||
handleError(w, "finding book", err, http.StatusInternalServerError)
|
||||
HandleError(w, "finding book", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
note, err := operations.CreateNote(user, a.Clock, params.BookUUID, params.Content, params.AddedOn, params.EditedOn, false)
|
||||
if err != nil {
|
||||
handleError(w, "creating note", err, http.StatusInternalServerError)
|
||||
HandleError(w, "creating note", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -96,10 +96,12 @@ func TestUpdateNote(t *testing.T) {
|
|||
noteUUID string
|
||||
noteBookUUID string
|
||||
noteBody string
|
||||
notePublic bool
|
||||
noteDeleted bool
|
||||
expectedNoteBody string
|
||||
expectedNoteBookName string
|
||||
expectedNoteBookUUID string
|
||||
expectedNotePublic bool
|
||||
}{
|
||||
{
|
||||
payload: fmt.Sprintf(`{
|
||||
|
|
@ -107,11 +109,13 @@ func TestUpdateNote(t *testing.T) {
|
|||
}`, updatedBody),
|
||||
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
|
||||
noteBookUUID: b1UUID,
|
||||
notePublic: false,
|
||||
noteBody: "original content",
|
||||
noteDeleted: false,
|
||||
expectedNoteBookUUID: b1UUID,
|
||||
expectedNoteBody: "some updated content",
|
||||
expectedNoteBookName: "css",
|
||||
expectedNotePublic: false,
|
||||
},
|
||||
{
|
||||
payload: fmt.Sprintf(`{
|
||||
|
|
@ -119,11 +123,13 @@ func TestUpdateNote(t *testing.T) {
|
|||
}`, b1UUID),
|
||||
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
|
||||
noteBookUUID: b1UUID,
|
||||
notePublic: false,
|
||||
noteBody: "original content",
|
||||
noteDeleted: false,
|
||||
expectedNoteBookUUID: b1UUID,
|
||||
expectedNoteBody: "original content",
|
||||
expectedNoteBookName: "css",
|
||||
expectedNotePublic: false,
|
||||
},
|
||||
{
|
||||
payload: fmt.Sprintf(`{
|
||||
|
|
@ -131,11 +137,13 @@ func TestUpdateNote(t *testing.T) {
|
|||
}`, b2UUID),
|
||||
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
|
||||
noteBookUUID: b1UUID,
|
||||
notePublic: false,
|
||||
noteBody: "original content",
|
||||
noteDeleted: false,
|
||||
expectedNoteBookUUID: b2UUID,
|
||||
expectedNoteBody: "original content",
|
||||
expectedNoteBookName: "js",
|
||||
expectedNotePublic: false,
|
||||
},
|
||||
{
|
||||
payload: fmt.Sprintf(`{
|
||||
|
|
@ -144,11 +152,13 @@ func TestUpdateNote(t *testing.T) {
|
|||
}`, b2UUID, updatedBody),
|
||||
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
|
||||
noteBookUUID: b1UUID,
|
||||
notePublic: false,
|
||||
noteBody: "original content",
|
||||
noteDeleted: false,
|
||||
expectedNoteBookUUID: b2UUID,
|
||||
expectedNoteBody: "some updated content",
|
||||
expectedNoteBookName: "js",
|
||||
expectedNotePublic: false,
|
||||
},
|
||||
{
|
||||
payload: fmt.Sprintf(`{
|
||||
|
|
@ -157,16 +167,77 @@ func TestUpdateNote(t *testing.T) {
|
|||
}`, b1UUID, updatedBody),
|
||||
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
|
||||
noteBookUUID: b1UUID,
|
||||
notePublic: false,
|
||||
noteBody: "",
|
||||
noteDeleted: true,
|
||||
expectedNoteBookUUID: b1UUID,
|
||||
expectedNoteBody: updatedBody,
|
||||
expectedNoteBookName: "js",
|
||||
expectedNotePublic: false,
|
||||
},
|
||||
{
|
||||
payload: fmt.Sprintf(`{
|
||||
"public": %t
|
||||
}`, true),
|
||||
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
|
||||
noteBookUUID: b1UUID,
|
||||
notePublic: false,
|
||||
noteBody: "original content",
|
||||
noteDeleted: false,
|
||||
expectedNoteBookUUID: b1UUID,
|
||||
expectedNoteBody: "original content",
|
||||
expectedNoteBookName: "css",
|
||||
expectedNotePublic: true,
|
||||
},
|
||||
{
|
||||
payload: fmt.Sprintf(`{
|
||||
"public": %t
|
||||
}`, false),
|
||||
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
|
||||
noteBookUUID: b1UUID,
|
||||
notePublic: true,
|
||||
noteBody: "original content",
|
||||
noteDeleted: false,
|
||||
expectedNoteBookUUID: b1UUID,
|
||||
expectedNoteBody: "original content",
|
||||
expectedNoteBookName: "css",
|
||||
expectedNotePublic: false,
|
||||
},
|
||||
{
|
||||
payload: fmt.Sprintf(`{
|
||||
"content": "%s",
|
||||
"public": %t
|
||||
}`, updatedBody, false),
|
||||
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
|
||||
noteBookUUID: b1UUID,
|
||||
notePublic: true,
|
||||
noteBody: "original content",
|
||||
noteDeleted: false,
|
||||
expectedNoteBookUUID: b1UUID,
|
||||
expectedNoteBody: updatedBody,
|
||||
expectedNoteBookName: "css",
|
||||
expectedNotePublic: false,
|
||||
},
|
||||
{
|
||||
payload: fmt.Sprintf(`{
|
||||
"book_uuid": "%s",
|
||||
"content": "%s",
|
||||
"public": %t
|
||||
}`, b2UUID, updatedBody, true),
|
||||
noteUUID: "ab50aa32-b232-40d8-b10f-10a7f9134053",
|
||||
noteBookUUID: b1UUID,
|
||||
notePublic: false,
|
||||
noteBody: "original content",
|
||||
noteDeleted: false,
|
||||
expectedNoteBookUUID: b2UUID,
|
||||
expectedNoteBody: updatedBody,
|
||||
expectedNoteBookName: "js",
|
||||
expectedNotePublic: true,
|
||||
},
|
||||
}
|
||||
|
||||
for idx, tc := range testCases {
|
||||
func() {
|
||||
t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
db := database.DBConn
|
||||
|
||||
|
|
@ -198,6 +269,7 @@ func TestUpdateNote(t *testing.T) {
|
|||
BookUUID: tc.noteBookUUID,
|
||||
Body: tc.noteBody,
|
||||
Deleted: tc.noteDeleted,
|
||||
Public: tc.notePublic,
|
||||
}
|
||||
testutils.MustExec(t, db.Save(¬e), "preparing note")
|
||||
|
||||
|
|
@ -207,7 +279,7 @@ func TestUpdateNote(t *testing.T) {
|
|||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, fmt.Sprintf("status code mismatch for test case %d", idx))
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "status code mismatch for test case")
|
||||
|
||||
var bookRecord database.Book
|
||||
var noteRecord database.Note
|
||||
|
|
@ -222,13 +294,14 @@ func TestUpdateNote(t *testing.T) {
|
|||
assert.Equalf(t, bookCount, 2, "book count mismatch")
|
||||
assert.Equalf(t, noteCount, 1, "note count mismatch")
|
||||
|
||||
assert.Equal(t, noteRecord.UUID, tc.noteUUID, fmt.Sprintf("note uuid mismatch for test case %d", idx))
|
||||
assert.Equal(t, noteRecord.Body, tc.expectedNoteBody, fmt.Sprintf("note content mismatch for test case %d", idx))
|
||||
assert.Equal(t, noteRecord.BookUUID, tc.expectedNoteBookUUID, fmt.Sprintf("note book_uuid mismatch for test case %d", idx))
|
||||
assert.Equal(t, noteRecord.USN, 102, fmt.Sprintf("note usn mismatch for test case %d", idx))
|
||||
assert.Equal(t, noteRecord.UUID, tc.noteUUID, "note uuid mismatch for test case")
|
||||
assert.Equal(t, noteRecord.Body, tc.expectedNoteBody, "note content mismatch for test case")
|
||||
assert.Equal(t, noteRecord.BookUUID, tc.expectedNoteBookUUID, "note book_uuid mismatch for test case")
|
||||
assert.Equal(t, noteRecord.Public, tc.expectedNotePublic, "note public mismatch for test case")
|
||||
assert.Equal(t, noteRecord.USN, 102, "note usn mismatch for test case")
|
||||
|
||||
assert.Equal(t, userRecord.MaxUSN, 102, fmt.Sprintf("user max_usn mismatch for test case %d", idx))
|
||||
}()
|
||||
assert.Equal(t, userRecord.MaxUSN, 102, "user max_usn mismatch for test case")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -20,8 +20,9 @@ package operations
|
|||
|
||||
import (
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/api/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/permissions"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
|
@ -78,18 +79,55 @@ func CreateNote(user database.User, clock clock.Clock, bookUUID, content string,
|
|||
return note, nil
|
||||
}
|
||||
|
||||
// UpdateNoteParams is the parameters for updating a note
|
||||
type UpdateNoteParams struct {
|
||||
BookUUID *string
|
||||
Content *string
|
||||
Public *bool
|
||||
}
|
||||
|
||||
// GetBookUUID gets the bookUUID from the UpdateNoteParams
|
||||
func (r UpdateNoteParams) GetBookUUID() string {
|
||||
if r.BookUUID == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return *r.BookUUID
|
||||
}
|
||||
|
||||
// GetContent gets the content from the UpdateNoteParams
|
||||
func (r UpdateNoteParams) GetContent() string {
|
||||
if r.Content == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return *r.Content
|
||||
}
|
||||
|
||||
// GetPublic gets the public field from the UpdateNoteParams
|
||||
func (r UpdateNoteParams) GetPublic() bool {
|
||||
if r.Public == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return *r.Public
|
||||
}
|
||||
|
||||
// UpdateNote creates a note with the next usn and updates the user's max_usn
|
||||
func UpdateNote(tx *gorm.DB, user database.User, clock clock.Clock, note database.Note, bookUUID, content *string) (database.Note, error) {
|
||||
func UpdateNote(tx *gorm.DB, user database.User, clock clock.Clock, note database.Note, p *UpdateNoteParams) (database.Note, error) {
|
||||
nextUSN, err := incrementUserUSN(tx, user.ID)
|
||||
if err != nil {
|
||||
return note, errors.Wrap(err, "incrementing user max_usn")
|
||||
}
|
||||
|
||||
if bookUUID != nil {
|
||||
note.BookUUID = *bookUUID
|
||||
if p.BookUUID != nil {
|
||||
note.BookUUID = p.GetBookUUID()
|
||||
}
|
||||
if content != nil {
|
||||
note.Body = *content
|
||||
if p.Content != nil {
|
||||
note.Body = p.GetContent()
|
||||
}
|
||||
if p.Public != nil {
|
||||
note.Public = p.GetPublic()
|
||||
}
|
||||
|
||||
note.USN = nextUSN
|
||||
|
|
@ -123,3 +161,31 @@ func DeleteNote(tx *gorm.DB, user database.User, note database.Note) (database.N
|
|||
|
||||
return note, nil
|
||||
}
|
||||
|
||||
// GetNote retrieves a note for the given user
|
||||
func GetNote(uuid string, user database.User) (database.Note, bool, error) {
|
||||
zeroNote := database.Note{}
|
||||
if !helpers.ValidateUUID(uuid) {
|
||||
return zeroNote, false, nil
|
||||
}
|
||||
|
||||
db := database.DBConn
|
||||
|
||||
conn := db.Where("notes.uuid = ? AND deleted = ?", uuid, false)
|
||||
conn = database.PreloadNote(conn)
|
||||
|
||||
var note database.Note
|
||||
conn = conn.Find(¬e)
|
||||
|
||||
if conn.RecordNotFound() {
|
||||
return zeroNote, false, nil
|
||||
} else if err := conn.Error; err != nil {
|
||||
return zeroNote, false, errors.Wrap(err, "finding note")
|
||||
}
|
||||
|
||||
if ok := permissions.ViewNote(&user, note); !ok {
|
||||
return zeroNote, false, nil
|
||||
}
|
||||
|
||||
return note, true, nil
|
||||
}
|
||||
|
|
@ -123,45 +123,45 @@ func TestCreateNote(t *testing.T) {
|
|||
|
||||
func TestUpdateNote(t *testing.T) {
|
||||
testCases := []struct {
|
||||
userUSN int
|
||||
expectedUSN int
|
||||
userUSN int
|
||||
}{
|
||||
{
|
||||
userUSN: 8,
|
||||
expectedUSN: 9,
|
||||
userUSN: 8,
|
||||
},
|
||||
{
|
||||
userUSN: 102229,
|
||||
expectedUSN: 102230,
|
||||
userUSN: 102229,
|
||||
},
|
||||
{
|
||||
userUSN: 8099,
|
||||
expectedUSN: 8100,
|
||||
userUSN: 8099,
|
||||
},
|
||||
}
|
||||
|
||||
for idx, tc := range testCases {
|
||||
func() {
|
||||
t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
db := database.DBConn
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
testutils.MustExec(t, db.Model(&user).Update("max_usn", tc.userUSN), fmt.Sprintf("preparing user max_usn for test case %d", idx))
|
||||
testutils.MustExec(t, db.Model(&user).Update("max_usn", tc.userUSN), "preparing user max_usn for test case")
|
||||
|
||||
anotherUser := testutils.SetupUserData()
|
||||
testutils.MustExec(t, db.Model(&anotherUser).Update("max_usn", 55), fmt.Sprintf("preparing user max_usn for test case %d", idx))
|
||||
testutils.MustExec(t, db.Model(&anotherUser).Update("max_usn", 55), "preparing user max_usn for test case")
|
||||
|
||||
b1 := database.Book{UserID: user.ID, Label: "js", Deleted: false}
|
||||
testutils.MustExec(t, db.Save(&b1), fmt.Sprintf("preparing b1 for test case %d", idx))
|
||||
testutils.MustExec(t, db.Save(&b1), "preparing b1 for test case")
|
||||
|
||||
note := database.Note{UserID: user.ID, Deleted: false, Body: "test content", BookUUID: b1.UUID}
|
||||
testutils.MustExec(t, db.Save(¬e), fmt.Sprintf("preparing note for test case %d", idx))
|
||||
testutils.MustExec(t, db.Save(¬e), "preparing note for test case")
|
||||
|
||||
c := clock.NewMock()
|
||||
content := "updated test content"
|
||||
public := true
|
||||
|
||||
tx := db.Begin()
|
||||
if _, err := UpdateNote(tx, user, c, note, nil, &content); err != nil {
|
||||
if _, err := UpdateNote(tx, user, c, note, &UpdateNoteParams{
|
||||
Content: &content,
|
||||
Public: &public,
|
||||
}); err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatal(errors.Wrap(err, "deleting note"))
|
||||
}
|
||||
|
|
@ -171,20 +171,21 @@ func TestUpdateNote(t *testing.T) {
|
|||
var noteRecord database.Note
|
||||
var userRecord database.User
|
||||
|
||||
testutils.MustExec(t, db.Model(&database.Book{}).Count(&bookCount), fmt.Sprintf("counting book for test case %d", idx))
|
||||
testutils.MustExec(t, db.Model(&database.Note{}).Count(¬eCount), fmt.Sprintf("counting notes for test case %d", idx))
|
||||
testutils.MustExec(t, db.First(¬eRecord), fmt.Sprintf("finding note for test case %d", idx))
|
||||
testutils.MustExec(t, db.Where("id = ?", user.ID).First(&userRecord), fmt.Sprintf("finding user for test case %d", idx))
|
||||
testutils.MustExec(t, db.Model(&database.Book{}).Count(&bookCount), "counting book for test case")
|
||||
testutils.MustExec(t, db.Model(&database.Note{}).Count(¬eCount), "counting notes for test case")
|
||||
testutils.MustExec(t, db.First(¬eRecord), "finding note for test case")
|
||||
testutils.MustExec(t, db.Where("id = ?", user.ID).First(&userRecord), "finding user for test case")
|
||||
|
||||
expectedUSN := tc.userUSN + 1
|
||||
assert.Equal(t, bookCount, 1, "book count mismatch")
|
||||
assert.Equal(t, noteCount, 1, "note count mismatch")
|
||||
assert.Equal(t, noteRecord.UserID, user.ID, "note UserID mismatch")
|
||||
assert.Equal(t, noteRecord.Body, content, "note Body mismatch")
|
||||
assert.Equal(t, noteRecord.Public, public, "note Public mismatch")
|
||||
assert.Equal(t, noteRecord.Deleted, false, "note Deleted mismatch")
|
||||
assert.Equal(t, noteRecord.USN, tc.expectedUSN, "note USN mismatch")
|
||||
|
||||
assert.Equal(t, userRecord.MaxUSN, tc.expectedUSN, "user MaxUSN mismatch")
|
||||
}()
|
||||
assert.Equal(t, noteRecord.USN, expectedUSN, "note USN mismatch")
|
||||
assert.Equal(t, userRecord.MaxUSN, expectedUSN, "user MaxUSN mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -255,3 +256,128 @@ func TestDeleteNote(t *testing.T) {
|
|||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNote(t *testing.T) {
|
||||
user := testutils.SetupUserData()
|
||||
anotherUser := testutils.SetupUserData()
|
||||
|
||||
db := database.DBConn
|
||||
defer testutils.ClearData()
|
||||
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, db.Save(&b1), "preparing b1")
|
||||
|
||||
privateNote := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
Body: "privateNote content",
|
||||
Deleted: false,
|
||||
Public: false,
|
||||
}
|
||||
testutils.MustExec(t, db.Save(&privateNote), "preparing privateNote")
|
||||
|
||||
publicNote := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
Body: "privateNote content",
|
||||
Deleted: false,
|
||||
Public: true,
|
||||
}
|
||||
testutils.MustExec(t, db.Save(&publicNote), "preparing privateNote")
|
||||
|
||||
var privateNoteRecord, publicNoteRecord database.Note
|
||||
testutils.MustExec(t, db.Where("uuid = ?", privateNote.UUID).Preload("Book").Preload("User").First(&privateNoteRecord), "finding privateNote")
|
||||
testutils.MustExec(t, db.Where("uuid = ?", publicNote.UUID).Preload("Book").Preload("User").First(&publicNoteRecord), "finding publicNote")
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
user database.User
|
||||
note database.Note
|
||||
expectedOK bool
|
||||
expectedNote database.Note
|
||||
}{
|
||||
{
|
||||
name: "owner accessing private note",
|
||||
user: user,
|
||||
note: privateNote,
|
||||
expectedOK: true,
|
||||
expectedNote: privateNoteRecord,
|
||||
},
|
||||
{
|
||||
name: "non-owner accessing private note",
|
||||
user: anotherUser,
|
||||
note: privateNote,
|
||||
expectedOK: false,
|
||||
expectedNote: database.Note{},
|
||||
},
|
||||
{
|
||||
name: "non-owner accessing public note",
|
||||
user: anotherUser,
|
||||
note: publicNote,
|
||||
expectedOK: true,
|
||||
expectedNote: publicNoteRecord,
|
||||
},
|
||||
{
|
||||
name: "guest accessing private note",
|
||||
user: database.User{},
|
||||
note: privateNote,
|
||||
expectedOK: false,
|
||||
expectedNote: database.Note{},
|
||||
},
|
||||
{
|
||||
name: "guest accessing public note",
|
||||
user: database.User{},
|
||||
note: publicNote,
|
||||
expectedOK: true,
|
||||
expectedNote: publicNoteRecord,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
note, ok, err := GetNote(tc.note.UUID, tc.user)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "executing"))
|
||||
}
|
||||
|
||||
assert.Equal(t, ok, tc.expectedOK, "ok mismatch")
|
||||
assert.DeepEqual(t, note, tc.expectedNote, "note mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNote_nonexistent(t *testing.T) {
|
||||
user := testutils.SetupUserData()
|
||||
|
||||
db := database.DBConn
|
||||
defer testutils.ClearData()
|
||||
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, db.Save(&b1), "preparing b1")
|
||||
|
||||
n1UUID := "4fd19336-671e-4ff3-8f22-662b80e22edc"
|
||||
n1 := database.Note{
|
||||
UUID: n1UUID,
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
Body: "n1 content",
|
||||
Deleted: false,
|
||||
Public: false,
|
||||
}
|
||||
testutils.MustExec(t, db.Save(&n1), "preparing n1")
|
||||
|
||||
nonexistentUUID := "4fd19336-671e-4ff3-8f22-662b80e22edd"
|
||||
note, ok, err := GetNote(nonexistentUUID, user)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "executing"))
|
||||
}
|
||||
|
||||
assert.Equal(t, ok, false, "ok mismatch")
|
||||
assert.DeepEqual(t, note, database.Note{}, "note mismatch")
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
38
pkg/server/permissions/permissions.go
Normal file
38
pkg/server/permissions/permissions.go
Normal 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
|
||||
}
|
||||
93
pkg/server/permissions/permissions_test.go
Normal file
93
pkg/server/permissions/permissions_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
101
pkg/server/tmpl/app.go
Normal 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
|
||||
}
|
||||
92
pkg/server/tmpl/app_test.go
Normal file
92
pkg/server/tmpl/app_test.go
Normal 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
141
pkg/server/tmpl/data.go
Normal 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: "",
|
||||
}
|
||||
}
|
||||
63
pkg/server/tmpl/data_test.go
Normal file
63
pkg/server/tmpl/data_test.go
Normal 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
29
pkg/server/tmpl/tmpl.go
Normal 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 }}" />`
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
83
pkg/server/web/handlers.go
Normal file
83
pkg/server/web/handlers.go
Normal 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))
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
18
web/package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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=$!
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
79
web/src/components/Common/Toggle.scss
Normal file
79
web/src/components/Common/Toggle.scss
Normal 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;
|
||||
}
|
||||
68
web/src/components/Common/Toggle.tsx
Normal file
68
web/src/components/Common/Toggle.tsx
Normal 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;
|
||||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -52,4 +52,7 @@
|
|||
&.right {
|
||||
border-right-color: #2a2a2a;
|
||||
}
|
||||
&.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ›
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
||||
|
|
|
|||
54
web/src/components/Note/ShareModal/CopyButton.tsx
Normal file
54
web/src/components/Note/ShareModal/CopyButton.tsx
Normal 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;
|
||||
54
web/src/components/Note/ShareModal/ShareModal.scss
Normal file
54
web/src/components/Note/ShareModal/ShareModal.scss
Normal 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);
|
||||
}
|
||||
202
web/src/components/Note/ShareModal/index.tsx
Normal file
202
web/src/components/Note/ShareModal/index.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue