mirror of
https://github.com/go-shiori/shiori.git
synced 2025-02-21 22:43:22 +08:00
Add account management in web interface
This commit is contained in:
parent
fcc77e2db8
commit
3503484c2b
9 changed files with 455 additions and 26 deletions
|
@ -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)
|
||||
|
||||
|
|
|
@ -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{}
|
||||
|
|
|
@ -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
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue