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:
Felipe Martin 2024-06-08 17:48:55 +02:00 committed by GitHub
parent 650f192176
commit 72aecd2b60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 371 additions and 27 deletions

View file

@ -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": [

View file

@ -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": [

View file

@ -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:

View file

@ -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 {

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -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"

View 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,
}
}

View 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)
})
}

View file

@ -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

View file

@ -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();
}
},
};

View file

@ -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;
}
}
}
}
}