refactor: refactor eiblog

This commit is contained in:
henry.chen
2025-07-16 19:45:50 +08:00
parent 0a410f09f3
commit 8fcabd5e15
67 changed files with 1282 additions and 1330 deletions

View File

@@ -0,0 +1,67 @@
package config
import (
"os"
"path/filepath"
"strings"
"github.com/eiblog/eiblog/pkg/config"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
// Config config
type Config struct {
config.APIMode
Database config.Database // 数据库配置
BackupTo string // 备份到, default: qiniu
Interval string // 备份周期, default: 7d
Validity int // 备份保留时间, default: 60
Qiniu config.Qiniu // 七牛OSS配置
}
// Conf 配置
var Conf Config
// load config file
func init() {
// run mode
mode := config.RunMode(os.Getenv("RUN_MODE"))
if !mode.IsRunMode() {
panic("config: unsupported env RUN_MODE" + mode)
}
logrus.Infof("Run mode:%s", mode)
// 加载配置文件
dir, err := config.WalkWorkDir()
if err != nil {
panic(err)
}
path := filepath.Join(dir, "etc", "app.yml")
data, err := os.ReadFile(path)
if err != nil {
panic(err)
}
err = yaml.Unmarshal(data, &Conf)
if err != nil {
panic(err)
}
Conf.RunMode = mode
// read env
readDatabaseEnv()
}
func readDatabaseEnv() {
key := strings.ToUpper(Conf.Name) + "_DB_DRIVER"
if d := os.Getenv(key); d != "" {
Conf.Database.Driver = d
}
key = strings.ToUpper(Conf.Name) + "_DB_SOURCE"
if s := os.Getenv(key); s != "" {
Conf.Database.Source = s
}
}

60
cmd/backup/docs/docs.go Normal file
View File

@@ -0,0 +1,60 @@
// Package docs Code generated by swaggo/swag. DO NOT EDIT
package docs
import "github.com/swaggo/swag"
const docTemplate = `{
"schemes": {{ marshal .Schemes }},
"swagger": "2.0",
"info": {
"description": "{{escape .Description}}",
"title": "{{.Title}}",
"contact": {},
"version": "{{.Version}}"
},
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/ping": {
"get": {
"description": "ping",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"ping"
],
"summary": "ping",
"responses": {
"200": {
"description": "it's ok",
"schema": {
"type": "string"
}
}
}
}
}
}
}`
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
Version: "1.0",
Host: "",
BasePath: "",
Schemes: []string{},
Title: "backup API",
Description: "This is a backup server.",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",
RightDelim: "}}",
}
func init() {
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
}

View File

@@ -0,0 +1,34 @@
{
"swagger": "2.0",
"info": {
"description": "This is a backup server.",
"title": "backup API",
"contact": {},
"version": "1.0"
},
"paths": {
"/ping": {
"get": {
"description": "ping",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"ping"
],
"summary": "ping",
"responses": {
"200": {
"description": "it's ok",
"schema": {
"type": "string"
}
}
}
}
}
}
}

View File

@@ -0,0 +1,22 @@
info:
contact: {}
description: This is a backup server.
title: backup API
version: "1.0"
paths:
/ping:
get:
consumes:
- application/json
description: ping
produces:
- application/json
responses:
"200":
description: it's ok
schema:
type: string
summary: ping
tags:
- ping
swagger: "2.0"

14
cmd/backup/etc/app.yml Normal file
View File

@@ -0,0 +1,14 @@
apimode:
name: cmd-backup
listen: 0.0.0.0:9000
database: # 数据库配置
driver: sqlite
source: ./db.sqlite
backupto: qiniu # 备份到, default: qiniu
interval: 7d # 备份周期, default: 7d
validity: 60 # 备份保留时间, default: 60
qiniu: # 七牛OSS
bucket: eiblog
domain: st.deepzz.com
accesskey: MB6AXl_Sj_mmFsL-Lt59Dml2Vmy2o8XMmiCbbSeC
secretkey: BIrMy0fsZ0_SHNceNXk3eDuo7WmVYzj2-zrmd5Tf

View File

@@ -0,0 +1,17 @@
package internal
import (
"github.com/eiblog/eiblog/cmd/backup/config"
"github.com/eiblog/eiblog/pkg/third/qiniu"
)
// QiniuClient 七牛客户端
var QiniuClient *qiniu.QiniuClient
func init() {
var err error
QiniuClient, err = qiniu.NewQiniuClient(config.Conf.Qiniu)
if err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,24 @@
package ping
import (
"net/http"
"github.com/gin-gonic/gin"
)
// RegisterRoutes register routes
func RegisterRoutes(group gin.IRoutes) {
group.GET("/ping", handlePing)
}
// handlePing ping
// @Summary ping
// @Description ping
// @Tags ping
// @Accept json
// @Produce json
// @Success 200 {string} string "it's ok"
// @Router /ping [get]
func handlePing(c *gin.Context) {
c.String(http.StatusOK, "it's ok")
}

View File

@@ -0,0 +1,14 @@
package swag
import (
_ "github.com/eiblog/eiblog/cmd/eiblog/docs" // docs
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
// RegisterRoutes register routes
func RegisterRoutes(group gin.IRoutes) {
group.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
}

View File

@@ -0,0 +1,138 @@
package qiniu
import (
"context"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/eiblog/eiblog/cmd/backup/config"
"github.com/eiblog/eiblog/cmd/backup/handler/internal"
"github.com/eiblog/eiblog/pkg/connector/db"
"github.com/eiblog/eiblog/pkg/third/qiniu"
)
// BackupRestorer qiniu backup restorer
type BackupRestorer struct{}
// Backup implements timer.BackupRestorer
func (s BackupRestorer) Backup(now time.Time) error {
switch config.Conf.Database.Driver {
case "mongodb":
return backupFromMongoDB(now)
default:
return errors.New("unsupported source backup to qiniu: " +
config.Conf.Database.Driver)
}
}
// Restore implements timer.BackupRestorer
func (s BackupRestorer) Restore() error {
switch config.Conf.Database.Driver {
case "mongodb":
return restoreToMongoDB()
default:
return errors.New("unsupported source restore from qiniu: " +
config.Conf.Database.Driver)
}
}
func backupFromMongoDB(now time.Time) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*20)
defer cancel()
// dump
u, err := url.Parse(config.Conf.Database.Source)
if err != nil {
return err
}
arg := fmt.Sprintf("mongodump -h %s -d eiblog -o /tmp", u.Host)
cmd := exec.CommandContext(ctx, "sh", "-c", arg)
err = cmd.Run()
if err != nil {
return err
}
// tar
name := fmt.Sprintf("eiblog-%s.tar.gz", now.Format("2006-01-02"))
arg = fmt.Sprintf("tar czf /tmp/%s -C /tmp eiblog", name)
cmd = exec.CommandContext(ctx, "sh", "-c", arg)
err = cmd.Run()
if err != nil {
return err
}
// upload file
f, err := os.Open("/tmp/" + name)
if err != nil {
return err
}
s, err := f.Stat()
if err != nil {
return err
}
uploadParams := qiniu.UploadParams{
Name: filepath.Join("blog", name), // blog/eiblog-xx.tar.gz
Size: s.Size(),
Data: f,
NoCompletePath: true,
}
_, err = internal.QiniuClient.Upload(uploadParams)
if err != nil {
return err
}
// after days delete
deleteParams := qiniu.DeleteParams{
Name: filepath.Join("blog", name), // blog/eiblog-xx.tar.gz
Days: config.Conf.Validity,
NoCompletePath: true,
}
return internal.QiniuClient.Delete(deleteParams)
}
func restoreToMongoDB() error {
// backup file
params := qiniu.ContentParams{
Prefix: "blog/",
}
raw, err := internal.QiniuClient.Content(params)
if err != nil {
return err
}
f, err := os.OpenFile("/tmp/eiblog.tar.gz", os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
return err
}
_, _ = f.Write(raw)
defer f.Close()
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*20)
defer cancel()
// drop database
mdb, err := db.NewMDB(config.Conf.Database)
if err != nil {
return err
}
err = mdb.Drop(ctx)
if err != nil {
return err
}
// unarchive
arg := "tar xzf /tmp/eiblog.tar.gz -C /tmp"
cmd := exec.CommandContext(ctx, "sh", "-c", arg)
err = cmd.Run()
if err != nil {
return err
}
// restore
u, err := url.Parse(config.Conf.Database.Source)
if err != nil {
return err
}
arg = fmt.Sprintf("mongorestore -h %s -d eiblog /tmp/eiblog", u.Host)
cmd = exec.CommandContext(ctx, "sh", "-c", arg)
return cmd.Run()
}

View File

@@ -0,0 +1,73 @@
package timer
import (
"errors"
"strconv"
"time"
"github.com/eiblog/eiblog/cmd/backup/config"
"github.com/eiblog/eiblog/cmd/backup/handler/timer/qiniu"
"github.com/sirupsen/logrus"
)
// BackupRestorer 备份恢复器
type BackupRestorer interface {
Backup(now time.Time) error
Restore() error
}
// Start to backup with ticker
func Start(restore bool) (err error) {
var storage BackupRestorer
// backup instance
switch config.Conf.BackupTo {
case "qiniu":
storage = qiniu.BackupRestorer{}
default:
return errors.New("timer: unknown backup to driver: " +
config.Conf.BackupTo)
}
if restore {
err = storage.Restore()
if err != nil {
return err
}
logrus.Info("timer: Restore success")
}
// parse duration
interval, err := ParseDuration(config.Conf.Interval)
if err != nil {
return err
}
t := time.NewTicker(interval)
for now := range t.C {
err = storage.Backup(now)
if err != nil {
logrus.Error("timer: Start.Backup: ", now.Format(time.RFC3339), err)
}
}
return nil
}
// ParseDuration parse string to duration
func ParseDuration(d string) (time.Duration, error) {
if len(d) == 0 {
return 0, errors.New("timer: incorrect duration input")
}
length := len(d)
switch d[length-1] {
case 's', 'm', 'h':
return time.ParseDuration(d)
case 'd':
di, err := strconv.Atoi(d[:length-1])
if err != nil {
return 0, err
}
return time.Duration(di) * time.Hour * 24, nil
}
return 0, errors.New("timer: unsupported duration:" + d)
}

View File

