Add account management in web interface

This commit is contained in:
Radhi Fadlillah 2019-05-31 22:41:29 +07:00
parent fcc77e2db8
commit 3503484c2b
9 changed files with 455 additions and 26 deletions

View file

@ -46,12 +46,18 @@ type DB interface {
// GetBookmark fetchs bookmark based on its ID or URL.
GetBookmark(id int, url string) (model.Bookmark, bool)
// GetAccounts fetch list of accounts with matching keyword.
// SaveAccount saves new account in database
SaveAccount(username, password string) error
// GetAccounts fetch list of account (without its password) with matching keyword.
GetAccounts(keyword string) ([]model.Account, error)
// GetAccount fetch account with matching username.
GetAccount(username string) (model.Account, bool)
// DeleteAccounts removes all record with matching usernames
DeleteAccounts(usernames ...string) error
// GetTags fetch list of tags and its frequency from database.
GetTags() ([]model.Tag, error)

View file

@ -8,6 +8,7 @@ import (
"github.com/go-shiori/shiori/internal/model"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
)
// SQLiteDatabase is implementation of Database interface
@ -422,11 +423,29 @@ func (db *SQLiteDatabase) GetBookmark(id int, url string) (model.Bookmark, bool)
return book, book.ID != 0
}
// GetAccounts fetch list of accounts with matching keyword.
// SaveAccount saves new account to database. Returns error if any happened.
func (db *SQLiteDatabase) SaveAccount(username, password string) (err error) {
// Hash password with bcrypt
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 10)
if err != nil {
return err
}
// Insert account to database
_, err = db.Exec(`INSERT INTO account
(username, password) VALUES (?, ?)
ON CONFLICT(username) DO UPDATE SET
password = ?`,
username, hashedPassword, hashedPassword)
return err
}
// GetAccounts fetch list of account (without its password) with matching keyword.
func (db *SQLiteDatabase) GetAccounts(keyword string) ([]model.Account, error) {
// Create query
args := []interface{}{}
query := `SELECT id, username, password FROM account WHERE 1`
query := `SELECT id, username FROM account WHERE 1`
if keyword != "" {
query += " AND username LIKE ?"
@ -456,6 +475,37 @@ func (db *SQLiteDatabase) GetAccount(username string) (model.Account, bool) {
return account, account.ID != 0
}
// DeleteAccounts removes all record with matching usernames.
func (db *SQLiteDatabase) DeleteAccounts(usernames ...string) (err error) {
// Begin transaction
tx, err := db.Beginx()
if err != nil {
return err
}
// Make sure to rollback if panic ever happened
defer func() {
if r := recover(); r != nil {
panicErr, _ := r.(error)
tx.Rollback()
err = panicErr
}
}()
// Delete account
stmtDelete, _ := tx.Preparex(`DELETE FROM account WHERE username = ?`)
for _, username := range usernames {
stmtDelete.MustExec(username)
}
// Commit transaction
err = tx.Commit()
checkError(err)
return err
}
// GetTags fetch list of tags and their frequency.
func (db *SQLiteDatabase) GetTags() ([]model.Tag, error) {
tags := []model.Tag{}

View file

@ -27,7 +27,7 @@ type Bookmark struct {
type Account struct {
ID int `db:"id" json:"id"`
Username string `db:"username" json:"username"`
Password string `db:"password" json:"password"`
Password string `db:"password" json:"password,omitempty"`
}
// LoginRequest is request from user to access web interface.

File diff suppressed because one or more lines are too long

View file

@ -1,11 +1,6 @@
var template = `
<div id="page-setting">
<div class="page-header">
<p>Settings</p>
<a href="#" title="Refresh setting">
<i class="fas fa-fw fa-sync-alt"></i>
</a>
</div>
<h1 class="page-header">Settings</h1>
<div class="setting-container">
<details open class="setting-group" id="setting-display">
<summary>Display</summary>
@ -22,6 +17,25 @@ var template = `
Use dark theme
</label>
</details>
<details open class="setting-group" id="setting-accounts">
<summary>Accounts</summary>
<ul>
<li v-if="accounts.length === 0">No accounts registered</li>
<li v-for="(account, idx) in accounts">
<span>{{account.username}}</span>
<a title="Change password" @click="showDialogChangePassword(account)">
<i class="fa fas fa-fw fa-key"></i>
</a>
<a title="Delete account" @click="showDialogDeleteAccount(account, idx)">
<i class="fa fas fa-fw fa-trash-alt"></i>
</a>
</li>
</ul>
<div class="setting-group-footer">
<a @click="loadAccounts">Refresh accounts</a>
<a @click="showDialogNewAccount">Add new account</a>
</div>
</details>
</div>
<div class="loading-overlay" v-if="loading"><i class="fas fa-fw fa-spin fa-spinner"></i></div>
<custom-dialog v-bind="dialog"/>
@ -39,6 +53,7 @@ export default {
data() {
return {
loading: false,
accounts: []
}
},
methods: {
@ -48,6 +63,214 @@ export default {
listMode: this.displayOptions.listMode,
nightMode: this.displayOptions.nightMode,
});
}
},
loadAccounts() {
if (this.loading) return;
this.loading = true;
fetch("/api/accounts")
.then(response => {
if (!response.ok) throw response;
return response.json();
})
.then(json => {
this.loading = false;
this.accounts = json;
})
.catch(err => {
this.loading = false;
err.text().then(msg => {
this.showErrorDialog(msg, err.status);
})
});
},
showDialogNewAccount() {
this.showDialog({
title: "New Account",
content: "Input new account's data :",
fields: [{
name: "username",
label: "Username",
value: "",
}, {
name: "password",
label: "Password",
type: "password",
value: "",
}, {
name: "repeat",
label: "Repeat password",
type: "password",
value: "",
}],
mainText: "OK",
secondText: "Cancel",
mainClick: (data) => {
if (data.username === "") {
this.showErrorDialog("Username must not empty");
return;
}
if (data.password === "") {
this.showErrorDialog("Password must not empty");
return;
}
if (data.password !== data.repeat) {
this.showErrorDialog("Password does not match");
return;
}
var request = {
username: data.username,
password: data.password
}
this.dialog.loading = true;
fetch("/api/accounts", {
method: "post",
body: JSON.stringify(request),
headers: {
"Content-Type": "application/json",
},
})
.then(response => {
if (!response.ok) throw response;
return response;
})
.then(() => {
this.dialog.loading = false;
this.dialog.visible = false;
this.accounts.push({ username: data.username });
this.accounts.sort((a, b) => {
var nameA = a.username.toLowerCase(),
nameB = b.username.toLowerCase();
if (nameA < nameB) {
return -1;
}
if (nameA > nameB) {
return 1;
}
return 0;
});
})
.catch(err => {
this.dialog.loading = false;
err.text().then(msg => {
this.showErrorDialog(msg, err.status);
})
});
}
});
},
showDialogChangePassword(account) {
this.showDialog({
title: "Change Password",
content: "Input new password :",
fields: [{
name: "oldPassword",
label: "Old password",
type: "password",
value: "",
}, {
name: "password",
label: "New password",
type: "password",
value: "",
}, {
name: "repeat",
label: "Repeat password",
type: "password",
value: "",
}],
mainText: "OK",
secondText: "Cancel",
mainClick: (data) => {
if (data.oldPassword === "") {
this.showErrorDialog("Old password must not empty");
return;
}
if (data.password === "") {
this.showErrorDialog("New password must not empty");
return;
}
if (data.password !== data.repeat) {
this.showErrorDialog("Password does not match");
return;
}
var request = {
username: account.username,
oldPassword: data.oldPassword,
newPassword: data.password
}
this.dialog.loading = true;
fetch("/api/accounts", {
method: "put",
body: JSON.stringify(request),
headers: {
"Content-Type": "application/json",
},
})
.then(response => {
if (!response.ok) throw response;
return response;
})
.then(() => {
this.dialog.loading = false;
this.dialog.visible = false;
})
.catch(err => {
this.dialog.loading = false;
err.text().then(msg => {
this.showErrorDialog(msg, err.status);
})
});
}
});
},
showDialogDeleteAccount(account, idx) {
this.showDialog({
title: "Delete Account",
content: `Delete account "${account.username}" ?`,
mainText: "Yes",
secondText: "No",
mainClick: () => {
this.dialog.loading = true;
fetch(`/api/accounts`, {
method: "delete",
body: JSON.stringify([account.username]),
headers: {
"Content-Type": "application/json",
},
})
.then(response => {
if (!response.ok) throw response;
return response;
})
.then(() => {
this.dialog.loading = false;
this.dialog.visible = false;
this.accounts.splice(idx, 1);
})
.catch(err => {
this.dialog.loading = false;
err.text().then(msg => {
this.showErrorDialog(msg, err.status);
})
});
}
});
},
},
mounted() {
this.loadAccounts();
}
}

View file

@ -55,7 +55,7 @@ body {
>.error-message {
width: 100%;
max-width: 400px;
font-size: 0.9em;
font-size: 1em;
background-color: var(--contentBg);
border: 1px solid var(--border);
padding: 16px;
@ -557,13 +557,13 @@ body {
font-weight: 600;
&:hover {
color: var(--mainDark);
color: var(--main);
}
&:focus {
outline: none;
color: var(--mainDark);
border-bottom: 1px dashed var(--mainDark);
color: var(--main);
border-bottom: 1px dashed var(--main);
}
}
}
@ -598,4 +598,40 @@ body {
}
}
}
#setting-accounts {
summary {
margin-bottom: 0;
}
ul {
list-style: none;
li {
padding: 8px;
display: flex;
flex-flow: row nowrap;
align-items: center;
&:not(:last-child) {
border-bottom: 1px solid var(--border);
}
span {
font-size: 1em;
color: var(--color);
flex: 1 0;
}
a {
margin-left: 8px;
color: var(--colorLink);
&:hover {
color: var(--main);
}
}
}
}
}
}

