package data
import (
"bufio"
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"github.com/leonelquinteros/gotext"
"github.com/samber/lo"
"github.com/spf13/cast"
"gorm.io/gorm"
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/biz"
"github.com/acepanel/panel/internal/http/request"
"github.com/acepanel/panel/pkg/acme"
"github.com/acepanel/panel/pkg/cert"
"github.com/acepanel/panel/pkg/embed"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/punycode"
"github.com/acepanel/panel/pkg/shell"
"github.com/acepanel/panel/pkg/systemctl"
"github.com/acepanel/panel/pkg/types"
"github.com/acepanel/panel/pkg/webserver"
webservertypes "github.com/acepanel/panel/pkg/webserver/types"
)
type websiteRepo struct {
t *gotext.Locale
db *gorm.DB
log *slog.Logger
cache biz.CacheRepo
database biz.DatabaseRepo
databaseServer biz.DatabaseServerRepo
databaseUser biz.DatabaseUserRepo
cert biz.CertRepo
certAccount biz.CertAccountRepo
setting biz.SettingRepo
}
func NewWebsiteRepo(t *gotext.Locale, db *gorm.DB, log *slog.Logger, cache biz.CacheRepo, database biz.DatabaseRepo, databaseServer biz.DatabaseServerRepo, databaseUser biz.DatabaseUserRepo, cert biz.CertRepo, certAccount biz.CertAccountRepo, setting biz.SettingRepo) biz.WebsiteRepo {
return &websiteRepo{
t: t,
db: db,
log: log,
cache: cache,
database: database,
databaseServer: databaseServer,
databaseUser: databaseUser,
cert: cert,
certAccount: certAccount,
setting: setting,
}
}
func (r *websiteRepo) GetRewrites() (map[string]string, error) {
webServer, err := r.setting.Get(biz.SettingKeyWebserver)
if err != nil {
return make(map[string]string), nil
}
entries, err := embed.RewritesFS.ReadDir(filepath.Join("rewrites", webServer))
if err != nil {
return make(map[string]string), nil
}
rw := make(map[string]string)
for _, entry := range entries {
if entry.IsDir() {
continue
}
if content, err := embed.RewritesFS.ReadFile(filepath.Join("rewrites", webServer, entry.Name())); err == nil {
rw[strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))] = string(content)
}
}
return rw, nil
}
func (r *websiteRepo) UpdateDefaultConfig(req *request.WebsiteDefaultConfig) error {
webServer, err := r.setting.Get(biz.SettingKeyWebserver)
if err != nil {
return err
}
var htmlPath string
switch webServer {
case "nginx":
htmlPath = filepath.Join(app.Root, "server/nginx/html")
case "apache":
htmlPath = filepath.Join(app.Root, "server/apache/htdocs")
default:
htmlPath = filepath.Join(app.Root, "server/nginx/html")
}
if err = io.Write(filepath.Join(htmlPath, "index.html"), req.Index, 0644); err != nil {
return err
}
if err = io.Write(filepath.Join(htmlPath, "stop.html"), req.Stop, 0644); err != nil {
return err
}
if req.NotFound != "" {
if err = io.Write(filepath.Join(htmlPath, "404.html"), req.NotFound, 0644); err != nil {
return err
}
}
if err = r.setting.SetSlice(biz.SettingKeyWebsiteTLSVersions, req.TLSVersions); err != nil {
return err
}
if err = r.setting.Set(biz.SettingKeyWebsiteCipherSuites, req.CipherSuites); err != nil {
return err
}
return r.reloadWebServer()
}
func (r *websiteRepo) Count() (int64, error) {
var count int64
if err := r.db.Model(&biz.Website{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *websiteRepo) Get(id uint) (*types.WebsiteSetting, error) {
website := new(biz.Website)
if err := r.db.Where("id", id).First(website).Error; err != nil {
return nil, err
}
vhost, err := r.getVhost(website)
if err != nil {
return nil, err
}
setting := new(types.WebsiteSetting)
setting.ID = website.ID
setting.Name = website.Name
setting.Type = string(website.Type)
setting.Path = website.Path
setting.SSL = website.SSL
// 监听地址
setting.Listens = vhost.Listen()
// 域名
domains := vhost.ServerName()
domains, err = punycode.DecodeDomains(domains)
if err != nil {
return nil, err
}
setting.Domains = domains
// 运行目录
setting.Root = vhost.Root()
// 默认文档
setting.Index = vhost.Index()
// 防跨站
if website.Type == biz.WebsiteTypePHP && io.Exists(filepath.Join(setting.Root, ".user.ini")) {
userIni, _ := io.Read(filepath.Join(setting.Root, ".user.ini"))
if strings.Contains(userIni, "open_basedir") {
setting.OpenBasedir = true
}
}
// SSL
if setting.SSL {
sslConfig := vhost.SSLConfig()
setting.HTTPRedirect = sslConfig.HTTPRedirect
setting.HSTS = sslConfig.HSTS
setting.OCSP = sslConfig.OCSP
setting.SSLProtocols = sslConfig.Protocols
setting.SSLCiphers = sslConfig.Ciphers
}
// 证书
crt, _ := os.ReadFile(filepath.Join(app.Root, "sites", website.Name, "config", "fullchain.pem"))
setting.SSLCert = string(crt)
key, _ := os.ReadFile(filepath.Join(app.Root, "sites", website.Name, "config", "private.key"))
setting.SSLKey = string(key)
// 解析证书信息
if decode, err := cert.ParseCert(crt); err == nil {
setting.SSLNotBefore = decode.NotBefore.Format(time.DateTime)
setting.SSLNotAfter = decode.NotAfter.Format(time.DateTime)
setting.SSLIssuer = decode.Issuer.CommonName
setting.SSLOCSPServer = decode.OCSPServer
// 合并 DNSNames 和 IPAddresses
setting.SSLDNSNames = decode.DNSNames
for _, ip := range decode.IPAddresses {
setting.SSLDNSNames = append(setting.SSLDNSNames, ip.String())
}
}
// 访问日志
if setting.AccessLog = vhost.AccessLog(); setting.AccessLog == "" {
setting.AccessLog = fmt.Sprintf("%s/sites/%s/log/access.log", app.Root, website.Name)
}
// 错误日志
if setting.ErrorLog = vhost.ErrorLog(); setting.ErrorLog == "" {
setting.ErrorLog = fmt.Sprintf("%s/sites/%s/log/error.log", app.Root, website.Name)
}
// PHP 网站特有
if phpVhost, ok := vhost.(webservertypes.PHPVhost); ok {
setting.PHP = phpVhost.PHP()
// 伪静态
setting.Rewrite = phpVhost.Config("010-rewrite.conf", "site")
}
// 反向代理网站特有
if proxyVhost, ok := vhost.(webservertypes.ProxyVhost); ok {
setting.Upstreams = proxyVhost.Upstreams()
setting.Proxies = proxyVhost.Proxies()
}
// 重定向配置
if redirectVhost, ok := vhost.(webservertypes.VhostRedirect); ok {
setting.Redirects = redirectVhost.Redirects()
}
// 高级设置(限流限速、真实 IP、基本认证)
setting.RateLimit = vhost.RateLimit()
setting.RealIP = vhost.RealIP()
// 读取基本认证用户列表
setting.BasicAuth = r.readBasicAuthUsers(website.Name)
// 自定义配置
configDir := filepath.Join(app.Root, "sites", website.Name, "config")
setting.CustomConfigs = r.getCustomConfigs(configDir)
return setting, err
}
func (r *websiteRepo) GetByName(name string) (*types.WebsiteSetting, error) {
website := new(biz.Website)
if err := r.db.Where("name", name).First(website).Error; err != nil {
return nil, err
}
return r.Get(website.ID)
}
func (r *websiteRepo) List(typ string, page, limit uint) ([]*biz.Website, int64, error) {
websites := make([]*biz.Website, 0)
var total int64
if err := r.db.Model(&biz.Website{}).Count(&total).Error; err != nil {
return nil, 0, err
}
query := r.db
if typ != "all" {
query = query.Where("type = ?", typ)
}
if err := query.Order("id DESC").Offset(int((page - 1) * limit)).Limit(int(limit)).Find(&websites).Error; err != nil {
return nil, 0, err
}
// 取证书剩余有效时间和PHP版本
for _, website := range websites {
crt, _ := os.ReadFile(filepath.Join(app.Root, "sites", website.Name, "config", "fullchain.pem"))
if decode, err := cert.ParseCert(crt); err == nil {
hours := time.Until(decode.NotAfter).Hours()
website.CertExpire = fmt.Sprintf("%.2f", hours/24)
}
if website.Type == biz.WebsiteTypePHP {
website.PHP = r.getPHPVersion(website.Name)
}
// 获取域名
if vhost, err := r.getVhost(website); err == nil {
if domains, err := punycode.DecodeDomains(vhost.ServerName()); err == nil {
website.Domains = domains
}
}
}
return websites, total, nil
}
func (r *websiteRepo) Create(ctx context.Context, req *request.WebsiteCreate) (*biz.Website, error) {
w := &biz.Website{
Name: req.Name,
Type: biz.WebsiteType(req.Type),
Status: true,
Path: req.Path,
SSL: false,
Remark: req.Remark,
}
webServer, err := r.setting.Get(biz.SettingKeyWebserver)
if err != nil {
return nil, err
}
vhost, err := r.getVhost(w)
if err != nil {
return nil, err
}
// 创建配置文件目录
if err = os.MkdirAll(filepath.Join(app.Root, "sites", req.Name, "config", "site"), 0600); err != nil {
return nil, err
}
if err = os.MkdirAll(filepath.Join(app.Root, "sites", req.Name, "config", "shared"), 0600); err != nil {
return nil, err
}
// 创建日志目录
if err = os.MkdirAll(filepath.Join(app.Root, "sites", req.Name, "log"), 0755); err != nil {
return nil, err
}
// 监听地址
var listens []webservertypes.Listen
for _, listen := range req.Listens {
listens = append(listens, webservertypes.Listen{Address: listen})
}
if err = vhost.SetListen(listens); err != nil {
return nil, err
}
// 域名
domains, err := punycode.EncodeDomains(req.Domains)
if err != nil {
return nil, err
}
if err = vhost.SetServerName(domains); err != nil {
return nil, err
}
// 运行目录
if err = vhost.SetRoot(req.Path); err != nil {
return nil, err
}
// 日志
if err = vhost.SetAccessLog(filepath.Join(app.Root, "sites", req.Name, "log", "access.log")); err != nil {
return nil, err
}
if err = vhost.SetErrorLog(filepath.Join(app.Root, "sites", req.Name, "log", "error.log")); err != nil {
return nil, err
}
// 404 页面
var errorPageConfig string
switch webServer {
case "nginx":
errorPageConfig = `error_page 404 /404.html;`
case "apache":
errorPageConfig = `ErrorDocument 404 /404.html`
}
if err = vhost.SetConfig("010-error-404.conf", "site", errorPageConfig); err != nil {
return nil, err
}
// 反向代理支持
if proxyVhost, ok := vhost.(webservertypes.ProxyVhost); ok {
if err = proxyVhost.SetProxies([]webservertypes.Proxy{
{
Location: "^~ /",
Pass: req.Proxy,
},
}); err != nil {
return nil, err
}
}
// PHP 支持
if phpVhost, ok := vhost.(webservertypes.PHPVhost); ok {
if err = phpVhost.SetPHP(req.PHP); err != nil {
return nil, err
}
if err = phpVhost.SetIndex([]string{"index.php", "index.html"}); err != nil {
return nil, err
}
if err = phpVhost.SetConfig("010-rewrite.conf", "site", ""); err != nil {
return nil, err
}
var cacheConfig string
switch webServer {
case "nginx":
cacheConfig = `# browser cache
location ~ .*\.(bmp|jpg|jpeg|png|gif|svg|ico|tiff|webp|avif|heif|heic|jxl)$ {
expires 30d;
access_log /dev/null;
error_log /dev/null;
}
location ~ .*\.(js|css|ttf|otf|woff|woff2|eot)$ {
expires 6h;
access_log /dev/null;
error_log /dev/null;
}
# deny sensitive files
location ~ ^/(\.user.ini|\.htaccess|\.git|\.svn|\.env) {
return 404;
}
`
case "apache":
cacheConfig = `# browser cache
ExpiresActive On
ExpiresByType image/bmp "access plus 30 days"
ExpiresByType image/jpeg "access plus 30 days"
ExpiresByType image/png "access plus 30 days"
ExpiresByType image/gif "access plus 30 days"
ExpiresByType image/svg+xml "access plus 30 days"
ExpiresByType image/x-icon "access plus 30 days"
ExpiresByType image/tiff "access plus 30 days"
ExpiresByType image/webp "access plus 30 days"
ExpiresByType image/avif "access plus 30 days"
ExpiresByType image/heif "access plus 30 days"
ExpiresByType image/heic "access plus 30 days"
ExpiresByType image/jxl "access plus 30 days"
ExpiresByType text/css "access plus 6 hours"
ExpiresByType application/javascript "access plus 6 hours"
ExpiresByType font/ttf "access plus 6 hours"
ExpiresByType font/otf "access plus 6 hours"
ExpiresByType font/woff "access plus 6 hours"
ExpiresByType font/woff2 "access plus 6 hours"
ExpiresByType application/vnd.ms-fontobject "access plus 6 hours"
# deny sensitive files
Require all denied
`
}
if err = phpVhost.SetConfig("010-cache.conf", "site", cacheConfig); err != nil {
return nil, err
}
}
// 初始化网站目录
if err = os.MkdirAll(req.Path, 0755); err != nil {
return nil, err
}
var index []byte
switch app.Locale {
case "zh_CN":
index, err = embed.WebsiteFS.ReadFile(filepath.Join("website", "index_zh_CN.html"))
case "zh_TW":
index, err = embed.WebsiteFS.ReadFile(filepath.Join("website", "index_zh_TW.html"))
default:
index, err = embed.WebsiteFS.ReadFile(filepath.Join("website", "index.html"))
}
if err != nil {
return nil, errors.New(r.t.Get("failed to get index template file: %v", err))
}
if err = io.Write(filepath.Join(req.Path, "index.html"), string(index), 0644); err != nil {
return nil, err
}
var notFound []byte
// 如果存在自定义 404 页面,则使用自定义的
var custom404Path string
switch webServer {
case "nginx":
custom404Path = filepath.Join(app.Root, "server/nginx/html/404.html")
case "apache":
custom404Path = filepath.Join(app.Root, "server/apache/htdocs/404.html")
}
if io.Exists(custom404Path) {
notFound, _ = os.ReadFile(custom404Path)
} else {
switch app.Locale {
case "zh_CN":
notFound, _ = embed.WebsiteFS.ReadFile(filepath.Join("website", "404_zh_CN.html"))
case "zh_TW":
notFound, _ = embed.WebsiteFS.ReadFile(filepath.Join("website", "404_zh_TW.html"))
default:
notFound, _ = embed.WebsiteFS.ReadFile(filepath.Join("website", "404.html"))
}
}
if err = io.Write(filepath.Join(req.Path, "404.html"), string(notFound), 0644); err != nil {
return nil, err
}
// 写配置
if err = vhost.SetConfig("001-acme.conf", "site", ""); err != nil {
return nil, err
}
if err = vhost.Save(); err != nil {
return nil, err
}
if err = io.Write(filepath.Join(app.Root, "sites", req.Name, "config", "fullchain.pem"), "", 0600); err != nil {
return nil, err
}
if err = io.Write(filepath.Join(app.Root, "sites", req.Name, "config", "private.key"), "", 0600); err != nil {
return nil, err
}
// 设置目录权限
// sites/site_name 0755 root
// sites/site_name/config 0600 root
// sites/site_name/log 0701 root
// sites/site_name/public 0755 www
if err = io.Chmod(filepath.Join(app.Root, "sites", req.Name), 0755); err != nil {
return nil, err
}
if err = io.Chmod(req.Path, 0755); err != nil {
return nil, err
}
if err = io.Chown(req.Path, "www", "www"); err != nil {
return nil, err
}
if err = io.Chmod(filepath.Join(app.Root, "sites", req.Name, "log"), 0701); err != nil {
return nil, err
}
if err = io.Chmod(filepath.Join(app.Root, "sites", req.Name, "config"), 0600); err != nil {
return nil, err
}
// PHP 网站默认开启防跨站
if req.Type == "php" {
userIni := filepath.Join(req.Path, ".user.ini")
if !io.Exists(userIni) {
if err = io.Write(userIni, fmt.Sprintf("open_basedir=%s:/tmp/", req.Path), 0644); err != nil {
return nil, err
}
}
_, _ = shell.Execf(`chattr +i '%s'`, userIni)
}
// 创建面板网站
if err = r.db.Create(w).Error; err != nil {
return nil, err
}
// 记录日志
r.log.Info("website created", slog.String("type", biz.OperationTypeWebsite), slog.Uint64("operator_id", getOperatorID(ctx)), slog.String("name", req.Name), slog.String("website_type", req.Type), slog.String("path", req.Path))
// 重载 Web 服务器
if err = r.reloadWebServer(); err != nil {
return nil, err
}
// 创建数据库
name := "local_" + req.DBType
if req.DB {
server, err := r.databaseServer.GetByName(name)
if err != nil {
return nil, errors.New(r.t.Get("can't find %s database server, please add it first", name))
}
if err = r.database.Create(ctx, &request.DatabaseCreate{
ServerID: server.ID,
Name: req.DBName,
CreateUser: true,
Username: req.DBUser,
Password: req.DBPassword,
Host: "localhost",
Comment: fmt.Sprintf("website %s", req.Name),
}); err != nil {
return nil, err
}
}
return w, nil
}
func (r *websiteRepo) Update(ctx context.Context, req *request.WebsiteUpdate) error {
website := new(biz.Website)
if err := r.db.Where("id", req.ID).First(website).Error; err != nil {
return err
}
vhost, err := r.getVhost(website)
if err != nil {
return err
}
// 监听地址
if err = vhost.SetListen(req.Listens); err != nil {
return err
}
// 域名
domains, err := punycode.EncodeDomains(req.Domains)
if err != nil {
return err
}
if err = vhost.SetServerName(domains); err != nil {
return err
}
// 首页文件
if err = vhost.SetIndex(req.Index); err != nil {
return err
}
// 运行目录
if !io.Exists(req.Root) {
return errors.New(r.t.Get("runtime directory does not exist"))
}
if err = vhost.SetRoot(req.Root); err != nil {
return err
}
// 运行目录
if !io.Exists(req.Path) {
return errors.New(r.t.Get("website directory does not exist"))
}
website.Path = req.Path
// SSL
certPath := filepath.Join(app.Root, "sites", website.Name, "config", "fullchain.pem")
keyPath := filepath.Join(app.Root, "sites", website.Name, "config", "private.key")
if err = io.Write(certPath, req.SSLCert, 0600); err != nil {
return err
}
if err = io.Write(keyPath, req.SSLKey, 0600); err != nil {
return err
}
website.SSL = req.SSL
if req.SSL {
if _, err = cert.ParseCert([]byte(req.SSLCert)); err != nil {
return errors.New(r.t.Get("failed to parse certificate: %v", err))
}
if _, err = cert.ParseKey([]byte(req.SSLKey)); err != nil {
return errors.New(r.t.Get("failed to parse private key: %v", err))
}
quic := false
for _, listen := range req.Listens {
if slices.Contains(listen.Args, "quic") {
quic = true
break
}
}
defaultTLSVersions, _ := r.setting.GetSlice(biz.SettingKeyWebsiteTLSVersions)
defaultCipherSuites, _ := r.setting.Get(biz.SettingKeyWebsiteCipherSuites)
if err = vhost.SetSSLConfig(&webservertypes.SSLConfig{
Cert: certPath,
Key: keyPath,
Protocols: lo.If(len(req.SSLProtocols) > 0, req.SSLProtocols).Else(defaultTLSVersions),
Ciphers: lo.If(req.SSLCiphers != "", req.SSLCiphers).Else(defaultCipherSuites),
HSTS: req.HSTS,
OCSP: req.OCSP,
HTTPRedirect: req.HTTPRedirect,
AltSvc: lo.If(quic, `'h3=":$server_port"; ma=2592000'`).Else(``),
}); err != nil {
return err
}
} else {
if err = vhost.ClearSSL(); err != nil {
return err
}
}
// PHP
if phpVhost, ok := vhost.(webservertypes.PHPVhost); ok {
if err = phpVhost.SetPHP(req.PHP); err != nil {
return err
}
// 伪静态
if err = phpVhost.SetConfig("010-rewrite.conf", "site", req.Rewrite); err != nil {
return err
}
// 防跨站
if !strings.HasSuffix(req.Root, "/") {
req.Root += "/"
}
userIni := filepath.Join(req.Root, ".user.ini")
if req.OpenBasedir {
if !io.Exists(userIni) || req.Root != website.Path {
// 之前没有开启,或者修改了运行目录,重新写入
if err = io.Write(userIni, fmt.Sprintf("open_basedir=%s:%s:/tmp/", req.Root, req.Path), 0644); err != nil {
return err
}
}
_, _ = shell.Execf(`chattr +i '%s'`, userIni)
} else {
if io.Exists(userIni) {
if err = io.Remove(userIni); err != nil {
return err
}
}
}
}
// 反向代理
if proxyVhost, ok := vhost.(webservertypes.ProxyVhost); ok {
if err = proxyVhost.SetUpstreams(req.Upstreams); err != nil {
return err
}
if err = proxyVhost.SetProxies(req.Proxies); err != nil {
return err
}
}
// 重定向配置
if redirectVhost, ok := vhost.(webservertypes.VhostRedirect); ok {
if err = redirectVhost.SetRedirects(req.Redirects); err != nil {
return err
}
}
// 高级设置(日志路径、限流限速、真实 IP、基本认证)
// 日志路径
if req.AccessLog != "" {
if err = vhost.SetAccessLog(req.AccessLog); err != nil {
return err
}
}
if req.ErrorLog != "" {
if err = vhost.SetErrorLog(req.ErrorLog); err != nil {
return err
}
}
// 限流限速
if req.RateLimit != nil {
if err = vhost.SetRateLimit(req.RateLimit); err != nil {
return err
}
} else {
if err = vhost.ClearRateLimit(); err != nil {
return err
}
}
// 真实 IP 配置
if req.RealIP != nil {
if err = vhost.SetRealIP(req.RealIP); err != nil {
return err
}
} else {
if err = vhost.ClearRealIP(); err != nil {
return err
}
}
// 基本认证创建 htpasswd 文件
if len(req.BasicAuth) > 0 {
htpasswdPath := filepath.Join(app.Root, "sites", website.Name, "htpasswd")
if err = r.writeBasicAuthUsers(htpasswdPath, req.BasicAuth); err != nil {
return err
}
if err = vhost.SetBasicAuth(map[string]string{"user_file": htpasswdPath}); err != nil {
return err
}
} else {
// 清除基本认证配置和 htpasswd 文件
htpasswdPath := filepath.Join(app.Root, "sites", website.Name, "htpasswd")
_ = io.Remove(htpasswdPath)
if err = vhost.ClearBasicAuth(); err != nil {
return err
}
}
// 自定义配置
configDir := filepath.Join(app.Root, "sites", website.Name, "config")
if err = r.saveCustomConfigs(configDir, req.CustomConfigs); err != nil {
return err
}
// 保存配置
if err = vhost.Save(); err != nil {
return err
}
if err = r.db.Save(website).Error; err != nil {
return err
}
// 记录日志
r.log.Info("website updated", slog.String("type", biz.OperationTypeWebsite), slog.Uint64("operator_id", getOperatorID(ctx)), slog.Uint64("id", uint64(req.ID)), slog.String("name", website.Name))
return r.reloadWebServer()
}
func (r *websiteRepo) Delete(ctx context.Context, req *request.WebsiteDelete) error {
website := new(biz.Website)
if err := r.db.Preload("Cert").Where("id", req.ID).First(website).Error; err != nil {
return err
}
if website.Cert != nil {
return errors.New(r.t.Get("website %s has bound certificates, please delete the certificate first", website.Name))
}
if req.Path {
_ = io.Remove(filepath.Join(app.Root, "sites", website.Name))
} else {
// 仅删除配置和日志
_ = io.Remove(filepath.Join(app.Root, "sites", website.Name, "config"))
_ = io.Remove(filepath.Join(app.Root, "sites", website.Name, "log"))
_ = io.Remove(filepath.Join(app.Root, "sites", website.Name, "htpasswd"))
}
if req.DB {
if mysql, err := r.databaseServer.GetByName("local_mysql"); err == nil {
_ = r.databaseUser.DeleteByNames(mysql.ID, []string{website.Name})
_ = r.database.Delete(ctx, mysql.ID, website.Name)
}
if postgres, err := r.databaseServer.GetByName("local_postgresql"); err == nil {
_ = r.databaseUser.DeleteByNames(postgres.ID, []string{website.Name})
_ = r.database.Delete(ctx, postgres.ID, website.Name)
}
}
if err := r.db.Delete(website).Error; err != nil {
return err
}
// 记录日志
r.log.Info("website deleted", slog.String("type", biz.OperationTypeWebsite), slog.Uint64("operator_id", getOperatorID(ctx)), slog.Uint64("id", uint64(req.ID)), slog.String("name", website.Name))
return r.reloadWebServer()
}
func (r *websiteRepo) ClearLog(id uint) error {
website := new(biz.Website)
if err := r.db.Where("id", id).First(website).Error; err != nil {
return err
}
_, err := shell.Execf(`cat /dev/null > %s/sites/%s/log/access.log`, app.Root, website.Name)
return err
}
func (r *websiteRepo) UpdateRemark(id uint, remark string) error {
website := new(biz.Website)
if err := r.db.Where("id", id).First(website).Error; err != nil {
return err
}
website.Remark = remark
return r.db.Save(website).Error
}
func (r *websiteRepo) ResetConfig(id uint) error {
website := new(biz.Website)
if err := r.db.Where("id", id).First(&website).Error; err != nil {
return err
}
// 清空配置
_, err := shell.Execf(`rm -rf '%s'`, fmt.Sprintf("%s/sites/%s/config/*", app.Root, website.Name))
if err != nil {
return err
}
// 初始化配置
vhost, err := r.getVhost(website)
if err != nil {
return err
}
// 重置配置
if err = vhost.Reset(); err != nil {
return err
}
// 运行目录
if err = vhost.SetRoot(website.Path); err != nil {
return err
}
// 日志
if err = vhost.SetAccessLog(filepath.Join(app.Root, "sites", website.Name, "log", "access.log")); err != nil {
return err
}
if err = vhost.SetErrorLog(filepath.Join(app.Root, "sites", website.Name, "log", "error.log")); err != nil {
return err
}
// 保存配置
if err = vhost.SetConfig("001-acme.conf", "site", ""); err != nil {
return err
}
if err = vhost.Save(); err != nil {
return err
}
if err = io.Write(filepath.Join(app.Root, "sites", website.Name, "config", "fullchain.pem"), "", 0600); err != nil {
return err
}
if err = io.Write(filepath.Join(app.Root, "sites", website.Name, "config", "private.key"), "", 0600); err != nil {
return err
}
// PHP 网站默认伪静态
if website.Type == biz.WebsiteTypePHP {
if err = io.Write(filepath.Join(app.Root, "sites", website.Name, "config", "site", "010-rewrite.conf"), "", 0600); err != nil {
return err
}
}
// 设置目录权限
if err = io.Chown(website.Path, "root", "root"); err != nil {
return err
}
if err = io.Chmod(filepath.Join(app.Root, "sites", website.Name), 0755); err != nil {
return err
}
if err = io.Chmod(website.Path, 0755); err != nil {
return err
}
if err = io.Chown(website.Path, "www", "www"); err != nil {
return err
}
if err = io.Chmod(filepath.Join(app.Root, "sites", website.Name, "log"), 0701); err != nil {
return err
}
if err = io.Chmod(filepath.Join(app.Root, "sites", website.Name, "config"), 0600); err != nil {
return err
}
website.Status = true
website.SSL = false
if err = r.db.Save(website).Error; err != nil {
return err
}
return r.reloadWebServer()
}
func (r *websiteRepo) UpdateStatus(id uint, status bool) error {
website := new(biz.Website)
if err := r.db.Where("id", id).First(&website).Error; err != nil {
return err
}
vhost, err := r.getVhost(website)
if err != nil {
return err
}
if err = vhost.SetEnable(status); err != nil {
return err
}
if err = vhost.Save(); err != nil {
return err
}
website.Status = status
if err = r.db.Save(website).Error; err != nil {
return err
}
return r.reloadWebServer()
}
func (r *websiteRepo) UpdateCert(req *request.WebsiteUpdateCert) error {
website := new(biz.Website)
if err := r.db.Where("name", req.Name).First(&website).Error; err != nil {
return err
}
if _, err := cert.ParseCert([]byte(req.Cert)); err != nil {
return errors.New(r.t.Get("failed to parse certificate: %v", err))
}
if _, err := cert.ParseKey([]byte(req.Key)); err != nil {
return errors.New(r.t.Get("failed to parse private key: %v", err))
}
certPath := filepath.Join(app.Root, "sites", website.Name, "config", "fullchain.pem")
keyPath := filepath.Join(app.Root, "sites", website.Name, "config", "private.key")
if err := io.Write(certPath, req.Cert, 0600); err != nil {
return err
}
if err := io.Write(keyPath, req.Key, 0600); err != nil {
return err
}
if website.SSL {
return r.reloadWebServer()
}
return nil
}
func (r *websiteRepo) ObtainCert(ctx context.Context, id uint) error {
website, err := r.Get(id)
if err != nil {
return err
}
if slices.Contains(website.Domains, "*") {
return errors.New(r.t.Get("not support one-key obtain wildcard certificate, please use Cert menu to obtain it with DNS method"))
}
account, err := r.certAccount.GetDefault(cast.ToUint(ctx.Value("user_id")))
if err != nil {
return err
}
newCert, err := r.cert.GetByWebsite(website.ID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
newCert, err = r.cert.Create(ctx, &request.CertCreate{
Type: string(acme.KeyEC256),
Domains: website.Domains,
AutoRenewal: true,
AccountID: account.ID,
WebsiteID: website.ID,
})
if err != nil {
return err
}
} else {
return err
}
}
newCert.Domains = website.Domains
if err = r.db.Save(newCert).Error; err != nil {
return err
}
_, err = r.cert.ObtainAuto(newCert.ID)
if err != nil {
return err
}
return r.cert.Deploy(newCert.ID, website.ID)
}
// customConfigStartNum 自定义配置起始序号
const customConfigStartNum = 800
// customConfigEndNum 自定义配置结束序号
const customConfigEndNum = 999
// getCustomConfigs 获取网站自定义配置列表
func (r *websiteRepo) getCustomConfigs(configDir string) []types.WebsiteCustomConfig {
var configs []types.WebsiteCustomConfig
// 从 site 和 shared 目录读取自定义配置
for _, scope := range []string{"site", "shared"} {
scopeDir := filepath.Join(configDir, scope)
entries, err := os.ReadDir(scopeDir)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
// 匹配文件名格式: 800-999-name.conf
name := entry.Name()
if !strings.HasSuffix(name, ".conf") {
continue
}
// 解析序号
parts := strings.SplitN(name, "-", 2)
if len(parts) < 2 {
continue
}
num, err := strconv.Atoi(parts[0])
if err != nil || num < customConfigStartNum || num > customConfigEndNum {
continue
}
// 提取配置名称(去掉序号前缀和.conf后缀)
configName := strings.TrimSuffix(parts[1], ".conf")
if configName == "" {
continue
}
// 读取配置内容
content, err := io.Read(filepath.Join(scopeDir, name))
if err != nil {
continue
}
configs = append(configs, types.WebsiteCustomConfig{
Name: configName,
Scope: scope,
Content: content,
})
}
}
return configs
}
// saveCustomConfigs 保存网站自定义配置
func (r *websiteRepo) saveCustomConfigs(configDir string, configs []request.WebsiteCustomConfig) error {
if err := r.clearCustomConfigs(configDir); err != nil {
return err
}
// 分别跟踪 site 和 shared 目录的序号
siteNum := customConfigStartNum
sharedNum := customConfigStartNum
for _, cfg := range configs {
var num int
switch cfg.Scope {
case "site":
num = siteNum
siteNum++
case "shared":
num = sharedNum
sharedNum++
default:
return fmt.Errorf("invalid config scope: %s", cfg.Scope)
}
if num > customConfigEndNum {
return errors.New(r.t.Get("maximum number of custom configurations reached (limit: %d)", customConfigEndNum-customConfigStartNum+1))
}
fileName := fmt.Sprintf("%03d-%s.conf", num, cfg.Name)
filePath := filepath.Join(configDir, cfg.Scope, fileName)
if err := io.Write(filePath, cfg.Content, 0600); err != nil {
return fmt.Errorf("failed to write custom config: %w", err)
}
}
return nil
}
// clearCustomConfigs 清除网站自定义配置文件
func (r *websiteRepo) clearCustomConfigs(configDir string) error {
for _, scope := range []string{"site", "shared"} {
scopeDir := filepath.Join(configDir, scope)
entries, err := os.ReadDir(scopeDir)
if err != nil {
if os.IsNotExist(err) {
continue
}
return err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".conf") {
continue
}
parts := strings.SplitN(name, "-", 2)
if len(parts) < 2 {
continue
}
num, err := strconv.Atoi(parts[0])
if err != nil || num < customConfigStartNum || num > customConfigEndNum {
continue
}
filePath := filepath.Join(scopeDir, name)
if err = os.Remove(filePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove custom config: %w", err)
}
}
}
return nil
}
func (r *websiteRepo) getVhost(website *biz.Website) (webservertypes.Vhost, error) {
webServer, err := r.setting.Get(biz.SettingKeyWebserver)
if err != nil {
return nil, err
}
var vhost webservertypes.Vhost
switch website.Type {
case biz.WebsiteTypeProxy:
vhost, err = webserver.NewProxyVhost(webserver.Type(webServer), filepath.Join(app.Root, "sites", website.Name, "config"))
case biz.WebsiteTypePHP:
vhost, err = webserver.NewPHPVhost(webserver.Type(webServer), filepath.Join(app.Root, "sites", website.Name, "config"))
case biz.WebsiteTypeStatic:
vhost, err = webserver.NewStaticVhost(webserver.Type(webServer), filepath.Join(app.Root, "sites", website.Name, "config"))
default:
return nil, errors.New(r.t.Get("unsupported website type: %s", website.Type))
}
if err != nil {
return nil, err
}
return vhost, nil
}
func (r *websiteRepo) getPHPVersion(name string) uint {
vhost, err := webserver.NewPHPVhost(webserver.TypeNginx, filepath.Join(app.Root, "sites", name, "config"))
if err != nil {
return 0
}
return vhost.PHP()
}
func (r *websiteRepo) reloadWebServer() error {
webServer, err := r.setting.Get(biz.SettingKeyWebserver, "unknown")
if err != nil {
return err
}
switch webServer {
case "nginx":
if err = systemctl.Reload("nginx"); err != nil {
_, err = shell.Execf("nginx -t")
return err
}
case "apache":
if err = systemctl.Reload("apache"); err != nil {
_, err = shell.Execf("apachectl configtest")
return err
}
default:
return errors.New(r.t.Get("unsupported web server: %s", webServer))
}
return nil
}
// readBasicAuthUsers 读取 htpasswd 文件中的用户列表
func (r *websiteRepo) readBasicAuthUsers(siteName string) map[string]string {
htpasswdPath := filepath.Join(app.Root, "sites", siteName, "htpasswd")
if !io.Exists(htpasswdPath) {
return nil
}
file, err := os.Open(htpasswdPath)
if err != nil {
return nil
}
defer func(file *os.File) { _ = file.Close() }(file)
users := make(map[string]string)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// htpasswd 格式: username:{PLAIN}password或直接username:password
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
users[parts[0]] = strings.TrimPrefix(parts[1], "{PLAIN}")
}
}
if len(users) == 0 {
return nil
}
return users
}
// writeBasicAuthUsers 将用户凭证写入 htpasswd 文件
func (r *websiteRepo) writeBasicAuthUsers(htpasswdPath string, users map[string]string) error {
webServer, err := r.setting.Get(biz.SettingKeyWebserver, "unknown")
if err != nil {
return err
}
var lines []string
for username, password := range users {
if username == "" || password == "" {
continue
}
switch webServer {
case "nginx":
lines = append(lines, fmt.Sprintf("%s:%s", username, "{PLAIN}"+password))
case "apache":
lines = append(lines, fmt.Sprintf("%s:%s", username, password))
default:
return errors.New(r.t.Get("unsupported web server: %s", webServer))
}
}
content := strings.Join(lines, "\n")
if content != "" {
content += "\n"
}
return io.Write(htpasswdPath, content, 0644) // 必须 0644,Nginx 在运行中以 www 用户读取
}