@@ -3,16 +3,20 @@ package main
import (
"flag"
"fmt"
"github.com/eiblog/eiblog/pkg/config"
"github.com/eiblog/eiblog/pkg/core/backup/ping"
"github.com/eiblog/eiblog/pkg/core/backup/swag"
"github.com/eiblog/eiblog/pkg/core/backup/timer"
"github.com/eiblog/eiblog/cmd/backup/config"
"github.com/eiblog/eiblog/cmd/backup/handler/ping"
"github.com/eiblog/eiblog/cmd/backup/handler/swag"
"github.com/eiblog/eiblog/cmd/backup/handler/timer"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
// @title backup API
// @version 1.0
// @description This is a backup server.
var restore bool
func init() {
@@ -20,15 +24,15 @@ func init() {
}
func main() {
fmt.Println("Hi, it's App " + config.Conf.BackupApp.Name)
flag.Parse()
logrus.Info("Hi, it's App " + config.Conf.Name)
flag.Parse()
endRun := make(chan error, 1)
runCommand(restore, endRun)
runHTTPServer(endRun)
fmt.Println(<-endRun)
logrus.Fatal(<-endRun)
}
func runCommand(restore bool, endRun chan error) {
@@ -38,11 +42,7 @@ func runCommand(restore bool, endRun chan error) {
}
func runHTTPServer(endRun chan error) {
if !config.Conf.BackupApp.EnableHTTP {
return
}
if config.Conf.RunMode == config.ModeProd {
if config.Conf.RunMode.IsReleaseMode() {
gin.SetMode(gin.ReleaseMode)
}
e := gin.Default()
@@ -54,9 +54,8 @@ func runHTTPServer(endRun chan error) {
ping.RegisterRoutes(e)
// start
address := fmt.Sprintf(":%d", config.Conf.BackupApp.HTTPPort)
go func() {
endRun <- e.Run(address)
endRun <- e.Run(config.Conf.Listen)
}()
fmt.Println("HTTP server running on: " + address)
logrus.Info("HTTP server running on: " + config.Conf.Listen)
}

View File

@@ -0,0 +1,82 @@
package config
import (
"os"
"path/filepath"
"strings"
"github.com/eiblog/eiblog/pkg/config"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
var (
// Conf 配置
Conf Config
// WorkDir 工作目录
WorkDir string
)
// Config config
type Config struct {
config.APIMode
// 静态资源版本, 每次更改了 js/css 都需要提高该值
StaticVersion int
// 数据库配置
Database config.Database
// 热词, 手动指定, 用于搜索
HotWords []string
// Elasticsearch 配置
ESHost string
General config.General
Disqus config.Disqus
Google config.Google
Qiniu config.Qiniu
Twitter config.Twitter
FeedRPC config.FeedRPC
Account config.Account
}
// init 初始化配置
func init() {
// run mode
mode := config.RunMode(os.Getenv("RUN_MODE"))
if !mode.IsRunMode() {
panic("config: unsupported env RUN_MODE" + mode)
}
logrus.Infof("Run mode:%s", mode)
// 加载配置文件
var err error
WorkDir, err = config.WalkWorkDir()
if err != nil {
panic(err)
}
path := filepath.Join(WorkDir, "etc", "app.yml")
data, err := os.ReadFile(path)
if err != nil {
panic(err)
}
err = yaml.Unmarshal(data, &Conf)
if err != nil {
panic(err)
}
Conf.RunMode = mode
// 读取环境变量配置
readDatabaseEnv()
}
func readDatabaseEnv() {
key := strings.ToUpper(Conf.Name) + "_DB_DRIVER"
if d := os.Getenv(key); d != "" {
Conf.Database.Driver = d
}
key = strings.ToUpper(Conf.Name) + "_DB_SOURCE"
if s := os.Getenv(key); s != "" {
Conf.Database.Source = s
}
}

36
cmd/eiblog/docs/docs.go Normal file
View File

@@ -0,0 +1,36 @@
// Package docs Code generated by swaggo/swag. DO NOT EDIT
package docs
import "github.com/swaggo/swag"
const docTemplate = `{
"schemes": {{ marshal .Schemes }},
"swagger": "2.0",
"info": {
"description": "{{escape .Description}}",
"title": "{{.Title}}",
"contact": {},
"version": "{{.Version}}"
},
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {}
}`
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
Version: "",
Host: "",
BasePath: "",
Schemes: []string{},
Title: "",
Description: "",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",
RightDelim: "}}",
}
func init() {
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
}

View File

@@ -0,0 +1,7 @@
{
"swagger": "2.0",
"info": {
"contact": {}
},
"paths": {}
}

View File

@@ -0,0 +1,4 @@
info:
contact: {}
paths: {}
swagger: "2.0"

49
cmd/eiblog/etc/app.yml Normal file
View File

@@ -0,0 +1,49 @@
apimode:
name: cmd-eiblog
listen: 0.0.0.0:9000
host: example.com
database: # 数据库配置
driver: sqlite
source: ./db.sqlite
hotwords: # 热搜词
- docker
- mongodb
- curl
- dns
staticversion: 1
eshost: # http://elasticsearch:9200
general: # 常规配置
pagenum: 10 # 首页展示文章数量
pagesize: 20 # 管理界面
descprefix: "Desc:" # 文章描述前缀
identifier: <!--more--> # 截取预览标识
length: 400 # 自动截取预览, 字符数
timezone: Asia/Shanghai # 时区
disqus: # 评论相关
shortname: xxxxxx
publickey: wdSgxRm9rdGAlLKFcFdToBe3GT4SibmV7Y8EjJQ0r4GWXeKtxpopMAeIeoI2dTEg
accesstoken: 50023908f39f4607957e909b495326af
google: # 谷歌分析
url: https://www.google-analytics.com/g/collect
tid: G-xxxxxxxxxx
v: "2"
adsense: <script async src="https://pagead2.googlesyndication.com/xxx" crossorigin="anonymous"></script>
qiniu: # 七牛OSS
bucket: eiblog
domain: st.deepzz.com
accesskey: MB6AXl_Sj_mmFsL-Lt59Dml2Vmy2o8XMmiCbbSeC
secretkey: BIrMy0fsZ0_SHNceNXk3eDuo7WmVYzj2-zrmd5Tf
twitter: # twitter card
card: summary
site: deepzz02
image: st.deepzz.com/static/img/avatar.jpg
address: twitter.com/deepzz02
feedrpc: # rss ping
feedrurl: https://deepzz.superfeedr.com/
pingrpc:
- http://ping.baidu.com/ping/RPC2
- http://rpc.pingomatic.com/
# 数据初始化操作,可到博客后台修改
account:
username: deepzz # *后台登录用户名
password: deepzz # *登录明文密码

View File

@@ -0,0 +1,529 @@
package admin
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/eiblog/eiblog/cmd/eiblog/handler/internal"
"github.com/eiblog/eiblog/pkg/middleware"
"github.com/eiblog/eiblog/pkg/model"
"github.com/eiblog/eiblog/pkg/third/qiniu"
"github.com/eiblog/eiblog/tools"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
// 通知cookie
const (
NoticeSuccess = "success"
NoticeNotice = "notice"
NoticeError = "error"
)
// RegisterRoutes register routes
func RegisterRoutes(e *gin.Engine) {
e.POST("/admin/login", handleAcctLogin)
}
// RegisterRoutesAuthz register routes
func RegisterRoutesAuthz(group gin.IRoutes) {
group.POST("/api/account", handleAPIAccount)
group.POST("/api/blog", handleAPIBlogger)
group.POST("/api/password", handleAPIPassword)
group.POST("/api/post-delete", handleAPIPostDelete)
group.POST("/api/post-add", handleAPIPostCreate)
group.POST("/api/serie-delete", handleAPISerieDelete)
group.POST("/api/serie-add", handleAPISerieCreate)
group.POST("/api/serie-sort", handleAPISerieSort)
group.POST("/api/draft-delete", handleDraftDelete)
group.POST("/api/trash-delete", handleAPITrashDelete)
group.POST("/api/trash-recover", handleAPITrashRecover)
group.POST("/api/file-upload", handleAPIQiniuUpload)
group.POST("/api/file-delete", handleAPIQiniuDelete)
}
// handleAcctLogin 登录接口
func handleAcctLogin(c *gin.Context) {
user := c.PostForm("user")
pwd := c.PostForm("password")
// code := c.PostForm("code") // 二次验证
if user == "" || pwd == "" {
logrus.Warnf("参数错误: %s %s", user, pwd)
c.Redirect(http.StatusFound, "/admin/login")
return
}
if internal.Ei.Account.Username != user ||
internal.Ei.Account.Password != tools.EncryptPasswd(user, pwd) {
logrus.Warnf("账号或密码错误 %s, %s", user, pwd)
c.Redirect(http.StatusFound, "/admin/login")
return
}
// 登录成功
middleware.SetLogin(c, user)
internal.Ei.Account.LoginIP = c.ClientIP()
internal.Ei.Account.LoginAt = time.Now()
internal.Ei.UpdateAccount(context.Background(), user, map[string]interface{}{
"login_ip": internal.Ei.Account.LoginIP,
"login_at": internal.Ei.Account.LoginAt,
})
c.Redirect(http.StatusFound, "/admin/profile")
}
// handleAPIBlogger 更新博客信息
func handleAPIBlogger(c *gin.Context) {
bn := c.PostForm("blogName")
bt := c.PostForm("bTitle")
ba := c.PostForm("beiAn")
st := c.PostForm("subTitle")
ss := c.PostForm("seriessay")
as := c.PostForm("archivessay")
if bn == "" || bt == "" {
responseNotice(c, NoticeNotice, "参数错误", "")
return
}
err := internal.Ei.UpdateBlogger(context.Background(), map[string]interface{}{
"blog_name": bn,
"b_title": bt,
"bei_an": ba,
"sub_title": st,
"series_say": ss,
"archives_say": as,
})
if err != nil {
logrus.Error("handleAPIBlogger.UpdateBlogger: ", err)
responseNotice(c, NoticeNotice, err.Error(), "")
return
}
internal.Ei.Blogger.BlogName = bn
internal.Ei.Blogger.BTitle = bt
internal.Ei.Blogger.BeiAn = ba
internal.Ei.Blogger.SubTitle = st
internal.Ei.Blogger.SeriesSay = ss
internal.Ei.Blogger.ArchivesSay = as
internal.PagesCh <- internal.PageSeries
internal.PagesCh <- internal.PageArchive
responseNotice(c, NoticeSuccess, "更新成功", "")
}
// handleAPIAccount 更新账户信息
func handleAPIAccount(c *gin.Context) {
e := c.PostForm("email")
pn := c.PostForm("phoneNumber")
ad := c.PostForm("address")
if (e != "" && !tools.ValidateEmail(e)) || (pn != "" &&
!tools.ValidatePhoneNo(pn)) {
responseNotice(c, NoticeNotice, "参数错误", "")
return
}
err := internal.Ei.UpdateAccount(context.Background(), internal.Ei.Account.Username,
map[string]interface{}{
"email": e,
"phone_n": pn,
"address": ad,
})
if err != nil {
logrus.Error("handleAPIAccount.UpdateAccount: ", err)
responseNotice(c, NoticeNotice, err.Error(), "")
return
}
internal.Ei.Account.Email = e
internal.Ei.Account.PhoneN = pn
internal.Ei.Account.Address = ad
responseNotice(c, NoticeSuccess, "更新成功", "")
}
// handleAPIPassword 更新密码
func handleAPIPassword(c *gin.Context) {
od := c.PostForm("old")
nw := c.PostForm("new")
cf := c.PostForm("confirm")
if nw != cf {
responseNotice(c, NoticeNotice, "两次密码输入不一致", "")
return
}
if !tools.ValidatePassword(nw) {
responseNotice(c, NoticeNotice, "密码格式错误", "")
return
}
if internal.Ei.Account.Password != tools.EncryptPasswd(internal.Ei.Account.Username, od) {
responseNotice(c, NoticeNotice, "原始密码不正确", "")
return
}
newPwd := tools.EncryptPasswd(internal.Ei.Account.Username, nw)
err := internal.Ei.UpdateAccount(context.Background(), internal.Ei.Account.Username,
map[string]interface{}{
"password": newPwd,
})
if err != nil {
logrus.Error("handleAPIPassword.UpdateAccount: ", err)
responseNotice(c, NoticeNotice, err.Error(), "")
return
}
internal.Ei.Account.Password = newPwd
responseNotice(c, NoticeSuccess, "更新成功", "")
}
// handleDraftDelete 删除草稿, 物理删除
func handleDraftDelete(c *gin.Context) {
for _, v := range c.PostFormArray("mid[]") {
id, err := strconv.Atoi(v)
if err != nil || id < 1 {
responseNotice(c, NoticeNotice, "参数错误", "")
return
}
err = internal.Ei.RemoveArticle(context.Background(), id)
if err != nil {
logrus.Error("handleDraftDelete.RemoveArticle: ", err)
responseNotice(c, NoticeNotice, "删除失败", "")
return
}
}
responseNotice(c, NoticeSuccess, "删除成功", "")
}
// handleAPIPostDelete 删除文章,移入回收箱
func handleAPIPostDelete(c *gin.Context) {
var ids []int
for _, v := range c.PostFormArray("cid[]") {
id, err := strconv.Atoi(v)
if err != nil || id < internal.ArticleStartID {
responseNotice(c, NoticeNotice, "参数错误", "")
return
}
err = internal.Ei.DelArticle(id)
if err != nil {
logrus.Error("handleAPIPostDelete.DelArticle: ", err)
responseNotice(c, NoticeNotice, err.Error(), "")
return
}
ids = append(ids, id)
}
// elasticsearch
err := internal.ESClient.ElasticDelIndex(ids)
if err != nil {
logrus.Error("handleAPIPostDelete.ElasticDelIndex: ", err)
}
// TODO disqus delete
responseNotice(c, NoticeSuccess, "删除成功", "")
}
// handleAPIPostCreate 创建文章
func handleAPIPostCreate(c *gin.Context) {
var (
err error
do string
cid int
)
defer func() {
now := time.Now().In(tools.TimeLocation)
switch do {
case "auto": // 自动保存
if err != nil {
c.JSON(http.StatusOK, gin.H{"fail": 1, "time": now.Format("15:04:05 PM"), "cid": cid})
return
}
c.JSON(http.StatusOK, gin.H{"success": 0, "time": now.Format("15:04:05 PM"), "cid": cid})
case "save", "publish": // 草稿,发布
if err != nil {
responseNotice(c, NoticeNotice, err.Error(), "")
return
}
uri := "/admin/manage-draft"
if do == "publish" {
uri = "/admin/manage-posts"
}
c.Redirect(http.StatusFound, uri)
}
}()
do = c.PostForm("do") // auto or save or publish
slug := c.PostForm("slug")
title := c.PostForm("title")
text := c.PostForm("text")
date := parseLocationDate(c.PostForm("date"))
serie := c.PostForm("serie")
tag := c.PostForm("tags")
update := c.PostForm("update")
if slug == "" || title == "" || text == "" {
err = errors.New("参数错误")
return
}
var tags []string
if tag != "" {
tags = strings.Split(tag, ",")
}
serieid, _ := strconv.Atoi(serie)
article := &model.Article{
Title: title,
Content: text,
Slug: slug,
IsDraft: do != "publish",
Author: internal.Ei.Account.Username,
SerieID: serieid,
Tags: tags,
CreatedAt: date,
}
cid, err = strconv.Atoi(c.PostForm("cid"))
// 新文章
if err != nil || cid < 1 {
err = internal.Ei.AddArticle(article)
if err != nil {
logrus.Error("handleAPIPostCreate.AddArticle: ", err)
return
}
cid = article.ID
if !article.IsDraft {
// disqus
internal.DisqusClient.ThreadCreate(article, internal.Ei.Blogger.BTitle)
// 异步执行,快
go func() {
// elastic
internal.ESClient.ElasticAddIndex(article)
// rss
internal.Pinger.PingFunc(internal.Ei.Blogger.BTitle, slug)
}()
}
return
}
// 旧文章
article.ID = cid
artc, _ := internal.Ei.FindArticleByID(article.ID) // cache
if artc != nil {
article.IsDraft = false
article.Count = artc.Count
article.UpdatedAt = artc.UpdatedAt
}
if update == "true" || update == "1" {
article.UpdatedAt = time.Now()
}
// 数据库更新
err = internal.Ei.UpdateArticle(context.Background(), article.ID, map[string]interface{}{
"title": article.Title,
"content": article.Content,
"serie_id": article.SerieID,
"is_draft": article.IsDraft,
"tags": article.Tags,
"updated_at": article.UpdatedAt,
"created_at": article.CreatedAt,
})
if err != nil {
logrus.Error("handleAPIPostCreate.UpdateArticle: ", err)
return
}
if !article.IsDraft {
internal.Ei.RepArticle(artc, article)
// disqus
if artc == nil {
internal.DisqusClient.ThreadCreate(article, internal.Ei.Blogger.BTitle)
}
// 异步执行,快
go func() {
// elastic
internal.ESClient.ElasticAddIndex(article)
// rss
internal.Pinger.PingFunc(internal.Ei.Blogger.BTitle, slug)
}()
}
}
// handleAPISerieDelete 只能逐一删除,专题下不能有文章
func handleAPISerieDelete(c *gin.Context) {
for _, v := range c.PostFormArray("mid[]") {
id, err := strconv.Atoi(v)
if err != nil || id < 1 {
responseNotice(c, NoticeNotice, err.Error(), "")
return
}
err = internal.Ei.DelSerie(id)
if err != nil {
responseNotice(c, NoticeNotice, err.Error(), "")
return
}
}
responseNotice(c, NoticeSuccess, "删除成功", "")
}
// handleAPISerieSort 专题排序
func handleAPISerieSort(c *gin.Context) {
v := c.PostFormArray("mid[]")
logrus.Debug(v)
}
// handleAPISerieCreate 添加专题,如果专题有提交 mid 即更新专题
func handleAPISerieCreate(c *gin.Context) {
name := c.PostForm("name")
slug := c.PostForm("slug")
desc := c.PostForm("description")
if name == "" || slug == "" || desc == "" {
responseNotice(c, NoticeNotice, "参数错误", "")
return
}
mid, err := strconv.Atoi(c.PostForm("mid"))
if err == nil && mid > 0 {
var serie *model.Serie
for _, v := range internal.Ei.Series {
if v.ID == mid {
serie = v
break
}
}
if serie == nil {
responseNotice(c, NoticeNotice, "专题不存在", "")
return
}
err = internal.Ei.UpdateSerie(context.Background(), mid, map[string]interface{}{
"slug": slug,
"name": name,
"desc": desc,
})
if err != nil {
logrus.Error("handleAPISerieCreate.UpdateSerie: ", err)
responseNotice(c, NoticeNotice, err.Error(), "")
return
}
serie.Slug = slug
serie.Name = name
serie.Desc = desc
internal.PagesCh <- internal.PageSeries
} else {
err = internal.Ei.AddSerie(&model.Serie{
Slug: slug,
Name: name,
Desc: desc,
CreatedAt: time.Now(),
})
if err != nil {
logrus.Error("handleAPISerieCreate.InsertSerie: ", err)
responseNotice(c, NoticeNotice, err.Error(), "")
return
}
}
responseNotice(c, NoticeSuccess, "操作成功", "")
}
// handleAPITrashDelete 删除回收箱, 物理删除
func handleAPITrashDelete(c *gin.Context) {
for _, v := range c.PostFormArray("mid[]") {
id, err := strconv.Atoi(v)
if err != nil || id < 1 {
responseNotice(c, NoticeNotice, "参数错误", "")
return
}
err = internal.Ei.RemoveArticle(context.Background(), id)
if err != nil {
responseNotice(c, NoticeNotice, err.Error(), "")
return
}
}
responseNotice(c, NoticeSuccess, "删除成功", "")
}
// handleAPITrashRecover 恢复到草稿
func handleAPITrashRecover(c *gin.Context) {
for _, v := range c.PostFormArray("mid[]") {
id, err := strconv.Atoi(v)
if err != nil || id < 1 {
responseNotice(c, NoticeNotice, "参数错误", "")
return
}
err = internal.Ei.UpdateArticle(context.Background(), id, map[string]interface{}{
"deleted_at": time.Time{},
"is_draft": true,
})
if err != nil {
responseNotice(c, NoticeNotice, err.Error(), "")
return
}
}
responseNotice(c, NoticeSuccess, "恢复成功", "")
}
// handleAPIQiniuUpload 上传文件
func handleAPIQiniuUpload(c *gin.Context) {
type Size interface {
Size() int64
}
file, header, err := c.Request.FormFile("file")
if err != nil {
logrus.Error("handleAPIQiniuUpload.FormFile: ", err)
c.String(http.StatusBadRequest, err.Error())
return
}
s, ok := file.(Size)
if !ok {
logrus.Error("assert failed")
c.String(http.StatusBadRequest, "false")
return
}
filename := strings.ToLower(header.Filename)
params := qiniu.UploadParams{
Name: filename,
Size: s.Size(),
Data: file,
}
url, err := internal.QiniuClient.Upload(params)
if err != nil {
logrus.Error("handleAPIQiniuUpload.QiniuUpload: ", err)
c.String(http.StatusBadRequest, err.Error())
return
}
typ := header.Header.Get("Content-Type")
c.JSON(http.StatusOK, gin.H{
"title": filename,
"isImage": typ[:5] == "image",
"url": url,
"bytes": fmt.Sprintf("%dkb", s.Size()/1000),
})
}
// handleAPIQiniuDelete 删除文件
func handleAPIQiniuDelete(c *gin.Context) {
defer c.String(http.StatusOK, "删掉了吗?鬼知道。。。")
name := c.PostForm("title")
if name == "" {
logrus.Error("handleAPIQiniuDelete.PostForm: 参数错误")
return
}
params := qiniu.DeleteParams{
Name: name,
}
err := internal.QiniuClient.Delete(params)
if err != nil {
logrus.Error("handleAPIQiniuDelete.QiniuDelete: ", err)
}
}
// parseLocationDate 解析日期
func parseLocationDate(date string) time.Time {
t, err := time.ParseInLocation("2006-01-02 15:04", date, tools.TimeLocation)
if err == nil {
return t.UTC()
}
return time.Now()
}
func responseNotice(c *gin.Context, typ, content, hl string) {
if hl != "" {
c.SetCookie("notice_highlight", hl, 86400, "/", "", true, false)
}
c.SetCookie("notice_type", typ, 86400, "/", "", true, false)
c.SetCookie("notice", fmt.Sprintf("[\"%s\"]", content), 86400, "/", "", true, false)
c.Redirect(http.StatusFound, c.Request.Referer())
}

View File

@@ -0,0 +1,48 @@
package file
import (
"net/http"
"github.com/gin-gonic/gin"
)
// RegisterRoutes register routes
func RegisterRoutes(e *gin.Engine) {
e.GET("/rss.html", handleFeed)
e.GET("/feed", handleFeed)
e.GET("/opensearch.xml", handleOpensearch)
e.GET("/sitemap.xml", handleSitemap)
e.GET("/robots.txt", handleRobots)
e.GET("/crossdomain.xml", handleCrossDomain)
e.GET("/favicon.ico", handleFavicon)
}
// handleFeed feed.xml
func handleFeed(c *gin.Context) {
http.ServeFile(c.Writer, c.Request, "assets/feed.xml")
}
// handleOpensearch opensearch.xml
func handleOpensearch(c *gin.Context) {
http.ServeFile(c.Writer, c.Request, "assets/opensearch.xml")
}
// handleRobots robotx.txt
func handleRobots(c *gin.Context) {
http.ServeFile(c.Writer, c.Request, "assets/robots.txt")
}
// handleSitemap sitemap.xml
func handleSitemap(c *gin.Context) {
http.ServeFile(c.Writer, c.Request, "assets/sitemap.xml")
}
// handleCrossDomain crossdomain.xml
func handleCrossDomain(c *gin.Context) {
http.ServeFile(c.Writer, c.Request, "assets/crossdomain.xml")
}
// handleFavicon favicon.ico
func handleFavicon(c *gin.Context) {
http.ServeFile(c.Writer, c.Request, "assets/favicon.ico")
}

View File

@@ -0,0 +1,163 @@
package file
import (
"os"
"path/filepath"
"text/template"
"time"
"github.com/eiblog/eiblog/cmd/eiblog/config"
"github.com/eiblog/eiblog/cmd/eiblog/handler/internal"
"github.com/eiblog/eiblog/tools"
"github.com/sirupsen/logrus"
)
var xmlTmpl *template.Template
func init() {
root := filepath.Join(config.WorkDir, "website", "template", "*.xml")
var err error
xmlTmpl, err = template.New("").Funcs(template.FuncMap{
"dateformat": tools.DateFormat,
"imgtonormal": tools.ImgToNormal,
}).ParseGlob(root)
if err != nil {
panic(err)
}
generateOpensearch()
generateRobots()
generateCrossdomain()
go timerFeed()
go timerSitemap()
}
// timerFeed 定时刷新feed
func timerFeed() {
tpl := xmlTmpl.Lookup("feedTpl.xml")
if tpl == nil {
logrus.Info("file: not found: feedTpl.xml")
return
}
now := time.Now()
_, _, articles := internal.Ei.PageArticleFE(1, 20)
params := map[string]interface{}{
"Title": internal.Ei.Blogger.BTitle,
"SubTitle": internal.Ei.Blogger.SubTitle,
"Host": config.Conf.Host,
"FeedrURL": config.Conf.FeedRPC.FeedrURL,
"BuildDate": now.Format(time.RFC1123Z),
"Articles": articles,
}
f, err := os.OpenFile("assets/feed.xml", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
if err != nil {
logrus.Error("file: timerFeed.OpenFile: ", err)
return
}
defer f.Close()
err = tpl.Execute(f, params)
if err != nil {
logrus.Error("file: timerFeed.Execute: ", err)
return
}
time.AfterFunc(time.Hour*4, timerFeed)
}
// timerSitemap 定时刷新sitemap
func timerSitemap() {
tpl := xmlTmpl.Lookup("sitemapTpl.xml")
if tpl == nil {
logrus.Info("file: not found: sitemapTpl.xml")
return
}
params := map[string]interface{}{
"Articles": internal.Ei.Articles,
"Host": config.Conf.Host,
}
f, err := os.OpenFile("assets/sitemap.xml", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
if err != nil {
logrus.Error("file: timerSitemap.OpenFile: ", err)
return
}
defer f.Close()
err = tpl.Execute(f, params)
if err != nil {
logrus.Error("file: timerSitemap.Execute: ", err)
return
}
time.AfterFunc(time.Hour*24, timerSitemap)
}
// generateOpensearch 生成opensearch.xml
func generateOpensearch() {
tpl := xmlTmpl.Lookup("opensearchTpl.xml")
if tpl == nil {
logrus.Info("file: not found: opensearchTpl.xml")
return
}
params := map[string]string{
"BTitle": internal.Ei.Blogger.BTitle,
"SubTitle": internal.Ei.Blogger.SubTitle,
"Host": config.Conf.Host,
}
f, err := os.OpenFile("assets/opensearch.xml", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
if err != nil {
logrus.Error("file: generateOpensearch.OpenFile: ", err)
return
}
defer f.Close()
err = tpl.Execute(f, params)
if err != nil {
logrus.Error("file: generateOpensearch.Execute: ", err)
return
}
}
// generateRobots 生成robots.txt
func generateRobots() {
tpl := xmlTmpl.Lookup("robotsTpl.xml")
if tpl == nil {
logrus.Info("file: not found: robotsTpl.xml")
return
}
params := map[string]string{
"Host": config.Conf.Host,
}
f, err := os.OpenFile("assets/robots.txt", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
if err != nil {
logrus.Error("file: generateRobots.OpenFile: ", err)
return
}
defer f.Close()
err = tpl.Execute(f, params)
if err != nil {
logrus.Error("file: generateRobots.Execute: ", err)
return
}
}
// generateCrossdomain 生成crossdomain.xml
func generateCrossdomain() {
tpl := xmlTmpl.Lookup("crossdomainTpl.xml")
if tpl == nil {
logrus.Info("file: not found: crossdomainTpl.xml")
return
}
params := map[string]string{
"Host": config.Conf.Host,
}
f, err := os.OpenFile("assets/crossdomain.xml", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
if err != nil {
logrus.Error("file: generateCrossdomain.OpenFile: ", err)
return
}
defer f.Close()
err = tpl.Execute(f, params)
if err != nil {
logrus.Error("file: generateCrossdomain.Execute: ", err)
return
}
}

View File

@@ -0,0 +1,581 @@
// Package internal provides ...
package internal
import (
"bytes"
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/eiblog/eiblog/cmd/eiblog/config"
"github.com/eiblog/eiblog/cmd/eiblog/handler/internal/store"
"github.com/eiblog/eiblog/pkg/model"
"github.com/eiblog/eiblog/tools"
"github.com/sirupsen/logrus"
)
var (
// Ei eiblog cache
Ei *Cache
// PagesCh regenerate pages chan
PagesCh = make(chan string, 2)
// PageSeries the page series regenerate flag
PageSeries = "series-md"
// PageArchive the page archive regenerate flag
PageArchive = "archive-md"
// ArticleStartID article start id
ArticleStartID = 11
// TrashArticleExp trash article timeout
TrashArticleExp = time.Duration(-48) * time.Hour
)
func init() {
// init timezone
var err error
tools.TimeLocation, err = time.LoadLocation(
config.Conf.General.Timezone)
if err != nil {
panic(err)
}
// init store
logrus.Info("store drivers: ", store.Drivers())
store, err := store.NewStore(config.Conf.Database.Driver,
config.Conf.Database.Source)
if err != nil {
panic(err)
}
// Ei init
Ei = &Cache{
lock: sync.Mutex{},
Store: store,
TagArticles: make(map[string]model.SortedArticles),
ArticlesMap: make(map[string]*model.Article),
}
err = Ei.loadOrInit()
if err != nil {
panic(err)
}
go Ei.regeneratePages()
go Ei.timerClean()
go Ei.timerDisqus()
}
// Cache 整站缓存
type Cache struct {
lock sync.Mutex
store.Store
// load from db
Blogger *model.Blogger
Account *model.Account
Articles model.SortedArticles
// auto generate
PageSeries string // page
Series model.SortedSeries
PageArchives string // page
Archives model.SortedArchives
TagArticles map[string]model.SortedArticles // tagname:articles
ArticlesMap map[string]*model.Article // slug:article
}
// AddArticle 添加文章
func (c *Cache) AddArticle(article *model.Article) error {
c.lock.Lock()
defer c.lock.Unlock()
// store
err := c.InsertArticle(context.Background(), article, ArticleStartID)
if err != nil {
return err
}
// 是否是草稿
if article.IsDraft {
return nil
}
// 正式发布文章
c.refreshCache(article, false)
return nil
}
// RepArticle 替换文章
func (c *Cache) RepArticle(oldArticle, newArticle *model.Article) {
c.lock.Lock()
defer c.lock.Unlock()
c.ArticlesMap[newArticle.Slug] = newArticle
GenerateExcerptMarkdown(newArticle)
if newArticle.ID < ArticleStartID {
return
}
if oldArticle != nil { // 移除旧文章
c.refreshCache(oldArticle, true)
}
c.refreshCache(newArticle, false)
}
// DelArticle 删除文章
func (c *Cache) DelArticle(id int) error {
c.lock.Lock()
defer c.lock.Unlock()
article, _ := c.FindArticleByID(id)
if article == nil {
return nil
}
// set delete
err := c.UpdateArticle(context.Background(), id, map[string]interface{}{
"deleted_at": time.Now(),
})
if err != nil {
return err
}
// drop from tags,series,archives
c.refreshCache(article, true)
return nil
}
// AddSerie 添加专题
func (c *Cache) AddSerie(serie *model.Serie) error {
c.lock.Lock()
defer c.lock.Unlock()
err := c.InsertSerie(context.Background(), serie)
if err != nil {
return err
}
c.Series = append(c.Series, serie)
PagesCh <- PageSeries
return nil
}
// DelSerie 删除专题
func (c *Cache) DelSerie(id int) error {
c.lock.Lock()
defer c.lock.Unlock()
for i, serie := range c.Series {
if serie.ID == id {
if len(serie.Articles) > 0 {
return errors.New("请删除该专题下的所有文章")
}
err := c.RemoveSerie(context.Background(), id)
if err != nil {
return err
}
c.Series[i] = nil
c.Series = append(c.Series[:i], c.Series[i+1:]...)
PagesCh <- PageSeries
break
}
}
return nil
}
// PageArticleFE 文章翻页
func (c *Cache) PageArticleFE(page int, pageSize int) (prev,
next int, articles []*model.Article) {
var l int
for l = len(c.Articles); l > 0; l-- {
if c.Articles[l-1].ID >= ArticleStartID {
break
}
}
if l == 0 {
return 0, 0, nil
}
m := l / pageSize
if d := l % pageSize; d > 0 {
m++
}
if page > m {
page = m
}
if page > 1 {
prev = page - 1
}
if page < m {
next = page + 1
}
s := (page - 1) * pageSize
e := page * pageSize
if e > l {
e = l
}
articles = c.Articles[s:e]
return
}
// PageArticleBE 后台文章分页
func (c *Cache) PageArticleBE(se int, kw string, draft, del bool, p,
n int) ([]*model.Article, int) {
search := store.SearchArticles{
Page: p,
Limit: n,
Fields: make(map[string]interface{}),
}
if draft {
search.Fields[store.SearchArticleDraft] = true
} else if del {
search.Fields[store.SearchArticleTrash] = true
} else {
search.Fields[store.SearchArticleDraft] = false
if se > 0 {
search.Fields[store.SearchArticleSerieID] = se
}
if kw != "" {
search.Fields[store.SearchArticleTitle] = kw
}
}
articles, count, err := c.LoadArticleList(context.Background(), search)
if err != nil {
return nil, 0
}
max := count / n
if count%n > 0 {
max++
}
return articles, max
}
// FindArticleByID 通过ID查找文章
func (c *Cache) FindArticleByID(id int) (*model.Article, int) {
for i, article := range c.Articles {
if article.ID == id {
return article, i
}
}
return nil, -1
}
// refreshCache 刷新缓存
func (c *Cache) refreshCache(article *model.Article, del bool) {
if del {
_, idx := c.FindArticleByID(article.ID)
delete(c.ArticlesMap, article.Slug)
c.Articles = append(c.Articles[:idx], c.Articles[idx+1:]...)
// 从链表移除
c.recalcLinkedList(article, true)
// 从tag、serie、archive移除
c.redelArticle(article)
return
}
// 添加文章
defer GenerateExcerptMarkdown(article)
c.ArticlesMap[article.Slug] = article
c.Articles = append([]*model.Article{article}, c.Articles...)
sort.Sort(c.Articles)
// 从链表添加
c.recalcLinkedList(article, false)
// 从tag、serie、archive添加
c.readdArticle(article, true)
}
// recalcLinkedList 重算文章链表
func (c *Cache) recalcLinkedList(article *model.Article, del bool) {
// 删除操作
if del {
if article.Prev == nil && article.Next != nil {
article.Next.Prev = nil
} else if article.Prev != nil && article.Next == nil {
article.Prev.Next = nil
} else if article.Prev != nil && article.Next != nil {
article.Prev.Next = article.Next
article.Next.Prev = article.Prev
}
return
}
// 添加操作
_, idx := c.FindArticleByID(article.ID)
if idx == 0 && c.Articles[idx+1].ID >= ArticleStartID {
article.Next = c.Articles[idx+1]
c.Articles[idx+1].Prev = article
} else if idx > 0 && c.Articles[idx-1].ID >= ArticleStartID {
article.Prev = c.Articles[idx-1]
if c.Articles[idx-1].Next != nil {
article.Next = c.Articles[idx-1].Next
c.Articles[idx-1].Next.Prev = article
}
c.Articles[idx-1].Next = article
}
}
// readdArticle 添加文章到tag、series、archive
func (c *Cache) readdArticle(article *model.Article, needSort bool) {
// tag
for _, tag := range article.Tags {
c.TagArticles[tag] = append(c.TagArticles[tag], article)
if needSort {
sort.Sort(c.TagArticles[tag])
}
}
// series
for i, serie := range c.Series {
if serie.ID != article.SerieID {
continue
}
c.Series[i].Articles = append(c.Series[i].Articles, article)
if needSort {
sort.Sort(c.Series[i].Articles)
PagesCh <- PageSeries // 重建专题
}
}
// archive
y, m, _ := article.CreatedAt.Date()
for i, archive := range c.Archives {
ay, am, _ := archive.Time.Date()
if y != ay || m != am {
continue
}
c.Archives[i].Articles = append(c.Archives[i].Articles, article)
if needSort {
sort.Sort(c.Archives[i].Articles)
PagesCh <- PageArchive // 重建归档
}
return
}
// 新建归档
c.Archives = append(c.Archives, &model.Archive{
Time: article.CreatedAt,
Articles: model.SortedArticles{article},
})
if needSort { // 重建归档
PagesCh <- PageArchive
}
}
// redelArticle 从tag、series、archive删除文章
func (c *Cache) redelArticle(article *model.Article) {
// tag
for _, tag := range article.Tags {
for i, v := range c.TagArticles[tag] {
if v == article {
c.TagArticles[tag] = append(c.TagArticles[tag][0:i], c.TagArticles[tag][i+1:]...)
if len(c.TagArticles[tag]) == 0 {
delete(c.TagArticles, tag)
}
}
}
}
// serie
for i, serie := range c.Series {
if serie.ID == article.SerieID {
for j, v := range serie.Articles {
if v == article {
c.Series[i].Articles = append(c.Series[i].Articles[0:j],
c.Series[i].Articles[j+1:]...)
PagesCh <- PageSeries
break
}
}
}
}
// archive
for i, archive := range c.Archives {
ay, am, _ := archive.Time.Date()
if y, m, _ := article.CreatedAt.Date(); ay == y && am == m {
for j, v := range archive.Articles {
if v == article {
c.Archives[i].Articles = append(c.Archives[i].Articles[0:j],
c.Archives[i].Articles[j+1:]...)
if len(c.Archives[i].Articles) == 0 {
c.Archives = append(c.Archives[:i], c.Archives[i+1:]...)
}
PagesCh <- PageArchive
break
}
}
}
}
}
// loadOrInit 读取数据或初始化
func (c *Cache) loadOrInit() error {
// blogger
blogger := &model.Blogger{
BlogName: strings.Title(config.Conf.Account.Username),
SubTitle: "Rome was not built in one day.",
BeiAn: "蜀ICP备xxxxxxxx号-1",
BTitle: fmt.Sprintf("%s's Blog", strings.Title(config.Conf.Account.Username)),
Copyright: `本站使用「<a href="//creativecommons.org/licenses/by/4.0/">署名 4.0 国际</a>」创作共享协议,转载请注明作者及原网址。`,
}
created, err := c.LoadInsertBlogger(context.Background(), blogger)
if err != nil {
return err
}
c.Blogger = blogger
if created { // init articles: about blogroll
about := &model.Article{
ID: 1, // 固定ID
Author: config.Conf.Account.Username,
Title: "关于",
Slug: "about",
CreatedAt: time.Time{}.AddDate(0, 0, 1),
}
err = c.InsertArticle(context.Background(), about, ArticleStartID)
if err != nil {
return err
}
// 推送到 disqus
go DisqusClient.ThreadCreate(about, blogger.BTitle)
blogroll := &model.Article{
ID: 2, // 固定ID
Author: config.Conf.Account.Username,
Title: "友情链接",
Slug: "blogroll",
CreatedAt: time.Time{}.AddDate(0, 0, 7),
}
err = c.InsertArticle(context.Background(), blogroll, ArticleStartID)
if err != nil {
return err
}
}
// account
pwd := tools.EncryptPasswd(config.Conf.Account.Username,
config.Conf.Account.Password)
account := &model.Account{
Username: config.Conf.Account.Username,
Password: pwd,
}
_, err = c.LoadInsertAccount(context.Background(), account)
if err != nil {
return err
}
c.Account = account
// series
series, err := c.LoadAllSerie(context.Background())
if err != nil {
return err
}
c.Series = series
// all articles
search := store.SearchArticles{
Page: 1,
Limit: 9999,
Fields: map[string]interface{}{store.SearchArticleDraft: false},
}
articles, _, err := c.LoadArticleList(context.Background(), search)
if err != nil {
return err
}
for i, v := range articles {
// 渲染页面
GenerateExcerptMarkdown(v)
c.ArticlesMap[v.Slug] = v
// 分析文章
if v.ID < ArticleStartID {
continue
}
if i > 0 {
v.Prev = articles[i-1]
}
if i < len(articles)-1 &&
articles[i+1].ID >= ArticleStartID {
v.Next = articles[i+1]
}
c.readdArticle(v, false)
}
Ei.Articles = articles
// 重建专题与归档
PagesCh <- PageSeries
PagesCh <- PageArchive
return nil
}
// regeneratePages 重新生成series,archive页面
func (c *Cache) regeneratePages() {
for {
switch page := <-PagesCh; page {
case PageSeries:
sort.Sort(c.Series)
buf := bytes.Buffer{}
buf.WriteString(c.Blogger.SeriesSay)
buf.WriteString("\n\n")
for _, series := range c.Series {
buf.WriteString(fmt.Sprintf("### %s{#toc-%d}", series.Name, series.ID))
buf.WriteByte('\n')
buf.WriteString(series.Desc)
buf.WriteString("\n\n")
for _, article := range series.Articles {
//eg. * [标题一](/post/hello-world.html) <span class="date">(Man 02, 2006)</span>
str := fmt.Sprintf("* [%s](/post/%s.html) <span class=\"date\">(%s)</span>\n",
article.Title, article.Slug, article.CreatedAt.Format("Jan 02, 2006"))
buf.WriteString(str)
}
buf.WriteString("\n")
}
c.PageSeries = string(PageRender(buf.Bytes()))
case PageArchive:
sort.Sort(c.Archives)
buf := bytes.Buffer{}
buf.WriteString(c.Blogger.ArchivesSay + "\n")
var (
currentYear string
gt12Month = len(c.Archives) > 12
)
for _, archive := range c.Archives {
t := archive.Time.In(tools.TimeLocation)
if gt12Month {
year := t.Format("2006 年")
if currentYear != year {
currentYear = year
buf.WriteString(fmt.Sprintf("\n### %s\n\n", t.Format("2006 年")))
}
} else {
buf.WriteString(fmt.Sprintf("\n### %s\n\n", t.Format("2006年1月")))
}
for i, article := range archive.Articles {
createdAt := article.CreatedAt.In(tools.TimeLocation)
if i == 0 && gt12Month {
str := fmt.Sprintf("* *[%s](/post/%s.html) <span class=\"date\">(%s)</span>*\n",
article.Title, article.Slug, createdAt.Format("Jan 02, 2006"))
buf.WriteString(str)
} else {
str := fmt.Sprintf("* [%s](/post/%s.html) <span class=\"date\">(%s)</span>\n",
article.Title, article.Slug, createdAt.Format("Jan 02, 2006"))
buf.WriteString(str)
}
}
}
c.PageArchives = string(PageRender(buf.Bytes()))
}
}
}
// timerClean 定时清理文章
func (c *Cache) timerClean() {
ticker := time.NewTicker(time.Hour)
for now := range ticker.C {
exp := now.Add(TrashArticleExp)
err := c.CleanArticles(context.Background(), exp)
if err != nil {
logrus.Error("cache.timerClean.CleanArticles: ", err)
}
}
}
// timerDisqus disqus定时操作
func (c *Cache) timerDisqus() {
ticker := time.NewTicker(5 * time.Hour)
for range ticker.C {
err := DisqusClient.PostsCount(c.ArticlesMap)
if err != nil {
logrus.Error("cache.timerDisqus.PostsCount: ", err)
}
}
}

View File

@@ -0,0 +1,38 @@
package internal
import (
"github.com/eiblog/eiblog/cmd/eiblog/config"
"github.com/eiblog/eiblog/pkg/third/disqus"
"github.com/eiblog/eiblog/pkg/third/es"
"github.com/eiblog/eiblog/pkg/third/pinger"
"github.com/eiblog/eiblog/pkg/third/qiniu"
"github.com/sirupsen/logrus"
)
var (
ESClient *es.ESClient
DisqusClient *disqus.DisqusClient
QiniuClient *qiniu.QiniuClient
Pinger *pinger.Pinger
)
func init() {
var err error
ESClient, err = es.NewESClient(config.Conf.ESHost)
if err != nil {
logrus.Fatal("init es client: ", err)
}
DisqusClient, err = disqus.NewDisqusClient(config.Conf.Host, config.Conf.Disqus)
if err != nil {
logrus.Fatal("init disqus client: ", err)
}
QiniuClient, err = qiniu.NewQiniuClient(config.Conf.Qiniu)
if err != nil {
logrus.Fatal("init qiniu client: ", err)
}
Pinger, err = pinger.NewPinger(config.Conf.Host, config.Conf.FeedRPC)
if err != nil {
logrus.Fatal("init pinger: ", err)
}
}

View File

@@ -0,0 +1,82 @@
package internal
import (
"regexp"
"strings"
"github.com/eiblog/eiblog/cmd/eiblog/config"
"github.com/eiblog/eiblog/pkg/model"
"github.com/eiblog/eiblog/tools"
"github.com/eiblog/blackfriday"
)
// blackfriday 配置
const (
commonHTMLFlags = 0 |
blackfriday.HTML_TOC |
blackfriday.HTML_USE_XHTML |
blackfriday.HTML_USE_SMARTYPANTS |
blackfriday.HTML_SMARTYPANTS_FRACTIONS |
blackfriday.HTML_SMARTYPANTS_DASHES |
blackfriday.HTML_SMARTYPANTS_LATEX_DASHES |
blackfriday.HTML_NOFOLLOW_LINKS
commonExtensions = 0 |
blackfriday.EXTENSION_NO_INTRA_EMPHASIS |
blackfriday.EXTENSION_TABLES |
blackfriday.EXTENSION_FENCED_CODE |
blackfriday.EXTENSION_AUTOLINK |
blackfriday.EXTENSION_STRIKETHROUGH |
blackfriday.EXTENSION_SPACE_HEADERS |
blackfriday.EXTENSION_HEADER_IDS |
blackfriday.EXTENSION_BACKSLASH_LINE_BREAK |
blackfriday.EXTENSION_DEFINITION_LISTS
)
var (
// 渲染markdown操作和截取摘要操作
regIdentifier = regexp.MustCompile(config.Conf.General.Identifier)
// header
regHeader = regexp.MustCompile("</nav></div>")
)
// PageRender 渲染markdown
func PageRender(md []byte) []byte {
renderer := blackfriday.HtmlRenderer(commonHTMLFlags, "", "")
return blackfriday.Markdown(md, renderer, commonExtensions)
}
// GenerateExcerptMarkdown 生成预览和描述
func GenerateExcerptMarkdown(article *model.Article) {
if strings.HasPrefix(article.Content, config.Conf.General.DescPrefix) {
index := strings.Index(article.Content, "\r\n")
prefix := article.Content[len(config.Conf.General.DescPrefix):index]
article.Desc = tools.IgnoreHTMLTag(prefix)
article.Content = article.Content[index:]
}
// 查找目录
content := PageRender([]byte(article.Content))
index := regHeader.FindIndex(content)
if index != nil {
article.Header = string(content[0:index[1]])
article.Content = string(content[index[1]:])
} else {
article.Content = string(content)
}
// excerpt
index = regIdentifier.FindStringIndex(article.Content)
if index != nil {
article.Excerpt = tools.IgnoreHTMLTag(article.Content[:index[0]])
return
}
uc := []rune(article.Content)
length := config.Conf.General.Length
if len(uc) < length {
length = len(uc)
}
article.Excerpt = tools.IgnoreHTMLTag(string(uc[0:length]))
}

View File

@@ -0,0 +1,355 @@
// Package store provides ...
package store
import (
"context"
"sort"
"time"
"github.com/eiblog/eiblog/pkg/model"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"go.mongodb.org/mongo-driver/mongo/readpref"
)
// example:
// driver: mongodb
// source: mongodb://localhost:27017
const (
mongoDBName = "eiblog"
collectionAccount = "account"
collectionArticle = "article"
collectionBlogger = "blogger"
collectionCounter = "counter"
collectionSerie = "serie"
counterNameSerie = "serie"
counterNameArticle = "article"
)
type mongodb struct {
*mongo.Client
}
// Init init mongodb client
func (db *mongodb) Init(name, source string) (Store, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
opts := options.Client().ApplyURI(source)
client, err := mongo.Connect(ctx, opts)
if err != nil {
return nil, err
}
err = client.Ping(ctx, readpref.Primary())
if err != nil {
return nil, err
}
db.Client = client
// create index
indexModel := mongo.IndexModel{
Keys: bson.D{bson.E{Key: "username", Value: 1}},
Options: options.Index().SetUnique(true).SetSparse(true),
}
db.Database(mongoDBName).Collection(collectionAccount).
Indexes().
CreateOne(context.Background(), indexModel)
indexModel = mongo.IndexModel{
Keys: bson.D{bson.E{Key: "slug", Value: 1}},
Options: options.Index().SetUnique(true).SetSparse(true),
}
db.Database(mongoDBName).Collection(collectionArticle).
Indexes().
CreateOne(context.Background(), indexModel)
indexModel = mongo.IndexModel{
Keys: bson.D{bson.E{Key: "slug", Value: 1}},
Options: options.Index().SetUnique(true).SetSparse(true),
}
db.Database(mongoDBName).Collection(collectionSerie).
Indexes().
CreateOne(context.Background(), indexModel)
return db, nil
}
// LoadInsertBlogger 读取或创建博客
func (db *mongodb) LoadInsertBlogger(ctx context.Context,
blogger *model.Blogger) (created bool, err error) {
collection := db.Database(mongoDBName).Collection(collectionBlogger)
filter := bson.M{}
result := collection.FindOne(ctx, filter)
err = result.Err()
if err != nil {
if err != mongo.ErrNoDocuments {
return
}
_, err = collection.InsertOne(ctx, blogger)
created = true
} else {
err = result.Decode(blogger)
}
return
}
// UpdateBlogger 更新博客
func (db *mongodb) UpdateBlogger(ctx context.Context,
fields map[string]interface{}) error {
collection := db.Database(mongoDBName).Collection(collectionBlogger)
filter := bson.M{}
params := bson.M{}
for k, v := range fields {
params[k] = v
}
update := bson.M{"$set": params}
_, err := collection.UpdateOne(ctx, filter, update)
return err
}
// LoadInsertAccount 读取或创建账户
func (db *mongodb) LoadInsertAccount(ctx context.Context,
acct *model.Account) (created bool, err error) {
collection := db.Database(mongoDBName).Collection(collectionAccount)
filter := bson.M{"username": acct.Username}
result := collection.FindOne(ctx, filter)
err = result.Err()
if err != nil {
if err != mongo.ErrNoDocuments {
return
}
_, err = collection.InsertOne(ctx, acct)
created = true
} else {
err = result.Decode(acct)
}
return
}
// UpdateAccount 更新账户
func (db *mongodb) UpdateAccount(ctx context.Context, name string,
fields map[string]interface{}) error {
collection := db.Database(mongoDBName).Collection(collectionAccount)
filter := bson.M{"username": name}
params := bson.M{}
for k, v := range fields {
params[k] = v
}
update := bson.M{"$set": params}
_, err := collection.UpdateOne(ctx, filter, update)
return err
}
// InsertSerie 创建专题
func (db *mongodb) InsertSerie(ctx context.Context, serie *model.Serie) error {
collection := db.Database(mongoDBName).Collection(collectionSerie)
serie.ID = db.nextValue(ctx, counterNameSerie)
_, err := collection.InsertOne(ctx, serie)
return err
}
// RemoveSerie 删除专题
func (db *mongodb) RemoveSerie(ctx context.Context, id int) error {
collection := db.Database(mongoDBName).Collection(collectionSerie)
filter := bson.M{"id": id}
_, err := collection.DeleteOne(ctx, filter)
return err
}
// UpdateSerie 更新专题
func (db *mongodb) UpdateSerie(ctx context.Context, id int,
fields map[string]interface{}) error {
collection := db.Database(mongoDBName).Collection(collectionSerie)
filter := bson.M{"id": id}
params := bson.M{}
for k, v := range fields {
params[k] = v
}
update := bson.M{"$set": params}
_, err := collection.UpdateOne(ctx, filter, update)
return err
}
// LoadAllSerie 查询所有专题
func (db *mongodb) LoadAllSerie(ctx context.Context) (model.SortedSeries, error) {
collection := db.Database(mongoDBName).Collection(collectionSerie)
opts := options.Find().SetSort(bson.M{"id": -1})
filter := bson.M{}
cur, err := collection.Find(ctx, filter, opts)
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var series model.SortedSeries
for cur.Next(ctx) {
obj := model.Serie{}
err = cur.Decode(&obj)
if err != nil {
return nil, err
}
series = append(series, &obj)
}
return series, nil
}
// InsertArticle 创建文章
func (db *mongodb) InsertArticle(ctx context.Context, article *model.Article, startID int) error {
// 可手动分配ID或者分配ID, 占位至起始id
for article.ID == 0 {
id := db.nextValue(ctx, counterNameArticle)
if id < startID {
continue
} else {
article.ID = id
}
}
collection := db.Database(mongoDBName).Collection(collectionArticle)
_, err := collection.InsertOne(ctx, article)
return err
}
// RemoveArticle 硬删除文章
func (db *mongodb) RemoveArticle(ctx context.Context, id int) error {
collection := db.Database(mongoDBName).Collection(collectionArticle)
filter := bson.M{"id": id}
_, err := collection.DeleteOne(ctx, filter)
return err
}
// CleanArticles 清理回收站文章
func (db *mongodb) CleanArticles(ctx context.Context, exp time.Time) error {
collection := db.Database(mongoDBName).Collection(collectionArticle)
// 超过两天自动删除
filter := bson.M{"deleted_at": bson.M{"$gt": time.Time{}, "$lt": exp}}
_, err := collection.DeleteMany(ctx, filter)
return err
}
// UpdateArticle 更新文章
func (db *mongodb) UpdateArticle(ctx context.Context, id int,
fields map[string]interface{}) error {
collection := db.Database(mongoDBName).Collection(collectionArticle)
filter := bson.M{"id": id}
params := bson.M{}
for k, v := range fields {
params[k] = v
}
update := bson.M{"$set": params}
_, err := collection.UpdateOne(ctx, filter, update)
return err
}
// LoadArticle 查找文章
func (db *mongodb) LoadArticle(ctx context.Context, id int) (*model.Article, error) {
collection := db.Database(mongoDBName).Collection(collectionArticle)
filter := bson.M{"id": id}
result := collection.FindOne(ctx, filter)
err := result.Err()
if err != nil {
return nil, err
}
article := &model.Article{}
err = result.Decode(article)
return article, err
}
// LoadArticleList 获取文章列表
func (db *mongodb) LoadArticleList(ctx context.Context, search SearchArticles) (
model.SortedArticles, int, error) {
collection := db.Database(mongoDBName).Collection(collectionArticle)
filter := bson.M{}
for k, v := range search.Fields {
switch k {
case SearchArticleDraft:
if ok := v.(bool); ok {
filter["is_draft"] = true
} else {
filter["is_draft"] = false
filter["deleted_at"] = bson.M{"$eq": time.Time{}}
}
case SearchArticleTitle:
filter["title"] = bson.M{
"$regex": v.(string),
"$options": "$i",
}
case SearchArticleSerieID:
filter["serie_id"] = v.(int)
case SearchArticleTrash:
filter["deleted_at"] = bson.M{"$ne": time.Time{}}
}
}
// search count
count, err := collection.CountDocuments(ctx, filter)
if err != nil {
return nil, 0, err
}
opts := options.Find().SetLimit(int64(search.Limit)).
SetSkip(int64((search.Page - 1) * search.Limit)).
SetSort(bson.M{"created_at": -1})
cur, err := collection.Find(ctx, filter, opts)
if err != nil {
return nil, 0, err
}
defer cur.Close(ctx)
var articles model.SortedArticles
for cur.Next(ctx) {
obj := model.Article{}
err = cur.Decode(&obj)
if err != nil {
return nil, 0, err
}
articles = append(articles, &obj)
}
sort.Sort(articles)
return articles, int(count), nil
}
// counter counter
type counter struct {
Name string
NextVal int
}
// nextValue counter value
func (db *mongodb) nextValue(ctx context.Context, name string) int {
collection := db.Database(mongoDBName).Collection(collectionCounter)
opts := options.FindOneAndUpdate().SetUpsert(true).
SetReturnDocument(options.After)
filter := bson.M{"name": name}
update := bson.M{"$inc": bson.M{"nextval": 1}}
next := counter{}
err := collection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&next)
if err != nil {
return -1
}
return next.NextVal
}
// register store
func init() {
Register("mongodb", &mongodb{})
}

View File

@@ -0,0 +1,183 @@
// Package store provides ...
package store
import (
"context"
"testing"
"time"
"github.com/eiblog/eiblog/pkg/model"
)
var (
store Store
acct *model.Account
blogger *model.Blogger
series *model.Serie
article *model.Article
)
func init() {
var err error
store, err = NewStore("mongodb", "mongodb://127.0.0.1:27017")
if err != nil {
panic(err)
}
// account
acct = &model.Account{
Username: "deepzz",
Password: "deepzz",
Email: "deepzz@example.com",
PhoneN: "12345678900",
Address: "address",
CreatedAt: time.Now(),
}
// blogger
blogger = &model.Blogger{
BlogName: "Deepzz",
SubTitle: "不抛弃,不放弃",
BeiAn: "beian",
BTitle: "Deepzz's Blog",
Copyright: "Copyright",
}
// series
series = &model.Serie{
Slug: "slug",
Name: "series name",
Desc: "series desc",
CreatedAt: time.Now(),
}
// article
article = &model.Article{
Author: "deepzz",
Slug: "slug",
Title: "title",
Count: 0,
Content: "### count",
SerieID: 0,
Tags: nil,
IsDraft: false,
UpdatedAt: time.Now(),
CreatedAt: time.Now(),
}
}
func TestLoadInsertAccount(t *testing.T) {
ok, err := store.LoadInsertAccount(context.Background(), acct)
if err != nil {
t.Fatal(err)
}
t.Log(ok)
}
func TestUpdateAccount(t *testing.T) {
err := store.UpdateAccount(context.Background(), "deepzz", map[string]interface{}{
"phonn": "09876543211",
"loginua": "chrome",
"password": "123456",
"logintime": time.Now(),
"logouttime": time.Now(),
})
if err != nil {
t.Fatal(err)
}
}
func TestLoadInsertBlogger(t *testing.T) {
ok, err := store.LoadInsertBlogger(context.Background(), blogger)
if err != nil {
t.Fatal(err)
}
t.Log(ok)
}
func TestUpdateBlogger(t *testing.T) {
err := store.UpdateBlogger(context.Background(), map[string]interface{}{
"blogname": "blogname",
})
if err != nil {
t.Fatal(err)
}
}
func TestInsertSeries(t *testing.T) {
err := store.InsertSerie(context.Background(), series)
if err != nil {
t.Fatal(err)
}
}
func TestRemoveSeries(t *testing.T) {
err := store.RemoveSerie(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
}
func TestUpdateSeries(t *testing.T) {
err := store.UpdateSerie(context.Background(), 2, map[string]interface{}{
"desc": "update desc",
})
if err != nil {
t.Fatal(err)
}
}
func TestLoadAllSeries(t *testing.T) {
series, err := store.LoadAllSerie(context.Background())
if err != nil {
t.Fatal(err)
}
t.Logf("load all series: %d", len(series))
}
func TestInsertArticle(t *testing.T) {
article.ID = 12
err := store.InsertArticle(context.Background(), article, 10)
if err != nil {
t.Fatal(err)
}
}
func TestRemoveArticle(t *testing.T) {
err := store.RemoveArticle(context.Background(), 11)
if err != nil {
t.Fatal(err)
}
}
func TestDeleteArticle(t *testing.T) {
err := store.RemoveArticle(context.Background(), 12)
if err != nil {
t.Fatal(err)
}
}
func TestCleanArticles(t *testing.T) {
err := store.CleanArticles(context.Background(), time.Now())
if err != nil {
t.Fatal(err)
}
}
func TestUpdateArticle(t *testing.T) {
err := store.UpdateArticle(context.Background(), 13, map[string]interface{}{
"title": "new title",
"updatetime": time.Now(),
})
if err != nil {
t.Fatal(err)
}
}
func TestLoadAllArticle(t *testing.T) {
_, total, err := store.LoadArticleList(context.Background(), SearchArticles{
Page: 1,
Limit: 1000,
})
if err != nil {
t.Fatal(err)
}
t.Logf("load all articles: %d", total)
}

View File

@@ -0,0 +1,206 @@
// Package store provides ...
package store
import (
"context"
"errors"
"time"
"github.com/eiblog/eiblog/pkg/model"
"gorm.io/driver/clickhouse"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/driver/sqlserver"
"gorm.io/gorm"
)
// example:
// driver: mysql
// source: user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local
//
// driver: postgres
// source: host=localhost user=gorm password=gorm dbname=gorm port=9920 sslmode=disable
//
// driver: sqlite
// source: /path/gorm.db
//
// driver: sqlserver
// source: sqlserver://gorm:LoremIpsum86@localhost:9930?database=gorm
//
// driver: clickhouse
// source: tcp://localhost:9000?database=gorm&username=gorm&password=gorm&read_timeout=10&write_timeout=20
type rdbms struct {
*gorm.DB
}
// Init 数据库初始化, 建表, 加索引操作等
// name 应该为具体的关系数据库驱动名
func (db *rdbms) Init(name, source string) (Store, error) {
var (
gormDB *gorm.DB
err error
)
switch name {
case "mysql":
// https://github.com/go-sql-driver/mysql
gormDB, err = gorm.Open(mysql.Open(source), &gorm.Config{})
case "postgres":
// https://github.com/go-gorm/postgres
gormDB, err = gorm.Open(postgres.Open(source), &gorm.Config{})
case "sqlite":
// github.com/mattn/go-sqlite3
gormDB, err = gorm.Open(sqlite.Open(source), &gorm.Config{})
case "sqlserver":
// github.com/denisenkom/go-mssqldb
gormDB, err = gorm.Open(sqlserver.Open(source), &gorm.Config{})
case "clickhouse":
gormDB, err = gorm.Open(clickhouse.Open(source), &gorm.Config{})
}
if err != nil {
return nil, err
}
// auto migrate
gormDB.AutoMigrate(
&model.Account{},
&model.Blogger{},
&model.Article{},
&model.Serie{},
)
db.DB = gormDB
return db, nil
}
// LoadInsertBlogger 读取或创建博客
func (db *rdbms) LoadInsertBlogger(ctx context.Context, blogger *model.Blogger) (bool, error) {
result := db.FirstOrCreate(blogger)
return result.RowsAffected > 0, result.Error
}
// UpdateBlogger 更新博客
func (db *rdbms) UpdateBlogger(ctx context.Context, fields map[string]interface{}) error {
return db.Model(model.Blogger{}).Session(&gorm.Session{AllowGlobalUpdate: true}).
Updates(fields).Error
}
// LoadInsertAccount 读取或创建账户
func (db *rdbms) LoadInsertAccount(ctx context.Context, acct *model.Account) (bool, error) {
result := db.Where("username=?", acct.Username).FirstOrCreate(acct)
return result.RowsAffected > 0, result.Error
}
// UpdateAccount 更新账户
func (db *rdbms) UpdateAccount(ctx context.Context, name string, fields map[string]interface{}) error {
return db.Model(model.Account{}).Where("username=?", name).Updates(fields).Error
}
// InsertSerie 创建专题
func (db *rdbms) InsertSerie(ctx context.Context, serie *model.Serie) error {
return db.Create(serie).Error
}
// RemoveSerie 删除专题
func (db *rdbms) RemoveSerie(ctx context.Context, id int) error {
return db.Where("id=?", id).Delete(model.Serie{}).Error
}
// UpdateSerie 更新专题
func (db *rdbms) UpdateSerie(ctx context.Context, id int, fields map[string]interface{}) error {
return db.Model(model.Serie{}).Where("id=?", id).Updates(fields).Error
}
// LoadAllSerie 读取所有专题
func (db *rdbms) LoadAllSerie(ctx context.Context) (model.SortedSeries, error) {
var series model.SortedSeries
err := db.Order("id DESC").Find(&series).Error
return series, err
}
// InsertArticle 创建文章
func (db *rdbms) InsertArticle(ctx context.Context, article *model.Article, startID int) error {
if article.ID == 0 {
// auto generate id
var id int
err := db.Model(model.Article{}).Select("MAX(id)").Row().Scan(&id)
if err != nil {
return err
}
if id < startID {
id = startID
} else {
id++
}
article.ID = id
}
return db.Create(article).Error
}
// RemoveArticle 硬删除文章
func (db *rdbms) RemoveArticle(ctx context.Context, id int) error {
return db.Where("id=?", id).Delete(model.Article{}).Error
}
// CleanArticles 清理回收站文章
func (db *rdbms) CleanArticles(ctx context.Context, exp time.Time) error {
return db.Where("deleted_at > ? AND deleted_at < ?", time.Time{}, exp).Delete(model.Article{}).Error
}
// UpdateArticle 更新文章
func (db *rdbms) UpdateArticle(ctx context.Context, id int, fields map[string]interface{}) error {
return db.Model(model.Article{}).Where("id=?", id).Updates(fields).Error
}
// LoadArticle 查找文章
func (db *rdbms) LoadArticle(ctx context.Context, id int) (*model.Article, error) {
article := &model.Article{}
err := db.Where("id=?", id).First(article).Error
return article, err
}
// LoadArticleList 查找文章列表
func (db *rdbms) LoadArticleList(ctx context.Context, search SearchArticles) (model.SortedArticles, int, error) {
gormDB := db.Model(model.Article{})
for k, v := range search.Fields {
switch k {
case SearchArticleDraft:
if ok := v.(bool); ok {
gormDB = gormDB.Where("is_draft=?", true)
} else {
gormDB = gormDB.Where("is_draft=? AND deleted_at=?", false, time.Time{})
}
case SearchArticleTitle:
gormDB = gormDB.Where("title LIKE ?", "%"+v.(string)+"%")
case SearchArticleSerieID:
gormDB = gormDB.Where("serie_id=?", v.(int))
case SearchArticleTrash:
gormDB = gormDB.Where("deleted_at!=?", time.Time{})
}
}
// search count
var count int64
err := gormDB.Count(&count).Error
if err != nil {
return nil, 0, err
}
var articles model.SortedArticles
err = gormDB.Limit(search.Limit).
Offset((search.Page - 1) * search.Limit).
Order("created_at DESC").Find(&articles).Error
return articles, int(count), err
}
// DropDatabase drop eiblog database
func (db *rdbms) DropDatabase(ctx context.Context) error {
return errors.New("can not drop eiblog database in rdbms")
}
// register store
func init() {
Register("mysql", &rdbms{})
Register("postgres", &rdbms{})
Register("sqlite", &rdbms{})
Register("sqlserver", &rdbms{})
Register("clickhouse", &rdbms{})
}

View File

@@ -0,0 +1,111 @@
// Package store provides ...
package store
import (
"context"
"fmt"
"sort"
"sync"
"time"
"github.com/eiblog/eiblog/pkg/model"
)
var (
storeMu sync.RWMutex
stores = make(map[string]Driver)
)
// search field
const (
SearchArticleDraft = "draft"
SearchArticleTrash = "trash"
SearchArticleTitle = "title"
SearchArticleSerieID = "serieid"
)
// SearchArticles 搜索字段
type SearchArticles struct {
Page int // 第几页/1
Limit int // 每页大小
Fields map[string]interface{} // 字段:值
}
// Store 存储后端
type Store interface {
// LoadInsertBlogger 读取或创建博客
LoadInsertBlogger(ctx context.Context, blogger *model.Blogger) (bool, error)
// UpdateBlogger 更新博客
UpdateBlogger(ctx context.Context, fields map[string]interface{}) error
// LoadInsertAccount 读取或创建账户
LoadInsertAccount(ctx context.Context, acct *model.Account) (bool, error)
// UpdateAccount 更新账户
UpdateAccount(ctx context.Context, name string, fields map[string]interface{}) error
// InsertSerie 创建专题
InsertSerie(ctx context.Context, serie *model.Serie) error
// RemoveSerie 删除专题
RemoveSerie(ctx context.Context, id int) error
// UpdateSerie 更新专题
UpdateSerie(ctx context.Context, id int, fields map[string]interface{}) error
// LoadAllSerie 读取所有专题
LoadAllSerie(ctx context.Context) (model.SortedSeries, error)
// InsertArticle 创建文章
InsertArticle(ctx context.Context, article *model.Article, startID int) error
// RemoveArticle 硬删除文章
RemoveArticle(ctx context.Context, id int) error
// CleanArticles 清理回收站文章
CleanArticles(ctx context.Context, exp time.Time) error
// UpdateArticle 更新文章
UpdateArticle(ctx context.Context, id int, fields map[string]interface{}) error
// LoadArticle 查找文章
LoadArticle(ctx context.Context, id int) (*model.Article, error)
// LoadArticleList 查找文章列表
LoadArticleList(ctx context.Context, search SearchArticles) (model.SortedArticles, int, error)
}
// Driver 存储驱动
type Driver interface {
// Init 数据库初始化, 建表, 加索引操作等
Init(name, source string) (Store, error)
}
// Register 注册驱动
func Register(name string, driver Driver) {
storeMu.Lock()
defer storeMu.Unlock()
if driver == nil {
panic("store: register driver is nil")
}
if _, dup := stores[name]; dup {
panic("store: register called twice for driver " + name)
}
stores[name] = driver
}
// Drivers 获取所有
func Drivers() []string {
storeMu.Lock()
defer storeMu.Unlock()
list := make([]string, 0, len(stores))
for name := range stores {
list = append(list, name)
}
sort.Strings(list)
return list
}
// NewStore 新建存储
func NewStore(name string, source string) (Store, error) {
storeMu.RLock()
driver, ok := stores[name]
storeMu.RUnlock()
if !ok {
return nil, fmt.Errorf("store: unknown driver %q (forgotten import?)", name)
}
return driver.Init(name, source)
}

View File

@@ -0,0 +1,264 @@
package page
import (
"bytes"
"context"
"encoding/json"
"fmt"
htemplate "html/template"
"net/http"
"strconv"
"github.com/eiblog/eiblog/cmd/eiblog/config"
"github.com/eiblog/eiblog/cmd/eiblog/handler/internal"
"github.com/eiblog/eiblog/cmd/eiblog/handler/internal/store"
"github.com/eiblog/eiblog/pkg/middleware"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
// baseBEParams 基础参数
func baseBEParams(_ *gin.Context) gin.H {
return gin.H{
"Author": internal.Ei.Account.Username,
"Qiniu": config.Conf.Qiniu,
}
}
// handleLoginPage 登录页面
func handleLoginPage(c *gin.Context) {
logout := c.Query("logout")
if logout == "true" {
middleware.SetLogout(c)
} else if middleware.IsLogined(c) {
c.Redirect(http.StatusFound, "/admin/profile")
return
}
params := gin.H{"BTitle": internal.Ei.Blogger.BTitle}
renderHTMLAdminLayout(c, "login.html", params)
}
// handleAdminProfile 个人配置
func handleAdminProfile(c *gin.Context) {
params := baseBEParams(c)
params["Title"] = "个人配置 | " + internal.Ei.Blogger.BTitle
params["Path"] = c.Request.URL.Path
params["Console"] = true
params["Ei"] = internal.Ei
renderHTMLAdminLayout(c, "admin-profile", params)
}
// T tag struct
type T struct {
ID string `json:"id"`
Tags string `json:"tags"`
}
// handleAdminPost 写文章页
func handleAdminPost(c *gin.Context) {
params := baseBEParams(c)
id, err := strconv.Atoi(c.Query("cid"))
if err == nil && id > 0 {
article, _ := internal.Ei.LoadArticle(context.Background(), id)
if article != nil {
params["Title"] = "编辑文章 | " + internal.Ei.Blogger.BTitle
params["Edit"] = article
}
}
if params["Title"] == nil {
params["Title"] = "撰写文章 | " + internal.Ei.Blogger.BTitle
}
params["Path"] = c.Request.URL.Path
params["Domain"] = config.Conf.Host
params["Series"] = internal.Ei.Series
var tags []T
for tag := range internal.Ei.TagArticles {
tags = append(tags, T{tag, tag})
}
str, _ := json.Marshal(tags)
params["Tags"] = string(str)
renderHTMLAdminLayout(c, "admin-post", params)
}
// handleAdminPosts 文章管理页
func handleAdminPosts(c *gin.Context) {
kw := c.Query("keywords")
tmp := c.Query("serie")
se, err := strconv.Atoi(tmp)
if err != nil || se < 1 {
se = 0
}
pg, err := strconv.Atoi(c.Query("page"))
if err != nil || pg < 1 {
pg = 1
}
vals := c.Request.URL.Query()
params := baseBEParams(c)
params["Title"] = "文章管理 | " + internal.Ei.Blogger.BTitle
params["Manage"] = true
params["Path"] = c.Request.URL.Path
params["Series"] = internal.Ei.Series
params["Serie"] = se
params["KW"] = kw
var max int
params["List"], max = internal.Ei.PageArticleBE(se, kw, false, false,
pg, config.Conf.General.PageSize)
if pg < max {
vals.Set("page", fmt.Sprint(pg+1))
params["Next"] = vals.Encode()
}
if pg > 1 {
vals.Set("page", fmt.Sprint(pg-1))
params["Prev"] = vals.Encode()
}
params["PP"] = make(map[int]string, max)
for i := 0; i < max; i++ {
vals.Set("page", fmt.Sprint(i+1))
params["PP"].(map[int]string)[i+1] = vals.Encode()
}
params["Cur"] = pg
renderHTMLAdminLayout(c, "admin-posts", params)
}
// handleAdminSeries 专题列表
func handleAdminSeries(c *gin.Context) {
params := baseBEParams(c)
params["Title"] = "专题管理 | " + internal.Ei.Blogger.BTitle
params["Manage"] = true
params["Path"] = c.Request.URL.Path
params["List"] = internal.Ei.Series
renderHTMLAdminLayout(c, "admin-series", params)
}
// handleAdminSerie 编辑专题
func handleAdminSerie(c *gin.Context) {
params := baseBEParams(c)
id, err := strconv.Atoi(c.Query("mid"))
params["Title"] = "新增专题 | " + internal.Ei.Blogger.BTitle
if err == nil && id > 0 {
for _, v := range internal.Ei.Series {
if v.ID == id {
params["Title"] = "编辑专题 | " + internal.Ei.Blogger.BTitle
params["Edit"] = v
break
}
}
}
params["Manage"] = true
params["Path"] = c.Request.URL.Path
renderHTMLAdminLayout(c, "admin-serie", params)
}
// handleAdminTags 标签列表
func handleAdminTags(c *gin.Context) {
params := baseBEParams(c)
params["Title"] = "标签管理 | " + internal.Ei.Blogger.BTitle
params["Manage"] = true
params["Path"] = c.Request.URL.Path
params["List"] = internal.Ei.TagArticles
renderHTMLAdminLayout(c, "admin-tags", params)
}
// handleDraftDelete 编辑页删除草稿
func handleDraftDelete(c *gin.Context) {
id, err := strconv.Atoi(c.Query("cid"))
if err != nil || id < 1 {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
err = internal.Ei.RemoveArticle(context.Background(), id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "删除错误"})
return
}
c.Redirect(http.StatusFound, "/admin/write-post")
}
// handleAdminDraft 草稿箱页
func handleAdminDraft(c *gin.Context) {
params := baseBEParams(c)
params["Title"] = "草稿箱 | " + internal.Ei.Blogger.BTitle
params["Manage"] = true
params["Path"] = c.Request.URL.Path
var err error
search := store.SearchArticles{
Page: 1,
Limit: 9999,
Fields: map[string]interface{}{store.SearchArticleDraft: true},
}
params["List"], _, err = internal.Ei.LoadArticleList(context.Background(), search)
if err != nil {
logrus.Error("handleDraft.LoadDraftArticles: ", err)
c.Status(http.StatusBadRequest)
} else {
c.Status(http.StatusOK)
}
renderHTMLAdminLayout(c, "admin-draft", params)
}
// handleAdminTrash 回收箱页
func handleAdminTrash(c *gin.Context) {
params := baseBEParams(c)
params["Title"] = "回收箱 | " + internal.Ei.Blogger.BTitle
params["Manage"] = true
params["Path"] = c.Request.URL.Path
var err error
search := store.SearchArticles{
Page: 1,
Limit: 9999,
Fields: map[string]interface{}{store.SearchArticleTrash: true},
}
params["List"], _, err = internal.Ei.LoadArticleList(context.Background(), search)
if err != nil {
logrus.Error("handleTrash.LoadArticleList: ", err)
}
renderHTMLAdminLayout(c, "admin-trash", params)
}
// handleAdminGeneral 基本设置
func handleAdminGeneral(c *gin.Context) {
params := baseBEParams(c)
params["Title"] = "基本设置 | " + internal.Ei.Blogger.BTitle
params["Setting"] = true
params["Path"] = c.Request.URL.Path
renderHTMLAdminLayout(c, "admin-general", params)
}
// handleAdminDiscussion 阅读设置
func handleAdminDiscussion(c *gin.Context) {
params := baseBEParams(c)
params["Title"] = "阅读设置 | " + internal.Ei.Blogger.BTitle
params["Setting"] = true
params["Path"] = c.Request.URL.Path
renderHTMLAdminLayout(c, "admin-discussion", params)
}
// renderHTMLAdminLayout 渲染admin页面
func renderHTMLAdminLayout(c *gin.Context, name string, data gin.H) {
c.Header("Content-Type", "text/html; charset=utf-8")
// special page
if name == "login.html" {
err := htmlTmpl.ExecuteTemplate(c.Writer, name, data)
if err != nil {
panic(err)
}
return
}
buf := bytes.Buffer{}
err := htmlTmpl.ExecuteTemplate(&buf, name, data)
if err != nil {
panic(err)
}
data["LayoutContent"] = htemplate.HTML(buf.String())
err = htmlTmpl.ExecuteTemplate(c.Writer, "adminLayout.html", data)
if err != nil {
panic(err)
}
if c.Writer.Status() == 0 {
c.Status(http.StatusOK)
}
}

View File

@@ -0,0 +1,408 @@
package page
import (
"bytes"
"context"
"fmt"
htemplate "html/template"
"io"
"math/rand"
"net/http"
"strconv"
"strings"
"time"
"github.com/eiblog/eiblog/cmd/eiblog/config"
"github.com/eiblog/eiblog/cmd/eiblog/handler/internal"
"github.com/eiblog/eiblog/pkg/third/disqus"
"github.com/eiblog/eiblog/tools"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
// baseFEParams 基础参数
func baseFEParams(c *gin.Context) gin.H {
version := 0
cookie, err := c.Request.Cookie("v")
if err != nil || cookie.Value !=
fmt.Sprint(config.Conf.StaticVersion) {
version = config.Conf.StaticVersion
}
return gin.H{
"BlogName": internal.Ei.Blogger.BlogName,
"SubTitle": internal.Ei.Blogger.SubTitle,
"BTitle": internal.Ei.Blogger.BTitle,
"BeiAn": internal.Ei.Blogger.BeiAn,
"Domain": config.Conf.Host,
"CopyYear": time.Now().Year(),
"Twitter": config.Conf.Twitter,
"Qiniu": config.Conf.Qiniu,
"Disqus": config.Conf.Disqus,
"AdSense": config.Conf.Google.AdSense,
"Version": version,
}
}
// handleNotFound not found page
func handleNotFound(c *gin.Context) {
params := baseFEParams(c)
params["Title"] = "Not Found"
params["Description"] = "404 Not Found"
params["Path"] = ""
c.Status(http.StatusNotFound)
renderHTMLHomeLayout(c, "notfound", params)
}
// handleHomePage 首页
func handleHomePage(c *gin.Context) {
params := baseFEParams(c)
params["Title"] = internal.Ei.Blogger.BTitle + " | " + internal.Ei.Blogger.SubTitle
params["Description"] = "博客首页," + internal.Ei.Blogger.SubTitle
params["Path"] = c.Request.URL.Path
params["CurrentPage"] = "blog-home"
pn, err := strconv.Atoi(c.Query("pn"))
if err != nil || pn < 1 {
pn = 1
}
params["Prev"], params["Next"], params["List"] = internal.Ei.PageArticleFE(pn,
config.Conf.General.PageNum)
renderHTMLHomeLayout(c, "home", params)
}
// handleArticlePage 文章页
func handleArticlePage(c *gin.Context) {
slug := c.Param("slug")
if !strings.HasSuffix(slug, ".html") || internal.Ei.ArticlesMap[slug[:len(slug)-5]] == nil {
handleNotFound(c)
return
}
article := internal.Ei.ArticlesMap[slug[:len(slug)-5]]
params := baseFEParams(c)
params["Title"] = article.Title + " | " + internal.Ei.Blogger.BTitle
params["Path"] = c.Request.URL.Path
params["CurrentPage"] = "post-" + article.Slug
params["Article"] = article
var name string
switch slug {
case "blogroll.html":
name = "blogroll"
params["Description"] = "友情连接," + internal.Ei.Blogger.SubTitle
case "about.html":
name = "about"
params["Description"] = "关于作者," + internal.Ei.Blogger.SubTitle
default:
params["Description"] = article.Desc + "" + internal.Ei.Blogger.SubTitle
name = "article"
params["Copyright"] = internal.Ei.Blogger.Copyright
if !article.UpdatedAt.IsZero() {
params["Days"] = int(time.Since(article.UpdatedAt).Hours()) / 24
} else {
params["Days"] = int(time.Since(article.CreatedAt).Hours()) / 24
}
if article.SerieID > 0 {
for _, series := range internal.Ei.Series {
if series.ID == article.SerieID {
params["Serie"] = series
}
}
}
}
renderHTMLHomeLayout(c, name, params)
}
// handleSeriesPage 专题页
func handleSeriesPage(c *gin.Context) {
params := baseFEParams(c)
params["Title"] = "专题 | " + internal.Ei.Blogger.BTitle
params["Description"] = "专题列表," + internal.Ei.Blogger.SubTitle
params["Path"] = c.Request.URL.Path
params["CurrentPage"] = "series"
params["Article"] = internal.Ei.PageSeries
renderHTMLHomeLayout(c, "series", params)
}
// handleArchivePage 归档页
func handleArchivePage(c *gin.Context) {
params := baseFEParams(c)
params["Title"] = "归档 | " + internal.Ei.Blogger.BTitle
params["Description"] = "博客归档," + internal.Ei.Blogger.SubTitle
params["Path"] = c.Request.URL.Path
params["CurrentPage"] = "archives"
params["Article"] = internal.Ei.PageArchives
renderHTMLHomeLayout(c, "archives", params)
}
// handleSearchPage 搜索页
func handleSearchPage(c *gin.Context) {
params := baseFEParams(c)
params["Title"] = "站内搜索 | " + internal.Ei.Blogger.BTitle
params["Description"] = "站内搜索," + internal.Ei.Blogger.SubTitle
params["Path"] = ""
params["CurrentPage"] = "search-post"
q := strings.TrimSpace(c.Query("q"))
if q != "" {
start, err := strconv.Atoi(c.Query("start"))
if start < 1 || err != nil {
start = 1
}
params["Word"] = q
vals := c.Request.URL.Query()
result, err := internal.ESClient.ElasticSearch(q, config.Conf.General.PageNum, start-1)
if err != nil {
logrus.Error("HandleSearchPage.ElasticSearch: ", err)
} else {
result.Took /= 1000
for i, v := range result.Hits.Hits {
article := internal.Ei.ArticlesMap[v.Source.Slug]
if len(v.Highlight.Content) == 0 && article != nil {
result.Hits.Hits[i].Highlight.Content = []string{article.Excerpt}
}
}
params["SearchResult"] = result
if num := start - config.Conf.General.PageNum; num > 0 {
vals.Set("start", fmt.Sprint(num))
params["Prev"] = vals.Encode()
}
if num := start + config.Conf.General.PageNum; result.Hits.Total >= num {
vals.Set("start", fmt.Sprint(num))
params["Next"] = vals.Encode()
}
}
} else {
params["HotWords"] = config.Conf.HotWords
}
renderHTMLHomeLayout(c, "search", params)
}
// disqusComments 服务端获取评论详细
type disqusComments struct {
ErrNo int `json:"errno"`
ErrMsg string `json:"errmsg"`
Data struct {
Next string `json:"next"`
Total int `json:"total"`
Comments []commentsDetail `json:"comments"`
Thread string `json:"thread"`
} `json:"data"`
}
// handleDisqusList 获取评论列表
func handleDisqusList(c *gin.Context) {
dcs := &disqusComments{}
defer c.JSON(http.StatusOK, dcs)
slug := c.Param("slug")
cursor := c.Query("cursor")
artc := internal.Ei.ArticlesMap[slug]
if artc != nil {
dcs.Data.Thread = artc.Thread
}
postsList, err := internal.DisqusClient.PostsList(artc, cursor)
if err != nil {
logrus.Error("hadnleDisqusList.PostsList: ", err)
dcs.ErrNo = 0
dcs.ErrMsg = "系统错误"
return
}
dcs.ErrNo = postsList.Code
if postsList.Cursor.HasNext {
dcs.Data.Next = postsList.Cursor.Next
}
dcs.Data.Total = len(postsList.Response)
dcs.Data.Comments = make([]commentsDetail, len(postsList.Response))
for i, v := range postsList.Response {
if dcs.Data.Thread == "" {
dcs.Data.Thread = v.Thread
}
dcs.Data.Comments[i] = commentsDetail{
ID: v.ID,
Name: v.Author.Name,
Parent: v.Parent,
URL: v.Author.ProfileURL,
Avatar: v.Author.Avatar.Cache,
CreatedAtStr: tools.ConvertStr(v.CreatedAt),
Message: v.Message,
IsDeleted: v.IsDeleted,
}
}
// query thread & update
if artc != nil && artc.Thread == "" {
if dcs.Data.Thread != "" {
artc.Thread = dcs.Data.Thread
} else if internal.DisqusClient.ThreadDetails(artc) == nil {
dcs.Data.Thread = artc.Thread
}
internal.Ei.UpdateArticle(context.Background(), artc.ID,
map[string]interface{}{
"thread": artc.Thread,
})
}
}
// handleDisqusPage 评论页
func handleDisqusPage(c *gin.Context) {
array := strings.Split(c.Param("slug"), "|")
if len(array) != 4 || array[1] == "" {
c.String(http.StatusOK, "出错啦。。。")
return
}
article := internal.Ei.ArticlesMap[array[0]]
params := gin.H{
"Title": "发表评论 | " + internal.Ei.Blogger.BTitle,
"ATitle": article.Title,
"Thread": array[1],
"Slug": article.Slug,
}
renderHTMLHomeLayout(c, "disqus.html", params)
}
// 发表评论
// [thread:[5279901489] parent:[] identifier:[post-troubleshooting-https]
// next:[] author_name:[你好] author_email:[chenqijing2@163.com] message:[fdsfdsf]]
type disqusCreate struct {
ErrNo int `json:"errno"`
ErrMsg string `json:"errmsg"`
Data commentsDetail `json:"data"`
}
type commentsDetail struct {
ID string `json:"id"`
Parent int `json:"parent"`
Name string `json:"name"`
URL string `json:"url"`
Avatar string `json:"avatar"`
CreatedAtStr string `json:"createdAtStr"`
Message string `json:"message"`
IsDeleted bool `json:"isDeleted"`
}
// handleDisqusCreate 评论文章
func handleDisqusCreate(c *gin.Context) {
resp := &disqusCreate{}
defer c.JSON(http.StatusOK, resp)
msg := c.PostForm("message")
email := c.PostForm("author_email")
name := c.PostForm("author_name")
thread := c.PostForm("thread")
identifier := c.PostForm("identifier")
if msg == "" || email == "" || name == "" || thread == "" || identifier == "" {
resp.ErrNo = 1
resp.ErrMsg = "参数错误"
return
}
logrus.Infof("email: %s comments: %s", email, thread)
comment := disqus.PostComment{
Message: msg,
Parent: c.PostForm("parent"),
Thread: thread,
AuthorEmail: email,
AuthorName: name,
Identifier: identifier,
IPAddress: c.ClientIP(),
}
postDetail, err := internal.DisqusClient.PostCreate(&comment)
if err != nil {
logrus.Error("handleDisqusCreate.PostCreate: ", err)
resp.ErrNo = 1
resp.ErrMsg = "提交评论失败,请重试"
return
}
err = internal.DisqusClient.PostApprove(postDetail.Response.ID)
if err != nil {
logrus.Error("handleDisqusCreate.PostApprove: ", err)
resp.ErrNo = 1
resp.ErrMsg = "提交评论失败,请重试"
}
resp.ErrNo = 0
resp.Data = commentsDetail{
ID: postDetail.Response.ID,
Name: name,
Parent: postDetail.Response.Parent,
URL: postDetail.Response.Author.ProfileURL,
Avatar: postDetail.Response.Author.Avatar.Cache,
CreatedAtStr: tools.ConvertStr(postDetail.Response.CreatedAt),
Message: postDetail.Response.Message,
IsDeleted: postDetail.Response.IsDeleted,
}
}
// handleBeaconPage 服务端推送谷歌统计
// https://www.thyngster.com/ga4-measurement-protocol-cheatsheet/
func handleBeaconPage(c *gin.Context) {
ua := c.Request.UserAgent()
vals := c.Request.URL.Query()
vals.Set("v", config.Conf.Google.V)
vals.Set("tid", config.Conf.Google.Tid)
cookie, _ := c.Cookie("u")
vals.Set("cid", cookie)
vals.Set("dl", c.Request.Referer()) // document location
vals.Set("en", "page_view") // event name
vals.Set("sct", "1") // Session Count
vals.Set("seg", "1") // Session Engagment
vals.Set("_uip", c.ClientIP()) // user ip
vals.Set("_p", fmt.Sprint(201226219+rand.Intn(499999999))) // random page load hash
vals.Set("_ee", "1") // external event
go func() {
url := config.Conf.Google.URL + "?" + vals.Encode()
req, err := http.NewRequest("POST", url, nil)
if err != nil {
logrus.Error("HandleBeaconPage.NewRequest: ", err)
return
}
req.Header.Set("User-Agent", ua)
req.Header.Set("Sec-Ch-Ua", c.GetHeader("Sec-Ch-Ua"))
req.Header.Set("Sec-Ch-Ua-Platform", c.GetHeader("Sec-Ch-Ua-Platform"))
req.Header.Set("Sec-Ch-Ua-Mobile", c.GetHeader("Sec-Ch-Ua-Mobile"))
res, err := http.DefaultClient.Do(req)
if err != nil {
logrus.Error("HandleBeaconPage.Do: ", err)
return
}
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil {
logrus.Error("HandleBeaconPage.ReadAll: ", err)
return
}
if res.StatusCode/100 != 2 {
logrus.Error(string(data))
}
}()
c.Status(http.StatusNoContent)
}
// renderHTMLHomeLayout homelayout html
func renderHTMLHomeLayout(c *gin.Context, name string, data gin.H) {
c.Header("Content-Type", "text/html; charset=utf-8")
// special page
if name == "disqus.html" {
err := htmlTmpl.ExecuteTemplate(c.Writer, name, data)
if err != nil {
panic(err)
}
return
}
buf := bytes.Buffer{}
err := htmlTmpl.ExecuteTemplate(&buf, name, data)
if err != nil {
panic(err)
}
data["LayoutContent"] = htemplate.HTML(buf.String())
err = htmlTmpl.ExecuteTemplate(c.Writer, "homeLayout.html", data)
if err != nil {
panic(err)
}
if c.Writer.Status() == 0 {
c.Status(http.StatusOK)
}
}

View File

@@ -0,0 +1,72 @@
package page
import (
"io/fs"
"path/filepath"
"strings"
"text/template"
"github.com/eiblog/eiblog/cmd/eiblog/config"
"github.com/eiblog/eiblog/tools"
"github.com/gin-gonic/gin"
)
// htmlTmpl html template cache
var htmlTmpl *template.Template
func init() {
htmlTmpl = template.New("eiblog").Funcs(tools.TplFuncMap)
root := filepath.Join(config.WorkDir, "website")
files := tools.ReadDirFiles(root, func(fi fs.DirEntry) bool {
name := fi.Name()
if strings.HasPrefix(name, ".") {
return true
}
// should not read template dir
if fi.IsDir() && name == "template" {
return true
}
return false
})
_, err := htmlTmpl.ParseFiles(files...)
if err != nil {
panic(err)
}
}
// RegisterRoutes register routes
func RegisterRoutes(e *gin.Engine) {
e.NoRoute(handleNotFound)
e.GET("/", handleHomePage)
e.GET("/post/:slug", handleArticlePage)
e.GET("/series.html", handleSeriesPage)
e.GET("/archives.html", handleArchivePage)
e.GET("/search.html", handleSearchPage)
e.GET("/disqus/post-:slug", handleDisqusList)
e.GET("/disqus/form/post-:slug", handleDisqusPage)
e.POST("/disqus/create", handleDisqusCreate)
e.GET("/beacon.html", handleBeaconPage)
// login page
e.GET("/admin/login", handleLoginPage)
}
// RegisterRoutesAuthz register admin
func RegisterRoutesAuthz(group gin.IRoutes) {
// console
group.GET("/profile", handleAdminProfile)
// write
group.GET("/write-post", handleAdminPost)
group.GET("/draft-delete", handleDraftDelete)
// manage
group.GET("/manage-posts", handleAdminPosts)
group.GET("/manage-series", handleAdminSeries)
group.GET("/add-serie", handleAdminSerie)
group.GET("/manage-tags", handleAdminTags)
group.GET("/manage-draft", handleAdminDraft)
group.GET("/manage-trash", handleAdminTrash)
group.GET("/options-general", handleAdminGeneral)
group.GET("/options-discussion", handleAdminDiscussion)
}

View File

@@ -0,0 +1,14 @@
package swag
import (
_ "github.com/eiblog/eiblog/cmd/eiblog/docs" // docs
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
// RegisterRoutes register routes
func RegisterRoutes(group gin.IRoutes) {
group.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
}

View File

@@ -2,52 +2,48 @@
package main
import (
"fmt"
"path/filepath"
"github.com/eiblog/eiblog/pkg/config"
"github.com/eiblog/eiblog/pkg/core/eiblog"
"github.com/eiblog/eiblog/pkg/core/eiblog/admin"
"github.com/eiblog/eiblog/pkg/core/eiblog/file"
"github.com/eiblog/eiblog/pkg/core/eiblog/page"
"github.com/eiblog/eiblog/pkg/core/eiblog/swag"
"github.com/eiblog/eiblog/pkg/mid"
"github.com/eiblog/eiblog/cmd/eiblog/config"
"github.com/eiblog/eiblog/cmd/eiblog/handler/admin"
"github.com/eiblog/eiblog/cmd/eiblog/handler/file"
"github.com/eiblog/eiblog/cmd/eiblog/handler/page"
"github.com/eiblog/eiblog/cmd/eiblog/handler/swag"
"github.com/eiblog/eiblog/pkg/middleware"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
func main() {
fmt.Println("Hi, it's App " + config.Conf.EiBlogApp.Name)
logrus.Info("Hi, it's App " + config.Conf.Name)
endRun := make(chan error, 1)
runHTTPServer(endRun)
fmt.Println(<-endRun)
logrus.Fatal(<-endRun)
}
func runHTTPServer(endRun chan error) {
if !config.Conf.EiBlogApp.EnableHTTP {
return
}
if config.Conf.RunMode == config.ModeProd {
if config.Conf.RunMode.IsReleaseMode() {
gin.SetMode(gin.ReleaseMode)
}
e := gin.Default()
// middleware
e.Use(mid.UserMiddleware())
e.Use(mid.SessionMiddleware(mid.SessionOpts{
Name: "su",
Secure: config.Conf.RunMode == config.ModeProd,
Secret: []byte("ZGlzvcmUoMTAsICI="),
}))
e.Use(middleware.UserMiddleware())
e.Use(middleware.SessionMiddleware(
middleware.SessionOpts{
Name: "su",
Secure: config.Conf.RunMode.IsReleaseMode(),
Secret: []byte("ZGlzvcmUoMTAsICI="),
}))
// swag
swag.RegisterRoutes(e)
// static files, page
root := filepath.Join(config.WorkDir, "assets")
e.Static("/static", root)
e.Static("/static", filepath.Join(config.WorkDir, "assets"))
// static files
file.RegisterRoutes(e)
@@ -57,16 +53,15 @@ func runHTTPServer(endRun chan error) {
admin.RegisterRoutes(e)
// admin router
group := e.Group("/admin", eiblog.AuthFilter)
group := e.Group("/admin", middleware.AuthFilter)
{
page.RegisterRoutesAuthz(group)
admin.RegisterRoutesAuthz(group)
}
// start
address := fmt.Sprintf(":%d", config.Conf.EiBlogApp.HTTPPort)
go func() {
endRun <- e.Run(address)
endRun <- e.Run(config.Conf.Listen)
}()
fmt.Println("HTTP server running on: " + address)
logrus.Info("HTTP server running on: " + config.Conf.Listen)
}