File diff suppressed because one or more lines are too long

View file

@ -571,3 +571,112 @@ func (h *handler) apiUpdateBookmarkTags(w http.ResponseWriter, r *http.Request,
err = json.NewEncoder(w).Encode(&bookmarks)
checkError(err)
}
// apiGetAccounts is handler for GET /api/accounts
func (h *handler) apiGetAccounts(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
// Make sure session still valid
err := h.validateSession(r)
checkError(err)
// Get list of usernames from database
accounts, err := h.DB.GetAccounts("")
checkError(err)
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(&accounts)
checkError(err)
}
// apiInsertAccount is handler for POST /api/accounts
func (h *handler) apiInsertAccount(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
// Make sure session still valid
err := h.validateSession(r)
checkError(err)
// Decode request
var account model.Account
err = json.NewDecoder(r.Body).Decode(&account)
checkError(err)
// Save account to database
err = h.DB.SaveAccount(account.Username, account.Password)
checkError(err)
fmt.Fprint(w, 1)
}
// apiUpdateAccount is handler for PUT /api/accounts
func (h *handler) apiUpdateAccount(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
// Make sure session still valid
err := h.validateSession(r)
checkError(err)
// Decode request
request := struct {
Username string `json:"username"`
OldPassword string `json:"oldPassword"`
NewPassword string `json:"newPassword"`
}{}
err = json.NewDecoder(r.Body).Decode(&request)
checkError(err)
// Get existing account data from database
account, exist := h.DB.GetAccount(request.Username)
if !exist {
panic(fmt.Errorf("username doesn't exist"))
}
// Compare old password with database
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(request.OldPassword))
if err != nil {
panic(fmt.Errorf("old password doesn't match"))
}
// Save new password to database
err = h.DB.SaveAccount(request.Username, request.NewPassword)
checkError(err)
// Delete user's sessions
if val, found := h.UserCache.Get(request.Username); found {
userSessions := val.([]string)
for _, session := range userSessions {
h.SessionCache.Delete(session)
}
h.UserCache.Delete(request.Username)
}
fmt.Fprint(w, 1)
}
// apiDeleteAccount is handler for DELETE /api/accounts
func (h *handler) apiDeleteAccount(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
// Make sure session still valid
err := h.validateSession(r)
checkError(err)
// Decode request
usernames := []string{}
err = json.NewDecoder(r.Body).Decode(&usernames)
checkError(err)
// Delete accounts
err = h.DB.DeleteAccounts(usernames...)
checkError(err)
// Delete user's sessions
userSessions := []string{}
for _, username := range usernames {
if val, found := h.UserCache.Get(username); found {
userSessions = val.([]string)
for _, session := range userSessions {
h.SessionCache.Delete(session)
}
h.UserCache.Delete(username)
}
}
fmt.Fprint(w, 1)
}

View file

@ -45,6 +45,11 @@ func ServeApp(DB database.DB, dataDir string, port int) error {
router.PUT("/api/archive", hdl.apiUpdateArchive)
router.PUT("/api/bookmarks/tags", hdl.apiUpdateBookmarkTags)
router.GET("/api/accounts", hdl.apiGetAccounts)
router.PUT("/api/accounts", hdl.apiUpdateAccount)
router.POST("/api/accounts", hdl.apiInsertAccount)
router.DELETE("/api/accounts", hdl.apiDeleteAccount)
// Route for panic
router.PanicHandler = func(w http.ResponseWriter, r *http.Request, arg interface{}) {
http.Error(w, fmt.Sprint(arg), 500)