mirror of
https://github.com/go-shiori/shiori.git
synced 2025-09-19 11:27:02 +08:00
feat: new system info panel in webui settings (#926)
* frontend * fixed Database.DBx return value * api endpoint * updated swagger * fix openbsd variable dereference * tests * only load information if user is owner * memory improvement for other routes
This commit is contained in:
parent
650f192176
commit
72aecd2b60
18 changed files with 371 additions and 27 deletions
|
@ -181,6 +181,29 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/system/info": {
|
||||
"get": {
|
||||
"description": "Get general system information like Shiori version, database, and OS",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"system"
|
||||
],
|
||||
"summary": "Get general system information",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api_v1.infoResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Only owners can access this endpoint"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/tags": {
|
||||
"get": {
|
||||
"produces": [
|
||||
|
@ -228,6 +251,31 @@ const docTemplate = `{
|
|||
}
|
||||
},
|
||||
"definitions": {
|
||||
"api_v1.infoResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database": {
|
||||
"type": "string"
|
||||
},
|
||||
"os": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"commit": {
|
||||
"type": "string"
|
||||
},
|
||||
"date": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"api_v1.loginRequestPayload": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
|
@ -170,6 +170,29 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/system/info": {
|
||||
"get": {
|
||||
"description": "Get general system information like Shiori version, database, and OS",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"system"
|
||||
],
|
||||
"summary": "Get general system information",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api_v1.infoResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Only owners can access this endpoint"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/tags": {
|
||||
"get": {
|
||||
"produces": [
|
||||
|
@ -217,6 +240,31 @@
|
|||
}
|
||||
},
|
||||
"definitions": {
|
||||
"api_v1.infoResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database": {
|
||||
"type": "string"
|
||||
},
|
||||
"os": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"commit": {
|
||||
"type": "string"
|
||||
},
|
||||
"date": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"api_v1.loginRequestPayload": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
|
@ -1,4 +1,20 @@
|
|||
definitions:
|
||||
api_v1.infoResponse:
|
||||
properties:
|
||||
database:
|
||||
type: string
|
||||
os:
|
||||
type: string
|
||||
version:
|
||||
properties:
|
||||
commit:
|
||||
type: string
|
||||
date:
|
||||
type: string
|
||||
tag:
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
api_v1.loginRequestPayload:
|
||||
properties:
|
||||
password:
|
||||
|
@ -239,6 +255,22 @@ paths:
|
|||
summary: Get readable version of bookmark.
|
||||
tags:
|
||||
- Auth
|
||||
/api/v1/system/info:
|
||||
get:
|
||||
description: Get general system information like Shiori version, database, and
|
||||
OS
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/api_v1.infoResponse'
|
||||
"403":
|
||||
description: Only owners can access this endpoint
|
||||
summary: Get general system information
|
||||
tags:
|
||||
- system
|
||||
/api/v1/tags:
|
||||
get:
|
||||
produces:
|
||||
|
|
|
@ -65,7 +65,7 @@ func Connect(ctx context.Context, dbURL string) (DB, error) {
|
|||
// DB is interface for accessing and manipulating data in database.
|
||||
type DB interface {
|
||||
// DBx is the underlying sqlx.DB
|
||||
DBx() sqlx.DB
|
||||
DBx() *sqlx.DB
|
||||
|
||||
// Migrate runs migrations for this database
|
||||
Migrate(ctx context.Context) error
|
||||
|
@ -117,7 +117,7 @@ type DB interface {
|
|||
}
|
||||
|
||||
type dbbase struct {
|
||||
sqlx.DB
|
||||
*sqlx.DB
|
||||
}
|
||||
|
||||
func (db *dbbase) withTx(ctx context.Context, fn func(tx *sqlx.Tx) error) error {
|
||||
|
|
|
@ -80,12 +80,12 @@ func OpenMySQLDatabase(ctx context.Context, connString string) (mysqlDB *MySQLDa
|
|||
db.SetMaxOpenConns(100)
|
||||
db.SetConnMaxLifetime(time.Second) // in case mysql client has longer timeout (driver issue #674)
|
||||
|
||||
mysqlDB = &MySQLDatabase{dbbase: dbbase{*db}}
|
||||
mysqlDB = &MySQLDatabase{dbbase: dbbase{db}}
|
||||
return mysqlDB, err
|
||||
}
|
||||
|
||||
// DBX returns the underlying sqlx.DB object
|
||||
func (db *MySQLDatabase) DBx() sqlx.DB {
|
||||
func (db *MySQLDatabase) DBx() *sqlx.DB {
|
||||
return db.DB
|
||||
}
|
||||
|
||||
|
|
|
@ -76,12 +76,12 @@ func OpenPGDatabase(ctx context.Context, connString string) (pgDB *PGDatabase, e
|
|||
db.SetMaxOpenConns(100)
|
||||
db.SetConnMaxLifetime(time.Second)
|
||||
|
||||
pgDB = &PGDatabase{dbbase: dbbase{*db}}
|
||||
pgDB = &PGDatabase{dbbase: dbbase{db}}
|
||||
return pgDB, err
|
||||
}
|
||||
|
||||
// DBX returns the underlying sqlx.DB object
|
||||
func (db *PGDatabase) DBx() sqlx.DB {
|
||||
func (db *PGDatabase) DBx() *sqlx.DB {
|
||||
return db.DB
|
||||
}
|
||||
|
||||
|
|
|
@ -80,7 +80,7 @@ type tagContent struct {
|
|||
}
|
||||
|
||||
// DBX returns the underlying sqlx.DB object
|
||||
func (db *SQLiteDatabase) DBx() sqlx.DB {
|
||||
func (db *SQLiteDatabase) DBx() *sqlx.DB {
|
||||
return db.DB
|
||||
}
|
||||
|
||||
|
|
|
@ -20,6 +20,6 @@ func OpenSQLiteDatabase(ctx context.Context, databasePath string) (sqliteDB *SQL
|
|||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
sqliteDB = &SQLiteDatabase{dbbase: dbbase{*db}}
|
||||
sqliteDB = &SQLiteDatabase{dbbase: dbbase{db}}
|
||||
return sqliteDB, nil
|
||||
}
|
||||
|
|
|
@ -21,6 +21,6 @@ func OpenSQLiteDatabase(ctx context.Context, databasePath string) (sqliteDB *SQL
|
|||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
sqliteDB = &SQLiteDatabase{dbbase: dbbase{*db}}
|
||||
sqliteDB = &SQLiteDatabase{dbbase: dbbase{db}}
|
||||
return sqliteDB, nil
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ func (r *APIRoutes) Setup(g *gin.RouterGroup) model.Routes {
|
|||
r.handle(g, "/auth", NewAuthAPIRoutes(r.logger, r.deps, r.loginHandler))
|
||||
r.handle(g, "/bookmarks", NewBookmarksAPIRoutes(r.logger, r.deps))
|
||||
r.handle(g, "/tags", NewTagsPIRoutes(r.logger, r.deps))
|
||||
r.handle(g, "/system", NewSystemAPIRoutes(r.logger, r.deps))
|
||||
|
||||
return r
|
||||
}
|
||||
|
|
|
@ -99,13 +99,11 @@ func (r *AuthAPIRoutes) loginHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
responseMessage := loginResponseMessage{
|
||||
response.Send(c, http.StatusOK, loginResponseMessage{
|
||||
Token: token,
|
||||
SessionID: sessionID,
|
||||
Expiration: expiration.Unix(),
|
||||
}
|
||||
|
||||
response.Send(c, http.StatusOK, responseMessage)
|
||||
})
|
||||
}
|
||||
|
||||
// refreshHandler godoc
|
||||
|
@ -132,11 +130,9 @@ func (r *AuthAPIRoutes) refreshHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
responseMessage := loginResponseMessage{
|
||||
response.Send(c, http.StatusAccepted, loginResponseMessage{
|
||||
Token: token,
|
||||
}
|
||||
|
||||
response.Send(c, http.StatusAccepted, responseMessage)
|
||||
})
|
||||
}
|
||||
|
||||
// meHandler godoc
|
||||
|
|
|
@ -97,7 +97,7 @@ type readableResponseMessage struct {
|
|||
// @Tags Auth
|
||||
// @securityDefinitions.apikey ApiKeyAuth
|
||||
// @Produce json
|
||||
// @Success 200 {object} readableResponseMessage
|
||||
// @Success 200 {object} readableResponseMessage
|
||||
// @Failure 403 {object} nil "Token not provided/invalid"
|
||||
// @Router /api/v1/bookmarks/id/readable [get]
|
||||
func (r *BookmarksAPIRoutes) bookmarkReadable(c *gin.Context) {
|
||||
|
@ -107,12 +107,11 @@ func (r *BookmarksAPIRoutes) bookmarkReadable(c *gin.Context) {
|
|||
if err != nil {
|
||||
return
|
||||
}
|
||||
responseMessage := readableResponseMessage{
|
||||
|
||||
response.Send(c, 200, readableResponseMessage{
|
||||
Content: bookmark.Content,
|
||||
Html: bookmark.HTML,
|
||||
}
|
||||
|
||||
response.Send(c, 200, responseMessage)
|
||||
})
|
||||
}
|
||||
|
||||
// updateCache godoc
|
||||
|
@ -120,7 +119,7 @@ func (r *BookmarksAPIRoutes) bookmarkReadable(c *gin.Context) {
|
|||
// @Summary Update Cache and Ebook on server.
|
||||
// @Tags Auth
|
||||
// @securityDefinitions.apikey ApiKeyAuth
|
||||
// @Param payload body updateCachePayload true "Update Cache Payload"`
|
||||
// @Param payload body updateCachePayload true "Update Cache Payload"`
|
||||
// @Produce json
|
||||
// @Success 200 {object} model.BookmarkDTO
|
||||
// @Failure 403 {object} nil "Token not provided/invalid"
|
||||
|
|
74
internal/http/routes/api/v1/system.go
Normal file
74
internal/http/routes/api/v1/system.go
Normal file
|
@ -0,0 +1,74 @@
|
|||
package api_v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-shiori/shiori/internal/dependencies"
|
||||
"github.com/go-shiori/shiori/internal/http/context"
|
||||
"github.com/go-shiori/shiori/internal/http/middleware"
|
||||
"github.com/go-shiori/shiori/internal/http/response"
|
||||
"github.com/go-shiori/shiori/internal/model"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type SystemAPIRoutes struct {
|
||||
logger *logrus.Logger
|
||||
deps *dependencies.Dependencies
|
||||
}
|
||||
|
||||
func (r *SystemAPIRoutes) Setup(g *gin.RouterGroup) model.Routes {
|
||||
g.Use(middleware.AuthenticationRequired())
|
||||
g.GET("/info", r.infoHandler)
|
||||
return r
|
||||
}
|
||||
|
||||
type infoResponse struct {
|
||||
Version struct {
|
||||
Tag string `json:"tag"`
|
||||
Commit string `json:"commit"`
|
||||
Date string `json:"date"`
|
||||
} `json:"version"`
|
||||
Database string `json:"database"`
|
||||
OS string `json:"os"`
|
||||
}
|
||||
|
||||
// System info API endpoint godoc
|
||||
//
|
||||
// @Summary Get general system information
|
||||
// @Description Get general system information like Shiori version, database, and OS
|
||||
// @Tags system
|
||||
// @Produce json
|
||||
// @securityDefinitions.apikey ApiKeyAuth
|
||||
// @Success 200 {object} infoResponse
|
||||
// @Failure 403 {object} nil "Only owners can access this endpoint"
|
||||
// @Router /api/v1/system/info [get]
|
||||
func (r *SystemAPIRoutes) infoHandler(c *gin.Context) {
|
||||
ctx := context.NewContextFromGin(c)
|
||||
if !ctx.GetAccount().Owner {
|
||||
response.SendError(c, http.StatusForbidden, "Only owners can access this endpoint")
|
||||
return
|
||||
}
|
||||
|
||||
response.Send(c, 200, infoResponse{
|
||||
Version: struct {
|
||||
Tag string `json:"tag"`
|
||||
Commit string `json:"commit"`
|
||||
Date string `json:"date"`
|
||||
}{
|
||||
Tag: model.BuildVersion,
|
||||
Commit: model.BuildCommit,
|
||||
Date: model.BuildDate,
|
||||
},
|
||||
Database: r.deps.Database.DBx().DriverName(),
|
||||
OS: runtime.GOOS + " (" + runtime.GOARCH + ")",
|
||||
})
|
||||
}
|
||||
|
||||
func NewSystemAPIRoutes(logger *logrus.Logger, deps *dependencies.Dependencies) *SystemAPIRoutes {
|
||||
return &SystemAPIRoutes{
|
||||
logger: logger,
|
||||
deps: deps,
|
||||
}
|
||||
}
|
56
internal/http/routes/api/v1/system_test.go
Normal file
56
internal/http/routes/api/v1/system_test.go
Normal file
|
@ -0,0 +1,56 @@
|
|||
package api_v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/go-shiori/shiori/internal/testutil"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSystemRoute(t *testing.T) {
|
||||
logger := logrus.New()
|
||||
ctx := context.TODO()
|
||||
|
||||
t.Run("valid response", func(t *testing.T) {
|
||||
g := testutil.NewGin()
|
||||
g.Use(testutil.FakeAdminLoggedInMiddlewware)
|
||||
_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)
|
||||
router := NewSystemAPIRoutes(logger, deps)
|
||||
router.Setup(g.Group("/"))
|
||||
w := testutil.PerformRequest(g, http.MethodGet, "/info")
|
||||
response, err := testutil.NewTestResponseFromReader(w.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
response.AssertOk(t)
|
||||
})
|
||||
|
||||
t.Run("requires authentication", func(t *testing.T) {
|
||||
g := testutil.NewGin()
|
||||
_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)
|
||||
router := NewSystemAPIRoutes(logger, deps)
|
||||
router.Setup(g.Group("/"))
|
||||
w := testutil.PerformRequest(g, http.MethodGet, "/info")
|
||||
response, err := testutil.NewTestResponseFromReader(w.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
response.AssertNotOk(t)
|
||||
require.Equal(t, http.StatusUnauthorized, w.Result().StatusCode)
|
||||
})
|
||||
|
||||
t.Run("requires admin", func(t *testing.T) {
|
||||
g := testutil.NewGin()
|
||||
g.Use(testutil.FakeUserLoggedInMiddlewware)
|
||||
_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)
|
||||
router := NewSystemAPIRoutes(logger, deps)
|
||||
router.Setup(g.Group("/"))
|
||||
w := testutil.PerformRequest(g, http.MethodGet, "/info")
|
||||
response, err := testutil.NewTestResponseFromReader(w.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
response.AssertNotOk(t)
|
||||
require.Equal(t, http.StatusForbidden, w.Result().StatusCode)
|
||||
})
|
||||
}
|
|
@ -7,6 +7,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-shiori/shiori/internal/model"
|
||||
)
|
||||
|
||||
// NewGin returns a new gin engine with test mode enabled.
|
||||
|
@ -46,3 +47,33 @@ func PerformRequestWithRecorder(recorder *httptest.ResponseRecorder, r http.Hand
|
|||
r.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
// FakeUserLoggedInMiddlewware is a middleware that sets a fake user account to context.
|
||||
// Keep in mind that this users is not saved in database so any tests that use this middleware
|
||||
// should not rely on database.
|
||||
func FakeUserLoggedInMiddlewware(ctx *gin.Context) {
|
||||
ctx.Set("account", &model.Account{
|
||||
ID: 1,
|
||||
Username: "user",
|
||||
Owner: false,
|
||||
})
|
||||
}
|
||||
|
||||
// FakeAdminLoggedInMiddlewware is a middleware that sets a fake admin account to context.
|
||||
// Keep in mind that this users is not saved in database so any tests that use this middleware
|
||||
// should not rely on database.
|
||||
func FakeAdminLoggedInMiddlewware(ctx *gin.Context) {
|
||||
ctx.Set("account", &model.Account{
|
||||
ID: 1,
|
||||
Username: "admin",
|
||||
Owner: true,
|
||||
})
|
||||
}
|
||||
|
||||
// AuthUserMiddleware is a middleware that manually sets an user as authenticated in the context
|
||||
// to be used in tests.
|
||||
func AuthUserMiddleware(user *model.AccountDTO) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
ctx.Set("account", user)
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -69,6 +69,14 @@ var template = `
|
|||
<a v-if="activeAccount.owner" @click="showDialogNewAccount">Add new account</a>
|
||||
</div>
|
||||
</details>
|
||||
<details v-if="activeAccount.owner" class="setting-group" id="setting-system-info">
|
||||
<summary>System info</summary>
|
||||
<ul>
|
||||
<li><b>Shiori version:</b> <span>{{system.version?.tag}}<span></li>
|
||||
<li><b>Database engine:</b> <span>{{system.database}}</span></li>
|
||||
<li><b>Operating system:</b> <span>{{system.os}}</span></li>
|
||||
</ul>
|
||||
</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"/>
|
||||
|
@ -87,6 +95,7 @@ export default {
|
|||
return {
|
||||
loading: false,
|
||||
accounts: [],
|
||||
system: {},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
|
@ -160,6 +169,28 @@ export default {
|
|||
});
|
||||
});
|
||||
},
|
||||
loadSystemInfo() {
|
||||
if (this.system.version !== undefined) return;
|
||||
|
||||
fetch(new URL("api/v1/system/info", document.baseURI), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + localStorage.getItem("shiori-token"),
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) throw response;
|
||||
return response.json();
|
||||
})
|
||||
.then((json) => {
|
||||
this.system = json.message;
|
||||
})
|
||||
.catch((err) => {
|
||||
this.getErrorMessage(err).then((msg) => {
|
||||
this.showErrorDialog(msg);
|
||||
});
|
||||
});
|
||||
},
|
||||
showDialogNewAccount() {
|
||||
this.showDialog({
|
||||
title: "New Account",
|
||||
|
@ -369,6 +400,9 @@ export default {
|
|||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadAccounts();
|
||||
if (this.activeAccount.owner) {
|
||||
this.loadAccounts();
|
||||
this.loadSystemInfo();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -679,8 +679,20 @@ a {
|
|||
&[open] summary {
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
::after {
|
||||
content: "-";
|
||||
&::after {
|
||||
content: "-" !important;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
padding: 4px 8px;
|
||||
color: var(--color);
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -783,4 +795,17 @@ a {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#setting-system-info {
|
||||
ul {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
|
||||
li {
|
||||
span {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue