mirror of
https://github.com/dnote/dnote
synced 2026-03-14 14:35:50 +01:00
Manage users with server CLI (#696)
This commit is contained in:
parent
41f25514f0
commit
850f9cc6c9
20 changed files with 1484 additions and 279 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -6,4 +6,4 @@ node_modules
|
|||
/test
|
||||
tmp
|
||||
*.db
|
||||
server
|
||||
/server
|
||||
|
|
|
|||
87
pkg/assert/prompt.go
Normal file
87
pkg/assert/prompt.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/* Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dnote contributors
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package assert
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// WaitForPrompt waits for an expected prompt to appear in stdout with a timeout.
|
||||
// Returns an error if the prompt is not found within the timeout period.
|
||||
// Handles prompts with or without newlines by reading character by character.
|
||||
func WaitForPrompt(stdout io.Reader, expectedPrompt string, timeout time.Duration) error {
|
||||
type result struct {
|
||||
found bool
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan result, 1)
|
||||
|
||||
go func() {
|
||||
reader := bufio.NewReader(stdout)
|
||||
var buffer strings.Builder
|
||||
found := false
|
||||
|
||||
for {
|
||||
b, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
resultCh <- result{found: found, err: err}
|
||||
return
|
||||
}
|
||||
|
||||
buffer.WriteByte(b)
|
||||
if strings.Contains(buffer.String(), expectedPrompt) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
resultCh <- result{found: found, err: nil}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
if res.err != nil && res.err != io.EOF {
|
||||
return errors.Wrap(res.err, "reading stdout")
|
||||
}
|
||||
if !res.found {
|
||||
return errors.Errorf("expected prompt '%s' not found in stdout", expectedPrompt)
|
||||
}
|
||||
return nil
|
||||
case <-time.After(timeout):
|
||||
return errors.Errorf("timeout waiting for prompt '%s'", expectedPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
// RespondToPrompt is a helper that waits for a prompt and sends a response.
|
||||
func RespondToPrompt(stdout io.Reader, stdin io.WriteCloser, expectedPrompt, response string, timeout time.Duration) error {
|
||||
if err := WaitForPrompt(stdout, expectedPrompt, timeout); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := io.WriteString(stdin, response); err != nil {
|
||||
return errors.Wrap(err, "writing response to stdin")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -20,7 +20,6 @@
|
|||
package testutils
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
|
@ -31,6 +30,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/cli/consts"
|
||||
"github.com/dnote/dnote/pkg/cli/context"
|
||||
"github.com/dnote/dnote/pkg/cli/database"
|
||||
|
|
@ -223,103 +223,32 @@ func MustWaitDnoteCmd(t *testing.T, opts RunDnoteCmdOptions, runFunc func(io.Rea
|
|||
return output
|
||||
}
|
||||
|
||||
// waitForPrompt waits for an expected prompt to appear in stdout with a timeout.
|
||||
// Returns an error if the prompt is not found within the timeout period.
|
||||
// Handles prompts with or without newlines by reading character by character.
|
||||
func waitForPrompt(stdout io.Reader, expectedPrompt string, timeout time.Duration) error {
|
||||
type result struct {
|
||||
found bool
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan result, 1)
|
||||
|
||||
go func() {
|
||||
reader := bufio.NewReader(stdout)
|
||||
var buffer strings.Builder
|
||||
found := false
|
||||
|
||||
for {
|
||||
b, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
resultCh <- result{found: found, err: err}
|
||||
return
|
||||
}
|
||||
|
||||
buffer.WriteByte(b)
|
||||
if strings.Contains(buffer.String(), expectedPrompt) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
resultCh <- result{found: found, err: nil}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
if res.err != nil && res.err != io.EOF {
|
||||
return errors.Wrap(res.err, "reading stdout")
|
||||
}
|
||||
if !res.found {
|
||||
return errors.Errorf("expected prompt '%s' not found in stdout", expectedPrompt)
|
||||
}
|
||||
return nil
|
||||
case <-time.After(timeout):
|
||||
return errors.Errorf("timeout waiting for prompt '%s'", expectedPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
// MustWaitForPrompt waits for an expected prompt with a default timeout.
|
||||
// Fails the test if the prompt is not found or an error occurs.
|
||||
func MustWaitForPrompt(t *testing.T, stdout io.Reader, expectedPrompt string) {
|
||||
if err := waitForPrompt(stdout, expectedPrompt, promptTimeout); err != nil {
|
||||
if err := assert.WaitForPrompt(stdout, expectedPrompt, promptTimeout); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// userRespondToPrompt is a helper that waits for a prompt and sends a response.
|
||||
func userRespondToPrompt(stdout io.Reader, stdin io.WriteCloser, expectedPrompt, response, action string) error {
|
||||
if err := waitForPrompt(stdout, expectedPrompt, promptTimeout); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := io.WriteString(stdin, response); err != nil {
|
||||
return errors.Wrapf(err, "indicating %s in stdin", action)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// userConfirmOutput simulates confirmation from the user by writing to stdin.
|
||||
// It waits for the expected prompt with a timeout to prevent deadlocks.
|
||||
func userConfirmOutput(stdout io.Reader, stdin io.WriteCloser, expectedPrompt string) error {
|
||||
return userRespondToPrompt(stdout, stdin, expectedPrompt, "y\n", "confirmation")
|
||||
}
|
||||
|
||||
// userCancelOutput simulates cancellation from the user by writing to stdin.
|
||||
// It waits for the expected prompt with a timeout to prevent deadlocks.
|
||||
func userCancelOutput(stdout io.Reader, stdin io.WriteCloser, expectedPrompt string) error {
|
||||
return userRespondToPrompt(stdout, stdin, expectedPrompt, "n\n", "cancellation")
|
||||
}
|
||||
|
||||
// ConfirmRemoveNote waits for prompt for removing a note and confirms.
|
||||
func ConfirmRemoveNote(stdout io.Reader, stdin io.WriteCloser) error {
|
||||
return userConfirmOutput(stdout, stdin, PromptRemoveNote)
|
||||
return assert.RespondToPrompt(stdout, stdin, PromptRemoveNote, "y\n", promptTimeout)
|
||||
}
|
||||
|
||||
// ConfirmRemoveBook waits for prompt for deleting a book confirms.
|
||||
func ConfirmRemoveBook(stdout io.Reader, stdin io.WriteCloser) error {
|
||||
return userConfirmOutput(stdout, stdin, PromptDeleteBook)
|
||||
return assert.RespondToPrompt(stdout, stdin, PromptDeleteBook, "y\n", promptTimeout)
|
||||
}
|
||||
|
||||
// UserConfirmEmptyServerSync waits for an empty server prompt and confirms.
|
||||
func UserConfirmEmptyServerSync(stdout io.Reader, stdin io.WriteCloser) error {
|
||||
return userConfirmOutput(stdout, stdin, PromptEmptyServer)
|
||||
return assert.RespondToPrompt(stdout, stdin, PromptEmptyServer, "y\n", promptTimeout)
|
||||
}
|
||||
|
||||
// UserCancelEmptyServerSync waits for an empty server prompt and confirms.
|
||||
// UserCancelEmptyServerSync waits for an empty server prompt and cancels.
|
||||
func UserCancelEmptyServerSync(stdout io.Reader, stdin io.WriteCloser) error {
|
||||
return userCancelOutput(stdout, stdin, PromptEmptyServer)
|
||||
return assert.RespondToPrompt(stdout, stdin, PromptEmptyServer, "n\n", promptTimeout)
|
||||
}
|
||||
|
||||
// UserContent simulates content from the user by writing to stdin.
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import (
|
|||
"syscall"
|
||||
|
||||
"github.com/dnote/dnote/pkg/cli/log"
|
||||
"github.com/dnote/dnote/pkg/prompt"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/ssh/terminal"
|
||||
)
|
||||
|
|
@ -73,26 +74,16 @@ func PromptPassword(message string, dest *string) error {
|
|||
|
||||
// Confirm prompts for user input to confirm a choice
|
||||
func Confirm(question string, optimistic bool) (bool, error) {
|
||||
var choices string
|
||||
if optimistic {
|
||||
choices = "(Y/n)"
|
||||
} else {
|
||||
choices = "(y/N)"
|
||||
}
|
||||
message := prompt.FormatQuestion(question, optimistic)
|
||||
|
||||
message := fmt.Sprintf("%s %s", question, choices)
|
||||
// Use log.Askf for colored prompt in CLI
|
||||
log.Askf(message, false)
|
||||
|
||||
var input string
|
||||
if err := PromptInput(message, &input); err != nil {
|
||||
confirmed, err := prompt.ReadYesNo(os.Stdin, optimistic)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "Failed to get user input")
|
||||
}
|
||||
|
||||
confirmed := input == "y"
|
||||
|
||||
if optimistic {
|
||||
confirmed = confirmed || input == ""
|
||||
}
|
||||
|
||||
return confirmed, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
|
@ -181,3 +182,142 @@ func TestServerUnknownCommand(t *testing.T) {
|
|||
assert.Equal(t, strings.Contains(outputStr, "Unknown command"), true, "output should contain unknown command message")
|
||||
assert.Equal(t, strings.Contains(outputStr, "Dnote server - a simple command line notebook"), true, "output should show help")
|
||||
}
|
||||
|
||||
func TestServerUserCreate(t *testing.T) {
|
||||
tmpDB := t.TempDir() + "/test.db"
|
||||
|
||||
cmd := exec.Command(testServerBinary, "user", "create",
|
||||
"--dbPath", tmpDB,
|
||||
"--email", "test@example.com",
|
||||
"--password", "password123")
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("user create failed: %v\nOutput: %s", err, output)
|
||||
}
|
||||
|
||||
outputStr := string(output)
|
||||
assert.Equal(t, strings.Contains(outputStr, "User created successfully"), true, "output should show success message")
|
||||
assert.Equal(t, strings.Contains(outputStr, "test@example.com"), true, "output should show email")
|
||||
|
||||
// Verify user exists in database
|
||||
db, err := gorm.Open(sqlite.Open(tmpDB), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open database: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
}()
|
||||
|
||||
var count int64
|
||||
db.Table("users").Count(&count)
|
||||
assert.Equal(t, count, int64(1), "should have created 1 user")
|
||||
}
|
||||
|
||||
func TestServerUserCreateShortPassword(t *testing.T) {
|
||||
tmpDB := t.TempDir() + "/test.db"
|
||||
|
||||
cmd := exec.Command(testServerBinary, "user", "create",
|
||||
"--dbPath", tmpDB,
|
||||
"--email", "test@example.com",
|
||||
"--password", "short")
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
// Should fail with short password
|
||||
if err == nil {
|
||||
t.Fatal("expected command to fail with short password")
|
||||
}
|
||||
|
||||
outputStr := string(output)
|
||||
assert.Equal(t, strings.Contains(outputStr, "password should be longer than 8 characters"), true, "output should show password error")
|
||||
}
|
||||
|
||||
func TestServerUserResetPassword(t *testing.T) {
|
||||
tmpDB := t.TempDir() + "/test.db"
|
||||
|
||||
// Create user first
|
||||
createCmd := exec.Command(testServerBinary, "user", "create",
|
||||
"--dbPath", tmpDB,
|
||||
"--email", "test@example.com",
|
||||
"--password", "oldpassword123")
|
||||
if output, err := createCmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("failed to create user: %v\nOutput: %s", err, output)
|
||||
}
|
||||
|
||||
// Reset password
|
||||
resetCmd := exec.Command(testServerBinary, "user", "reset-password",
|
||||
"--dbPath", tmpDB,
|
||||
"--email", "test@example.com",
|
||||
"--password", "newpassword123")
|
||||
output, err := resetCmd.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("reset-password failed: %v\nOutput: %s", err, output)
|
||||
}
|
||||
|
||||
outputStr := string(output)
|
||||
assert.Equal(t, strings.Contains(outputStr, "Password reset successfully"), true, "output should show success message")
|
||||
}
|
||||
|
||||
func TestServerUserRemove(t *testing.T) {
|
||||
tmpDB := t.TempDir() + "/test.db"
|
||||
|
||||
// Create user first
|
||||
createCmd := exec.Command(testServerBinary, "user", "create",
|
||||
"--dbPath", tmpDB,
|
||||
"--email", "test@example.com",
|
||||
"--password", "password123")
|
||||
if output, err := createCmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("failed to create user: %v\nOutput: %s", err, output)
|
||||
}
|
||||
|
||||
// Remove user with confirmation
|
||||
removeCmd := exec.Command(testServerBinary, "user", "remove",
|
||||
"--dbPath", tmpDB,
|
||||
"--email", "test@example.com")
|
||||
|
||||
// Pipe "y" to stdin to confirm removal
|
||||
stdin, err := removeCmd.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create stdin pipe: %v", err)
|
||||
}
|
||||
|
||||
// Capture output
|
||||
stdout, err := removeCmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create stdout pipe: %v", err)
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
removeCmd.Stderr = &stderr
|
||||
|
||||
// Start command
|
||||
if err := removeCmd.Start(); err != nil {
|
||||
t.Fatalf("failed to start remove command: %v", err)
|
||||
}
|
||||
|
||||
// Wait for prompt and send "y" to confirm
|
||||
if err := assert.RespondToPrompt(stdout, stdin, "Remove user test@example.com?", "y\n", 10*time.Second); err != nil {
|
||||
t.Fatalf("failed to confirm removal: %v", err)
|
||||
}
|
||||
|
||||
// Wait for command to finish
|
||||
if err := removeCmd.Wait(); err != nil {
|
||||
t.Fatalf("user remove failed: %v\nStderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
// Verify user was removed
|
||||
db, err := gorm.Open(sqlite.Open(tmpDB), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open database: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
}()
|
||||
|
||||
var count int64
|
||||
db.Table("users").Count(&count)
|
||||
assert.Equal(t, count, int64(0), "should have 0 users after removal")
|
||||
}
|
||||
|
|
|
|||
56
pkg/prompt/prompt.go
Normal file
56
pkg/prompt/prompt.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/* Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dnote contributors
|
||||
*
|
||||
* 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 prompt provides utilities for interactive yes/no prompts
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FormatQuestion formats a yes/no question with the appropriate choice indicator
|
||||
func FormatQuestion(question string, optimistic bool) string {
|
||||
choices := "(y/N)"
|
||||
if optimistic {
|
||||
choices = "(Y/n)"
|
||||
}
|
||||
return fmt.Sprintf("%s %s", question, choices)
|
||||
}
|
||||
|
||||
// ReadYesNo reads and parses a yes/no response from the given reader.
|
||||
// Returns true if confirmed, respecting optimistic mode.
|
||||
// In optimistic mode, empty input is treated as confirmation.
|
||||
func ReadYesNo(r io.Reader, optimistic bool) (bool, error) {
|
||||
reader := bufio.NewReader(r)
|
||||
input, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
input = strings.ToLower(strings.TrimSpace(input))
|
||||
confirmed := input == "y"
|
||||
|
||||
if optimistic {
|
||||
confirmed = confirmed || input == ""
|
||||
}
|
||||
|
||||
return confirmed, nil
|
||||
}
|
||||
148
pkg/prompt/prompt_test.go
Normal file
148
pkg/prompt/prompt_test.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/* Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dnote contributors
|
||||
*
|
||||
* 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 prompt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
)
|
||||
|
||||
func TestFormatQuestion(t *testing.T) {
|
||||
testCases := []struct {
|
||||
question string
|
||||
optimistic bool
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
question: "Are you sure?",
|
||||
optimistic: false,
|
||||
expected: "Are you sure? (y/N)",
|
||||
},
|
||||
{
|
||||
question: "Continue?",
|
||||
optimistic: true,
|
||||
expected: "Continue? (Y/n)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.question, func(t *testing.T) {
|
||||
result := FormatQuestion(tc.question, tc.optimistic)
|
||||
assert.Equal(t, result, tc.expected, "formatted question mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadYesNo(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
optimistic bool
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "pessimistic with y",
|
||||
input: "y\n",
|
||||
optimistic: false,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "pessimistic with Y (uppercase)",
|
||||
input: "Y\n",
|
||||
optimistic: false,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "pessimistic with n",
|
||||
input: "n\n",
|
||||
optimistic: false,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "pessimistic with empty",
|
||||
input: "\n",
|
||||
optimistic: false,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "pessimistic with whitespace",
|
||||
input: " \n",
|
||||
optimistic: false,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "optimistic with y",
|
||||
input: "y\n",
|
||||
optimistic: true,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "optimistic with n",
|
||||
input: "n\n",
|
||||
optimistic: true,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "optimistic with empty",
|
||||
input: "\n",
|
||||
optimistic: true,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "optimistic with whitespace",
|
||||
input: " \n",
|
||||
optimistic: true,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "invalid input defaults to no",
|
||||
input: "maybe\n",
|
||||
optimistic: false,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create a reader with test input
|
||||
reader := strings.NewReader(tc.input)
|
||||
|
||||
// Test ReadYesNo
|
||||
result, err := ReadYesNo(reader, tc.optimistic)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, result, tc.expected, "ReadYesNo result mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadYesNo_Error(t *testing.T) {
|
||||
// Test error case with EOF (empty reader)
|
||||
reader := strings.NewReader("")
|
||||
|
||||
_, err := ReadYesNo(reader, false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when reading from empty reader")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -79,4 +79,7 @@ var (
|
|||
ErrInvalidPassword appError = "Invalid currnet password."
|
||||
// ErrEmailTooLong is an error for email length exceeding the limit
|
||||
ErrEmailTooLong appError = "Email is too long."
|
||||
|
||||
// ErrUserHasExistingResources is an error for attempting to remove a user with existing notes or books
|
||||
ErrUserHasExistingResources appError = "cannot remove user with existing notes or books"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ func NewTest() App {
|
|||
WebURL: "http://127.0.0.0.1",
|
||||
Port: "3000",
|
||||
DisableRegistration: false,
|
||||
DBPath: ":memory:",
|
||||
DBPath: "",
|
||||
AssetBaseURL: "",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,15 @@ import (
|
|||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// validatePassword validates a password
|
||||
func validatePassword(password string) error {
|
||||
if len(password) < 8 {
|
||||
return ErrPasswordTooShort
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TouchLastLoginAt updates the last login timestamp
|
||||
func (a *App) TouchLastLoginAt(user database.User, tx *gorm.DB) error {
|
||||
t := a.Clock.Now()
|
||||
|
|
@ -45,8 +54,8 @@ func (a *App) CreateUser(email, password string, passwordConfirmation string) (d
|
|||
return database.User{}, ErrEmailRequired
|
||||
}
|
||||
|
||||
if len(password) < 8 {
|
||||
return database.User{}, ErrPasswordTooShort
|
||||
if err := validatePassword(password); err != nil {
|
||||
return database.User{}, err
|
||||
}
|
||||
|
||||
if password != passwordConfirmation {
|
||||
|
|
@ -102,8 +111,8 @@ func (a *App) CreateUser(email, password string, passwordConfirmation string) (d
|
|||
return user, nil
|
||||
}
|
||||
|
||||
// Authenticate authenticates a user
|
||||
func (a *App) Authenticate(email, password string) (*database.User, error) {
|
||||
// GetAccountByEmail finds an account by email
|
||||
func (a *App) GetAccountByEmail(email string) (*database.Account, error) {
|
||||
var account database.Account
|
||||
err := a.DB.Where("email = ?", email).First(&account).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
|
|
@ -112,6 +121,16 @@ func (a *App) Authenticate(email, password string) (*database.User, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// Authenticate authenticates a user
|
||||
func (a *App) Authenticate(email, password string) (*database.User, error) {
|
||||
account, err := a.GetAccountByEmail(email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(account.Password.String), []byte(password))
|
||||
if err != nil {
|
||||
return nil, ErrLoginInvalid
|
||||
|
|
@ -126,6 +145,78 @@ func (a *App) Authenticate(email, password string) (*database.User, error) {
|
|||
return &user, nil
|
||||
}
|
||||
|
||||
// UpdateAccountPassword updates an account's password with validation
|
||||
func UpdateAccountPassword(db *gorm.DB, account *database.Account, newPassword string) error {
|
||||
// Validate password
|
||||
if err := validatePassword(newPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return pkgErrors.Wrap(err, "hashing password")
|
||||
}
|
||||
|
||||
// Update the password
|
||||
if err := db.Model(&account).Update("password", string(hashedPassword)).Error; err != nil {
|
||||
return pkgErrors.Wrap(err, "updating password")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveUser removes a user and their account from the system
|
||||
// Returns an error if the user has any notes or books
|
||||
func (a *App) RemoveUser(email string) error {
|
||||
// Find the account and user
|
||||
account, err := a.GetAccountByEmail(email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if user has any notes
|
||||
var noteCount int64
|
||||
if err := a.DB.Model(&database.Note{}).Where("user_id = ? AND deleted = ?", account.UserID, false).Count(¬eCount).Error; err != nil {
|
||||
return pkgErrors.Wrap(err, "counting notes")
|
||||
}
|
||||
if noteCount > 0 {
|
||||
return ErrUserHasExistingResources
|
||||
}
|
||||
|
||||
// Check if user has any books
|
||||
var bookCount int64
|
||||
if err := a.DB.Model(&database.Book{}).Where("user_id = ? AND deleted = ?", account.UserID, false).Count(&bookCount).Error; err != nil {
|
||||
return pkgErrors.Wrap(err, "counting books")
|
||||
}
|
||||
if bookCount > 0 {
|
||||
return ErrUserHasExistingResources
|
||||
}
|
||||
|
||||
// Delete account and user in a transaction
|
||||
tx := a.DB.Begin()
|
||||
|
||||
if err := tx.Delete(&account).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return pkgErrors.Wrap(err, "deleting account")
|
||||
}
|
||||
|
||||
var user database.User
|
||||
if err := tx.Where("id = ?", account.UserID).First(&user).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return pkgErrors.Wrap(err, "finding user")
|
||||
}
|
||||
|
||||
if err := tx.Delete(&user).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return pkgErrors.Wrap(err, "deleting user")
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SignIn signs in a user
|
||||
func (a *App) SignIn(user *database.User) (*database.Session, error) {
|
||||
err := a.TouchLastLoginAt(*user, a.DB)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,42 @@ import (
|
|||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestValidatePassword(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
password string
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "valid password",
|
||||
password: "password123",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "valid password exactly 8 chars",
|
||||
password: "12345678",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "password too short",
|
||||
password: "1234567",
|
||||
wantErr: ErrPasswordTooShort,
|
||||
},
|
||||
{
|
||||
name: "empty password",
|
||||
password: "",
|
||||
wantErr: ErrPasswordTooShort,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validatePassword(tc.password)
|
||||
assert.Equal(t, err, tc.wantErr, "error mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUser_ProValue(t *testing.T) {
|
||||
db := testutils.InitMemoryDB(t)
|
||||
|
||||
|
|
@ -46,6 +82,36 @@ func TestCreateUser_ProValue(t *testing.T) {
|
|||
|
||||
}
|
||||
|
||||
func TestGetAccountByEmail(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
db := testutils.InitMemoryDB(t)
|
||||
|
||||
user := testutils.SetupUserData(db)
|
||||
testutils.SetupAccountData(db, user, "alice@example.com", "password123")
|
||||
|
||||
a := NewTest()
|
||||
a.DB = db
|
||||
|
||||
account, err := a.GetAccountByEmail("alice@example.com")
|
||||
|
||||
assert.Equal(t, err, nil, "should not error")
|
||||
assert.Equal(t, account.Email.String, "alice@example.com", "email mismatch")
|
||||
assert.Equal(t, account.UserID, user.ID, "user ID mismatch")
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
db := testutils.InitMemoryDB(t)
|
||||
|
||||
a := NewTest()
|
||||
a.DB = db
|
||||
|
||||
account, err := a.GetAccountByEmail("nonexistent@example.com")
|
||||
|
||||
assert.Equal(t, err, ErrNotFound, "should return ErrNotFound")
|
||||
assert.Equal(t, account, (*database.Account)(nil), "account should be nil")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateUser(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
db := testutils.InitMemoryDB(t)
|
||||
|
|
@ -92,3 +158,236 @@ func TestCreateUser(t *testing.T) {
|
|||
assert.Equal(t, accountCount, int64(1), "account count mismatch")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateAccountPassword(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
db := testutils.InitMemoryDB(t)
|
||||
|
||||
user := testutils.SetupUserData(db)
|
||||
account := testutils.SetupAccountData(db, user, "alice@example.com", "oldpassword123")
|
||||
|
||||
err := UpdateAccountPassword(db, &account, "newpassword123")
|
||||
|
||||
assert.Equal(t, err, nil, "should not error")
|
||||
|
||||
// Verify password was updated in database
|
||||
var updatedAccount database.Account
|
||||
testutils.MustExec(t, db.Where("id = ?", account.ID).First(&updatedAccount), "finding updated account")
|
||||
|
||||
// Verify new password works
|
||||
passwordErr := bcrypt.CompareHashAndPassword([]byte(updatedAccount.Password.String), []byte("newpassword123"))
|
||||
assert.Equal(t, passwordErr, nil, "New password should match")
|
||||
|
||||
// Verify old password no longer works
|
||||
oldPasswordErr := bcrypt.CompareHashAndPassword([]byte(updatedAccount.Password.String), []byte("oldpassword123"))
|
||||
assert.NotEqual(t, oldPasswordErr, nil, "Old password should not match")
|
||||
})
|
||||
|
||||
t.Run("password too short", func(t *testing.T) {
|
||||
db := testutils.InitMemoryDB(t)
|
||||
|
||||
user := testutils.SetupUserData(db)
|
||||
account := testutils.SetupAccountData(db, user, "alice@example.com", "oldpassword123")
|
||||
|
||||
err := UpdateAccountPassword(db, &account, "short")
|
||||
|
||||
assert.Equal(t, err, ErrPasswordTooShort, "should return ErrPasswordTooShort")
|
||||
|
||||
// Verify password was NOT updated in database
|
||||
var unchangedAccount database.Account
|
||||
testutils.MustExec(t, db.Where("id = ?", account.ID).First(&unchangedAccount), "finding unchanged account")
|
||||
|
||||
// Verify old password still works
|
||||
passwordErr := bcrypt.CompareHashAndPassword([]byte(unchangedAccount.Password.String), []byte("oldpassword123"))
|
||||
assert.Equal(t, passwordErr, nil, "Old password should still match")
|
||||
})
|
||||
|
||||
t.Run("empty password", func(t *testing.T) {
|
||||
db := testutils.InitMemoryDB(t)
|
||||
|
||||
user := testutils.SetupUserData(db)
|
||||
account := testutils.SetupAccountData(db, user, "alice@example.com", "oldpassword123")
|
||||
|
||||
err := UpdateAccountPassword(db, &account, "")
|
||||
|
||||
assert.Equal(t, err, ErrPasswordTooShort, "should return ErrPasswordTooShort")
|
||||
|
||||
// Verify password was NOT updated in database
|
||||
var unchangedAccount database.Account
|
||||
testutils.MustExec(t, db.Where("id = ?", account.ID).First(&unchangedAccount), "finding unchanged account")
|
||||
|
||||
// Verify old password still works
|
||||
passwordErr := bcrypt.CompareHashAndPassword([]byte(unchangedAccount.Password.String), []byte("oldpassword123"))
|
||||
assert.Equal(t, passwordErr, nil, "Old password should still match")
|
||||
})
|
||||
|
||||
t.Run("transaction rollback", func(t *testing.T) {
|
||||
db := testutils.InitMemoryDB(t)
|
||||
|
||||
user := testutils.SetupUserData(db)
|
||||
account := testutils.SetupAccountData(db, user, "alice@example.com", "oldpassword123")
|
||||
|
||||
// Start a transaction and rollback to verify UpdateAccountPassword respects transactions
|
||||
tx := db.Begin()
|
||||
err := UpdateAccountPassword(tx, &account, "newpassword123")
|
||||
assert.Equal(t, err, nil, "should not error")
|
||||
tx.Rollback()
|
||||
|
||||
// Verify password was NOT updated after rollback
|
||||
var unchangedAccount database.Account
|
||||
testutils.MustExec(t, db.Where("id = ?", account.ID).First(&unchangedAccount), "finding unchanged account")
|
||||
|
||||
// Verify old password still works
|
||||
passwordErr := bcrypt.CompareHashAndPassword([]byte(unchangedAccount.Password.String), []byte("oldpassword123"))
|
||||
assert.Equal(t, passwordErr, nil, "Old password should still match after rollback")
|
||||
})
|
||||
|
||||
t.Run("transaction commit", func(t *testing.T) {
|
||||
db := testutils.InitMemoryDB(t)
|
||||
|
||||
user := testutils.SetupUserData(db)
|
||||
account := testutils.SetupAccountData(db, user, "alice@example.com", "oldpassword123")
|
||||
|
||||
// Start a transaction and commit to verify UpdateAccountPassword respects transactions
|
||||
tx := db.Begin()
|
||||
err := UpdateAccountPassword(tx, &account, "newpassword123")
|
||||
assert.Equal(t, err, nil, "should not error")
|
||||
tx.Commit()
|
||||
|
||||
// Verify password was updated after commit
|
||||
var updatedAccount database.Account
|
||||
testutils.MustExec(t, db.Where("id = ?", account.ID).First(&updatedAccount), "finding updated account")
|
||||
|
||||
// Verify new password works
|
||||
passwordErr := bcrypt.CompareHashAndPassword([]byte(updatedAccount.Password.String), []byte("newpassword123"))
|
||||
assert.Equal(t, passwordErr, nil, "New password should match after commit")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoveUser(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
db := testutils.InitMemoryDB(t)
|
||||
|
||||
user := testutils.SetupUserData(db)
|
||||
testutils.SetupAccountData(db, user, "alice@example.com", "password123")
|
||||
|
||||
a := NewTest()
|
||||
a.DB = db
|
||||
|
||||
err := a.RemoveUser("alice@example.com")
|
||||
|
||||
assert.Equal(t, err, nil, "should not error")
|
||||
|
||||
// Verify user was deleted
|
||||
var userCount int64
|
||||
testutils.MustExec(t, db.Model(&database.User{}).Count(&userCount), "counting users")
|
||||
assert.Equal(t, userCount, int64(0), "user should be deleted")
|
||||
|
||||
// Verify account was deleted
|
||||
var accountCount int64
|
||||
testutils.MustExec(t, db.Model(&database.Account{}).Count(&accountCount), "counting accounts")
|
||||
assert.Equal(t, accountCount, int64(0), "account should be deleted")
|
||||
})
|
||||
|
||||
t.Run("user not found", func(t *testing.T) {
|
||||
db := testutils.InitMemoryDB(t)
|
||||
|
||||
a := NewTest()
|
||||
a.DB = db
|
||||
|
||||
err := a.RemoveUser("nonexistent@example.com")
|
||||
|
||||
assert.Equal(t, err, ErrNotFound, "should return ErrNotFound")
|
||||
})
|
||||
|
||||
t.Run("user has notes", func(t *testing.T) {
|
||||
db := testutils.InitMemoryDB(t)
|
||||
|
||||
user := testutils.SetupUserData(db)
|
||||
testutils.SetupAccountData(db, user, "alice@example.com", "password123")
|
||||
|
||||
book := database.Book{UserID: user.ID, Label: "testbook", Deleted: false}
|
||||
testutils.MustExec(t, db.Save(&book), "creating book")
|
||||
|
||||
note := database.Note{UserID: user.ID, BookUUID: book.UUID, Body: "test note", Deleted: false}
|
||||
testutils.MustExec(t, db.Save(¬e), "creating note")
|
||||
|
||||
a := NewTest()
|
||||
a.DB = db
|
||||
|
||||
err := a.RemoveUser("alice@example.com")
|
||||
|
||||
assert.Equal(t, err, ErrUserHasExistingResources, "should return ErrUserHasExistingResources")
|
||||
|
||||
// Verify user was NOT deleted
|
||||
var userCount int64
|
||||
testutils.MustExec(t, db.Model(&database.User{}).Count(&userCount), "counting users")
|
||||
assert.Equal(t, userCount, int64(1), "user should not be deleted")
|
||||
|
||||
// Verify account was NOT deleted
|
||||
var accountCount int64
|
||||
testutils.MustExec(t, db.Model(&database.Account{}).Count(&accountCount), "counting accounts")
|
||||
assert.Equal(t, accountCount, int64(1), "account should not be deleted")
|
||||
})
|
||||
|
||||
t.Run("user has books", func(t *testing.T) {
|
||||
db := testutils.InitMemoryDB(t)
|
||||
|
||||
user := testutils.SetupUserData(db)
|
||||
testutils.SetupAccountData(db, user, "alice@example.com", "password123")
|
||||
|
||||
book := database.Book{UserID: user.ID, Label: "testbook", Deleted: false}
|
||||
testutils.MustExec(t, db.Save(&book), "creating book")
|
||||
|
||||
a := NewTest()
|
||||
a.DB = db
|
||||
|
||||
err := a.RemoveUser("alice@example.com")
|
||||
|
||||
assert.Equal(t, err, ErrUserHasExistingResources, "should return ErrUserHasExistingResources")
|
||||
|
||||
// Verify user was NOT deleted
|
||||
var userCount int64
|
||||
testutils.MustExec(t, db.Model(&database.User{}).Count(&userCount), "counting users")
|
||||
assert.Equal(t, userCount, int64(1), "user should not be deleted")
|
||||
|
||||
// Verify account was NOT deleted
|
||||
var accountCount int64
|
||||
testutils.MustExec(t, db.Model(&database.Account{}).Count(&accountCount), "counting accounts")
|
||||
assert.Equal(t, accountCount, int64(1), "account should not be deleted")
|
||||
})
|
||||
|
||||
t.Run("user has deleted notes and books", func(t *testing.T) {
|
||||
db := testutils.InitMemoryDB(t)
|
||||
|
||||
user := testutils.SetupUserData(db)
|
||||
testutils.SetupAccountData(db, user, "alice@example.com", "password123")
|
||||
|
||||
book := database.Book{UserID: user.ID, Label: "testbook", Deleted: false}
|
||||
testutils.MustExec(t, db.Save(&book), "creating book")
|
||||
|
||||
note := database.Note{UserID: user.ID, BookUUID: book.UUID, Body: "test note", Deleted: false}
|
||||
testutils.MustExec(t, db.Save(¬e), "creating note")
|
||||
|
||||
// Soft delete the note and book
|
||||
testutils.MustExec(t, db.Model(¬e).Update("deleted", true), "soft deleting note")
|
||||
testutils.MustExec(t, db.Model(&book).Update("deleted", true), "soft deleting book")
|
||||
|
||||
a := NewTest()
|
||||
a.DB = db
|
||||
|
||||
err := a.RemoveUser("alice@example.com")
|
||||
|
||||
assert.Equal(t, err, nil, "should not error when user only has deleted notes and books")
|
||||
|
||||
// Verify user was deleted
|
||||
var userCount int64
|
||||
testutils.MustExec(t, db.Model(&database.User{}).Count(&userCount), "counting users")
|
||||
assert.Equal(t, userCount, int64(0), "user should be deleted")
|
||||
|
||||
// Verify account was deleted
|
||||
var accountCount int64
|
||||
testutils.MustExec(t, db.Model(&database.Account{}).Count(&accountCount), "counting accounts")
|
||||
assert.Equal(t, accountCount, int64(0), "account should be deleted")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
111
pkg/server/cmd/helpers.go
Normal file
111
pkg/server/cmd/helpers.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/* Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dnote contributors
|
||||
*
|
||||
* 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 cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/config"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/log"
|
||||
"github.com/dnote/dnote/pkg/server/mailer"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func initDB(dbPath string) *gorm.DB {
|
||||
db := database.Open(dbPath)
|
||||
database.InitSchema(db)
|
||||
database.Migrate(db)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func initApp(cfg config.Config) app.App {
|
||||
db := initDB(cfg.DBPath)
|
||||
|
||||
emailBackend, err := mailer.NewDefaultBackend(cfg.IsProd())
|
||||
if err != nil {
|
||||
emailBackend = &mailer.DefaultBackend{Enabled: false}
|
||||
} else {
|
||||
log.Info("Email backend configured")
|
||||
}
|
||||
|
||||
return app.App{
|
||||
DB: db,
|
||||
Clock: clock.New(),
|
||||
EmailTemplates: mailer.NewTemplates(),
|
||||
EmailBackend: emailBackend,
|
||||
HTTP500Page: cfg.HTTP500Page,
|
||||
AppEnv: cfg.AppEnv,
|
||||
WebURL: cfg.WebURL,
|
||||
DisableRegistration: cfg.DisableRegistration,
|
||||
Port: cfg.Port,
|
||||
DBPath: cfg.DBPath,
|
||||
AssetBaseURL: cfg.AssetBaseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// setupFlagSet creates a FlagSet with standard usage format
|
||||
func setupFlagSet(name, usageCmd string) *flag.FlagSet {
|
||||
fs := flag.NewFlagSet(name, flag.ExitOnError)
|
||||
fs.Usage = func() {
|
||||
fmt.Printf(`Usage:
|
||||
%s [flags]
|
||||
|
||||
Flags:
|
||||
`, usageCmd)
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
// requireString validates that a required string flag is not empty
|
||||
func requireString(fs *flag.FlagSet, value, fieldName string) {
|
||||
if value == "" {
|
||||
fmt.Printf("Error: %s is required\n", fieldName)
|
||||
fs.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// setupAppWithDB creates config, initializes app, and returns cleanup function
|
||||
func setupAppWithDB(fs *flag.FlagSet, dbPath string) (*app.App, func()) {
|
||||
cfg, err := config.New(config.Params{
|
||||
DBPath: dbPath,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %s\n\n", err)
|
||||
fs.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
a := initApp(cfg)
|
||||
cleanup := func() {
|
||||
sqlDB, err := a.DB.DB()
|
||||
if err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
return &a, cleanup
|
||||
}
|
||||
60
pkg/server/cmd/root.go
Normal file
60
pkg/server/cmd/root.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/* Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dnote contributors
|
||||
*
|
||||
* 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 cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func rootCmd() {
|
||||
fmt.Printf(`Dnote server - a simple command line notebook
|
||||
|
||||
Usage:
|
||||
dnote-server [command] [flags]
|
||||
|
||||
Available commands:
|
||||
start: Start the server (use 'dnote-server start --help' for flags)
|
||||
user: Manage users (use 'dnote-server user' for subcommands)
|
||||
version: Print the version
|
||||
`)
|
||||
}
|
||||
|
||||
// Execute is the main entry point for the CLI
|
||||
func Execute() {
|
||||
if len(os.Args) < 2 {
|
||||
rootCmd()
|
||||
return
|
||||
}
|
||||
|
||||
cmd := os.Args[1]
|
||||
|
||||
switch cmd {
|
||||
case "start":
|
||||
startCmd(os.Args[2:])
|
||||
case "user":
|
||||
userCmd(os.Args[2:])
|
||||
case "version":
|
||||
versionCmd()
|
||||
default:
|
||||
fmt.Printf("Unknown command %s\n", cmd)
|
||||
rootCmd()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
91
pkg/server/cmd/start.go
Normal file
91
pkg/server/cmd/start.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/* Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dnote contributors
|
||||
*
|
||||
* 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 cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/buildinfo"
|
||||
"github.com/dnote/dnote/pkg/server/config"
|
||||
"github.com/dnote/dnote/pkg/server/controllers"
|
||||
"github.com/dnote/dnote/pkg/server/log"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func startCmd(args []string) {
|
||||
fs := setupFlagSet("start", "dnote-server start")
|
||||
|
||||
appEnv := fs.String("appEnv", "", "Application environment (env: APP_ENV, default: PRODUCTION)")
|
||||
port := fs.String("port", "", "Server port (env: PORT, default: 3001)")
|
||||
webURL := fs.String("webUrl", "", "Full URL to server without trailing slash (env: WebURL, default: http://localhost:3001)")
|
||||
dbPath := fs.String("dbPath", "", "Path to SQLite database file (env: DBPath, default: $XDG_DATA_HOME/dnote/server.db)")
|
||||
disableRegistration := fs.Bool("disableRegistration", false, "Disable user registration (env: DisableRegistration, default: false)")
|
||||
logLevel := fs.String("logLevel", "", "Log level: debug, info, warn, or error (env: LOG_LEVEL, default: info)")
|
||||
|
||||
fs.Parse(args)
|
||||
|
||||
cfg, err := config.New(config.Params{
|
||||
AppEnv: *appEnv,
|
||||
Port: *port,
|
||||
WebURL: *webURL,
|
||||
DBPath: *dbPath,
|
||||
DisableRegistration: *disableRegistration,
|
||||
LogLevel: *logLevel,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %s\n\n", err)
|
||||
fs.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Set log level
|
||||
log.SetLevel(cfg.LogLevel)
|
||||
|
||||
app := initApp(cfg)
|
||||
defer func() {
|
||||
sqlDB, err := app.DB.DB()
|
||||
if err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
ctl := controllers.New(&app)
|
||||
rc := controllers.RouteConfig{
|
||||
WebRoutes: controllers.NewWebRoutes(&app, ctl),
|
||||
APIRoutes: controllers.NewAPIRoutes(&app, ctl),
|
||||
Controllers: ctl,
|
||||
}
|
||||
|
||||
r, err := controllers.NewRouter(&app, rc)
|
||||
if err != nil {
|
||||
panic(errors.Wrap(err, "initializing router"))
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"version": buildinfo.Version,
|
||||
"port": cfg.Port,
|
||||
}).Info("Dnote server starting")
|
||||
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%s", cfg.Port), r); err != nil {
|
||||
log.ErrorWrap(err, "server failed")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
190
pkg/server/cmd/user.go
Normal file
190
pkg/server/cmd/user.go
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
/* Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dnote contributors
|
||||
*
|
||||
* 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 cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/dnote/dnote/pkg/prompt"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/log"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// confirm prompts for user input to confirm a choice
|
||||
func confirm(r io.Reader, question string, optimistic bool) (bool, error) {
|
||||
message := prompt.FormatQuestion(question, optimistic)
|
||||
fmt.Print(message + " ")
|
||||
|
||||
confirmed, err := prompt.ReadYesNo(r, optimistic)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "reading stdin")
|
||||
}
|
||||
|
||||
return confirmed, nil
|
||||
}
|
||||
|
||||
func userCreateCmd(args []string) {
|
||||
fs := setupFlagSet("create", "dnote-server user create")
|
||||
|
||||
email := fs.String("email", "", "User email address (required)")
|
||||
password := fs.String("password", "", "User password (required)")
|
||||
dbPath := fs.String("dbPath", "", "Path to SQLite database file (env: DBPath, default: $XDG_DATA_HOME/dnote/server.db)")
|
||||
|
||||
fs.Parse(args)
|
||||
|
||||
requireString(fs, *email, "email")
|
||||
requireString(fs, *password, "password")
|
||||
|
||||
a, cleanup := setupAppWithDB(fs, *dbPath)
|
||||
defer cleanup()
|
||||
|
||||
_, err := a.CreateUser(*email, *password, *password)
|
||||
if err != nil {
|
||||
log.ErrorWrap(err, "creating user")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("User created successfully\n")
|
||||
fmt.Printf("Email: %s\n", *email)
|
||||
}
|
||||
|
||||
func userRemoveCmd(args []string, stdin io.Reader) {
|
||||
fs := setupFlagSet("remove", "dnote-server user remove")
|
||||
|
||||
email := fs.String("email", "", "User email address (required)")
|
||||
dbPath := fs.String("dbPath", "", "Path to SQLite database file (env: DBPath, default: $XDG_DATA_HOME/dnote/server.db)")
|
||||
|
||||
fs.Parse(args)
|
||||
|
||||
requireString(fs, *email, "email")
|
||||
|
||||
a, cleanup := setupAppWithDB(fs, *dbPath)
|
||||
defer cleanup()
|
||||
|
||||
// Check if user exists first
|
||||
_, err := a.GetAccountByEmail(*email)
|
||||
if err != nil {
|
||||
if errors.Is(err, app.ErrNotFound) {
|
||||
fmt.Printf("Error: user with email %s not found\n", *email)
|
||||
} else {
|
||||
log.ErrorWrap(err, "finding account")
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Show confirmation prompt
|
||||
ok, err := confirm(stdin, fmt.Sprintf("Remove user %s?", *email), false)
|
||||
if err != nil {
|
||||
log.ErrorWrap(err, "getting confirmation")
|
||||
os.Exit(1)
|
||||
}
|
||||
if !ok {
|
||||
fmt.Println("Aborted by user")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Remove the user
|
||||
if err := a.RemoveUser(*email); err != nil {
|
||||
if errors.Is(err, app.ErrNotFound) {
|
||||
fmt.Printf("Error: user with email %s not found\n", *email)
|
||||
} else if errors.Is(err, app.ErrUserHasExistingResources) {
|
||||
fmt.Printf("Error: %s\n", err)
|
||||
} else {
|
||||
log.ErrorWrap(err, "removing user")
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("User removed successfully\n")
|
||||
fmt.Printf("Email: %s\n", *email)
|
||||
}
|
||||
|
||||
func userResetPasswordCmd(args []string) {
|
||||
fs := setupFlagSet("reset-password", "dnote-server user reset-password")
|
||||
|
||||
email := fs.String("email", "", "User email address (required)")
|
||||
password := fs.String("password", "", "New password (required)")
|
||||
dbPath := fs.String("dbPath", "", "Path to SQLite database file (env: DBPath, default: $XDG_DATA_HOME/dnote/server.db)")
|
||||
|
||||
fs.Parse(args)
|
||||
|
||||
requireString(fs, *email, "email")
|
||||
requireString(fs, *password, "password")
|
||||
|
||||
a, cleanup := setupAppWithDB(fs, *dbPath)
|
||||
defer cleanup()
|
||||
|
||||
// Find the account
|
||||
account, err := a.GetAccountByEmail(*email)
|
||||
if err != nil {
|
||||
if errors.Is(err, app.ErrNotFound) {
|
||||
fmt.Printf("Error: user with email %s not found\n", *email)
|
||||
} else {
|
||||
log.ErrorWrap(err, "finding account")
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Update the password
|
||||
if err := app.UpdateAccountPassword(a.DB, account, *password); err != nil {
|
||||
log.ErrorWrap(err, "updating password")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Password reset successfully\n")
|
||||
fmt.Printf("Email: %s\n", *email)
|
||||
}
|
||||
|
||||
func userCmd(args []string) {
|
||||
if len(args) < 1 {
|
||||
fmt.Println(`Usage:
|
||||
dnote-server user [command]
|
||||
|
||||
Available commands:
|
||||
create: Create a new user
|
||||
remove: Remove a user
|
||||
reset-password: Reset a user's password`)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
subcommand := args[0]
|
||||
subArgs := []string{}
|
||||
if len(args) > 1 {
|
||||
subArgs = args[1:]
|
||||
}
|
||||
|
||||
switch subcommand {
|
||||
case "create":
|
||||
userCreateCmd(subArgs)
|
||||
case "remove":
|
||||
userRemoveCmd(subArgs, os.Stdin)
|
||||
case "reset-password":
|
||||
userResetPasswordCmd(subArgs)
|
||||
default:
|
||||
fmt.Printf("Unknown subcommand: %s\n\n", subcommand)
|
||||
fmt.Println(`Available commands:
|
||||
create: Create a new user
|
||||
remove: Remove a user (only if they have no notes or books)
|
||||
reset-password: Reset a user's password`)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
114
pkg/server/cmd/user_test.go
Normal file
114
pkg/server/cmd/user_test.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/* Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dnote contributors
|
||||
*
|
||||
* 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 cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestUserCreateCmd(t *testing.T) {
|
||||
tmpDB := t.TempDir() + "/test.db"
|
||||
|
||||
// Call the function directly
|
||||
userCreateCmd([]string{"--dbPath", tmpDB, "--email", "test@example.com", "--password", "password123"})
|
||||
|
||||
// Verify user was created in database
|
||||
db := testutils.InitDB(tmpDB)
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
}()
|
||||
|
||||
var count int64
|
||||
testutils.MustExec(t, db.Model(&database.User{}).Count(&count), "counting users")
|
||||
assert.Equal(t, count, int64(1), "should have 1 user")
|
||||
|
||||
var account database.Account
|
||||
testutils.MustExec(t, db.Where("email = ?", "test@example.com").First(&account), "finding account")
|
||||
assert.Equal(t, account.Email.String, "test@example.com", "email mismatch")
|
||||
}
|
||||
|
||||
func TestUserRemoveCmd(t *testing.T) {
|
||||
tmpDB := t.TempDir() + "/test.db"
|
||||
|
||||
// Create a user first
|
||||
db := testutils.InitDB(tmpDB)
|
||||
user := testutils.SetupUserData(db)
|
||||
testutils.SetupAccountData(db, user, "test@example.com", "password123")
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
|
||||
// Remove the user with mock stdin that responds "y"
|
||||
mockStdin := strings.NewReader("y\n")
|
||||
userRemoveCmd([]string{"--dbPath", tmpDB, "--email", "test@example.com"}, mockStdin)
|
||||
|
||||
// Verify user was removed
|
||||
db2 := testutils.InitDB(tmpDB)
|
||||
defer func() {
|
||||
sqlDB2, _ := db2.DB()
|
||||
sqlDB2.Close()
|
||||
}()
|
||||
|
||||
var count int64
|
||||
testutils.MustExec(t, db2.Model(&database.User{}).Count(&count), "counting users")
|
||||
assert.Equal(t, count, int64(0), "should have 0 users")
|
||||
}
|
||||
|
||||
func TestUserResetPasswordCmd(t *testing.T) {
|
||||
tmpDB := t.TempDir() + "/test.db"
|
||||
|
||||
// Create a user first
|
||||
db := testutils.InitDB(tmpDB)
|
||||
user := testutils.SetupUserData(db)
|
||||
account := testutils.SetupAccountData(db, user, "test@example.com", "oldpassword123")
|
||||
oldPasswordHash := account.Password.String
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
|
||||
// Reset password
|
||||
userResetPasswordCmd([]string{"--dbPath", tmpDB, "--email", "test@example.com", "--password", "newpassword123"})
|
||||
|
||||
// Verify password was changed
|
||||
db2 := testutils.InitDB(tmpDB)
|
||||
defer func() {
|
||||
sqlDB2, _ := db2.DB()
|
||||
sqlDB2.Close()
|
||||
}()
|
||||
|
||||
var updatedAccount database.Account
|
||||
testutils.MustExec(t, db2.Where("email = ?", "test@example.com").First(&updatedAccount), "finding account")
|
||||
|
||||
// Verify password hash changed
|
||||
assert.Equal(t, updatedAccount.Password.String != oldPasswordHash, true, "password hash should be different")
|
||||
assert.Equal(t, len(updatedAccount.Password.String) > 0, true, "password should be set")
|
||||
|
||||
// Verify new password works
|
||||
err := bcrypt.CompareHashAndPassword([]byte(updatedAccount.Password.String), []byte("newpassword123"))
|
||||
assert.Equal(t, err, nil, "new password should match")
|
||||
|
||||
// Verify old password doesn't work
|
||||
err = bcrypt.CompareHashAndPassword([]byte(updatedAccount.Password.String), []byte("oldpassword123"))
|
||||
assert.Equal(t, err != nil, true, "old password should not match")
|
||||
}
|
||||
29
pkg/server/cmd/version.go
Normal file
29
pkg/server/cmd/version.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/* Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dnote contributors
|
||||
*
|
||||
* 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 cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/buildinfo"
|
||||
)
|
||||
|
||||
func versionCmd() {
|
||||
fmt.Printf("dnote-server-%s\n", buildinfo.Version)
|
||||
}
|
||||
|
|
@ -396,27 +396,21 @@ func (u *Users) PasswordReset(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
tx := u.app.DB.Begin()
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(params.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
handleHTMLError(w, r, err, "hashing password", u.PasswordResetConfirmView, vd)
|
||||
return
|
||||
}
|
||||
|
||||
var account database.Account
|
||||
if err := u.app.DB.Where("user_id = ?", token.UserID).First(&account).Error; err != nil {
|
||||
tx.Rollback()
|
||||
handleHTMLError(w, r, err, "finding user", u.PasswordResetConfirmView, vd)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Model(&account).Update("password", string(hashedPassword)).Error; err != nil {
|
||||
tx := u.app.DB.Begin()
|
||||
|
||||
// Update the password
|
||||
if err := app.UpdateAccountPassword(tx, &account, params.Password); err != nil {
|
||||
tx.Rollback()
|
||||
handleHTMLError(w, r, err, "updating password", u.PasswordResetConfirmView, vd)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Model(&token).Update("used_at", time.Now()).Error; err != nil {
|
||||
tx.Rollback()
|
||||
handleHTMLError(w, r, err, "updating password reset token", u.PasswordResetConfirmView, vd)
|
||||
|
|
@ -514,18 +508,7 @@ func (u *Users) PasswordUpdate(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if err := validatePassword(form.NewPassword); err != nil {
|
||||
handleHTMLError(w, r, err, "invalid password", u.SettingView, vd)
|
||||
return
|
||||
}
|
||||
|
||||
hashedNewPassword, err := bcrypt.GenerateFromPassword([]byte(form.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
handleHTMLError(w, r, err, "hashing password", u.SettingView, vd)
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.app.DB.Model(&account).Update("password", string(hashedNewPassword)).Error; err != nil {
|
||||
if err := app.UpdateAccountPassword(u.app.DB, &account, form.NewPassword); err != nil {
|
||||
handleHTMLError(w, r, err, "updating password", u.SettingView, vd)
|
||||
return
|
||||
}
|
||||
|
|
@ -537,14 +520,6 @@ func (u *Users) PasswordUpdate(w http.ResponseWriter, r *http.Request) {
|
|||
views.RedirectAlert(w, r, "/", http.StatusFound, alert)
|
||||
}
|
||||
|
||||
func validatePassword(password string) error {
|
||||
if len(password) < 8 {
|
||||
return app.ErrPasswordTooShort
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type updateProfileForm struct {
|
||||
Email string `schema:"email"`
|
||||
Password string `schema:"password"`
|
||||
|
|
|
|||
38
pkg/server/log/log_test.go
Normal file
38
pkg/server/log/log_test.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/* Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dnote contributors
|
||||
*
|
||||
* 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 log
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetLevel(t *testing.T) {
|
||||
// Reset to default after test
|
||||
defer SetLevel(LevelInfo)
|
||||
|
||||
SetLevel(LevelDebug)
|
||||
if currentLevel != LevelDebug {
|
||||
t.Errorf("Expected level %s, got %s", LevelDebug, currentLevel)
|
||||
}
|
||||
|
||||
SetLevel(LevelError)
|
||||
if currentLevel != LevelError {
|
||||
t.Errorf("Expected level %s, got %s", LevelError, currentLevel)
|
||||
}
|
||||
}
|
||||
|
|
@ -19,156 +19,9 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/buildinfo"
|
||||
"github.com/dnote/dnote/pkg/server/config"
|
||||
"github.com/dnote/dnote/pkg/server/controllers"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/log"
|
||||
"github.com/dnote/dnote/pkg/server/mailer"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
"github.com/dnote/dnote/pkg/server/cmd"
|
||||
)
|
||||
|
||||
func initDB(dbPath string) *gorm.DB {
|
||||
db := database.Open(dbPath)
|
||||
database.InitSchema(db)
|
||||
database.Migrate(db)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func initApp(cfg config.Config) app.App {
|
||||
db := initDB(cfg.DBPath)
|
||||
|
||||
emailBackend, err := mailer.NewDefaultBackend(cfg.IsProd())
|
||||
if err != nil {
|
||||
emailBackend = &mailer.DefaultBackend{Enabled: false}
|
||||
} else {
|
||||
log.Info("Email backend configured")
|
||||
}
|
||||
|
||||
return app.App{
|
||||
DB: db,
|
||||
Clock: clock.New(),
|
||||
EmailTemplates: mailer.NewTemplates(),
|
||||
EmailBackend: emailBackend,
|
||||
HTTP500Page: cfg.HTTP500Page,
|
||||
AppEnv: cfg.AppEnv,
|
||||
WebURL: cfg.WebURL,
|
||||
DisableRegistration: cfg.DisableRegistration,
|
||||
Port: cfg.Port,
|
||||
DBPath: cfg.DBPath,
|
||||
AssetBaseURL: cfg.AssetBaseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func startCmd(args []string) {
|
||||
startFlags := flag.NewFlagSet("start", flag.ExitOnError)
|
||||
startFlags.Usage = func() {
|
||||
fmt.Printf(`Usage:
|
||||
dnote-server start [flags]
|
||||
|
||||
Flags:
|
||||
`)
|
||||
startFlags.PrintDefaults()
|
||||
}
|
||||
|
||||
appEnv := startFlags.String("appEnv", "", "Application environment (env: APP_ENV, default: PRODUCTION)")
|
||||
port := startFlags.String("port", "", "Server port (env: PORT, default: 3001)")
|
||||
webURL := startFlags.String("webUrl", "", "Full URL to server without trailing slash (env: WebURL, default: http://localhost:3001)")
|
||||
dbPath := startFlags.String("dbPath", "", "Path to SQLite database file (env: DBPath, default: $XDG_DATA_HOME/dnote/server.db)")
|
||||
disableRegistration := startFlags.Bool("disableRegistration", false, "Disable user registration (env: DisableRegistration, default: false)")
|
||||
logLevel := startFlags.String("logLevel", "", "Log level: debug, info, warn, or error (env: LOG_LEVEL, default: info)")
|
||||
|
||||
startFlags.Parse(args)
|
||||
|
||||
cfg, err := config.New(config.Params{
|
||||
AppEnv: *appEnv,
|
||||
Port: *port,
|
||||
WebURL: *webURL,
|
||||
DBPath: *dbPath,
|
||||
DisableRegistration: *disableRegistration,
|
||||
LogLevel: *logLevel,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %s\n\n", err)
|
||||
startFlags.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Set log level
|
||||
log.SetLevel(cfg.LogLevel)
|
||||
|
||||
app := initApp(cfg)
|
||||
defer func() {
|
||||
sqlDB, err := app.DB.DB()
|
||||
if err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
ctl := controllers.New(&app)
|
||||
rc := controllers.RouteConfig{
|
||||
WebRoutes: controllers.NewWebRoutes(&app, ctl),
|
||||
APIRoutes: controllers.NewAPIRoutes(&app, ctl),
|
||||
Controllers: ctl,
|
||||
}
|
||||
|
||||
r, err := controllers.NewRouter(&app, rc)
|
||||
if err != nil {
|
||||
panic(errors.Wrap(err, "initializing router"))
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"version": buildinfo.Version,
|
||||
"port": cfg.Port,
|
||||
}).Info("Dnote server starting")
|
||||
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%s", cfg.Port), r); err != nil {
|
||||
log.ErrorWrap(err, "server failed")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func versionCmd() {
|
||||
fmt.Printf("dnote-server-%s\n", buildinfo.Version)
|
||||
}
|
||||
|
||||
func rootCmd() {
|
||||
fmt.Printf(`Dnote server - a simple command line notebook
|
||||
|
||||
Usage:
|
||||
dnote-server [command] [flags]
|
||||
|
||||
Available commands:
|
||||
start: Start the server (use 'dnote-server start --help' for flags)
|
||||
version: Print the version
|
||||
`)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
rootCmd()
|
||||
return
|
||||
}
|
||||
|
||||
cmd := os.Args[1]
|
||||
|
||||
switch cmd {
|
||||
case "start":
|
||||
startCmd(os.Args[2:])
|
||||
case "version":
|
||||
versionCmd()
|
||||
default:
|
||||
fmt.Printf("Unknown command %s\n", cmd)
|
||||
rootCmd()
|
||||
os.Exit(1)
|
||||
}
|
||||
cmd.Execute()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue