Support read-only publish service

* 🎨 kernel supports read-only publishing services

* 🐛 Fix authentication vulnerabilities

* 🎨 Protect secret information

* 🎨 Adjust the permission control

* 🎨 Adjust the permission control

* 🎨 Fixed the vulnerability that `getFile` gets file `conf.json`

* 🎨 Add API `/api/setting/setPublish`

* 🎨 Add API `/api/setting/getPublish`

* 🐛 Fixed the issue that PWA-related files could not pass BasicAuth

* 🎨 Add a settings panel for publishing features

* 📝 Add guide for `Publish Service`

* 📝 Update Japanese user guide

* 🎨 Merge fixed static file services
This commit is contained in:
Yingyi / 颖逸 2024-06-12 21:03:51 +08:00 committed by GitHub
parent 536879cb84
commit ba2193403d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 3690 additions and 375 deletions

View file

@ -18,7 +18,6 @@ package server
import (
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
@ -65,7 +64,7 @@ func killRunningKernel() {
}
func killByPort(port string) {
if !isPortOpen(port) {
if !util.IsPortOpen(port) {
return
}
@ -87,19 +86,6 @@ func killByPort(port string) {
logging.LogInfof("killed process [name=%s, pid=%s]", name, pid)
}
func isPortOpen(port string) bool {
timeout := time.Second
conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", port), timeout)
if nil != err {
return false
}
if nil != conn {
conn.Close()
return true
}
return false
}
func kill(pid string) {
var killCmd *exec.Cmd
if gulu.OS.IsWindows() {

View file

@ -0,0 +1,41 @@
// SiYuan - Refactor your thinking
// Copyright (c) 2020-present, b3log.org
//
// This program 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.
//
// This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
package proxy
import (
"net/http"
"net/http/httputil"
"github.com/siyuan-note/logging"
"github.com/siyuan-note/siyuan/kernel/util"
)
func InitFixedPortService(host string) {
if util.FixedPort != util.ServerPort {
if util.IsPortOpen(util.FixedPort) {
return
}
// 启动一个固定 6806 端口的反向代理服务器,这样浏览器扩展才能直接使用 127.0.0.1:6806不用配置端口
proxy := httputil.NewSingleHostReverseProxy(util.ServerURL)
logging.LogInfof("fixed port service [%s:%s] is running", host, util.FixedPort)
if proxyErr := http.ListenAndServe(host+":"+util.FixedPort, proxy); nil != proxyErr {
logging.LogWarnf("boot fixed port service [%s] failed: %s", util.ServerURL, proxyErr)
}
logging.LogInfof("fixed port service [%s:%s] is stopped", host, util.FixedPort)
}
}

View file

@ -0,0 +1,161 @@
// SiYuan - Refactor your thinking
// Copyright (c) 2020-present, b3log.org
//
// This program 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.
//
// This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
package proxy
import (
"fmt"
"net"
"net/http"
"net/http/httputil"
"strconv"
"github.com/siyuan-note/logging"
"github.com/siyuan-note/siyuan/kernel/model"
"github.com/siyuan-note/siyuan/kernel/util"
)
type PublishServiceTransport struct{}
var (
Host = "0.0.0.0"
Port = "0"
listener net.Listener
transport = PublishServiceTransport{}
proxy = &httputil.ReverseProxy{
Rewrite: rewrite,
Transport: transport,
}
)
func InitPublishService() (uint16, error) {
model.InitAccounts()
if listener != nil {
if !model.Conf.Publish.Enable {
// 关闭发布服务
closePublishListener()
return 0, nil
}
if port, err := util.ParsePort(Port); err != nil {
return 0, err
} else if port != model.Conf.Publish.Port {
// 关闭原端口的发布服务
if err = closePublishListener(); err != nil {
return 0, err
}
// 重新启动新端口的发布服务
initPublishService()
}
} else {
if !model.Conf.Publish.Enable {
return 0, nil
}
// 启动新端口的发布服务
initPublishService()
}
return util.ParsePort(Port)
}
func initPublishService() (err error) {
if err = initPublishListener(); err == nil {
go startPublishReverseProxyService()
}
return
}
func initPublishListener() (err error) {
// Start new listener
listener, err = net.Listen("tcp", fmt.Sprintf("%s:%d", Host, model.Conf.Publish.Port))
if err != nil {
logging.LogErrorf("start listener failed: %s", err)
return
}
_, Port, err = net.SplitHostPort(listener.Addr().String())
if nil != err {
logging.LogErrorf("split host and port failed: %s", err)
return
}
return
}
func closePublishListener() (err error) {
listener_ := listener
listener = nil
if err = listener_.Close(); err != nil {
logging.LogErrorf("close listener %s failed: %s", listener_.Addr().String(), err)
listener = listener_
}
return
}
func startPublishReverseProxyService() {
logging.LogInfof("publish service [%s:%s] is running", Host, Port)
// 服务进行时一直阻塞
if err := http.Serve(listener, proxy); nil != err {
if listener != nil {
logging.LogErrorf("boot publish service failed: %s", err)
}
}
logging.LogInfof("publish service [%s:%s] is stopped", Host, Port)
}
func rewrite(r *httputil.ProxyRequest) {
r.SetURL(util.ServerURL)
r.SetXForwarded()
// r.Out.Host = r.In.Host // if desired
}
func (PublishServiceTransport) RoundTrip(request *http.Request) (response *http.Response, err error) {
if model.Conf.Publish.Auth.Enable {
// Basic Auth
username, password, ok := request.BasicAuth()
account := model.GetBasicAuthAccount(username)
if !ok ||
account == nil ||
account.Username == "" || // 匿名用户
account.Password != password {
return &http.Response{
StatusCode: http.StatusUnauthorized,
Status: http.StatusText(http.StatusUnauthorized),
Proto: request.Proto,
ProtoMajor: request.ProtoMajor,
ProtoMinor: request.ProtoMinor,
Request: request,
Header: http.Header{
"WWW-Authenticate": {"Basic realm=" + strconv.Quote("Authorization Required")},
},
Close: false,
ContentLength: -1,
}, nil
} else {
// set JWT
request.Header.Set(model.XAuthTokenKey, account.Token)
}
} else {
request.Header.Set(model.XAuthTokenKey, model.GetBasicAuthAccount("").Token)
}
response, err = http.DefaultTransport.RoundTrip(request)
return
}

View file

@ -20,9 +20,9 @@ import (
"bytes"
"fmt"
"html/template"
"mime"
"net"
"net/http"
"net/http/httputil"
"net/http/pprof"
"net/url"
"os"
@ -42,10 +42,13 @@ import (
"github.com/siyuan-note/siyuan/kernel/api"
"github.com/siyuan-note/siyuan/kernel/cmd"
"github.com/siyuan-note/siyuan/kernel/model"
"github.com/siyuan-note/siyuan/kernel/server/proxy"
"github.com/siyuan-note/siyuan/kernel/util"
)
var cookieStore = cookie.NewStore([]byte("ATN51UlxVq1Gcvdf"))
var (
cookieStore = cookie.NewStore([]byte("ATN51UlxVq1Gcvdf"))
)
func Serve(fastMode bool) {
gin.SetMode(gin.ReleaseMode)
@ -57,6 +60,7 @@ func Serve(fastMode bool) {
model.Timing,
model.Recover,
corsMiddleware(), // 后端服务支持 CORS 预检请求验证 https://github.com/siyuan-note/siyuan/pull/5593
jwtMiddleware, // 解析 JWT https://github.com/siyuan-note/siyuan/issues/11364
gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedExtensions([]string{".pdf", ".mp3", ".wav", ".ogg", ".mov", ".weba", ".mkv", ".mp4", ".webm"})),
)
@ -78,7 +82,10 @@ func Serve(fastMode bool) {
serveEmojis(ginServer)
serveTemplates(ginServer)
servePublic(ginServer)
serveSnippets(ginServer)
serveRepoDiff(ginServer)
serveCheckAuth(ginServer)
serveFixedStaticFiles(ginServer)
api.ServeAPI(ginServer)
var host string
@ -108,34 +115,27 @@ func Serve(fastMode bool) {
}
util.ServerPort = port
util.ServerURL, err = url.Parse("http://127.0.0.1:" + port)
if err != nil {
logging.LogErrorf("parse server url failed: %s", err)
}
pid := fmt.Sprintf("%d", os.Getpid())
if !fastMode {
rewritePortJSON(pid, port)
}
logging.LogInfof("kernel [pid=%s] http server [%s] is booting", pid, host+":"+port)
util.HttpServing = true
go util.HookUILoaded()
go func() {
time.Sleep(1 * time.Second)
if util.FixedPort != port {
if isPortOpen(util.FixedPort) {
return
}
// 启动一个 6806 端口的反向代理服务器,这样浏览器扩展才能直接使用 127.0.0.1:6806不用配置端口
serverURL, _ := url.Parse("http://127.0.0.1:" + port)
proxy := httputil.NewSingleHostReverseProxy(serverURL)
logging.LogInfof("reverse proxy server [%s] is booting", host+":"+util.FixedPort)
if proxyErr := http.ListenAndServe(host+":"+util.FixedPort, proxy); nil != proxyErr {
logging.LogWarnf("boot reverse proxy server [%s] failed: %s", serverURL, proxyErr)
}
// 反代服务器启动失败不影响核心服务器启动
}
go proxy.InitFixedPortService(host)
go proxy.InitPublishService()
// 反代服务器启动失败不影响核心服务器启动
}()
go util.HookUILoaded()
if err = http.Serve(ln, ginServer.Handler()); nil != err {
if !fastMode {
logging.LogErrorf("boot kernel failed: %s", err)
@ -196,11 +196,33 @@ func servePublic(ginServer *gin.Engine) {
ginServer.Static("/public/", filepath.Join(util.DataDir, "public"))
}
func serveAppearance(ginServer *gin.Engine) {
ginServer.StaticFile("favicon.ico", filepath.Join(util.WorkingDir, "stage", "icon.png"))
ginServer.StaticFile("manifest.json", filepath.Join(util.WorkingDir, "stage", "manifest.webmanifest"))
ginServer.StaticFile("manifest.webmanifest", filepath.Join(util.WorkingDir, "stage", "manifest.webmanifest"))
func serveSnippets(ginServer *gin.Engine) {
ginServer.Handle("GET", "/snippets/*filepath", func(c *gin.Context) {
filePath := strings.TrimPrefix(c.Request.URL.Path, "/snippets/")
ext := filepath.Ext(filePath)
name := strings.TrimSuffix(filePath, ext)
confSnippets, err := model.LoadSnippets()
if nil != err {
logging.LogErrorf("load snippets failed: %s", err)
c.Status(http.StatusNotFound)
return
}
for _, s := range confSnippets {
if s.Name == name && ("" != ext && s.Type == ext[1:]) {
c.Header("Content-Type", mime.TypeByExtension(ext))
c.String(http.StatusOK, s.Content)
return
}
}
// 没有在配置文件中命中时在文件系统上查找
filePath = filepath.Join(util.SnippetsPath, filePath)
c.File(filePath)
})
}
func serveAppearance(ginServer *gin.Engine) {
siyuan := ginServer.Group("", model.CheckAuth)
siyuan.Handle("GET", "/", func(c *gin.Context) {
@ -295,12 +317,13 @@ func serveAppearance(ginServer *gin.Engine) {
})
siyuan.Static("/stage/", filepath.Join(util.WorkingDir, "stage"))
ginServer.StaticFile("service-worker.js", filepath.Join(util.WorkingDir, "stage", "service-worker.js"))
siyuan.GET("/check-auth", serveCheckAuth)
}
func serveCheckAuth(c *gin.Context) {
func serveCheckAuth(ginServer *gin.Engine) {
ginServer.GET("/check-auth", serveAuthPage)
}
func serveAuthPage(c *gin.Context) {
data, err := os.ReadFile(filepath.Join(util.WorkingDir, "stage/auth.html"))
if nil != err {
logging.LogErrorf("load auth page failed: %s", err)
@ -363,20 +386,20 @@ func serveCheckAuth(c *gin.Context) {
}
func serveAssets(ginServer *gin.Engine) {
ginServer.POST("/upload", model.CheckAuth, model.Upload)
ginServer.POST("/upload", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, model.Upload)
ginServer.GET("/assets/*path", model.CheckAuth, func(context *gin.Context) {
requestPath := context.Param("path")
relativePath := path.Join("assets", requestPath)
p, err := model.GetAssetAbsPath(relativePath)
if nil != err {
context.Status(404)
context.Status(http.StatusNotFound)
return
}
http.ServeFile(context.Writer, context.Request, p)
return
})
ginServer.GET("/history/*path", model.CheckAuth, func(context *gin.Context) {
ginServer.GET("/history/*path", model.CheckAuth, model.CheckAdminRole, func(context *gin.Context) {
p := filepath.Join(util.HistoryDir, context.Param("path"))
http.ServeFile(context.Writer, context.Request, p)
return
@ -384,7 +407,7 @@ func serveAssets(ginServer *gin.Engine) {
}
func serveRepoDiff(ginServer *gin.Engine) {
ginServer.GET("/repo/diff/*path", model.CheckAuth, func(context *gin.Context) {
ginServer.GET("/repo/diff/*path", model.CheckAuth, model.CheckAdminRole, func(context *gin.Context) {
requestPath := context.Param("path")
p := filepath.Join(util.TempDir, "repo", "diff", requestPath)
http.ServeFile(context.Writer, context.Request, p)
@ -451,6 +474,17 @@ func serveWebSocket(ginServer *gin.Engine) {
}
}
// REF: https://github.com/siyuan-note/siyuan/issues/11364
if !authOk {
if token := model.ParseXAuthToken(s.Request); token != nil {
authOk = token.Valid && model.IsValidRole(model.GetClaimRole(model.GetTokenClaims(token)), []model.Role{
model.RoleAdministrator,
model.RoleEditor,
model.RoleReader,
})
}
}
if !authOk {
// 用于授权页保持连接,避免非常驻内存内核自动退出 https://github.com/siyuan-note/insider/issues/1099
authOk = strings.Contains(s.Request.RequestURI, "/ws?app=siyuan&id=auth")
@ -516,12 +550,24 @@ func serveWebSocket(ginServer *gin.Engine) {
s.Write(result.Bytes())
return
}
if util.ReadOnly && !command.IsRead() {
result := util.NewResult()
result.Code = -1
result.Msg = model.Conf.Language(34)
s.Write(result.Bytes())
return
if !command.IsRead() {
readonly := util.ReadOnly
if !readonly {
if token := model.ParseXAuthToken(s.Request); token != nil {
readonly = token.Valid && model.IsValidRole(model.GetClaimRole(model.GetTokenClaims(token)), []model.Role{
model.RoleReader,
model.RoleVisitor,
})
}
}
if readonly {
result := util.NewResult()
result.Code = -1
result.Msg = model.Conf.Language(34)
s.Write(result.Bytes())
return
}
}
end := time.Now()
@ -564,3 +610,30 @@ func corsMiddleware() gin.HandlerFunc {
c.Next()
}
}
// jwtMiddleware is a middleware to check jwt token
// REF: https://github.com/siyuan-note/siyuan/issues/11364
func jwtMiddleware(c *gin.Context) {
if token := model.ParseXAuthToken(c.Request); token != nil {
// c.Request.Header.Del(model.XAuthTokenKey)
if token.Valid {
claims := model.GetTokenClaims(token)
c.Set(model.ClaimsContextKey, claims)
c.Set(model.RoleContextKey, model.GetClaimRole(claims))
c.Next()
return
}
}
c.Set(model.RoleContextKey, model.RoleVisitor)
c.Next()
return
}
func serveFixedStaticFiles(ginServer *gin.Engine) {
ginServer.StaticFile("favicon.ico", filepath.Join(util.WorkingDir, "stage", "icon.png"))
ginServer.StaticFile("manifest.json", filepath.Join(util.WorkingDir, "stage", "manifest.webmanifest"))
ginServer.StaticFile("manifest.webmanifest", filepath.Join(util.WorkingDir, "stage", "manifest.webmanifest"))
ginServer.StaticFile("service-worker.js", filepath.Join(util.WorkingDir, "stage", "service-worker.js"))
}