├── .gitattributes ├── resource ├── static │ └── cactus │ │ ├── images │ │ └── logo.png │ │ ├── lib │ │ ├── vazir-font │ │ │ ├── Vazir.eot │ │ │ ├── Vazir.ttf │ │ │ ├── Vazir.woff │ │ │ ├── Vazir.woff2 │ │ │ ├── Vazir-Black.eot │ │ │ ├── Vazir-Black.ttf │ │ │ ├── Vazir-Bold.eot │ │ │ ├── Vazir-Bold.ttf │ │ │ ├── Vazir-Bold.woff │ │ │ ├── Vazir-Light.eot │ │ │ ├── Vazir-Light.ttf │ │ │ ├── Vazir-Thin.eot │ │ │ ├── Vazir-Thin.ttf │ │ │ ├── Vazir-Thin.woff │ │ │ ├── Vazir-Black.woff │ │ │ ├── Vazir-Black.woff2 │ │ │ ├── Vazir-Bold.woff2 │ │ │ ├── Vazir-Light.woff │ │ │ ├── Vazir-Light.woff2 │ │ │ ├── Vazir-Medium.eot │ │ │ ├── Vazir-Medium.ttf │ │ │ ├── Vazir-Medium.woff │ │ │ ├── Vazir-Medium.woff2 │ │ │ ├── Vazir-Thin.woff2 │ │ │ └── font-face.css │ │ ├── meslo-LG │ │ │ ├── MesloLGL-Bold.ttf │ │ │ ├── MesloLGM-Bold.ttf │ │ │ ├── MesloLGS-Bold.ttf │ │ │ ├── MesloLGL-Italic.ttf │ │ │ ├── MesloLGL-Regular.ttf │ │ │ ├── MesloLGM-Italic.ttf │ │ │ ├── MesloLGM-Regular.ttf │ │ │ ├── MesloLGS-Italic.ttf │ │ │ ├── MesloLGS-Regular.ttf │ │ │ ├── MesloLGL-BoldItalic.ttf │ │ │ ├── MesloLGM-BoldItalic.ttf │ │ │ └── MesloLGS-BoldItalic.ttf │ │ └── font-awesome │ │ │ └── webfonts │ │ │ ├── fa-brands-400.eot │ │ │ ├── fa-brands-400.ttf │ │ │ ├── fa-solid-900.eot │ │ │ ├── fa-solid-900.ttf │ │ │ ├── fa-solid-900.woff │ │ │ ├── fa-brands-400.woff │ │ │ ├── fa-brands-400.woff2 │ │ │ ├── fa-regular-400.eot │ │ │ ├── fa-regular-400.ttf │ │ │ ├── fa-regular-400.woff │ │ │ ├── fa-solid-900.woff2 │ │ │ └── fa-regular-400.woff2 │ │ ├── css │ │ ├── rtl.css │ │ └── main.css │ │ └── js │ │ └── main.js ├── theme │ ├── default │ │ ├── error.html │ │ ├── tags.html │ │ ├── article_chapters.html │ │ ├── article_title_item.html │ │ ├── page.html │ │ ├── search_form.html │ │ ├── menu.html │ │ ├── search.html │ │ ├── comments_entry.html │ │ ├── index.html │ │ ├── header.html │ │ ├── archive.html │ │ ├── article_list_entry.html │ │ ├── footer.html │ │ └── article.html │ └── admin │ │ ├── js.html │ │ ├── footer.html │ │ ├── css.html │ │ ├── login.html │ │ ├── tags.html │ │ ├── media.html │ │ ├── articles.html │ │ ├── header.html │ │ ├── comments.html │ │ ├── index.html │ │ └── publish.html └── translation │ ├── zh │ ├── user.json │ └── admin.json │ └── en │ ├── user.json │ └── admin.json ├── internal └── model │ ├── user.go │ ├── article_history.go │ ├── comment.go │ ├── article_test.go │ ├── config.go │ └── article.go ├── .gitignore ├── cmd └── web │ └── main.go ├── pkg ├── soliwriter │ └── writer.go ├── notify │ ├── telegram.go │ └── email.go ├── pagination │ └── pagination.go ├── translator │ └── translator.go └── blevejieba │ └── blevejieba.go ├── .air.toml ├── Dockerfile ├── .github └── workflows │ └── docker.yml ├── LICENSE ├── Taskfile.yml ├── router ├── manage_tags.go ├── search.go ├── media.go ├── user.go ├── manage_comment.go ├── comment.go ├── settings.go ├── manage_article.go ├── manager.go ├── article.go ├── archive.go └── router.go ├── data └── conf.yml.example ├── const.go ├── README.md ├── solitudes.go └── go.mod /.gitattributes: -------------------------------------------------------------------------------- 1 | resource/** linguist-vendored -------------------------------------------------------------------------------- /resource/static/cactus/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/images/logo.png -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir.eot -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir.woff -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir.woff2 -------------------------------------------------------------------------------- /resource/static/cactus/lib/meslo-LG/MesloLGL-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/meslo-LG/MesloLGL-Bold.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/meslo-LG/MesloLGM-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/meslo-LG/MesloLGM-Bold.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/meslo-LG/MesloLGS-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/meslo-LG/MesloLGS-Bold.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir-Black.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir-Black.eot -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir-Black.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir-Bold.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir-Bold.eot -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir-Bold.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir-Bold.woff -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir-Light.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir-Light.eot -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir-Light.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir-Thin.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir-Thin.eot -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir-Thin.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir-Thin.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir-Thin.woff -------------------------------------------------------------------------------- /resource/static/cactus/lib/meslo-LG/MesloLGL-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/meslo-LG/MesloLGL-Italic.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/meslo-LG/MesloLGL-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/meslo-LG/MesloLGL-Regular.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/meslo-LG/MesloLGM-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/meslo-LG/MesloLGM-Italic.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/meslo-LG/MesloLGM-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/meslo-LG/MesloLGM-Regular.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/meslo-LG/MesloLGS-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/meslo-LG/MesloLGS-Italic.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/meslo-LG/MesloLGS-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/meslo-LG/MesloLGS-Regular.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir-Black.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir-Black.woff -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir-Black.woff2 -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir-Bold.woff2 -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir-Light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir-Light.woff -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir-Light.woff2 -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir-Medium.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir-Medium.eot -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir-Medium.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir-Medium.woff -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir-Medium.woff2 -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/Vazir-Thin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/vazir-font/Vazir-Thin.woff2 -------------------------------------------------------------------------------- /resource/static/cactus/lib/meslo-LG/MesloLGL-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/meslo-LG/MesloLGL-BoldItalic.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/meslo-LG/MesloLGM-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/meslo-LG/MesloLGM-BoldItalic.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/meslo-LG/MesloLGS-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/meslo-LG/MesloLGS-BoldItalic.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/font-awesome/webfonts/fa-brands-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/font-awesome/webfonts/fa-brands-400.eot -------------------------------------------------------------------------------- /resource/static/cactus/lib/font-awesome/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/font-awesome/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/font-awesome/webfonts/fa-solid-900.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/font-awesome/webfonts/fa-solid-900.eot -------------------------------------------------------------------------------- /resource/static/cactus/lib/font-awesome/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/font-awesome/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/font-awesome/webfonts/fa-solid-900.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/font-awesome/webfonts/fa-solid-900.woff -------------------------------------------------------------------------------- /resource/static/cactus/lib/font-awesome/webfonts/fa-brands-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/font-awesome/webfonts/fa-brands-400.woff -------------------------------------------------------------------------------- /resource/static/cactus/lib/font-awesome/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/font-awesome/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /resource/static/cactus/lib/font-awesome/webfonts/fa-regular-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/font-awesome/webfonts/fa-regular-400.eot -------------------------------------------------------------------------------- /resource/static/cactus/lib/font-awesome/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/font-awesome/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /resource/static/cactus/lib/font-awesome/webfonts/fa-regular-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/font-awesome/webfonts/fa-regular-400.woff -------------------------------------------------------------------------------- /resource/static/cactus/lib/font-awesome/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/font-awesome/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /resource/static/cactus/lib/font-awesome/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naiba/solitudes/HEAD/resource/static/cactus/lib/font-awesome/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /resource/theme/default/error.html: -------------------------------------------------------------------------------- 1 | {{define "default/error"}} 2 | {{template "default/header" .}} 3 | {{template "default/menu" .}} 4 |
5 | {{.Data.title}} 6 |

{{.Data.msg}}

7 |
8 | {{template "default/search_form" .}} 9 | {{template "default/footer" .}} 10 | {{end}} -------------------------------------------------------------------------------- /internal/model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // User 用户表 4 | type User struct { 5 | Email string 6 | Nickname string 7 | /* 8 | Password 用户密码 9 | * default password: 123456 10 | * gen password: https://bcrypt-generator.com/ 11 | */ 12 | Password string 13 | Token string 14 | TokenExpires int64 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | .DS_Store 8 | /data/* 9 | !/data/conf.yml.example 10 | /main 11 | 12 | # Test binary, build with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | solitudes 18 | -------------------------------------------------------------------------------- /resource/theme/admin/js.html: -------------------------------------------------------------------------------- 1 | {{define "admin/js"}} 2 | 3 | 4 | 5 | {{end}} -------------------------------------------------------------------------------- /cmd/web/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/naiba/solitudes/router" 7 | ) 8 | 9 | func main() { 10 | if _, err := os.Stat("data/upload"); os.IsNotExist(err) { 11 | err = os.Mkdir("data/upload", os.ModeDir|os.ModePerm) 12 | if err != nil { 13 | panic(err) 14 | } 15 | } 16 | router.Serve() 17 | } 18 | -------------------------------------------------------------------------------- /resource/theme/default/tags.html: -------------------------------------------------------------------------------- 1 | {{define "default/tags"}} 2 | {{template "default/header" .}} 3 | {{template "default/menu" .}} 4 |
5 | {{range $i,$tag := .Data.tags}} 6 | 7 | {{$tag}} {{index $.Data.counts $i}} 8 | 9 | {{end}} 10 |
11 | {{template "default/footer" .}} 12 | {{end}} -------------------------------------------------------------------------------- /resource/theme/admin/footer.html: -------------------------------------------------------------------------------- 1 | {{define "admin/footer"}} 2 | 10 | 11 | 12 | {{template "admin/js"}} 13 | 14 | 15 | 16 | {{end}} -------------------------------------------------------------------------------- /resource/theme/default/article_chapters.html: -------------------------------------------------------------------------------- 1 | {{define "default/article_chapters"}} 2 | 12 | {{end}} -------------------------------------------------------------------------------- /resource/theme/default/article_title_item.html: -------------------------------------------------------------------------------- 1 | {{define "default/article_title_item"}} 2 | {{range $k,$v := .}} 3 |
  • 4 | 5 | {{add $k 1}}. {{$v.Title}} 6 | 7 | {{if $v.SubTitles}} 8 |
      9 | {{template "default/article_title_item" $v.SubTitles}} 10 |
    11 | {{end}} 12 |
  • 13 | {{end}} 14 | {{end}} -------------------------------------------------------------------------------- /internal/model/article_history.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | // ArticleHistory 文章修订历史 9 | type ArticleHistory struct { 10 | ArticleID string `gorm:"type:uuid;index"` 11 | Article Article 12 | Version uint `gorm:"index"` 13 | Desc string `gorm:"text"` 14 | Content string `gorm:"text"` 15 | CreatedAt time.Time 16 | } 17 | 18 | // GetIndexID get index data id 19 | func (t *ArticleHistory) GetIndexID() string { 20 | return fmt.Sprintf("%s.%d", t.ArticleID, t.Version) 21 | } 22 | -------------------------------------------------------------------------------- /resource/theme/default/page.html: -------------------------------------------------------------------------------- 1 | {{define "default/page"}} 2 | {{template "default/header" .}} 3 | {{template "default/menu" .}} 4 | {{if .Login}} 5 | [edit] 6 | {{end}} 7 |
    8 |
    9 | {{(md (.Data.article|articleIdx) .Data.article.Content)|unsafe}} 10 |
    11 |
    12 | 17 | {{template "default/footer" .}} 18 | {{end}} -------------------------------------------------------------------------------- /pkg/soliwriter/writer.go: -------------------------------------------------------------------------------- 1 | package soliwriter 2 | 3 | import "net/http" 4 | 5 | // InterceptResponseWriter 接管404 6 | type InterceptResponseWriter struct { 7 | http.ResponseWriter 8 | ErrH func(http.ResponseWriter, int) 9 | } 10 | 11 | // WriteHeader 写HTTP头 12 | func (w InterceptResponseWriter) WriteHeader(status int) { 13 | if status == http.StatusNotFound { 14 | w.ErrH(w.ResponseWriter, status) 15 | w.ErrH = nil 16 | } else { 17 | w.ResponseWriter.WriteHeader(status) 18 | } 19 | } 20 | 21 | func (w InterceptResponseWriter) Write(p []byte) (n int, err error) { 22 | if len(w.Header().Get("X-File-Server")) > 0 { 23 | return len(p), nil 24 | } 25 | return w.ResponseWriter.Write(p) 26 | } 27 | -------------------------------------------------------------------------------- /resource/theme/default/search_form.html: -------------------------------------------------------------------------------- 1 | {{define "default/search_form"}} 2 | 11 | 17 | {{end}} -------------------------------------------------------------------------------- /internal/model/comment.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | // Comment 评论表 6 | type Comment struct { 7 | ID string `gorm:"type:uuid;primary_key;default:uuid_generate_v4()"` 8 | CreatedAt time.Time 9 | 10 | ReplyTo *string `gorm:"type:uuid;index;default:NULL" form:"reply_to"` 11 | Nickname string `form:"nickname" validate:"required"` 12 | Content string `form:"content" validate:"required" gorm:"text"` 13 | Website string `form:"website"` 14 | Version uint `form:"-"` 15 | Email string `form:"email"` 16 | IP string `gorm:"inet"` 17 | UserAgent string 18 | IsAdmin bool 19 | 20 | ArticleID *string `gorm:"type:uuid;index;default:NULL" form:"article_id" validate:"required,uuid"` 21 | Article *Article 22 | ChildComments []*Comment `gorm:"foreignkey:ReplyTo" form:"-" validate:"-"` 23 | } 24 | -------------------------------------------------------------------------------- /resource/theme/default/menu.html: -------------------------------------------------------------------------------- 1 | {{define "default/menu"}} 2 |
    3 | 20 | {{end}} -------------------------------------------------------------------------------- /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = ".tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./solitudes" 8 | cmd = "task" 9 | delay = 60 10 | exclude_dir = [".gitea", "gse", "data"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "html"] 18 | include_file = [] 19 | kill_delay = "0s" 20 | log = "/tmp/build-errors.log" 21 | rerun = false 22 | rerun_delay = 500 23 | send_interrupt = true 24 | stop_on_error = true 25 | 26 | [color] 27 | app = "" 28 | build = "yellow" 29 | main = "magenta" 30 | runner = "green" 31 | watcher = "cyan" 32 | 33 | [log] 34 | main_only = false 35 | time = false 36 | 37 | [misc] 38 | clean_on_exit = true 39 | 40 | [screen] 41 | clear_on_rebuild = false 42 | keep_scroll = true 43 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine AS binarybuilder 2 | # Install build deps 3 | RUN apk --no-cache --no-progress add --virtual build-deps build-base git linux-pam-dev 4 | WORKDIR /naiba/solitudes/ 5 | COPY . . 6 | RUN go mod tidy -v && \ 7 | CGO_ENABLED=true go build -o solitudes -ldflags="-s -w -X github.com/naiba/solitudes.BuildVersion=`git rev-parse HEAD`" cmd/web/main.go 8 | 9 | FROM alpine:latest 10 | RUN echo http://dl-2.alpinelinux.org/alpine/edge/community/ >>/etc/apk/repositories && apk --no-cache --no-progress add \ 11 | tzdata \ 12 | libstdc++ \ 13 | ca-certificates 14 | # Copy binary to container 15 | WORKDIR /solitudes 16 | COPY resource ./resource 17 | COPY --from=binarybuilder /naiba/solitudes/solitudes . 18 | COPY --from=binarybuilder /go/pkg/mod/github.com/yanyiwu /go/pkg/mod/github.com/yanyiwu 19 | # Configure Docker Container 20 | VOLUME ["/solitudes/data"] 21 | EXPOSE 8080 22 | CMD ["/solitudes/solitudes"] 23 | -------------------------------------------------------------------------------- /resource/theme/default/search.html: -------------------------------------------------------------------------------- 1 | {{define "default/search"}} 2 | {{template "default/header" .}} 3 | {{template "default/menu" .}} 4 |
    5 |
    6 | {{template "default/search_form" .}} 7 | 25 |
    26 |
    27 | {{template "default/footer" .}} 28 | {{end}} -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Build Docker Image 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - "*.md" 7 | - ".*" 8 | 9 | jobs: 10 | build-step: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@master 14 | 15 | - name: Log in to the GHCR 16 | uses: docker/login-action@master 17 | with: 18 | registry: ghcr.io 19 | username: ${{ github.repository_owner }} 20 | password: ${{ secrets.GITHUB_TOKEN }} 21 | 22 | - name: Set up QEMU 23 | uses: docker/setup-qemu-action@v1 24 | 25 | - name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v1 27 | 28 | - name: docker build and push 29 | uses: docker/build-push-action@v2 30 | with: 31 | context: . 32 | file: ./Dockerfile 33 | platforms: linux/amd64,linux/arm64 34 | push: true 35 | tags: | 36 | ghcr.io/${{ github.repository_owner }}/solitudes 37 | -------------------------------------------------------------------------------- /resource/theme/admin/css.html: -------------------------------------------------------------------------------- 1 | {{define "admin/css"}} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 15 | 16 | {{end}} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 奶爸 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/model/article_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGenTOC(t *testing.T) { 8 | var post = &Article{ 9 | Content: `这个端到端加密的工具 10 | ## 端到端加密详解 11 | ### 名词解释 12 | ### 用户注册 13 | ### 用户登录 14 | ### 个人数据 15 | #### 读取 16 | #### 写入 17 | ### Team 数据 18 | #### 读取 19 | #### 写入 20 | ## 概览 21 | ### Team 成员管理 22 | ### 目前的缺陷 23 | `, 24 | } 25 | post.GenTOC() 26 | index := 0 27 | validateToc(t, post.Toc, &index, []int{2, 3, 3, 3, 3, 4, 4, 3, 4, 4, 2, 3, 3}) 28 | } 29 | 30 | func validateToc(t *testing.T, toc []*ArticleTOC, now *int, expect []int) { 31 | for _, toc_item := range toc { 32 | if toc_item.Level != expect[*now] { 33 | t.FailNow() 34 | } 35 | *now++ 36 | validateToc(t, toc_item.SubTitles, now, expect) 37 | } 38 | } 39 | 40 | func TestSanitizedAnchorName(t *testing.T) { 41 | unique := make(map[string]int) 42 | if sanitizedAnchorName(unique, "# 1 测试") != "1-测试" { 43 | t.FailNow() 44 | } 45 | if sanitizedAnchorName(unique, "# 1 测试") != "1-测试-" { 46 | t.FailNow() 47 | } 48 | if sanitizedAnchorName(unique, "# 1 测试") != "1-测试--" { 49 | t.FailNow() 50 | } 51 | if sanitizedAnchorName(unique, "测试") != "测试" { 52 | t.FailNow() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/model/config.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "os" 5 | 6 | "gopkg.in/yaml.v3" 7 | ) 8 | 9 | // Menu 自定义菜单 10 | type Menu struct { 11 | Name string 12 | Link string 13 | Icon string 14 | Black bool 15 | } 16 | 17 | // Config 系统配置 18 | type Config struct { 19 | Debug bool 20 | 21 | EnableTrustedProxyCheck bool 22 | TrustedProxies []string 23 | ProxyHeader string 24 | 25 | TGBotToken string 26 | TGChatID string 27 | 28 | Database string 29 | Akismet string 30 | Email struct { 31 | Host string 32 | Port int 33 | User string 34 | Pass string 35 | SSL bool 36 | } 37 | Site struct { 38 | SpaceName string 39 | SpaceDesc string 40 | SpaceKeywords string 41 | HomeTopContent string 42 | HomeBottomContent string 43 | Domain string 44 | Theme string 45 | HeaderMenus []Menu 46 | FooterMenus []Menu 47 | CustomCode string 48 | } 49 | 50 | User User 51 | ConfigFilePath string 52 | } 53 | 54 | // Save .. 55 | func (c *Config) Save() error { 56 | b, err := yaml.Marshal(&c) 57 | if err != nil { 58 | return err 59 | } 60 | return os.WriteFile(c.ConfigFilePath, b, os.FileMode(0655)) 61 | } 62 | -------------------------------------------------------------------------------- /resource/theme/default/comments_entry.html: -------------------------------------------------------------------------------- 1 | {{define "default/comments_entry"}} 2 | {{range .}} 3 |
    4 |
    5 | {{if .Email}} 6 | 7 | {{else}} 8 | 9 | {{end}} 10 |
    11 |
    12 |

    13 | {{if .Website}}{{.Nickname}}{{else}} 14 | {{.Nickname}} 15 | {{if .IsAdmin}} 👲 {{end}} 16 | {{end}} 17 | 18 | ·v{{.Version}} 19 | Reply 20 |

    21 |

    {{.Content}}

    22 |
    23 |
    24 |
    25 | {{template "default/comments_entry" .ChildComments}} 26 |
    27 | {{end}} 28 | {{end}} -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | # https://taskfile.dev 2 | 3 | # merge 185aa88ba3df16822828b1837fa7fd29397fd2e8 4 | 5 | version: '3' 6 | 7 | vars: 8 | GOPROXY: 'https://goproxy.cn,direct' 9 | GOSUMDB: sum.golang.google.cn 10 | GOOS: $(go env GOOS) 11 | GOARCH: $(go env GOARCH) 12 | BUILD_DATE: $(date +%Y%m%d%H%M) 13 | GIT_BRANCH: $(git branch -r --contains | head -1 | sed -E -e "s%(HEAD ->|origin|upstream)/?%%g" | xargs) 14 | GIT_COMMIT: $(git rev-parse --short HEAD || echo "abcdefgh") 15 | VERSION: "{{.GIT_COMMIT}}" 16 | 17 | tasks: 18 | 19 | mod: 20 | desc: go mod tidy 21 | cmds: 22 | - go mod tidy 23 | 24 | gofmt: 25 | cmds: 26 | - go install golang.org/x/tools/cmd/goimports@latest 27 | - gofmt -s -w . 28 | - goimports -w . 29 | 30 | golint: 31 | cmds: 32 | - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest 33 | - golangci-lint run -v ./... 34 | 35 | lint: 36 | cmds: 37 | - task: gofmt 38 | # - task: golint 39 | 40 | fmt: 41 | cmds: 42 | - task: mod 43 | - task: lint 44 | 45 | default: 46 | cmds: 47 | - task: fmt 48 | - task: mod 49 | - go build -o solitudes -ldflags "-s -w -X 'github.com/naiba/solitudes.BuildVersion={{.GIT_COMMIT}}'" cmd/web/main.go 50 | -------------------------------------------------------------------------------- /router/manage_tags.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/gofiber/fiber/v2" 9 | "github.com/naiba/solitudes" 10 | "github.com/naiba/solitudes/pkg/translator" 11 | ) 12 | 13 | func tagsManagePage(c *fiber.Ctx) error { 14 | var tags []string 15 | rows, err := solitudes.System.DB.Raw(`select count(*), unnest(articles.tags) t from articles group by t order by count desc`).Rows() 16 | if err == nil { 17 | defer rows.Close() 18 | for rows.Next() { 19 | var line string 20 | var count int 21 | rows.Scan(&count, &line) 22 | tags = append(tags, line) 23 | } 24 | } 25 | c.Status(http.StatusOK).Render("admin/tags", injectSiteData(c, fiber.Map{ 26 | "title": c.Locals(solitudes.CtxTranslator).(*translator.Translator).T("manage_tags"), 27 | "tags": tags, 28 | })) 29 | return nil 30 | } 31 | 32 | func deleteTag(c *fiber.Ctx) error { 33 | tagName := c.Query("tagName") 34 | return solitudes.System.DB.Exec("UPDATE articles SET tags = array_remove(tags, ?);", tagName).Error 35 | } 36 | 37 | func renameTag(c *fiber.Ctx) error { 38 | oldTagName := c.Query("oldTagName") 39 | newTagName := strings.TrimSpace(c.Query("newTagName")) 40 | if newTagName == "" { 41 | return errors.New("empty tag name") 42 | } 43 | return solitudes.System.DB.Exec("UPDATE articles SET tags = array_replace(tags, ?, ?);", oldTagName, newTagName).Error 44 | } 45 | -------------------------------------------------------------------------------- /resource/theme/default/index.html: -------------------------------------------------------------------------------- 1 | {{define "default/index"}} 2 | {{template "default/header" .}} 3 | {{template "default/menu" .}} 4 | {{if trim .Conf.Site.HomeTopContent}} 5 |
    6 | {{(md "home-top" .Conf.Site.HomeTopContent)|unsafe}} 7 |
    8 | {{end}} 9 | {{template "default/search_form" .}} 10 |
    11 | {{if .Data.topics}} 12 | 16 | 21 | {{end}} 22 | 26 | 39 |
    40 | {{if trim .Conf.Site.HomeBottomContent}} 41 |
    42 | {{(md "home-bottom" .Conf.Site.HomeBottomContent)|unsafe}} 43 |
    44 | {{end}} 45 | {{template "default/footer" .}} 46 | {{end}} -------------------------------------------------------------------------------- /data/conf.yml.example: -------------------------------------------------------------------------------- 1 | debug: true 2 | enabletrustedproxycheck: false 3 | trustedproxies: 4 | - 192.168.160.1 5 | proxyheader: X-Forwarded-For 6 | # 启用 UUID 扩展 docker-compose exec db psql -U solitudes solitudes -c 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp";' 7 | database: postgres://solitudes:thisispassword@db/solitudes?sslmode=disable 8 | user: 9 | email: hi@example.com 10 | nickname: naiba 11 | password: $2a$10$qXMp0vfCL2rdhYGr7VT7NuJLEMysmO.EsGAfgQGtMupITe7ZNbi86 #默认密码 123456 12 | site: 13 | spacename: Solitudes 14 | spacedesc: We love writing 15 | hometopcontent: "# Top:\n\nA fast, simple & powerful blog framework \U0001F44D\n" 16 | homebottomcontent: "# Bottom:\n\nA fast, simple & powerful blog framework \U0001F44D\n" 17 | theme: white 18 | headermenus: 19 | - name: Home 20 | link: / 21 | icon: "" 22 | black: false 23 | - name: Archive 24 | link: /archive/ 25 | icon: "" 26 | black: false 27 | - name: Books 28 | link: /books/ 29 | icon: "" 30 | black: false 31 | - name: About 32 | link: /about 33 | icon: fa fa-lightbulb 34 | black: false 35 | - name: Solitudes 36 | link: https://github.com/naiba/solitudes 37 | icon: fab fa-github 38 | black: true 39 | footermenus: 40 | - name: Home 41 | link: / 42 | icon: "" 43 | black: false 44 | - name: About 45 | link: /about 46 | icon: far fa-lightbulb 47 | black: false 48 | -------------------------------------------------------------------------------- /resource/theme/default/header.html: -------------------------------------------------------------------------------- 1 | {{define "default/header"}} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {{.Title}} 17 | 18 | 19 | 21 | 23 | 25 | 26 | 27 | 28 | {{end}} -------------------------------------------------------------------------------- /const.go: -------------------------------------------------------------------------------- 1 | package solitudes 2 | 3 | import ( 4 | "github.com/blevesearch/bleve/v2" 5 | "github.com/panjf2000/ants" 6 | "github.com/patrickmn/go-cache" 7 | "go.uber.org/dig" 8 | "golang.org/x/sync/singleflight" 9 | "gorm.io/gorm" 10 | 11 | "github.com/naiba/solitudes/internal/model" 12 | ) 13 | 14 | const ( 15 | // CtxAuthorized 用户已认证 16 | CtxAuthorized = "cazed" 17 | // CtxTranslator 翻译 18 | CtxTranslator = "ct" 19 | // AuthCookie 用户认证使用的Cookie名 20 | AuthCookie = "i_like_solitude" 21 | // CacheKeyPrefixRelatedChapters 缓存键前缀:章节 22 | CacheKeyPrefixRelatedChapters = "ckprc" 23 | // CacheKeyPrefixRelatedArticle 缓存键前缀:文章 24 | CacheKeyPrefixRelatedArticle = "ckpra" 25 | // CacheKeyPrefixRelatedSiblingArticle 缓存键前缀:相邻文章 26 | CacheKeyPrefixRelatedSiblingArticle = "ckprsa" 27 | ) 28 | 29 | // SysVeriable 全局变量 30 | type SysVeriable struct { 31 | Config *model.Config 32 | DB *gorm.DB 33 | Cache *cache.Cache 34 | Search bleve.Index 35 | SafeCache *singleflight.Group 36 | Pool *ants.Pool 37 | } 38 | 39 | const fullTextSearchIndexPath = "data/bleve" 40 | 41 | // Injector 运行时依赖注入 42 | var Injector *dig.Container 43 | 44 | // System 全局变量 45 | var System *SysVeriable 46 | 47 | // BuildVersion 构建版本 48 | var BuildVersion = "_BuildVersion_" 49 | 50 | // Templates 文章模板 51 | var Templates = map[byte]string{ 52 | 1: "Article template", 53 | 2: "Page template", 54 | } 55 | 56 | // TemplateIndex 模板索引 57 | var TemplateIndex = map[byte]string{ 58 | 1: "article", 59 | 2: "page", 60 | } 61 | 62 | func init() { 63 | BuildVersion = BuildVersion[:8] 64 | } 65 | -------------------------------------------------------------------------------- /resource/theme/default/archive.html: -------------------------------------------------------------------------------- 1 | {{define "default/archive"}} 2 | {{template "default/header" .}} 3 | {{template "default/menu" .}} 4 |
    5 | 18 | 32 |
    33 | 41 | {{template "default/footer" .}} 42 | {{end}} -------------------------------------------------------------------------------- /pkg/notify/telegram.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | 9 | "github.com/naiba/solitudes" 10 | "github.com/naiba/solitudes/internal/model" 11 | ) 12 | 13 | // TelegramMessage Telegram消息结构 14 | type TelegramMessage struct { 15 | ChatID string `json:"chat_id"` 16 | Text string `json:"text"` 17 | ParseMode string `json:"parse_mode"` 18 | } 19 | 20 | // TGNotify TG推送 21 | func TGNotify(comment *model.Comment, article *model.Article, err error) { 22 | // when err == nil skip admin 23 | if comment.IsAdmin && err == nil { 24 | return 25 | } 26 | 27 | // 检查配置 28 | if solitudes.System.Config.TGBotToken == "" || solitudes.System.Config.TGChatID == "" { 29 | return 30 | } 31 | 32 | var errmsg string 33 | if err != nil { 34 | errmsg = ` 35 | 36 | ### Email notify error 37 | 38 | ` + err.Error() 39 | } 40 | 41 | content := fmt.Sprintf(`### %s got a new comment 42 | 43 | - Article: %s 44 | - Author: %s (%s) 45 | - Content: %s%s`, 46 | article.Title, 47 | article.Title, 48 | comment.Nickname, 49 | comment.Email, 50 | comment.Content, 51 | errmsg) 52 | 53 | msg := TelegramMessage{ 54 | ChatID: solitudes.System.Config.TGChatID, 55 | Text: content, 56 | ParseMode: "Markdown", 57 | } 58 | 59 | sendTelegramMessage(msg) 60 | } 61 | 62 | func sendTelegramMessage(msg TelegramMessage) error { 63 | url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", solitudes.System.Config.TGBotToken) 64 | 65 | jsonData, err := json.Marshal(msg) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData)) 71 | if err != nil { 72 | return err 73 | } 74 | defer resp.Body.Close() 75 | 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /router/search.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/blevesearch/bleve/v2" 9 | "github.com/gofiber/fiber/v2" 10 | "github.com/naiba/solitudes" 11 | "github.com/naiba/solitudes/internal/model" 12 | "github.com/naiba/solitudes/pkg/translator" 13 | ) 14 | 15 | type searchResp struct { 16 | model.ArticleIndex 17 | Content string 18 | } 19 | 20 | func search(c *fiber.Ctx) error { 21 | keywords := c.Query("w") 22 | 23 | query := bleve.NewQueryStringQuery(keywords) 24 | searchRequest := bleve.NewSearchRequest(query) 25 | searchRequest.Highlight = bleve.NewHighlight() 26 | searchRequest.Fields = []string{"Title", "Version", "Slug"} 27 | searchRequest.Explain = true 28 | searchResult, err := solitudes.System.Search.Search(searchRequest) 29 | 30 | var result []searchResp 31 | if err == nil { 32 | for _, hit := range searchResult.Hits { 33 | if hit.Fields["Slug"] == nil || hit.Fields["Version"] == nil || hit.Fields["Title"] == nil { 34 | log.Printf("invalid search result: %+v", hit) 35 | continue 36 | } 37 | item := model.ArticleIndex{ 38 | Slug: hit.Fields["Slug"].(string), 39 | Version: hit.Fields["Version"].(float64), 40 | Title: hit.Fields["Title"].(string), 41 | } 42 | content := bytes.NewBufferString("") 43 | for _, fragments := range hit.Fragments { 44 | for _, fragment := range fragments { 45 | content.WriteString(fragment + "\n") 46 | } 47 | } 48 | result = append(result, searchResp{ 49 | item, content.String(), 50 | }) 51 | } 52 | } 53 | 54 | c.Status(http.StatusOK).Render("default/search", injectSiteData(c, fiber.Map{ 55 | "title": c.Locals(solitudes.CtxTranslator).(*translator.Translator).T("search_result_title", "#SOL.9527.WORD#"), 56 | "results": result, 57 | })) 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /resource/theme/default/article_list_entry.html: -------------------------------------------------------------------------------- 1 | {{define "default/article_list_entry"}} 2 |
  • 3 |
    4 | 5 |
    6 | {{if .article.IsTopic}} 7 |
    8 | {{if .article.IsBook}}📙{{end}}{{if .article.IsPrivate}}🛡️{{end}}{{(md .article.ID .article.Content)|unsafe}} 9 | {{if .article.Comments}} 10 |
    11 | {{range .article.Comments}} 12 |
    13 |

    {{if .Email}} 14 | 15 | {{else}} 16 | 17 | {{end}}{{.Nickname}}{{if .IsAdmin}}👲{{end}}: 

    18 |

    {{.Content}}

    19 | 21 |
    22 | {{end}} 23 |
    24 | {{end}} 25 | (👀{{.article.ReadNum}}{{if .article.CommentNum}},💬{{.article.CommentNum}}{{end}}) #Topic 27 | {{.tr.T "leave_a_comment"}}> 28 |
    29 | {{else}} 30 |
    31 | {{if .article.IsBook}}📙{{end}}{{if .article.IsPrivate}}🛡️{{end}}{{.article.Title}}{{if .article.ReadNum}} 32 | (👀{{.article.ReadNum}}{{if .article.CommentNum}},💬{{.article.CommentNum}}{{end}}){{end}} 33 |
    34 | {{end}} 35 |
  • 36 | {{end}} -------------------------------------------------------------------------------- /pkg/pagination/pagination.go: -------------------------------------------------------------------------------- 1 | package pagination 2 | 3 | import ( 4 | "math" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | // Param 分页参数 10 | type Param struct { 11 | DB *gorm.DB 12 | Page int 13 | Limit int 14 | OrderBy []string 15 | ShowSQL bool 16 | } 17 | 18 | // Paginator 分页返回结果 19 | type Paginator struct { 20 | TotalRecord int `json:"total_record"` 21 | TotalPage int `json:"total_page"` 22 | Offset int `json:"offset"` 23 | Limit int `json:"limit"` 24 | Page int `json:"page"` 25 | PrevPage int `json:"prev_page"` 26 | NextPage int `json:"next_page"` 27 | Records interface{} `json:"records"` 28 | } 29 | 30 | // Paginate 分页查询 31 | func Paging(p *Param, result interface{}) *Paginator { 32 | db := p.DB 33 | if p.ShowSQL { 34 | db = db.Debug() 35 | } 36 | 37 | // 设置默认值 38 | if p.Page < 1 { 39 | p.Page = 1 40 | } 41 | if p.Limit == 0 { 42 | p.Limit = 10 43 | } 44 | 45 | // 添加排序 46 | if len(p.OrderBy) > 0 { 47 | for _, order := range p.OrderBy { 48 | db = db.Order(order) 49 | } 50 | } 51 | 52 | // 计算总记录数 53 | var count int64 54 | db.Model(result).Count(&count) 55 | 56 | // 计算偏移量 57 | offset := 0 58 | if p.Page > 1 { 59 | offset = (p.Page - 1) * p.Limit 60 | } 61 | 62 | // 查询记录 63 | db.Limit(p.Limit).Offset(offset).Find(result) 64 | 65 | // 计算总页数 66 | totalPage := int(math.Ceil(float64(count) / float64(p.Limit))) 67 | 68 | // 计算上一页和下一页 69 | prevPage := p.Page 70 | if p.Page > 1 { 71 | prevPage = p.Page - 1 72 | } 73 | 74 | nextPage := p.Page 75 | if p.Page < totalPage { 76 | nextPage = p.Page + 1 77 | } 78 | 79 | return &Paginator{ 80 | TotalRecord: int(count), 81 | TotalPage: totalPage, 82 | Offset: offset, 83 | Limit: p.Limit, 84 | Page: p.Page, 85 | PrevPage: prevPage, 86 | NextPage: nextPage, 87 | Records: result, 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /resource/static/cactus/lib/vazir-font/font-face.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: Vazir; 3 | src: url('Vazir.eot'); 4 | src: url('Vazir.eot?#iefix') format('embedded-opentype'), 5 | url('Vazir.woff2') format('woff2'), 6 | url('Vazir.woff') format('woff'), 7 | url('Vazir.ttf') format('truetype'); 8 | font-weight: normal; 9 | } 10 | 11 | @font-face { 12 | font-family: Vazir; 13 | src: url('Vazir-Bold.eot'); 14 | src: url('Vazir-Bold.eot?#iefix') format('embedded-opentype'), 15 | url('Vazir-Bold.woff2') format('woff2'), 16 | url('Vazir-Bold.woff') format('woff'), 17 | url('Vazir-Bold.ttf') format('truetype'); 18 | font-weight: bold; 19 | } 20 | 21 | @font-face { 22 | font-family: Vazir; 23 | src: url('Vazir-Light.eot'); 24 | src: url('Vazir-Light.eot?#iefix') format('embedded-opentype'), 25 | url('Vazir-Light.woff2') format('woff2'), 26 | url('Vazir-Light.woff') format('woff'), 27 | url('Vazir-Light.ttf') format('truetype'); 28 | font-weight: 300; 29 | } 30 | 31 | @font-face { 32 | font-family: Vazir; 33 | src: url('Vazir-Medium.eot'); 34 | src: url('Vazir-Medium.eot?#iefix') format('embedded-opentype'), 35 | url('Vazir-Medium.woff2') format('woff2'), 36 | url('Vazir-Medium.woff') format('woff'), 37 | url('Vazir-Medium.ttf') format('truetype'); 38 | font-weight: 500; 39 | } 40 | 41 | @font-face { 42 | font-family: Vazir; 43 | src: url('Vazir-Thin.eot'); 44 | src: url('Vazir-Thin.eot?#iefix') format('embedded-opentype'), 45 | url('Vazir-Thin.woff2') format('woff2'), 46 | url('Vazir-Thin.woff') format('woff'), 47 | url('Vazir-Thin.ttf') format('truetype'); 48 | font-weight: 100; 49 | } 50 | 51 | @font-face { 52 | font-family: Vazir; 53 | src: url('Vazir-Black.eot'); 54 | src: url('Vazir-Black.eot?#iefix') format('embedded-opentype'), 55 | url('Vazir-Black.woff2') format('woff2'), 56 | url('Vazir-Black.woff') format('woff'), 57 | url('Vazir-Black.ttf') format('truetype'); 58 | font-weight: 900; 59 | } -------------------------------------------------------------------------------- /resource/static/cactus/css/rtl.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: Vazir; 3 | src: url("../lib/vazir-font/Vazir.eot"); 4 | src: url("../lib/vazir-font/Vazir.eot?#iefix") format('embedded-opentype'), url("../lib/vazir-font/Vazir.woff2") format('woff2'), url("../lib/vazir-font/Vazir.woff") format('woff'), url("../lib/vazir-font/Vazir.ttf") format('truetype'); 5 | font-weight: normal; 6 | } 7 | @font-face { 8 | font-family: Vazir; 9 | src: url("../lib/vazir-font/Vazir-Bold.eot"); 10 | src: url("../lib/vazir-font/Vazir-Bold.eot?#iefix") format('embedded-opentype'), url("../lib/vazir-font/Vazir-Bold.woff2") format('woff2'), url("../lib/vazir-font/Vazir-Bold.woff") format('woff'), url("../lib/vazir-font/Vazir-Bold.ttf") format('truetype'); 11 | font-weight: bold; 12 | } 13 | @font-face { 14 | font-family: Vazir; 15 | src: url("../lib/vazir-font/Vazir-Light.eot"); 16 | src: url("../lib/vazir-font/Vazir-Light.eot?#iefix") format('embedded-opentype'), url("../lib/vazir-font/Vazir-Light.woff2") format('woff2'), url("../lib/vazir-font/Vazir-Light.woff") format('woff'), url("../lib/vazir-font/Vazir-Light.ttf") format('truetype'); 17 | font-weight: 300; 18 | } 19 | .rtl { 20 | font-family: Vazir, sans-serif; 21 | direction: rtl; 22 | } 23 | .rtl #nav li { 24 | margin-right: 0px !important; 25 | padding-left: 15px; 26 | border-right: 0px !important; 27 | border-left: 1px dotted $color-accent-1; 28 | } 29 | .rtl #nav li:last-child { 30 | margin-right: 15px !important; 31 | border-left: 0 !important; 32 | } 33 | .rtl #header #logo { 34 | float: right; 35 | } 36 | .rtl #footer li { 37 | margin-right: 0px; 38 | padding-left: 15px; 39 | border-right: 0px; 40 | border-left: 1px dotted $color-border; 41 | } 42 | .rtl #footer li:last-child { 43 | margin-right: 15px !important; 44 | border-left: 0 !important; 45 | } 46 | .rtl #footer #logo { 47 | float: right; 48 | } 49 | .rtl article .content h2:before { 50 | right: -1rem; 51 | } 52 | @media (min-width: 480px) { 53 | .post-list .post-item .meta { 54 | text-align: right; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /pkg/translator/translator.go: -------------------------------------------------------------------------------- 1 | package translator 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/go-playground/locales" 7 | "github.com/go-playground/locales/currency" 8 | "github.com/go-playground/locales/en" 9 | "github.com/go-playground/locales/zh" 10 | ut "github.com/go-playground/universal-translator" 11 | ) 12 | 13 | // Translator 翻译 14 | type Translator struct { 15 | locales.Translator 16 | Trans ut.Translator 17 | } 18 | 19 | // T 普通翻译 20 | func (t *Translator) T(key interface{}, params ...string) string { 21 | 22 | s, err := t.Trans.T(key, params...) 23 | if err != nil { 24 | log.Printf("issue translating key: '%v' error: '%s'", key, err) 25 | } 26 | return s 27 | } 28 | 29 | // C cardinal 30 | func (t *Translator) C(key interface{}, num float64, digits uint64, param string) string { 31 | 32 | s, err := t.Trans.C(key, num, digits, param) 33 | if err != nil { 34 | log.Printf("issue translating cardinal key: '%v' error: '%s'", key, err) 35 | } 36 | 37 | return s 38 | } 39 | 40 | // O ordinal 41 | func (t *Translator) O(key interface{}, num float64, digits uint64, param string) string { 42 | 43 | s, err := t.Trans.C(key, num, digits, param) 44 | if err != nil { 45 | log.Printf("issue translating ordinal key: '%v' error: '%s'", key, err) 46 | } 47 | 48 | return s 49 | } 50 | 51 | // R range 52 | func (t *Translator) R(key interface{}, num1 float64, digits1 uint64, num2 float64, digits2 uint64, param1, param2 string) string { 53 | 54 | s, err := t.Trans.R(key, num1, digits1, num2, digits2, param1, param2) 55 | if err != nil { 56 | log.Printf("issue translating range key: '%v' error: '%s'", key, err) 57 | } 58 | 59 | return s 60 | } 61 | 62 | // Currency 货币 63 | func (t *Translator) Currency() currency.Type { 64 | switch t.Locale() { 65 | case "en": 66 | return currency.USD 67 | default: 68 | return currency.CNY 69 | } 70 | } 71 | 72 | // Trans translations 73 | var Trans *ut.UniversalTranslator 74 | 75 | func init() { 76 | en := en.New() 77 | Trans = ut.New(en, en, zh.New()) 78 | 79 | err := Trans.Import(ut.FormatJSON, "resource/translation") 80 | if err != nil { 81 | panic(err) 82 | } 83 | 84 | err = Trans.VerifyTranslations() 85 | if err != nil { 86 | panic(err) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /pkg/notify/email.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/matcornic/hermes/v2" 7 | "github.com/naiba/solitudes" 8 | "github.com/naiba/solitudes/internal/model" 9 | "gopkg.in/gomail.v2" 10 | ) 11 | 12 | // Email notify 13 | func Email(src, dist *model.Comment, article *model.Article) error { 14 | if dist == nil || dist.Email == "" { 15 | return errors.New("not replying to a comment or being replied to a person who does not leave a mailbox, without email notification") 16 | } 17 | if dist.Email == src.Email { 18 | return errors.New("same email from src to dist") 19 | } 20 | if dist.IsAdmin { 21 | return errors.New("reply to the administrator without notification") 22 | } 23 | email := hermes.Email{ 24 | Body: hermes.Body{ 25 | Name: dist.Nickname, 26 | Intros: []string{ 27 | dist.Nickname + ":" + dist.Content, 28 | src.Nickname + ":" + src.Content, 29 | }, 30 | Actions: []hermes.Action{ 31 | { 32 | Instructions: "View the article:", 33 | Button: hermes.Button{ 34 | Color: "#22BC66", // Optional action button color 35 | Text: "Open", 36 | Link: "https://" + solitudes.System.Config.Site.Domain + "/" + article.Slug, 37 | }, 38 | }, 39 | }, 40 | }, 41 | } 42 | 43 | var h = hermes.Hermes{ 44 | Product: hermes.Product{ 45 | Name: solitudes.System.Config.Site.SpaceName, 46 | Link: solitudes.System.Config.Site.Domain, 47 | Logo: "https://" + solitudes.System.Config.Site.Domain + "/static/cactus/images/logo.png?20211213", 48 | Copyright: "Copyright © " + solitudes.System.Config.Site.SpaceName + ". All rights reserved.", 49 | }, 50 | } 51 | 52 | emailBody, err := h.GenerateHTML(email) 53 | if err != nil { 54 | return err 55 | } 56 | m := gomail.NewMessage() 57 | m.SetHeader("From", solitudes.System.Config.Email.User) 58 | m.SetHeader("To", dist.Email) 59 | m.SetHeader("Subject", "Comment in ["+article.Title+"] got new reply") 60 | m.SetBody("text/html", emailBody) 61 | 62 | var sender = gomail.NewDialer(solitudes.System.Config.Email.Host, 63 | solitudes.System.Config.Email.Port, solitudes.System.Config.Email.User, 64 | solitudes.System.Config.Email.Pass) 65 | sender.SSL = solitudes.System.Config.Email.SSL 66 | 67 | return sender.DialAndSend(m) 68 | } 69 | -------------------------------------------------------------------------------- /resource/theme/admin/login.html: -------------------------------------------------------------------------------- 1 | {{define "admin/login"}} 2 | 3 | 4 | 5 | 6 | {{template "admin/css"}} 7 | 8 | {{.Tr.T "login"}} - Powered by Solitudes 9 | 10 | 11 | 12 |
    13 | 16 | 42 |
    43 | {{template "admin/js"}} 44 | 45 | 54 | 55 | 56 | 57 | {{end}} -------------------------------------------------------------------------------- /router/media.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "errors" 5 | "io/fs" 6 | "net/http" 7 | "os" 8 | "path" 9 | "path/filepath" 10 | "strconv" 11 | "time" 12 | 13 | "github.com/gofiber/fiber/v2" 14 | "gorm.io/gorm" 15 | 16 | "github.com/naiba/solitudes" 17 | "github.com/naiba/solitudes/internal/model" 18 | "github.com/naiba/solitudes/pkg/translator" 19 | ) 20 | 21 | func mediaHandler(c *fiber.Ctx) error { 22 | name := c.Query("name") 23 | return os.Remove("data/upload/" + path.Clean(name)) 24 | } 25 | 26 | type mediaInfo struct { 27 | Filename string 28 | Article model.Article 29 | UploadedAt time.Time 30 | } 31 | 32 | var errEnded = errors.New("file walk eneded") 33 | 34 | func media(c *fiber.Ctx) error { 35 | rawPage := c.Query("page") 36 | page64, _ := strconv.ParseInt(rawPage, 10, 64) 37 | page := int(page64) 38 | if page < 1 { 39 | page = 1 40 | } 41 | var files []os.FileInfo 42 | start := (page - 1) * 15 43 | end := page * 15 44 | fileIndex := 0 45 | err := filepath.Walk("data/upload", func(path string, info fs.FileInfo, err error) error { 46 | if err != nil { 47 | return err 48 | } 49 | if path == "data/upload" { 50 | return nil 51 | } 52 | if info.IsDir() { 53 | return filepath.SkipDir 54 | } 55 | if fileIndex >= start && fileIndex < end { 56 | files = append(files, info) 57 | } 58 | fileIndex++ 59 | if fileIndex >= end { 60 | return errEnded 61 | } 62 | return nil 63 | }) 64 | if err != nil && err != errEnded { 65 | return err 66 | } 67 | var innerMedias []mediaInfo 68 | for _, f := range files { 69 | var item mediaInfo 70 | item.UploadedAt = f.ModTime() 71 | item.Filename = f.Name() 72 | if err := solitudes.System.DB.Take(&item.Article, "content like ?", "%/upload/"+item.Filename+"%").Error; err == gorm.ErrRecordNotFound { 73 | var ah model.ArticleHistory 74 | if solitudes.System.DB.Take(&ah, "content like ?", "%/upload/"+item.Filename+"%").Error == nil { 75 | solitudes.System.DB.Take(&item.Article, "id = ?", ah.ArticleID) 76 | item.Article.Version = ah.Version 77 | } 78 | } 79 | innerMedias = append(innerMedias, item) 80 | } 81 | c.Status(http.StatusOK).Render("admin/media", injectSiteData(c, fiber.Map{ 82 | "title": c.Locals(solitudes.CtxTranslator).(*translator.Translator).T("manage_media"), 83 | "medias": innerMedias, 84 | "page": page, 85 | })) 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Solitudes 2 | 3 | ![构建状态](https://github.com/naiba/solitudes/workflows/Build%20Docker%20Image/badge.svg) 4 | 5 | :smoking: 在那些寂寞的日子里,有写作伴随着我。 6 | 7 | 奶爸的一个小梦想,写一本书。
    8 | Solitud.es 域名已赠予朋友。 9 | 10 | 本博客引擎的特色: 11 | 12 | - **全文搜索** 不分「简体/繁体」中文,「大写/小写」英文,都能搜索到。 13 | - **写书** 例子: 14 | - 封面:在发布文章时勾选为 `这是专栏`,发布的文章会作为你的书的封面。 15 | - 内容:在发布文章时将封面文章的 `UUID` 填入 `专栏ID`,发布的文章将会划为你的封面内的内容。 16 | - 章节:如果你想写一个超长篇内容,可以套嵌,将 `内容` 变成 `封面` 进行套娃。 17 | - **修订历史** 你对文章的所有修订记录都可被搜索及浏览 18 | - 新版本:编辑文章时,勾选 `大更新` 选项,会将你的文章版本升级。 19 | - 浏览所有版本:在链接后加 `/v*` 20 | - *无版本号展示最新版本* 21 | - *最新版本号会自动跳转到无版本号链接* 22 | - 可搜索:新旧两个版本文章都会出现在搜索结果。 23 | - **哔哔** 类似微博、推文,例子: 24 | - 发布:在发布文章时将 `Topic` 添加到标签,为了省心 `标题` 和 `链接` 可以留空会自动补充。 25 | - **Feed 自动发现** 粘贴博客任意链接到 RSS 阅读器即可自动发现订阅地址 26 | 27 | ## 部署 28 | 29 | docker-compose.yml 30 | 31 | ```yaml 32 | version: '3.3' 33 | 34 | services: 35 | db: 36 | image: postgres:13-alpine 37 | volumes: 38 | - ./postgres-data:/var/lib/postgresql/data 39 | restart: always 40 | environment: 41 | POSTGRES_PASSWORD: thisispassword 42 | POSTGRES_USER: solitudes 43 | POSTGRES_DB: solitudes 44 | 45 | solitudes: 46 | depends_on: 47 | - db 48 | image: ghcr.io/naiba/solitudes:latest 49 | ports: 50 | - "8080:8080" 51 | restart: always 52 | volumes: 53 | - ./blog-data:/solitudes/data 54 | # - ./blog-data/logo.png:/solitudes/resource/static/cactus/images/logo.png # 自定义logo 55 | ``` 56 | 57 | ```shell 58 | $ ls blog-data 59 | bleve conf.yml logo.png upload 60 | # conf.yml 是配置文件,参考 data/conf.yml.example 61 | # logo.png 是自己 logo,替换主题自带的 logo 62 | 63 | ``` 64 | 65 | 先启动 db 然后启用 UUID 扩展,再启动 solitudes 66 | 67 | 68 | ``` 69 | docker-compose up -d db 70 | docker-compose exec db psql -U solitudes solitudes -c 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp";' 71 | docker-compose up -d solitudes 72 | ``` 73 | 74 | 管理页面的地址是链接/admin 75 | 76 | ### 鸣谢 77 | 78 | - 主题来自 [probberechts/hexo-theme-cactus](https://github.com/probberechts/hexo-theme-cactus) 79 | - 管理面板界面 [AdminLTE](https://adminlte.io/) 80 | - 全文搜索引擎 [blevesearch/bleve](https://github.com/blevesearch/bleve) 81 | - Hacker Pie @88250 [lute](https://github.com/88250/lute) & @Vanessa219 [Vditor](https://github.com/Vanessa219/vditor) 82 | -------------------------------------------------------------------------------- /resource/theme/admin/tags.html: -------------------------------------------------------------------------------- 1 | {{define "admin/tags"}} 2 | {{template "admin/header" .}} 3 | 9 |
    10 |
    11 |

    12 | {{.Tr.T "manage_tags"}} 13 | Solitudes 14 |

    15 |
    16 | 17 |
    18 |
    19 |
    20 | {{range .Data.tags}} 21 |
    22 | 23 | 26 | 29 |
    30 | {{end}} 31 |
    32 |
    33 |
    34 |
    35 | 79 | {{template "admin/footer" .}} 80 | {{end}} -------------------------------------------------------------------------------- /resource/theme/admin/media.html: -------------------------------------------------------------------------------- 1 | {{define "admin/media"}} 2 | {{template "admin/header" .}} 3 |
    4 |
    5 |

    6 | {{.Tr.T "manage_media"}} 7 |

    8 |
    9 | 10 |
    11 |
    12 |
    13 |
    14 |
    15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {{range .Data.medias}} 26 | 27 | 28 | 31 | 32 | 34 | 35 | {{end}} 36 | 37 |
    {{.Tr.T "filename"}}{{.Tr.T "article"}}{{.Tr.T "uploaded_at"}}{{.Tr.T "manage"}}
    {{.Filename}}{{.Article.Title}} 30 | {{tf .UploadedAt ($.Tr.T "date_format")}}
    38 |
    39 |
    40 | 45 |
    46 |
    47 |
    48 |
    49 | 70 | {{template "admin/footer" .}} 71 | {{end}} -------------------------------------------------------------------------------- /resource/theme/default/footer.html: -------------------------------------------------------------------------------- 1 | {{define "default/footer"}} 2 |
    3 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 71 | {{ .Conf.Site.CustomCode | unsafe}} 72 | {{end}} -------------------------------------------------------------------------------- /pkg/blevejieba/blevejieba.go: -------------------------------------------------------------------------------- 1 | package blevejieba 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/blevesearch/bleve/v2/analysis" 9 | "github.com/blevesearch/bleve/v2/registry" 10 | "github.com/liuzl/gocc" 11 | "github.com/yanyiwu/gojieba" 12 | ) 13 | 14 | var goccI *gocc.OpenCC 15 | 16 | func init() { 17 | var err error 18 | goccI, err = gocc.New("t2s") 19 | if err != nil { 20 | panic(err) 21 | } 22 | registry.RegisterAnalyzer("jieba", analyzerConstructor) 23 | registry.RegisterTokenizer("jieba", tokenizerConstructor) 24 | fmt.Println("[bleve-jieba] inited") 25 | } 26 | 27 | // JiebaTokenizer .. 28 | type JiebaTokenizer struct { 29 | jieba *gojieba.Jieba 30 | useHmm bool 31 | tokenizeMode gojieba.TokenizeMode 32 | } 33 | 34 | func CleanText(text string) string { 35 | text = strings.ToLower(text) 36 | textNew, err := goccI.Convert(text) 37 | if err != nil { 38 | return text 39 | } 40 | return textNew 41 | } 42 | 43 | // Tokenize .. 44 | func (s *JiebaTokenizer) Tokenize(sentence []byte) analysis.TokenStream { 45 | result := make(analysis.TokenStream, 0) 46 | words := s.jieba.Tokenize(string(sentence), s.tokenizeMode, s.useHmm) 47 | for pos, word := range words { 48 | token := analysis.Token{ 49 | Start: word.Start, 50 | End: word.End, 51 | Position: pos + 1, 52 | Term: []byte(CleanText(word.Str)), 53 | Type: analysis.Ideographic, 54 | } 55 | result = append(result, &token) 56 | } 57 | return result 58 | } 59 | 60 | func tokenizerConstructor(config map[string]interface{}, cache *registry.Cache) (analysis.Tokenizer, error) { 61 | useHmm, ok := config["useHmm"].(bool) 62 | if !ok { 63 | return nil, errors.New("must specify useHmm") 64 | } 65 | tokenizeMode, ok := config["tokenizeMode"].(float64) 66 | if !ok { 67 | return nil, errors.New("must specify tokenizeMode") 68 | } 69 | tokenizer := &JiebaTokenizer{ 70 | jieba: gojieba.NewJieba(), 71 | useHmm: useHmm, 72 | tokenizeMode: gojieba.TokenizeMode(tokenizeMode), 73 | } 74 | return tokenizer, nil 75 | } 76 | 77 | func analyzerConstructor(config map[string]interface{}, cache *registry.Cache) (analysis.Analyzer, error) { 78 | tokenizerName, ok := config["tokenizer"].(string) 79 | if !ok { 80 | return nil, errors.New("must specify tokenizer") 81 | } 82 | tokenizer, err := cache.TokenizerNamed(tokenizerName) 83 | if err != nil { 84 | return nil, err 85 | } 86 | jbtk, ok := tokenizer.(*JiebaTokenizer) 87 | if !ok { 88 | return nil, errors.New("tokenizer must be of type jieba") 89 | } 90 | alz := &JiebaAnalyzer{ 91 | Tokenizer: jbtk, 92 | } 93 | return alz, nil 94 | } 95 | 96 | // JiebaAnalyzer from analysis.DefaultAnalyzer 97 | type JiebaAnalyzer struct { 98 | CharFilters []analysis.CharFilter 99 | Tokenizer *JiebaTokenizer 100 | TokenFilters []analysis.TokenFilter 101 | } 102 | 103 | func (a *JiebaAnalyzer) Analyze(input []byte) analysis.TokenStream { 104 | if a.CharFilters != nil { 105 | for _, cf := range a.CharFilters { 106 | input = cf.Filter(input) 107 | } 108 | } 109 | tokens := a.Tokenizer.Tokenize(input) 110 | if a.TokenFilters != nil { 111 | for _, tf := range a.TokenFilters { 112 | tokens = tf.Filter(tokens) 113 | } 114 | } 115 | return tokens 116 | } 117 | 118 | func (a *JiebaAnalyzer) Free() { 119 | if a.Tokenizer != nil { 120 | a.Tokenizer.jieba.Free() 121 | } else { 122 | panic("JiebaAnalyzer.Tokenizer is nil, this should not happen") 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /resource/theme/admin/articles.html: -------------------------------------------------------------------------------- 1 | {{define "admin/articles"}} 2 | {{template "admin/header" .}} 3 |
    4 |
    5 |

    6 | {{.Tr.T "manage_articles"}} 7 | Solitudes 8 |

    9 |
    10 | 11 |
    12 |
    13 |
    14 |
    15 |
    16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {{range .Data.articles}} 31 | 32 | 33 | 35 | 36 | 37 | 38 | 39 | 40 | 48 | 49 | {{end}} 50 | 51 |
    ID{{.Tr.T "title"}}{{.Tr.T "read"}}{{.Tr.T "slug"}}{{.Tr.T "book"}}Private{{.Tr.T "created_at"}}{{.Tr.T "manage"}}
    {{.ID}}{{.Title}} 34 | {{.ReadNum}}{{.Slug}}{{.IsBook}}{{.IsPrivate}}{{tf .CreatedAt ($.Tr.T "date_format")}} 41 |
    42 | {{$.Tr.T "edit"}} 44 | 46 |
    47 |
    52 |
    53 |
    54 | 59 |
    60 |
    61 |
    62 |
    63 | 80 | {{template "admin/footer" .}} 81 | {{end}} -------------------------------------------------------------------------------- /router/user.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/gofiber/fiber/v2" 10 | "github.com/naiba/solitudes/pkg/pagination" 11 | "github.com/naiba/solitudes/pkg/translator" 12 | "golang.org/x/crypto/bcrypt" 13 | "gorm.io/gorm" 14 | 15 | "github.com/naiba/solitudes" 16 | "github.com/naiba/solitudes/internal/model" 17 | ) 18 | 19 | type loginForm struct { 20 | Email string `form:"email" validate:"required,email"` 21 | Password string `form:"password" validate:"required"` 22 | Remember string `form:"remember"` 23 | } 24 | 25 | func loginHandler(c *fiber.Ctx) error { 26 | var lf loginForm 27 | if err := c.BodyParser(&lf); err != nil { 28 | return err 29 | } 30 | if err := validator.StructCtx(c.Context(), &lf); err != nil { 31 | return err 32 | } 33 | if lf.Email != solitudes.System.Config.User.Email || 34 | bcrypt.CompareHashAndPassword([]byte(solitudes.System.Config.User.Password), 35 | []byte(lf.Password)) != nil { 36 | return errors.New("invalid email or password") 37 | } 38 | token, err := bcrypt.GenerateFromPassword([]byte(fmt.Sprintf("%s%d", lf.Password, time.Now().UnixMicro())), bcrypt.DefaultCost) 39 | if err != nil { 40 | return err 41 | } 42 | solitudes.System.Config.User.Token = string(token) 43 | var expires time.Time 44 | if lf.Remember == "on" { 45 | expires = time.Now().AddDate(0, 3, 0) 46 | } else { 47 | expires = time.Now().Add(time.Hour * 4) 48 | } 49 | solitudes.System.Config.User.TokenExpires = expires.Unix() 50 | c.Cookie(&fiber.Cookie{ 51 | Name: solitudes.AuthCookie, 52 | Value: string(token), 53 | Expires: expires, 54 | }) 55 | solitudes.System.Config.Save() 56 | c.Redirect("/admin/", http.StatusFound) 57 | return nil 58 | } 59 | 60 | func login(c *fiber.Ctx) error { 61 | c.Status(http.StatusOK).Render("admin/login", injectSiteData(c, fiber.Map{})) 62 | return nil 63 | } 64 | 65 | func logoutHandler(c *fiber.Ctx) error { 66 | solitudes.System.Config.User.TokenExpires = time.Now().Unix() 67 | solitudes.System.Config.User.Token = "" 68 | solitudes.System.Config.Save() 69 | c.Redirect("/", http.StatusFound) 70 | return nil 71 | } 72 | 73 | func index(c *fiber.Ctx) error { 74 | var articles []model.Article 75 | var topics []model.Article 76 | solitudes.System.DB.Where("tags @> ARRAY[?]::varchar[]", "Topic").Order("created_at DESC").Limit(3).Find(&topics) 77 | for i := 0; i < len(topics); i++ { 78 | pagination.Paging(&pagination.Param{ 79 | DB: solitudes.System.DB.Where("reply_to is null and article_id = ?", topics[i].ID), 80 | Limit: 5, 81 | OrderBy: []string{"created_at DESC"}, 82 | }, &topics[i].Comments) 83 | } 84 | articleCount := 16 - len(topics)*2 85 | solitudes.System.DB.Where("array_length(tags, 1) is null").Or("NOT tags @> ARRAY[?]::varchar[]", "Topic").Order("created_at DESC").Limit(articleCount).Find(&articles) 86 | for i := 0; i < len(articles); i++ { 87 | articles[i].RelatedCount(solitudes.System.DB, solitudes.System.Pool, checkPoolSubmit) 88 | } 89 | tr := c.Locals(solitudes.CtxTranslator).(*translator.Translator) 90 | c.Status(http.StatusOK).Render("default/index", injectSiteData(c, fiber.Map{ 91 | "title": tr.T("home"), 92 | "articles": articles, 93 | "topics": topics, 94 | })) 95 | return nil 96 | } 97 | 98 | func count(c *fiber.Ctx) error { 99 | if c.Query("slug") == "" { 100 | return nil 101 | } 102 | // FIXME 允许刷新增加计数 103 | // key := c.IP() + c.Query("slug") 104 | // if _, ok := solitudes.System.Cache.Get(key); ok { 105 | // return nil 106 | // } 107 | // solitudes.System.Cache.Set(key, nil, time.Hour*20) 108 | solitudes.System.DB.Model(model.Article{}). 109 | Where("slug = ?", c.Query("slug")). 110 | UpdateColumn("read_num", gorm.Expr("read_num + ?", 1)) 111 | return nil 112 | } 113 | -------------------------------------------------------------------------------- /resource/translation/zh/user.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "locale": "zh", 4 | "key": "articles_in", 5 | "trans": "「{0}」分类下的文章" 6 | }, 7 | { 8 | "locale": "zh", 9 | "key": "writing", 10 | "trans": "写作" 11 | }, 12 | { 13 | "locale": "zh", 14 | "key": "date_format", 15 | "trans": "2006-01-02" 16 | }, 17 | { 18 | "locale": "zh", 19 | "key": "no_article", 20 | "trans": "暂无发布的文章" 21 | }, 22 | { 23 | "locale": "zh", 24 | "key": "pagination", 25 | "trans": "第 {0}/{1} 页,共 {2} 篇" 26 | }, 27 | { 28 | "locale": "zh", 29 | "key": "search", 30 | "trans": "搜索" 31 | }, 32 | { 33 | "locale": "zh", 34 | "key": "archive", 35 | "trans": "文章归档" 36 | }, 37 | { 38 | "locale": "zh", 39 | "key": "books", 40 | "trans": "专栏" 41 | }, 42 | { 43 | "locale": "zh", 44 | "key": "tags_cloud", 45 | "trans": "标签云" 46 | }, 47 | { 48 | "locale": "zh", 49 | "key": "comments", 50 | "trans": "评论区" 51 | }, 52 | { 53 | "locale": "zh", 54 | "key": "nickname", 55 | "trans": "昵称" 56 | }, 57 | { 58 | "locale": "zh", 59 | "key": "email", 60 | "trans": "邮箱" 61 | }, 62 | { 63 | "locale": "zh", 64 | "key": "may_not_reply_if_email_not_exist", 65 | "trans": "请留下正确的邮箱地址,以便博主回复您的评论。" 66 | }, 67 | { 68 | "locale": "zh", 69 | "key": "website", 70 | "trans": "个人网站" 71 | }, 72 | { 73 | "locale": "zh", 74 | "key": "not_required", 75 | "trans": "选填" 76 | }, 77 | { 78 | "locale": "zh", 79 | "key": "submit", 80 | "trans": "提交评论" 81 | }, 82 | { 83 | "locale": "zh", 84 | "key": "menu", 85 | "trans": "菜单选项" 86 | }, 87 | { 88 | "locale": "zh", 89 | "key": "return_top", 90 | "trans": "返回顶部" 91 | }, 92 | { 93 | "locale": "zh", 94 | "key": "share", 95 | "trans": "分享文章" 96 | }, 97 | { 98 | "locale": "zh", 99 | "key": "toc", 100 | "trans": "文章目录" 101 | }, 102 | { 103 | "locale": "zh", 104 | "key": "previous_post", 105 | "trans": "上一篇文章" 106 | }, 107 | { 108 | "locale": "zh", 109 | "key": "next_post", 110 | "trans": "下一篇文章" 111 | }, 112 | { 113 | "locale": "zh", 114 | "key": "chapters", 115 | "trans": "章节列表" 116 | }, 117 | { 118 | "locale": "zh", 119 | "key": "book", 120 | "trans": "专栏文章" 121 | }, 122 | { 123 | "locale": "zh", 124 | "key": "404_title", 125 | "trans": "404 页面未找到" 126 | }, 127 | { 128 | "locale": "zh", 129 | "key": "404_msg", 130 | "trans": "哎呀…… 这个页面可能已经飞到了外太空。" 131 | }, 132 | { 133 | "locale": "zh", 134 | "key": "search_engine_error", 135 | "trans": "搜索引擎出错了" 136 | }, 137 | { 138 | "locale": "zh", 139 | "key": "search_result_title", 140 | "trans": "搜索结果:「{0}」" 141 | }, 142 | { 143 | "locale": "zh", 144 | "key": "has_new_version", 145 | "trans": "您正在查看的是本文章的 v{0} 版本,该文章已更新至 v{3} 版本。" 146 | }, 147 | { 148 | "locale": "zh", 149 | "key": "has_old_version", 150 | "trans": "您正在查看的是本文章的 v{0} 版本,您还可以查看历史版本:{1}。" 151 | }, 152 | { 153 | "locale": "zh", 154 | "key": "leave_a_comment", 155 | "trans": "发表评论" 156 | }, 157 | { 158 | "locale": "zh", 159 | "key": "view_more", 160 | "trans": "查看更多内容" 161 | }, 162 | { 163 | "locale": "zh", 164 | "key": "topic", 165 | "trans": "哔哔" 166 | } 167 | ] 168 | -------------------------------------------------------------------------------- /router/manage_comment.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/adtac/go-akismet/akismet" 9 | "github.com/gofiber/fiber/v2" 10 | "github.com/naiba/solitudes" 11 | "github.com/naiba/solitudes/internal/model" 12 | "github.com/naiba/solitudes/pkg/pagination" 13 | "github.com/naiba/solitudes/pkg/translator" 14 | "gorm.io/gorm" 15 | ) 16 | 17 | func comments(c *fiber.Ctx) error { 18 | rawPage := c.Query("page") 19 | var page int64 20 | page, _ = strconv.ParseInt(rawPage, 10, 32) 21 | var cs []model.Comment 22 | pg := pagination.Paging(&pagination.Param{ 23 | DB: solitudes.System.DB.Preload("Article"), 24 | Page: int(page), 25 | Limit: 20, 26 | OrderBy: []string{"created_at DESC"}, 27 | }, &cs) 28 | c.Status(http.StatusOK).Render("admin/comments", injectSiteData(c, fiber.Map{ 29 | "title": c.Locals(solitudes.CtxTranslator).(*translator.Translator).T("manage_comments"), 30 | "comments": cs, 31 | "page": pg, 32 | })) 33 | return nil 34 | } 35 | 36 | func deleteComment(c *fiber.Ctx) error { 37 | id := c.Query("id") 38 | articleID := c.Query("aid") 39 | 40 | if len(id) < 10 || len(articleID) < 10 { 41 | return errors.New("error id") 42 | } 43 | 44 | err := solitudes.System.DB.Transaction(func(tx *gorm.DB) error { 45 | // 删除评论 46 | if err := tx.Delete(&model.Comment{}, "id = ?", id).Error; err != nil { 47 | return err 48 | } 49 | // 更新回复关系 50 | if err := tx.Model(&model.Comment{}).Where("reply_to = ?", id).Update("reply_to", nil).Error; err != nil { 51 | return err 52 | } 53 | // 更新文章评论数 54 | if err := tx.Model(&model.Article{}).Where("id = ?", articleID). 55 | UpdateColumn("comment_num", gorm.Expr("comment_num - ?", 1)).Error; err != nil { 56 | return err 57 | } 58 | return nil 59 | }) 60 | 61 | return err 62 | } 63 | 64 | func reportSpam(c *fiber.Ctx) error { 65 | id := c.Query("id") 66 | articleID := c.Query("aid") 67 | 68 | if len(id) < 10 || len(articleID) < 10 { 69 | return errors.New("error id") 70 | } 71 | 72 | var cm model.Comment 73 | if err := solitudes.System.DB.Take(&cm, "id = ?", id).Error; err != nil { 74 | return err 75 | } 76 | 77 | var article model.Article 78 | if err := solitudes.System.DB.Take(&article, "id = ?", articleID).Error; err != nil { 79 | return err 80 | } 81 | 82 | cmType, _, err := getCommentType(&commentForm{ 83 | Nickname: cm.Nickname, 84 | Email: cm.Email, 85 | Website: cm.Website, 86 | Content: cm.Content, 87 | ReplyTo: cm.ReplyTo, 88 | }) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | if err := akismet.SubmitSpam(&akismet.Comment{ 94 | Blog: "https://" + solitudes.System.Config.Site.Domain, // required 95 | UserIP: cm.IP, // required 96 | UserAgent: cm.UserAgent, // required 97 | CommentType: cmType, 98 | Referrer: string(c.Request().Header.Referer()), 99 | Permalink: "https://" + solitudes.System.Config.Site.Domain + "/" + article.Slug, 100 | CommentAuthor: cm.Nickname, 101 | CommentAuthorEmail: cm.Email, 102 | CommentAuthorURL: cm.Website, 103 | CommentContent: cm.Content, 104 | }, solitudes.System.Config.Akismet); err != nil { 105 | return err 106 | } 107 | 108 | err = solitudes.System.DB.Transaction(func(tx *gorm.DB) error { 109 | // 删除评论 110 | if err := tx.Delete(&model.Comment{}, "id = ?", id).Error; err != nil { 111 | return err 112 | } 113 | // 更新回复关系 114 | if err := tx.Model(&model.Comment{}).Where("reply_to = ?", id).Update("reply_to", nil).Error; err != nil { 115 | return err 116 | } 117 | // 更新文章评论数 118 | if err := tx.Model(&model.Article{}).Where("id = ?", articleID). 119 | UpdateColumn("comment_num", gorm.Expr("comment_num - ?", 1)).Error; err != nil { 120 | return err 121 | } 122 | return nil 123 | }) 124 | 125 | return err 126 | } 127 | -------------------------------------------------------------------------------- /resource/theme/admin/header.html: -------------------------------------------------------------------------------- 1 | {{define "admin/header"}} 2 | 3 | 4 | 5 | 6 | {{template "admin/css"}} 7 | 8 | 13 | {{.Title}} 14 | 15 | 16 | 17 |
    18 |
    19 | 23 | 49 |
    50 | 77 | {{end}} -------------------------------------------------------------------------------- /resource/theme/admin/comments.html: -------------------------------------------------------------------------------- 1 | {{define "admin/comments"}} 2 | {{template "admin/header" .}} 3 |
    4 |
    5 |

    6 | {{.Tr.T "manage_comments"}} 7 | Solitudes 8 |

    9 |
    10 | 11 |
    12 |
    13 |
    14 |
    15 |
    16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {{range .Data.comments}} 31 | 32 | 33 | 34 | 35 | 36 | 39 | 40 | 41 | 46 | 47 | {{end}} 48 | 49 |
    ID{{.Tr.T "content"}}{{.Tr.T "author"}}{{.Tr.T "website"}}{{.Tr.T "article"}}{{.Tr.T "version"}}{{.Tr.T "created_at"}}{{.Tr.T "manage"}}
    {{.ID}}{{.Content}}{{.Nickname}}({{.Email}}){{.Website}}{{.Article.Title}} 38 | {{.Version}}{{tf .CreatedAt ($.Tr.T "date_format")}}
    42 | 44 | 45 |
    50 |
    51 |
    52 | 57 |
    58 |
    59 |
    60 |
    61 | 94 | {{template "admin/footer" .}} 95 | {{end}} -------------------------------------------------------------------------------- /resource/translation/en/user.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "locale": "en", 4 | "key": "articles_in", 5 | "trans": "Articles in \"{0}\"" 6 | }, 7 | { 8 | "locale": "en", 9 | "key": "writing", 10 | "trans": "Writing" 11 | }, 12 | { 13 | "locale": "en", 14 | "key": "date_format", 15 | "trans": "02 Jan 2006" 16 | }, 17 | { 18 | "locale": "en", 19 | "key": "no_article", 20 | "trans": "No articles have been published yet" 21 | }, 22 | { 23 | "locale": "en", 24 | "key": "pagination", 25 | "trans": "Page {0}/{1}, {2} {3} in total." 26 | }, 27 | { 28 | "locale": "en", 29 | "key": "search", 30 | "trans": "Search" 31 | }, 32 | { 33 | "locale": "en", 34 | "key": "archive", 35 | "trans": "Archive" 36 | }, 37 | { 38 | "locale": "en", 39 | "key": "books", 40 | "trans": "Books" 41 | }, 42 | { 43 | "locale": "en", 44 | "key": "tags_cloud", 45 | "trans": "Tags" 46 | }, 47 | { 48 | "locale": "en", 49 | "key": "comments", 50 | "trans": "Comments" 51 | }, 52 | { 53 | "locale": "en", 54 | "key": "nickname", 55 | "trans": "Nickname" 56 | }, 57 | { 58 | "locale": "en", 59 | "key": "email", 60 | "trans": "Email" 61 | }, 62 | { 63 | "locale": "en", 64 | "key": "may_not_reply_if_email_not_exist", 65 | "trans": "Please provide a valid email address to receive notifications when the blogger replies." 66 | }, 67 | { 68 | "locale": "en", 69 | "key": "website", 70 | "trans": "Website" 71 | }, 72 | { 73 | "locale": "en", 74 | "key": "not_required", 75 | "trans": "Not Required" 76 | }, 77 | { 78 | "locale": "en", 79 | "key": "submit", 80 | "trans": "Submit" 81 | }, 82 | { 83 | "locale": "en", 84 | "key": "menu", 85 | "trans": "Menu" 86 | }, 87 | { 88 | "locale": "en", 89 | "key": "return_top", 90 | "trans": "Back to Top" 91 | }, 92 | { 93 | "locale": "en", 94 | "key": "share", 95 | "trans": "Share" 96 | }, 97 | { 98 | "locale": "en", 99 | "key": "toc", 100 | "trans": "Table of Contents" 101 | }, 102 | { 103 | "locale": "en", 104 | "key": "previous_post", 105 | "trans": "Previous Post" 106 | }, 107 | { 108 | "locale": "en", 109 | "key": "next_post", 110 | "trans": "Next Post" 111 | }, 112 | { 113 | "locale": "en", 114 | "key": "chapters", 115 | "trans": "Chapters" 116 | }, 117 | { 118 | "locale": "en", 119 | "key": "book", 120 | "trans": "Book" 121 | }, 122 | { 123 | "locale": "en", 124 | "key": "404_title", 125 | "trans": "404 Page Not Found" 126 | }, 127 | { 128 | "locale": "en", 129 | "key": "404_msg", 130 | "trans": "Oops... This page may have flown to Mars." 131 | }, 132 | { 133 | "locale": "en", 134 | "key": "search_engine_error", 135 | "trans": "Search Engine Error" 136 | }, 137 | { 138 | "locale": "en", 139 | "key": "search_result_title", 140 | "trans": "Search Results for \"{0}\"" 141 | }, 142 | { 143 | "locale": "en", 144 | "key": "has_new_version", 145 | "trans": "You are viewing version v{0} of this article, which has been updated to version v{3}." 146 | }, 147 | { 148 | "locale": "en", 149 | "key": "has_old_version", 150 | "trans": "You are viewing version v{0} of this article, which has older versions: {1}." 151 | }, 152 | { 153 | "locale": "en", 154 | "key": "leave_a_comment", 155 | "trans": "Leave a Comment" 156 | }, 157 | { 158 | "locale": "en", 159 | "key": "view_more", 160 | "trans": "View More" 161 | }, 162 | { 163 | "locale": "en", 164 | "key": "topic", 165 | "trans": "Topic" 166 | } 167 | ] -------------------------------------------------------------------------------- /router/comment.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/adtac/go-akismet/akismet" 7 | "github.com/gofiber/fiber/v2" 8 | "github.com/naiba/solitudes" 9 | "github.com/naiba/solitudes/internal/model" 10 | "github.com/naiba/solitudes/pkg/notify" 11 | "gorm.io/gorm" 12 | ) 13 | 14 | type commentForm struct { 15 | ReplyTo *string `json:"reply_to" validate:"omitempty,uuid4"` 16 | Nickname string `json:"nickname" validate:"required"` 17 | Content string `json:"content" validate:"required" gorm:"text"` 18 | Slug string `json:"slug" validate:"required" gorm:"index"` 19 | Website string `json:"website" validate:"omitempty,url"` 20 | Version uint `json:"version" validate:"required"` 21 | Email string `json:"email" validate:"omitempty,email"` 22 | } 23 | 24 | func commentHandler(c *fiber.Ctx) error { 25 | isAdmin := c.Locals(solitudes.CtxAuthorized).(bool) 26 | var cf commentForm 27 | if err := c.BodyParser(&cf); err != nil { 28 | return err 29 | } 30 | if err := validator.StructCtx(c.Context(), &cf); err != nil { 31 | return err 32 | } 33 | 34 | article, err := verifyArticle(&cf) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | commentType, replyTo, err := getCommentType(&cf) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | // akismet anti spam 45 | if solitudes.System.Config.Akismet != "" && !isAdmin { 46 | isSpam, err := akismet.Check(&akismet.Comment{ 47 | Blog: "https://" + solitudes.System.Config.Site.Domain, // required 48 | UserIP: c.IP(), // required 49 | UserAgent: string(c.Request().Header.UserAgent()), // required 50 | CommentType: commentType, 51 | Referrer: string(c.Request().Header.Referer()), 52 | Permalink: "https://" + solitudes.System.Config.Site.Domain + "/" + cf.Slug, 53 | CommentAuthor: cf.Nickname, 54 | CommentAuthorEmail: cf.Email, 55 | CommentAuthorURL: cf.Website, 56 | CommentContent: cf.Content, 57 | }, solitudes.System.Config.Akismet) 58 | if err != nil || isSpam { 59 | return errors.New("rejected by Akismet Anti-Spam System") 60 | } 61 | } 62 | 63 | var cm model.Comment 64 | fillCommentEntry(c, isAdmin, &cm, &cf, article) 65 | 66 | err = solitudes.System.DB.Transaction(func(tx *gorm.DB) error { 67 | if err := tx.Save(&cm).Error; err != nil { 68 | return err 69 | } 70 | 71 | if err := tx.Model(&model.Article{}). 72 | Where("id = ?", cm.ArticleID). 73 | UpdateColumn("comment_num", gorm.Expr("comment_num + ?", 1)).Error; err != nil { 74 | return err 75 | } 76 | 77 | return nil 78 | }) 79 | 80 | if err != nil { 81 | return err 82 | } 83 | 84 | //Email notify 85 | checkPoolSubmit(nil, solitudes.System.Pool.Submit(func() { 86 | err := notify.Email(&cm, replyTo, article) 87 | notify.TGNotify(&cm, article, err) 88 | })) 89 | return nil 90 | } 91 | 92 | func verifyArticle(cf *commentForm) (*model.Article, error) { 93 | var article model.Article 94 | if err := solitudes.System.DB.Select("id,version,title,slug").Take(&article, "slug = ?", cf.Slug).Error; err != nil { 95 | return nil, err 96 | } 97 | if cf.Version > article.Version || cf.Version == 0 { 98 | return nil, errors.New("error invalid version") 99 | } 100 | return &article, nil 101 | } 102 | 103 | func getCommentType(cf *commentForm) (commentType string, replyTo *model.Comment, err error) { 104 | if cf.ReplyTo != nil { 105 | commentType = "reply" 106 | var innerReplyTo model.Comment 107 | if solitudes.System.DB.Take(&innerReplyTo, "id = ?", cf.ReplyTo).Error != nil { 108 | err = errors.New("reply to invaild comment") 109 | return 110 | } 111 | replyTo = &innerReplyTo 112 | return 113 | } 114 | commentType = "comment" 115 | return 116 | } 117 | 118 | func fillCommentEntry(c *fiber.Ctx, isAdmin bool, cm *model.Comment, cf *commentForm, article *model.Article) { 119 | cm.ReplyTo = cf.ReplyTo 120 | cm.Content = cf.Content 121 | cm.ArticleID = &article.ID 122 | if isAdmin { 123 | cm.Nickname = solitudes.System.Config.User.Nickname 124 | cm.Email = solitudes.System.Config.User.Email 125 | } else { 126 | cm.Nickname = cf.Nickname 127 | cm.Email = cf.Email 128 | cm.Website = cf.Website 129 | cm.IP = c.IP() 130 | cm.UserAgent = string(c.Request().Header.UserAgent()) 131 | } 132 | cm.IsAdmin = isAdmin 133 | cm.Version = cf.Version 134 | } 135 | -------------------------------------------------------------------------------- /solitudes.go: -------------------------------------------------------------------------------- 1 | package solitudes 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "sync" 7 | "time" 8 | 9 | "github.com/blevesearch/bleve/v2" 10 | "github.com/panjf2000/ants" 11 | "github.com/patrickmn/go-cache" 12 | "github.com/yanyiwu/gojieba" 13 | "go.uber.org/dig" 14 | "golang.org/x/sync/singleflight" 15 | "gopkg.in/yaml.v3" 16 | "gorm.io/driver/postgres" 17 | "gorm.io/gorm" 18 | 19 | "github.com/naiba/solitudes/internal/model" 20 | _ "github.com/naiba/solitudes/pkg/blevejieba" 21 | ) 22 | 23 | func newBleveSearch() bleve.Index { 24 | _, err := os.Stat(fullTextSearchIndexPath) 25 | var index bleve.Index 26 | if err != nil { 27 | mapping := bleve.NewIndexMapping() 28 | mapping.DefaultAnalyzer = "jieba" 29 | if err := mapping.AddCustomTokenizer("jieba", map[string]interface{}{ 30 | "type": "jieba", 31 | "useHmm": true, 32 | "tokenizeMode": float64(gojieba.SearchMode), 33 | }); err != nil { 34 | panic(err) 35 | } 36 | if err := mapping.AddCustomAnalyzer("jieba", map[string]interface{}{ 37 | "type": "jieba", 38 | "tokenizer": "jieba", 39 | }); err != nil { 40 | panic(err) 41 | } 42 | index, err = bleve.New(fullTextSearchIndexPath, mapping) 43 | if err != nil { 44 | panic(err) 45 | } 46 | } else { 47 | index, err = bleve.Open(fullTextSearchIndexPath) 48 | if err != nil { 49 | panic(err) 50 | } 51 | } 52 | count, err := index.DocCount() 53 | log.Println("Bleve: DocCount", count, err) 54 | return index 55 | } 56 | 57 | func newCache() *cache.Cache { 58 | return cache.New(5*time.Minute, 10*time.Minute) 59 | } 60 | 61 | func newPool() *ants.Pool { 62 | p, err := ants.NewPool(20000) 63 | if err != nil { 64 | panic(err) 65 | } 66 | return p 67 | } 68 | 69 | func newDatabase(conf *model.Config) *gorm.DB { 70 | db, err := gorm.Open(postgres.Open(conf.Database), &gorm.Config{}) 71 | if err != nil { 72 | log.Println(conf) 73 | panic(err) 74 | } 75 | if conf.Debug { 76 | db = db.Debug() 77 | } 78 | return db 79 | } 80 | 81 | func newConfig() *model.Config { 82 | configFile := "data/conf.yml" 83 | content, err := os.ReadFile(configFile) 84 | if err != nil { 85 | panic(err) 86 | } 87 | var c model.Config 88 | err = yaml.Unmarshal(content, &c) 89 | if err != nil { 90 | panic(err) 91 | } 92 | c.ConfigFilePath = configFile 93 | log.Println("Config", c) 94 | return &c 95 | } 96 | 97 | func newSystem(c *model.Config, d *gorm.DB, h *cache.Cache, 98 | s bleve.Index, p *ants.Pool) *SysVeriable { 99 | return &SysVeriable{ 100 | Config: c, 101 | DB: d, 102 | Cache: h, 103 | Search: s, 104 | SafeCache: new(singleflight.Group), 105 | Pool: p, 106 | } 107 | } 108 | 109 | func migrate() { 110 | if err := System.DB.AutoMigrate(&model.Article{}, &model.ArticleHistory{}, &model.Comment{}, &model.User{}); err != nil { 111 | panic(err) 112 | } 113 | } 114 | 115 | func provide() { 116 | var providers = []interface{}{ 117 | newCache, 118 | newConfig, 119 | newDatabase, 120 | newSystem, 121 | newBleveSearch, 122 | newPool, 123 | } 124 | var err error 125 | for i := 0; i < len(providers); i++ { 126 | err = Injector.Provide(providers[i]) 127 | if err != nil { 128 | panic(err) 129 | } 130 | } 131 | err = Injector.Invoke(func(s *SysVeriable) { 132 | System = s 133 | }) 134 | if err != nil { 135 | panic(err) 136 | } 137 | } 138 | 139 | // BuildArticleIndex 重建索引 140 | func BuildArticleIndex() { 141 | System.Search.Close() 142 | if err := os.RemoveAll(fullTextSearchIndexPath); err != nil { 143 | panic(err) 144 | } 145 | System.Search = newBleveSearch() 146 | var as []model.Article 147 | var hs []model.ArticleHistory 148 | var wg sync.WaitGroup 149 | wg.Add(2) 150 | checkPoolSubmit(&wg, System.Pool.Submit(func() { 151 | System.DB.Find(&as) 152 | wg.Done() 153 | })) 154 | checkPoolSubmit(&wg, System.Pool.Submit(func() { 155 | System.DB.Preload("Article").Find(&hs) 156 | wg.Done() 157 | })) 158 | wg.Wait() 159 | for i := 0; i < len(as); i++ { 160 | System.Search.Index(as[i].GetIndexID(), as[i]) 161 | } 162 | for i := 0; i < len(hs); i++ { 163 | System.Search.Index(hs[i].GetIndexID(), hs[i]) 164 | } 165 | num, err := System.Search.DocCount() 166 | log.Printf("Doc indexed %d %+v\n", num, err) 167 | } 168 | 169 | func checkPoolSubmit(wg *sync.WaitGroup, err error) { 170 | if err != nil { 171 | log.Println(err) 172 | if wg != nil { 173 | wg.Done() 174 | } 175 | } 176 | } 177 | 178 | func init() { 179 | Injector = dig.New() 180 | provide() 181 | if System.DB != nil { 182 | migrate() 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /resource/static/cactus/css/main.css: -------------------------------------------------------------------------------- 1 | #search>form>div.row { 2 | display: flex; 3 | } 4 | 5 | .search-input { 6 | display: table-cell; 7 | vertical-align: middle; 8 | padding: 4px 7px; 9 | outline: none; 10 | border: solid 1px #666; 11 | border-radius: 5px; 12 | background-color: #fafafa; 13 | color: #22272a; 14 | font-size: 1.1rem; 15 | width: 38%; 16 | -webkit-border-radius: 5px; 17 | -moz-border-radius: 5px; 18 | } 19 | 20 | .base-button { 21 | padding-left: 9px; 22 | padding-right: 9px; 23 | padding-bottom: 5px; 24 | padding-top: 5px; 25 | border-radius: 5px; 26 | font-weight: bolder; 27 | color: black; 28 | background-color: #cb997e; 29 | border: none; 30 | font-size: 1.1rem; 31 | } 32 | 33 | .base-button:disabled { 34 | color: #ccc; 35 | background-color: #d8c3a5; 36 | } 37 | 38 | #search .base-button { 39 | display: table-cell; 40 | vertical-align: middle; 41 | margin-left: 10px; 42 | } 43 | 44 | #reply-list .comment-meta { 45 | margin: 0; 46 | word-wrap: break-word; 47 | } 48 | 49 | #reply-list time { 50 | color: #666; 51 | } 52 | 53 | #reply-list span { 54 | color: #ccc; 55 | } 56 | 57 | #reply-list .gravatar { 58 | width: 44px; 59 | padding: 6px; 60 | margin-right: 4px; 61 | float: left; 62 | } 63 | 64 | #reply-list .gravatar img { 65 | width: 100%; 66 | } 67 | 68 | #reply-list .child-node { 69 | margin-left: 50px; 70 | } 71 | 72 | .child-node .child-node { 73 | margin-left: unset !important; 74 | } 75 | 76 | #reply-list .row { 77 | margin-bottom: 10px; 78 | } 79 | 80 | #reply textarea, 81 | input { 82 | border: none; 83 | width: 100%; 84 | padding: 5px; 85 | resize: none; 86 | background-color: #fff1e6; 87 | } 88 | 89 | #reply>.row { 90 | display: flex; 91 | justify-content: space-between; 92 | } 93 | 94 | #reply>.row input { 95 | width: 32.6%; 96 | } 97 | 98 | #reply button { 99 | width: 100%; 100 | margin-top: 1rem; 101 | margin-bottom: 10px; 102 | } 103 | 104 | .pagination a { 105 | border: 1px dashed #cc2a41; 106 | } 107 | 108 | article footer { 109 | border: 1px dashed #cc2a41; 110 | padding: 10px; 111 | margin-top: 40px; 112 | } 113 | 114 | .comment-meta a:nth-child(3) { 115 | color: #666; 116 | background: none; 117 | } 118 | 119 | .comment-meta.admin a:first-child { 120 | color: #cc2a41; 121 | } 122 | 123 | @media (max-width: 768px) { 124 | div.footer-left { 125 | height: auto !important; 126 | } 127 | } 128 | 129 | @media screen and (max-width: 480px) { 130 | #header #nav ul li.icon { 131 | right: 0; 132 | } 133 | 134 | .search-input { 135 | width: 65%; 136 | } 137 | 138 | .home-bottom { 139 | margin-bottom: 10px; 140 | } 141 | } 142 | 143 | pre { 144 | border: none; 145 | padding: unset; 146 | margin: unset; 147 | margin-top: 14px; 148 | } 149 | 150 | p { 151 | margin: unset; 152 | margin-top: 10px; 153 | } 154 | 155 | .iframe__video { 156 | border: unset; 157 | display: block; 158 | margin: 0 auto; 159 | width: 80%; 160 | } 161 | 162 | #tags>a { 163 | display: inline-block; 164 | padding-left: 0.5rem; 165 | padding-right: 0.5rem; 166 | font-size: 1.2rem; 167 | font-weight: bolder; 168 | color: white; 169 | min-width: 5rem; 170 | text-align: center; 171 | margin: 0.2rem; 172 | } 173 | 174 | .sl-topic { 175 | border: 1px dashed #666; 176 | border-radius: .9rem; 177 | width: 100%; 178 | padding: .5rem; 179 | word-break: break-word; 180 | } 181 | 182 | .sl-topic img { 183 | max-width: 100%; 184 | } 185 | 186 | .sl-topic>small { 187 | display: block; 188 | text-align: right; 189 | } 190 | 191 | .sl-topic p { 192 | padding: unset; 193 | margin: unset; 194 | } 195 | 196 | .sl-comments { 197 | margin-top: .4rem; 198 | font-size: smaller; 199 | color: #cb997e; 200 | } 201 | 202 | .sl-comments>div { 203 | margin-top: unset; 204 | display: flex; 205 | } 206 | 207 | .sl-comments>div>time { 208 | margin-left: auto; 209 | } 210 | 211 | .sl-comments>div>p:first-of-type { 212 | display: flex; 213 | min-width: fit-content; 214 | height: fit-content; 215 | align-items: center; 216 | } 217 | 218 | .sl-comments>div>p:first-of-type>img { 219 | width: 1rem; 220 | height: 1rem; 221 | margin-right: .3rem; 222 | } 223 | 224 | .sl-comments>div>p:first-of-type>b { 225 | white-space: nowrap; 226 | } 227 | 228 | .sl-comments>div>p:last-of-type { 229 | display: -webkit-box; 230 | -webkit-box-orient: vertical; 231 | -webkit-line-clamp: 2; 232 | overflow: hidden; 233 | } 234 | 235 | div.h1.sl-menu>a:last-of-type { 236 | font-size: small; 237 | } 238 | 239 | li.vditor-task { 240 | list-style: none; 241 | } 242 | 243 | input[type="checkbox"] { 244 | width: auto; 245 | } 246 | 247 | ul:has(li.vditor-task) { 248 | padding-left: 1rem; 249 | } 250 | 251 | .language-plantuml > object { 252 | max-width: 100%; 253 | } -------------------------------------------------------------------------------- /resource/theme/admin/index.html: -------------------------------------------------------------------------------- 1 | {{define "admin/index"}} 2 | {{template "admin/header" .}} 3 |
    4 |
    5 |

    6 | {{.Tr.T "dashboard"}} 7 |

    8 |
    9 | 10 |
    11 |
    12 |
    13 |
    14 |
    15 |

    {{.Data.articleNum}}

    16 |

    {{.Tr.T "articles"}}

    17 |
    18 |
    19 | 20 |
    21 |
    22 |
    23 |
    24 |
    25 |
    26 |

    {{.Data.lastArticlePublish}}

    27 |

    {{.Tr.T "days_left_from_the_last_post"}}

    28 |
    29 |
    30 | 31 |
    32 |
    33 |
    34 |
    35 |
    36 |
    37 |

    {{.Data.commentNum}}

    38 |

    {{.Tr.T "comments"}}

    39 |
    40 |
    41 | 42 |
    43 |
    44 |
    45 |
    46 |
    47 |
    48 |

    {{.Data.lastComment}}

    49 |

    {{.Tr.T "days_left_from_the_last_comment"}}

    50 |
    51 |
    52 | 53 |
    54 |
    55 |
    56 |
    57 |
    58 |
    59 |
    60 |
    61 |

    {{.Data.tagNum}}

    62 |

    {{.Tr.T "labels"}}

    63 |
    64 |
    65 | 66 |
    67 |
    68 |
    69 |
    70 |
    71 |
    72 |

    {{.Data.memoryUsage}} M

    73 |

    {{.Tr.T "memory_usage"}}

    74 |
    75 |
    76 | 77 |
    78 |
    79 |
    80 |
    81 |
    82 |
    83 |

    {{.Data.gcNum}}

    84 |

    {{.Tr.T "gc_num"}}

    85 |
    86 |
    87 | 88 |
    89 |
    90 |
    91 |
    92 |
    93 |
    94 |

    {{.Data.routineNum}}

    95 |

    {{.Tr.T "routine_num"}}

    96 |
    97 |
    98 | 99 |
    100 |
    101 |
    102 |
    103 |
    104 |
    105 | 107 |
    108 |
    109 |
    110 |
    111 | 120 | {{template "admin/footer" .}} 121 | {{end}} -------------------------------------------------------------------------------- /router/settings.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | 8 | "github.com/gofiber/fiber/v2" 9 | "golang.org/x/crypto/bcrypt" 10 | 11 | "github.com/naiba/solitudes" 12 | "github.com/naiba/solitudes/internal/model" 13 | "github.com/naiba/solitudes/pkg/notify" 14 | "github.com/naiba/solitudes/pkg/translator" 15 | ) 16 | 17 | func settings(c *fiber.Ctx) error { 18 | c.Status(http.StatusOK).Render("admin/settings", injectSiteData(c, fiber.Map{ 19 | "title": c.Locals(solitudes.CtxTranslator).(*translator.Translator).T("site_settings"), 20 | })) 21 | return nil 22 | } 23 | 24 | type settingsRequest struct { 25 | SiteTitle string `json:"site_title,omitempty"` 26 | SiteDesc string `json:"site_desc,omitempty"` 27 | TgBotToken string `json:"tg_bot_token,omitempty"` 28 | TgChatId string `json:"tg_chat_id,omitempty"` 29 | MailServer string `json:"mail_server,omitempty"` 30 | MailPort int `json:"mail_port,omitempty"` 31 | MailUser string `json:"mail_user,omitempty"` 32 | MailPassword string `json:"mail_password,omitempty"` 33 | MailSSL bool `json:"mail_ssl,omitempty"` 34 | Akismet string `json:"akismet,omitempty"` 35 | SiteDomain string `json:"site_domain,omitempty"` 36 | SiteCustomCode string `json:"site_custom_code,omitempty"` 37 | SiteKeywords string `json:"site_keywords,omitempty"` 38 | SiteHeaderMenus string `json:"site_header_menus,omitempty"` 39 | SiteFooterMenus string `json:"site_footer_menus,omitempty"` 40 | SiteTheme string `json:"site_theme,omitempty"` 41 | SiteHomeTopContent string `json:"site_home_top_content,omitempty"` 42 | SiteHomeBottomContent string `json:"site_home_bottom_content,omitempty"` 43 | Email string `json:"email,omitempty" validate:"email"` 44 | Nickname string `json:"nickname,omitempty" validate:"trim"` 45 | OldPassword string `json:"old_password,omitempty" validate:"trim"` 46 | NewPassword string `json:"new_password,omitempty" validate:"trim"` 47 | } 48 | 49 | func settingsHandler(c *fiber.Ctx) error { 50 | var err error 51 | defer func() { 52 | err = solitudes.System.Config.Save() 53 | }() 54 | var sr settingsRequest 55 | if err := c.BodyParser(&sr); err != nil { 56 | return err 57 | } 58 | 59 | // 检查 Telegram 配置是否发生变化 60 | tgTokenChanged := solitudes.System.Config.TGBotToken != sr.TgBotToken && sr.TgBotToken != "" 61 | tgChatIDChanged := solitudes.System.Config.TGChatID != sr.TgChatId && sr.TgChatId != "" 62 | 63 | solitudes.System.Config.Site.SpaceName = sr.SiteTitle 64 | solitudes.System.Config.Site.SpaceDesc = sr.SiteDesc 65 | solitudes.System.Config.TGBotToken = sr.TgBotToken 66 | solitudes.System.Config.TGChatID = sr.TgChatId 67 | solitudes.System.Config.Email.Host = sr.MailServer 68 | solitudes.System.Config.Email.Port = sr.MailPort 69 | solitudes.System.Config.Email.User = sr.MailUser 70 | solitudes.System.Config.Email.Pass = sr.MailPassword 71 | solitudes.System.Config.Email.SSL = sr.MailSSL 72 | solitudes.System.Config.Akismet = sr.Akismet 73 | solitudes.System.Config.Site.Domain = sr.SiteDomain 74 | solitudes.System.Config.Site.CustomCode = sr.SiteCustomCode 75 | solitudes.System.Config.Site.SpaceKeywords = sr.SiteKeywords 76 | solitudes.System.Config.User.Nickname = sr.Nickname 77 | solitudes.System.Config.User.Email = sr.Email 78 | err = json.Unmarshal([]byte(sr.SiteHeaderMenus), &solitudes.System.Config.Site.HeaderMenus) 79 | if err != nil { 80 | return err 81 | } 82 | err = json.Unmarshal([]byte(sr.SiteFooterMenus), &solitudes.System.Config.Site.FooterMenus) 83 | if err != nil { 84 | return err 85 | } 86 | solitudes.System.Config.Site.Theme = sr.SiteTheme 87 | solitudes.System.Config.Site.HomeTopContent = sr.SiteHomeTopContent 88 | solitudes.System.Config.Site.HomeBottomContent = sr.SiteHomeBottomContent 89 | 90 | if len(sr.OldPassword) > 0 && len(sr.NewPassword) > 0 { 91 | if bcrypt.CompareHashAndPassword([]byte(solitudes.System.Config.User.Password), []byte(sr.OldPassword)) != nil { 92 | return errors.New("invalid email or password") 93 | } 94 | b, err := bcrypt.GenerateFromPassword([]byte(sr.NewPassword), 1) 95 | if err != nil { 96 | return err 97 | } 98 | solitudes.System.Config.User.Password = string(b) 99 | } 100 | 101 | if (tgTokenChanged || tgChatIDChanged) && solitudes.System.Config.TGBotToken != "" && solitudes.System.Config.TGChatID != "" { 102 | sendTelegramTestMessage() 103 | } 104 | 105 | return nil 106 | } 107 | 108 | func sendTelegramTestMessage() { 109 | testComment := &model.Comment{ 110 | Nickname: "System", 111 | Email: "system@test.com", 112 | Content: "🎉 Telegram notification has been configured successfully! Your bot is now ready to send notifications.", 113 | IsAdmin: false, // 设置为 false 确保消息会被发送 114 | } 115 | 116 | testArticle := &model.Article{ 117 | Title: "Telegram Configuration Test", 118 | } 119 | 120 | go notify.TGNotify(testComment, testArticle, nil) 121 | } 122 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/naiba/solitudes 2 | 3 | go 1.23.5 4 | 5 | require ( 6 | github.com/88250/lute v1.7.6 7 | github.com/adtac/go-akismet v0.0.0-20181220032308-0ca9e1023047 8 | github.com/blevesearch/bleve/v2 v2.5.0 9 | github.com/go-playground/locales v0.14.1 10 | github.com/go-playground/universal-translator v0.18.1 11 | github.com/go-playground/validator v9.31.0+incompatible 12 | github.com/gofiber/fiber/v2 v2.52.9 13 | github.com/gofiber/template/html/v2 v2.1.3 14 | github.com/gorilla/feeds v1.2.0 15 | github.com/hashicorp/go-uuid v1.0.3 16 | github.com/lib/pq v1.10.9 17 | github.com/liuzl/gocc v0.0.0-20231231122217-0372e1059ca5 18 | github.com/matcornic/hermes/v2 v2.1.0 19 | github.com/panjf2000/ants v1.3.0 20 | github.com/patrickmn/go-cache v2.1.0+incompatible 21 | github.com/samber/lo v1.49.1 22 | github.com/yanyiwu/gojieba v1.4.5 23 | go.uber.org/dig v1.18.0 24 | golang.org/x/crypto v0.36.0 25 | golang.org/x/sync v0.12.0 26 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df 27 | gopkg.in/yaml.v3 v3.0.1 28 | gorm.io/driver/postgres v1.5.11 29 | gorm.io/gorm v1.25.12 30 | ) 31 | 32 | require ( 33 | github.com/Masterminds/semver v1.4.2 // indirect 34 | github.com/Masterminds/sprig v2.16.0+incompatible // indirect 35 | github.com/PuerkitoBio/goquery v1.5.1 // indirect 36 | github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect 37 | github.com/adamzy/cedar-go v0.0.0-20170805034717-80a9c64b256d // indirect 38 | github.com/alecthomas/chroma v0.10.0 // indirect 39 | github.com/andybalholm/brotli v1.1.0 // indirect 40 | github.com/andybalholm/cascadia v1.1.0 // indirect 41 | github.com/aokoli/goutils v1.0.1 // indirect 42 | github.com/bits-and-blooms/bitset v1.22.0 // indirect 43 | github.com/blevesearch/bleve_index_api v1.2.7 // indirect 44 | github.com/blevesearch/geo v0.1.20 // indirect 45 | github.com/blevesearch/go-faiss v1.0.25 // indirect 46 | github.com/blevesearch/go-porterstemmer v1.0.3 // indirect 47 | github.com/blevesearch/gtreap v0.1.1 // indirect 48 | github.com/blevesearch/mmap-go v1.0.4 // indirect 49 | github.com/blevesearch/scorch_segment_api/v2 v2.3.9 // indirect 50 | github.com/blevesearch/segment v0.9.1 // indirect 51 | github.com/blevesearch/snowballstem v0.9.0 // indirect 52 | github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect 53 | github.com/blevesearch/vellum v1.1.0 // indirect 54 | github.com/blevesearch/zapx/v11 v11.4.1 // indirect 55 | github.com/blevesearch/zapx/v12 v12.4.1 // indirect 56 | github.com/blevesearch/zapx/v13 v13.4.1 // indirect 57 | github.com/blevesearch/zapx/v14 v14.4.1 // indirect 58 | github.com/blevesearch/zapx/v15 v15.4.1 // indirect 59 | github.com/blevesearch/zapx/v16 v16.2.2 // indirect 60 | github.com/dlclark/regexp2 v1.8.1 // indirect 61 | github.com/gofiber/template v1.8.3 // indirect 62 | github.com/gofiber/utils v1.1.0 // indirect 63 | github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect 64 | github.com/golang/protobuf v1.3.2 // indirect 65 | github.com/golang/snappy v0.0.4 // indirect 66 | github.com/google/uuid v1.6.0 // indirect 67 | github.com/gopherjs/gopherjs v1.17.2 // indirect 68 | github.com/gorilla/css v1.0.0 // indirect 69 | github.com/huandu/xstrings v1.2.0 // indirect 70 | github.com/imdario/mergo v0.3.6 // indirect 71 | github.com/jackc/pgpassfile v1.0.0 // indirect 72 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 73 | github.com/jackc/pgx/v5 v5.5.5 // indirect 74 | github.com/jackc/puddle/v2 v2.2.1 // indirect 75 | github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 // indirect 76 | github.com/jinzhu/inflection v1.0.0 // indirect 77 | github.com/jinzhu/now v1.1.5 // indirect 78 | github.com/json-iterator/go v0.0.0-20171115153421-f7279a603ede // indirect 79 | github.com/klauspost/compress v1.17.9 // indirect 80 | github.com/leodido/go-urn v1.4.0 // indirect 81 | github.com/liuzl/cedar-go v0.0.0-20170805034717-80a9c64b256d // indirect 82 | github.com/liuzl/da v0.0.0-20180704015230-14771aad5b1d // indirect 83 | github.com/mattn/go-colorable v0.1.13 // indirect 84 | github.com/mattn/go-isatty v0.0.20 // indirect 85 | github.com/mattn/go-runewidth v0.0.16 // indirect 86 | github.com/mschoch/smat v0.2.0 // indirect 87 | github.com/olekukonko/tablewriter v0.0.1 // indirect 88 | github.com/rivo/uniseg v0.2.0 // indirect 89 | github.com/russross/blackfriday/v2 v2.0.1 // indirect 90 | github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect 91 | github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect 92 | github.com/valyala/bytebufferpool v1.0.0 // indirect 93 | github.com/valyala/fasthttp v1.51.0 // indirect 94 | github.com/valyala/tcplisten v1.0.0 // indirect 95 | github.com/vanng822/css v0.0.0-20190504095207-a21e860bcd04 // indirect 96 | github.com/vanng822/go-premailer v0.0.0-20191214114701-be27abe028fe // indirect 97 | go.etcd.io/bbolt v1.4.0 // indirect 98 | golang.org/x/net v0.38.0 // indirect 99 | golang.org/x/sys v0.31.0 // indirect 100 | golang.org/x/text v0.23.0 // indirect 101 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 102 | gopkg.in/go-playground/assert.v1 v1.2.1 // indirect 103 | ) 104 | -------------------------------------------------------------------------------- /internal/model/article.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | "sync" 8 | "time" 9 | "unicode" 10 | 11 | "github.com/lib/pq" 12 | "github.com/panjf2000/ants" 13 | "github.com/samber/lo" 14 | "gorm.io/gorm" 15 | ) 16 | 17 | // ArticleTOC 文章标题 18 | type ArticleTOC struct { 19 | Title string 20 | Slug string 21 | SubTitles []*ArticleTOC `gorm:"-"` 22 | Parent *ArticleTOC `gorm:"-"` 23 | Level int `gorm:"-"` 24 | } 25 | 26 | // SibilingArticle 相邻文章 27 | type SibilingArticle struct { 28 | Next Article 29 | Prev Article 30 | } 31 | 32 | // Article 文章表 33 | type Article struct { 34 | ID string `gorm:"type:uuid;primary_key;default:uuid_generate_v4()"` 35 | CreatedAt time.Time 36 | UpdatedAt time.Time 37 | 38 | Slug string `form:"slug" validate:"required" gorm:"unique_index"` 39 | Title string `form:"title" validate:"required"` 40 | Content string `form:"content" validate:"required" gorm:"text"` 41 | TemplateID byte `form:"template" validate:"required"` 42 | IsBook bool `form:"is_book"` 43 | RawTags string `form:"tags" gorm:"-"` 44 | Tags pq.StringArray `gorm:"index;type:varchar(255)[]" validate:"-" form:"-"` 45 | ReadNum uint `gorm:"default:0;"` 46 | CommentNum uint `gorm:"default:0;"` 47 | Version uint `gorm:"default:1;"` 48 | BookRefer *string `form:"book_refer" validate:"omitempty,uuid4" gorm:"type:uuid;index;default:NULL"` 49 | IsPrivate bool `form:"is_private"` 50 | 51 | Comments []*Comment `gorm:"foreignKey:ArticleID"` 52 | ArticleHistories []*ArticleHistory `gorm:"foreignKey:ArticleID"` 53 | Toc []*ArticleTOC `gorm:"-"` 54 | Chapters []*Article `gorm:"foreignkey:BookRefer" form:"-" validate:"-"` 55 | Book *Article `gorm:"-" validate:"-" form:"-"` 56 | SibilingArticle *SibilingArticle `gorm:"-" validate:"-" form:"-"` 57 | 58 | // for form 59 | NewVersion uint `gorm:"-" form:"new_version"` 60 | } 61 | 62 | // ArticleIndex index data 63 | type ArticleIndex struct { 64 | Slug string 65 | Version float64 66 | Title string 67 | } 68 | 69 | // GetIndexID get index data id 70 | func (t *Article) GetIndexID() string { 71 | return fmt.Sprintf("%s.%d", t.ID, t.Version) 72 | } 73 | 74 | // BeforeSave hook 75 | func (t *Article) BeforeSave(tx *gorm.DB) (err error) { 76 | t.RawTags = strings.TrimSpace(t.RawTags) 77 | if t.RawTags == "" { 78 | return nil 79 | } 80 | t.Tags = strings.Split(t.RawTags, ",") 81 | return nil 82 | } 83 | 84 | // AfterFind hook 85 | func (t *Article) AfterFind(tx *gorm.DB) (err error) { 86 | t.RawTags = strings.Join(t.Tags, ",") 87 | return nil 88 | } 89 | 90 | var titleRegex = regexp.MustCompile(`^\s{0,2}(#{1,6})\s(.*)$`) 91 | 92 | // IsTopic 是否是哔哔 93 | func (t *Article) IsTopic() bool { 94 | return lo.Contains(t.Tags, "Topic") 95 | } 96 | 97 | // GenTOC 生成标题树 98 | func (t *Article) GenTOC() { 99 | lines := strings.Split(t.Content, "\n") 100 | uniqueHeadingID := make(map[string]int) 101 | var matches []string 102 | var currentToc *ArticleTOC 103 | for j := 0; j < len(lines); j++ { 104 | matches = titleRegex.FindStringSubmatch(lines[j]) 105 | if len(matches) != 3 { 106 | continue 107 | } 108 | var toc ArticleTOC 109 | toc.Level = len(matches[1]) 110 | toc.Title = string(matches[2]) 111 | toc.Slug = sanitizedAnchorName(uniqueHeadingID, string(matches[2])) 112 | if currentToc == nil { 113 | t.Toc = append(t.Toc, &toc) 114 | currentToc = &toc 115 | continue 116 | } 117 | parent := currentToc 118 | if currentToc.Level > toc.Level { 119 | // 父节点 120 | for i := -1; i < currentToc.Level-toc.Level; i++ { 121 | parent = parent.Parent 122 | if parent == nil || parent.Level < toc.Level { 123 | break 124 | } 125 | } 126 | if parent == nil { 127 | t.Toc = append(t.Toc, &toc) 128 | } else { 129 | toc.Parent = parent 130 | parent.SubTitles = append(parent.SubTitles, &toc) 131 | } 132 | } else if currentToc.Level == toc.Level { 133 | // 兄弟节点 134 | if parent.Parent == nil { 135 | t.Toc = append(t.Toc, &toc) 136 | } else { 137 | toc.Parent = parent.Parent 138 | toc.Parent.SubTitles = append(toc.Parent.SubTitles, &toc) 139 | } 140 | } else { 141 | // 子节点 142 | toc.Parent = parent 143 | parent.SubTitles = append(parent.SubTitles, &toc) 144 | } 145 | currentToc = &toc 146 | } 147 | } 148 | 149 | func removeLeadingHashtag(s string) string { 150 | for i := 0; i < len(s); i++ { 151 | if s[i] != '#' { 152 | return s[i:] 153 | } 154 | } 155 | return s 156 | } 157 | 158 | // 生成标题 ID 159 | func sanitizedAnchorName(unique map[string]int, text string) (ret string) { 160 | text = strings.TrimSpace(removeLeadingHashtag(strings.TrimSpace(text))) 161 | for _, r := range text { 162 | if unicode.IsLetter(r) || unicode.IsDigit(r) { 163 | ret += string(r) 164 | } else { 165 | ret += "-" 166 | } 167 | } 168 | for 0 < unique[ret] { 169 | ret += "-" 170 | } 171 | unique[ret] = 1 172 | return 173 | } 174 | 175 | // RelatedCount 合计专栏下文章计数 176 | func (t *Article) RelatedCount(db *gorm.DB, pool *ants.Pool, checkPoolSubmit func(*sync.WaitGroup, error)) { 177 | if !t.IsBook { 178 | return 179 | } 180 | var wg sync.WaitGroup 181 | wg.Add(1) 182 | checkPoolSubmit(&wg, pool.Submit(func() { 183 | innerRelatedCount(db, t, &wg, true) 184 | })) 185 | wg.Wait() 186 | } 187 | 188 | func innerRelatedCount(db *gorm.DB, p *Article, wg *sync.WaitGroup, root bool) { 189 | var chapters []*Article 190 | db.Model(&Article{}).Select("id", "is_book", "read_num", "comment_num").Where("book_refer = ?", p.ID).Find(&chapters) 191 | for i := 0; i < len(chapters); i++ { 192 | if chapters[i].IsBook { 193 | innerRelatedCount(db, chapters[i], nil, false) 194 | } 195 | p.ReadNum += chapters[i].ReadNum 196 | p.CommentNum += chapters[i].CommentNum 197 | } 198 | if root { 199 | wg.Done() 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /resource/static/cactus/js/main.js: -------------------------------------------------------------------------------- 1 | function ready(fn) { 2 | if (document.readyState != 'loading') { 3 | fn(); 4 | } else { 5 | document.addEventListener('DOMContentLoaded', fn); 6 | } 7 | } 8 | 9 | function scrollToTop(el) { 10 | window.scrollTo(0, 0); 11 | } 12 | 13 | function toggle(sel) { 14 | if (document.querySelector(sel).style.display) { 15 | document.querySelector(sel).style.display = '' 16 | } else { document.querySelector(sel).style.display = 'none' } 17 | } 18 | 19 | function randomColor() { 20 | const color = '#' + Math.floor(Math.random() * 16777215).toString(16) 21 | const m = color.match(/^#([0-9a-f]{2})[0-9a-f]{6}$/i) 22 | if (m && parseInt(m[1], 16) / 255 == 0) { 23 | return randomColor() 24 | } 25 | return color 26 | } 27 | 28 | function matches(el, selector) { 29 | return (el.matches || el.matchesSelector || el.msMatchesSelector || el.mozMatchesSelector || el.webkitMatchesSelector || el.oMatchesSelector).call(el, selector); 30 | }; 31 | 32 | ready(function () { 33 | /** 34 | * 标签云 35 | */ 36 | const tagsCloud = document.querySelectorAll("#tags>a"); 37 | if (tagsCloud.length > 0) { 38 | for (let i = 0; i < tagsCloud.length; i++) { 39 | while (!tagsCloud[i].style.backgroundColor) { 40 | tagsCloud[i].style.backgroundColor = randomColor(); 41 | } 42 | } 43 | } 44 | 45 | /** 46 | * video height 47 | */ 48 | document.querySelectorAll('.iframe__video').forEach(v => { 49 | v.setAttribute('height', v.clientWidth * 9 / 16) 50 | }) 51 | 52 | /** 53 | * Shows the responsive navigation menu on mobile. 54 | */ 55 | const mobileMenu = document.querySelector("#header > #nav > ul > .icon"); 56 | if (mobileMenu) { 57 | mobileMenu.addEventListener('click', function (e) { 58 | document.querySelector("#header > #nav > ul").classList.toggle("responsive"); 59 | }); 60 | } 61 | 62 | /** 63 | * Controls the different versions of the menu in blog post articles 64 | * for Desktop, tablet and mobile. 65 | */ 66 | if (document.querySelectorAll(".post").length) { 67 | var menu = document.querySelector("#menu"); 68 | var nav = document.querySelector("#menu > #nav"); 69 | var menuIcon = document.querySelector("#menu-icon, #menu-icon-tablet"); 70 | 71 | /** 72 | * Display the menu on hi-res laptops and desktops. 73 | */ 74 | const screenWidth = parseFloat(getComputedStyle(document.documentElement, null).width.replace("px", "")); 75 | if (screenWidth >= 1440) { 76 | menu.style.visibility = "visible"; 77 | menuIcon.classList.add("active"); 78 | } 79 | 80 | /** 81 | * Display the menu if the menu icon is clicked. 82 | */ 83 | menuIcon.addEventListener('click', function () { 84 | if (menu.style.visibility === "hidden") { 85 | menu.style.visibility = "visible"; 86 | menuIcon.classList.add("active"); 87 | } else { 88 | menu.style.visibility = "hidden"; 89 | menuIcon.classList.remove("active"); 90 | } 91 | return false; 92 | }); 93 | 94 | /** 95 | * Add a scroll listener to the menu to hide/show the navigation links. 96 | */ 97 | if (document.querySelectorAll("#menu").length) { 98 | window.onscroll = function () { 99 | const rect = menu.getBoundingClientRect(); 100 | const topDistance = rect.top + document.body.scrollTop; 101 | 102 | // hide only the navigation links on desktop 103 | if (!matches(nav, ":visible") && topDistance < 50) { 104 | nav.style.display = ''; 105 | } else if (matches(nav, ":visible") && topDistance > 100) { 106 | nav.style.display = 'none'; 107 | } 108 | 109 | // on tablet, hide the navigation icon as well and show a "scroll to top 110 | // icon" instead 111 | const menuIconVisible = matches(document.querySelector("#menu-icon"), ":visible"); 112 | if (!menuIconVisible && topDistance < 50) { 113 | document.querySelector("#menu-icon-tablet").style.display = ''; 114 | document.querySelector("#top-icon-tablet").style.display = 'none'; 115 | } else if (!menuIconVisible && topDistance > 100) { 116 | document.querySelector("#top-icon-tablet").style.display = ''; 117 | document.querySelector("#menu-icon-tablet").style.display = 'none'; 118 | } 119 | }; 120 | } 121 | 122 | /** 123 | * Show mobile navigation menu after scrolling upwards, 124 | * hide it again after scrolling downwards. 125 | */ 126 | if (document.querySelectorAll("#footer-post").length) { 127 | var lastScrollTop = 0; 128 | window.onscroll = function () { 129 | var topDistance = document.documentElement.scrollTop ? document.documentElement.scrollTop : document.body.scrollTop; 130 | 131 | if (topDistance > lastScrollTop) { 132 | // downscroll -> show menu 133 | document.querySelector("#footer-post").style.display = 'none'; 134 | } else { 135 | // upscroll -> hide menu 136 | document.querySelector("#footer-post").style.display = ''; 137 | } 138 | lastScrollTop = topDistance; 139 | 140 | // close all submenu"s on scroll 141 | document.querySelector("#nav-footer").style.display = 'none'; 142 | document.querySelector("#toc-footer").style.display = 'none'; 143 | document.querySelector("#share-footer").style.display = 'none'; 144 | 145 | // show a "navigation" icon when close to the top of the page, 146 | // otherwise show a "scroll to the top" icon 147 | if (topDistance < 50) { 148 | document.querySelector("#actions-footer > #top").style.display = 'none'; 149 | } else if (topDistance > 100) { 150 | document.querySelector("#actions-footer > #top").style.display = ''; 151 | } 152 | }; 153 | } 154 | } 155 | }) 156 | -------------------------------------------------------------------------------- /router/manage_article.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | "time" 10 | "unicode/utf8" 11 | 12 | "github.com/gofiber/fiber/v2" 13 | "github.com/naiba/solitudes" 14 | "github.com/naiba/solitudes/internal/model" 15 | "github.com/naiba/solitudes/pkg/translator" 16 | 17 | "github.com/naiba/solitudes/pkg/pagination" 18 | "gorm.io/gorm" 19 | ) 20 | 21 | func manageArticle(c *fiber.Ctx) error { 22 | rawPage := c.Query("page") 23 | var page int64 24 | page, _ = strconv.ParseInt(rawPage, 10, 32) 25 | var as []model.Article 26 | pg := pagination.Paging(&pagination.Param{ 27 | DB: solitudes.System.DB, 28 | Page: int(page), 29 | Limit: 20, 30 | OrderBy: []string{"created_at DESC"}, 31 | }, &as) 32 | for i := 0; i < len(as); i++ { 33 | as[i].RelatedCount(solitudes.System.DB, solitudes.System.Pool, checkPoolSubmit) 34 | } 35 | c.Status(http.StatusOK).Render("admin/articles", injectSiteData(c, fiber.Map{ 36 | "title": c.Locals(solitudes.CtxTranslator).(*translator.Translator).T("manage_articles"), 37 | "articles": as, 38 | "page": pg, 39 | })) 40 | return nil 41 | } 42 | 43 | func publish(c *fiber.Ctx) error { 44 | id := c.Query("id") 45 | var article model.Article 46 | if id != "" { 47 | solitudes.System.DB.Take(&article, "id = ?", id) 48 | } 49 | c.Status(http.StatusOK).Render("admin/publish", injectSiteData(c, fiber.Map{ 50 | "title": c.Locals(solitudes.CtxTranslator).(*translator.Translator).T("publish_article"), 51 | "templates": solitudes.Templates, 52 | "article": article, 53 | })) 54 | return nil 55 | } 56 | 57 | func deleteArticle(c *fiber.Ctx) error { 58 | id := c.Query("id") 59 | if len(id) < 10 { 60 | return errors.New("error article id") 61 | } 62 | var a model.Article 63 | if err := solitudes.System.DB.Select("id").Preload("ArticleHistories").Take(&a, "id = ?", id).Error; err != nil { 64 | return err 65 | } 66 | var indexIDs []string 67 | indexIDs = append(indexIDs, a.GetIndexID()) 68 | err := solitudes.System.DB.Transaction(func(tx *gorm.DB) error { 69 | // 删除文章 70 | if err := tx.Delete(&model.Article{}, "id = ?", a.ID).Error; err != nil { 71 | return err 72 | } 73 | // 删除文章历史 74 | for i := 0; i < len(a.ArticleHistories); i++ { 75 | indexIDs = append(indexIDs, a.ArticleHistories[i].GetIndexID()) 76 | } 77 | if err := tx.Delete(&model.ArticleHistory{}, "article_id = ?", a.ID).Error; err != nil { 78 | return err 79 | } 80 | // 删除评论 81 | if err := tx.Delete(&model.Comment{}, "article_id = ?", a.ID).Error; err != nil { 82 | return err 83 | } 84 | return nil 85 | }) 86 | 87 | if err != nil { 88 | return err 89 | } 90 | // delete full-text search data 91 | for i := 0; i < len(indexIDs); i++ { 92 | solitudes.System.Search.Delete(indexIDs[i]) 93 | } 94 | return nil 95 | } 96 | 97 | type publishArticle struct { 98 | ID string `form:"id"` 99 | Title string `form:"title"` 100 | Slug string `form:"slug"` 101 | Content string `form:"content"` 102 | Template byte `form:"template"` 103 | Tags string `form:"tags"` 104 | IsBook bool `form:"is_book"` 105 | IsPrivate bool `form:"is_private"` 106 | BookRefer string `form:"book_refer"` 107 | NewVersion uint `form:"new_version"` 108 | } 109 | 110 | func publishHandler(c *fiber.Ctx) error { 111 | var pa publishArticle 112 | if err := c.BodyParser(&pa); err != nil { 113 | return err 114 | } 115 | if err := validator.StructCtx(c.Context(), &pa); err != nil { 116 | return err 117 | } 118 | var bookRefer *string 119 | if pa.BookRefer != "" { 120 | bookRefer = &pa.BookRefer 121 | } 122 | // edit article 123 | newArticle := &model.Article{ 124 | ID: pa.ID, 125 | Title: strings.TrimSpace(pa.Title), 126 | Slug: strings.TrimSpace(pa.Slug), 127 | Content: clearNonUTF8Chars(pa.Content), 128 | NewVersion: pa.NewVersion, 129 | TemplateID: pa.Template, 130 | IsBook: pa.IsBook, 131 | IsPrivate: pa.IsPrivate, 132 | RawTags: pa.Tags, 133 | BookRefer: bookRefer, 134 | Version: 1, 135 | } 136 | 137 | if newArticle.IsTopic() { 138 | if len(newArticle.Slug) == 0 { 139 | newArticle.Slug = time.Now().Format("20060102150405") 140 | } 141 | if len(newArticle.Title) == 0 { 142 | newArticle.Title = newArticle.Slug 143 | } 144 | } 145 | 146 | if originalArticle, err := fetchOriginArticle(newArticle); err != nil { 147 | return err 148 | } else { 149 | err = solitudes.System.DB.Transaction(func(tx *gorm.DB) error { 150 | if pa.NewVersion == 1 { 151 | newArticle.CreatedAt = time.Now() 152 | history := model.ArticleHistory{ 153 | Content: originalArticle.Content, 154 | Version: originalArticle.Version, 155 | ArticleID: originalArticle.ID, 156 | CreatedAt: originalArticle.CreatedAt, 157 | } 158 | if err := tx.Create(&history).Error; err != nil { 159 | return err 160 | } 161 | } 162 | 163 | if err := tx.Save(&newArticle).Error; err != nil { 164 | return err 165 | } 166 | 167 | return nil 168 | }) 169 | 170 | if err != nil { 171 | return err 172 | } 173 | // indexing serch engine 174 | numBefore, _ := solitudes.System.Search.DocCount() 175 | errIndex := solitudes.System.Search.Index(newArticle.GetIndexID(), newArticle) 176 | numAfter, _ := solitudes.System.Search.DocCount() 177 | log.Printf("Doc %s indexed %d --> %d %+v\n", newArticle.GetIndexID(), numBefore, numAfter, errIndex) 178 | } 179 | return nil 180 | } 181 | 182 | func fetchOriginArticle(af *model.Article) (model.Article, error) { 183 | if af.ID == "" { 184 | return model.Article{}, nil 185 | } 186 | var originArticle model.Article 187 | if err := solitudes.System.DB.Take(&originArticle, "id = ?", af.ID).Error; err != nil { 188 | return model.Article{}, err 189 | } 190 | af.CreatedAt = originArticle.CreatedAt 191 | af.UpdatedAt = time.Now() 192 | af.Version = originArticle.Version 193 | af.CommentNum = originArticle.CommentNum 194 | af.ReadNum = originArticle.ReadNum 195 | if af.NewVersion == 1 { 196 | af.Version = originArticle.Version + 1 197 | } 198 | return originArticle, nil 199 | } 200 | 201 | func clearNonUTF8Chars(s string) string { 202 | v := make([]rune, 0, len(s)) 203 | for i, r := range s { 204 | // 清理非 UTF-8 字符 205 | if r == utf8.RuneError { 206 | _, size := utf8.DecodeRuneInString(s[i:]) 207 | if size == 1 { 208 | continue 209 | } 210 | } 211 | // 清理 backspace 212 | if r == '\b' { 213 | continue 214 | } 215 | v = append(v, r) 216 | } 217 | return string(v) 218 | } 219 | -------------------------------------------------------------------------------- /router/manager.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "os" 8 | "runtime" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/gofiber/fiber/v2" 14 | "github.com/hashicorp/go-uuid" 15 | 16 | "github.com/naiba/solitudes" 17 | "github.com/naiba/solitudes/internal/model" 18 | "github.com/naiba/solitudes/pkg/translator" 19 | ) 20 | 21 | func manager(c *fiber.Ctx) error { 22 | var articleNum, commentNum int64 23 | var lastArticle model.Article 24 | var lastComment model.Comment 25 | type tagNum struct { 26 | Count int 27 | } 28 | var tn tagNum 29 | 30 | var wg sync.WaitGroup 31 | wg.Add(5) 32 | checkPoolSubmit(&wg, solitudes.System.Pool.Submit(func() { 33 | solitudes.System.DB.Model(model.Article{}).Count(&articleNum) 34 | wg.Done() 35 | })) 36 | checkPoolSubmit(&wg, solitudes.System.Pool.Submit(func() { 37 | solitudes.System.DB.Model(model.Comment{}).Count(&commentNum) 38 | wg.Done() 39 | })) 40 | checkPoolSubmit(&wg, solitudes.System.Pool.Submit(func() { 41 | solitudes.System.DB.Raw(`select count(*) from (select tags,count(tags) from (select unnest(tags) as tags from articles) t group by tags) ts;`).Scan(&tn) 42 | wg.Done() 43 | })) 44 | checkPoolSubmit(&wg, solitudes.System.Pool.Submit(func() { 45 | solitudes.System.DB.Select("created_at").Order("created_at DESC").Take(&lastArticle) 46 | wg.Done() 47 | })) 48 | checkPoolSubmit(&wg, solitudes.System.Pool.Submit(func() { 49 | solitudes.System.DB.Select("created_at").Order("created_at DESC").Take(&lastComment) 50 | wg.Done() 51 | })) 52 | wg.Wait() 53 | 54 | var m runtime.MemStats 55 | runtime.ReadMemStats(&m) 56 | 57 | c.Status(http.StatusOK).Render("admin/index", injectSiteData(c, fiber.Map{ 58 | "title": c.Locals(solitudes.CtxTranslator).(*translator.Translator).T("dashboard"), 59 | "articleNum": articleNum, 60 | "commentNum": commentNum, 61 | "lastArticlePublish": fmt.Sprintf("%.2f", time.Since(lastArticle.CreatedAt).Hours()/24), 62 | "lastComment": fmt.Sprintf("%.2f", time.Since(lastComment.CreatedAt).Hours()/24), 63 | "tagNum": tn.Count, 64 | 65 | "memoryUsage": bToMb(m.Sys), 66 | "gcNum": m.NumGC, 67 | "routineNum": runtime.NumGoroutine(), 68 | })) 69 | return nil 70 | } 71 | 72 | func bToMb(b uint64) uint64 { 73 | return b / 1024 / 1024 74 | } 75 | 76 | var validExtNames = map[string]interface{}{ 77 | "jpg": nil, 78 | "jpeg": nil, 79 | "png": nil, 80 | "gif": nil, 81 | "mp4": nil, 82 | "zip": nil, 83 | "rar": nil, 84 | "wav": nil, 85 | "mp3": nil, 86 | } 87 | 88 | var contentTypeList = map[string]string{ 89 | "image/gif": "gif", 90 | "image/png": "png", 91 | "image/jpeg": "jpg", 92 | } 93 | 94 | type uploadResp struct { 95 | Msg string `json:"msg,omitempty"` 96 | Code int `json:"code"` 97 | Data struct { 98 | ErrFiles []string `json:"errFiles,omitempty"` 99 | SuccMap map[string]string `json:"succMap,omitempty"` 100 | } `json:"data,omitempty"` 101 | } 102 | 103 | func upload(c *fiber.Ctx) error { 104 | form, err := c.MultipartForm() 105 | if err != nil { 106 | c.Status(http.StatusOK).JSON(uploadResp{ 107 | Msg: err.Error(), 108 | Code: http.StatusBadRequest, 109 | }) 110 | return err 111 | } 112 | 113 | var errfiles []string 114 | succMap := make(map[string]string) 115 | 116 | files := form.File["file[]"] 117 | for _, f := range files { 118 | fs := strings.Split(f.Filename, ".") 119 | if len(fs) < 2 { 120 | errfiles = append(errfiles, f.Filename) 121 | continue 122 | } 123 | extName := fs[len(fs)-1] 124 | if _, ok := validExtNames[extName]; !ok { 125 | errfiles = append(errfiles, f.Filename) 126 | continue 127 | } 128 | fid, err := uuid.GenerateUUID() 129 | if err != nil { 130 | return err 131 | } 132 | extName = fmt.Sprintf("/upload/%s.%s", fid, extName) 133 | if err := c.SaveFile(f, "data"+extName); err != nil { 134 | errfiles = append(errfiles, f.Filename) 135 | } else { 136 | succMap[f.Filename] = extName 137 | } 138 | } 139 | c.Status(http.StatusOK).JSON(uploadResp{ 140 | Code: 0, 141 | Data: struct { 142 | ErrFiles []string "json:\"errFiles,omitempty\"" 143 | SuccMap map[string]string "json:\"succMap,omitempty\"" 144 | }{ 145 | ErrFiles: errfiles, 146 | SuccMap: succMap, 147 | }, 148 | }) 149 | return nil 150 | } 151 | 152 | type fetchRequest struct { 153 | URL string `json:"url,omitempty" validate:"required,min=11"` 154 | } 155 | 156 | type fetchResp struct { 157 | Msg string `json:"msg,omitempty"` 158 | Code int `json:"code"` 159 | Data struct { 160 | OriginalURL string `json:"originalURL,omitempty"` 161 | URL string `json:"url,omitempty"` 162 | } `json:"data,omitempty"` 163 | } 164 | 165 | func fetch(c *fiber.Ctx) error { 166 | var fr fetchRequest 167 | if err := c.BodyParser(&fr); err != nil { 168 | c.Status(http.StatusOK).JSON(fetchResp{ 169 | Code: http.StatusBadRequest, 170 | Msg: err.Error(), 171 | }) 172 | return err 173 | } 174 | if err := validator.StructCtx(c.Context(), &fr); err != nil { 175 | c.Status(http.StatusOK).JSON(fetchResp{ 176 | Code: http.StatusBadRequest, 177 | Msg: err.Error(), 178 | }) 179 | return err 180 | } 181 | 182 | fid, err := uuid.GenerateUUID() 183 | if err != nil { 184 | return err 185 | } 186 | 187 | // Get the data 188 | resp, err := http.Get(fr.URL) 189 | if err != nil { 190 | c.Status(http.StatusOK).JSON(fetchResp{ 191 | Code: http.StatusBadRequest, 192 | Msg: err.Error(), 193 | }) 194 | return err 195 | } 196 | defer resp.Body.Close() 197 | 198 | var filename string 199 | contentType := resp.Header.Get("Content-Type") 200 | if ext, ok := contentTypeList[contentType]; ok { 201 | filename = fmt.Sprintf("/upload/%s.%s", fid, ext) 202 | // Create the file 203 | out, err := os.Create("data/" + filename) 204 | if err != nil { 205 | c.Status(http.StatusOK).JSON(fetchResp{ 206 | Code: http.StatusBadRequest, 207 | Msg: err.Error(), 208 | }) 209 | return err 210 | } 211 | defer out.Close() 212 | 213 | // Write the body to file 214 | _, err = io.Copy(out, resp.Body) 215 | if err != nil { 216 | c.Status(http.StatusOK).JSON(fetchResp{ 217 | Code: http.StatusBadRequest, 218 | Msg: err.Error(), 219 | }) 220 | return err 221 | } 222 | } 223 | 224 | c.Status(http.StatusOK).JSON(fetchResp{ 225 | Code: 0, 226 | Data: struct { 227 | OriginalURL string "json:\"originalURL,omitempty\"" 228 | URL string "json:\"url,omitempty\"" 229 | }{ 230 | fr.URL, 231 | filename, 232 | }, 233 | }) 234 | return nil 235 | } 236 | 237 | func rebuildFullTextSearch(c *fiber.Ctx) error { 238 | solitudes.BuildArticleIndex() 239 | return nil 240 | } 241 | -------------------------------------------------------------------------------- /resource/theme/admin/publish.html: -------------------------------------------------------------------------------- 1 | {{define "admin/publish"}} 2 | {{template "admin/header" .}} 3 |
    4 |
    5 |

    6 | {{.Tr.T "publish_article"}} 7 |

    8 |
    9 | 10 |
    11 |
    12 |
    13 |
    14 |
    15 | 16 |
    17 | 18 | 20 |
    21 |
    22 |
    23 |
    24 |
    25 | 26 |
    27 | 29 |
    30 |
    31 |
    32 |
    33 |
    34 | 35 |
    36 | 38 |
    39 |
    40 |
    41 |
    42 |
    43 |
    44 | 51 |
    52 |
    53 |
    54 |
    55 |
    56 | 57 |
    58 | 60 |
    61 |
    62 |
    63 |
    64 |
    65 |
    66 |
    67 | 71 |
    72 |
    73 |
    74 |
    75 |
    76 |
    77 |
    78 |
    79 | 83 |
    84 |
    85 |
    86 |
    87 |
    88 |
    89 |
    90 | 93 |
    94 |
    95 |
    96 |
    97 |
    98 |
    99 |
    100 |
    101 | 102 |
    103 |
    104 |
    105 |
    106 | 107 | 108 | 146 | {{template "admin/footer" .}} 147 | {{end}} -------------------------------------------------------------------------------- /router/article.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | "sync" 8 | 9 | "github.com/gofiber/fiber/v2" 10 | "github.com/naiba/solitudes/pkg/pagination" 11 | "gorm.io/gorm" 12 | 13 | "github.com/naiba/solitudes" 14 | "github.com/naiba/solitudes/internal/model" 15 | "github.com/naiba/solitudes/pkg/translator" 16 | ) 17 | 18 | func article(c *fiber.Ctx) error { 19 | var a model.Article 20 | if err := solitudes.System.DB.Take(&a, "slug = ?", c.Params("slug")).Error; err == gorm.ErrRecordNotFound { 21 | tr := c.Locals(solitudes.CtxTranslator).(*translator.Translator) 22 | c.Status(http.StatusNotFound).Render("default/error", injectSiteData(c, fiber.Map{ 23 | "title": tr.T("404_title"), 24 | "msg": tr.T("404_msg"), 25 | })) 26 | return err 27 | } else if err != nil { 28 | return err 29 | } 30 | if len(a.Tags) == 0 { 31 | a.Tags = nil 32 | } 33 | 34 | var title string 35 | // load history 36 | if c.Params("version") != "" { 37 | version, err := strconv.ParseUint(c.Params("version")[1:], 10, 64) 38 | if err != nil { 39 | return err 40 | } 41 | if uint(version) == a.Version { 42 | c.Redirect("/"+a.Slug, http.StatusFound) 43 | return err 44 | } 45 | var history model.ArticleHistory 46 | if err := solitudes.System.DB.Take(&history, "article_id = ? and version = ?", a.ID, version).Error; err == gorm.ErrRecordNotFound { 47 | tr := c.Locals(solitudes.CtxTranslator).(*translator.Translator) 48 | c.Status(http.StatusNotFound).Render("default/error", injectSiteData(c, fiber.Map{ 49 | "title": tr.T("404_title"), 50 | "msg": tr.T("404_msg"), 51 | })) 52 | return err 53 | } else if err != nil { 54 | return err 55 | } 56 | a.NewVersion = a.Version 57 | a.Version = history.Version 58 | a.Content = history.Content 59 | a.CreatedAt = history.CreatedAt 60 | title = fmt.Sprintf("%s v%d", a.Title, a.Version) 61 | } else { 62 | title = a.Title 63 | } 64 | var wg sync.WaitGroup 65 | wg.Add(5) 66 | checkPoolSubmit(&wg, solitudes.System.Pool.Submit(func() { 67 | relatedChapters(&a) 68 | wg.Done() 69 | })) 70 | checkPoolSubmit(&wg, solitudes.System.Pool.Submit(func() { 71 | relatedBook(&a) 72 | wg.Done() 73 | })) 74 | checkPoolSubmit(&wg, solitudes.System.Pool.Submit(func() { 75 | // load prevPost,nextPost 76 | relatedSiblingArticle(&a) 77 | wg.Done() 78 | })) 79 | checkPoolSubmit(&wg, solitudes.System.Pool.Submit(func() { 80 | a.GenTOC() 81 | wg.Done() 82 | })) 83 | var pg *pagination.Paginator 84 | checkPoolSubmit(&wg, solitudes.System.Pool.Submit(func() { 85 | // load root comments 86 | pageSlice := c.Query("comment_page") 87 | var page int64 88 | if pageSlice != "" { 89 | page, _ = strconv.ParseInt(pageSlice, 10, 32) 90 | } 91 | pg = pagination.Paging(&pagination.Param{ 92 | DB: solitudes.System.DB.Where("reply_to is null and article_id = ?", a.ID), 93 | Page: int(page), 94 | Limit: 20, 95 | OrderBy: []string{"created_at DESC"}, 96 | }, &a.Comments) 97 | // load childComments 98 | relatedChildComments(&a, a.Comments, true) 99 | wg.Done() 100 | })) 101 | wg.Wait() 102 | a.RelatedCount(solitudes.System.DB, solitudes.System.Pool, checkPoolSubmit) 103 | 104 | // 检查私有博文 105 | if a.IsPrivate && !c.Locals(solitudes.CtxAuthorized).(bool) { 106 | a.Content = "Private Article" 107 | } 108 | 109 | c.Status(http.StatusOK).Render("default/"+solitudes.TemplateIndex[a.TemplateID], injectSiteData(c, fiber.Map{ 110 | "title": title, 111 | "keywords": a.RawTags, 112 | "article": a, 113 | "comment_page": pg, 114 | })) 115 | return nil 116 | } 117 | 118 | func relatedSiblingArticle(p *model.Article) (prev model.Article, next model.Article) { 119 | sibiling, _, _ := solitudes.System.SafeCache.Do(solitudes.CacheKeyPrefixRelatedSiblingArticle+p.ID, func() (interface{}, error) { 120 | var sb model.SibilingArticle 121 | if p.BookRefer == nil { 122 | solitudes.System.DB.Select("id,title,slug").Order("created_at ASC").Take(&sb.Next, "book_refer is null and created_at > ?", p.CreatedAt) 123 | solitudes.System.DB.Select("id,title,slug").Order("created_at DESC").Where("book_refer is null and created_at < ?", p.CreatedAt).Take(&sb.Prev) 124 | } else { 125 | // if this is a book chapter 126 | solitudes.System.DB.Select("id,title,slug").Order("created_at ASC").Take(&sb.Next, "book_refer = ? and created_at > ?", p.BookRefer, p.CreatedAt) 127 | solitudes.System.DB.Select("id,title,slug").Order("created_at DESC").Where("book_refer = ? and created_at < ?", p.BookRefer, p.CreatedAt).Take(&sb.Prev) 128 | } 129 | return sb, nil 130 | }) 131 | if sibiling != nil { 132 | x := sibiling.(model.SibilingArticle) 133 | p.SibilingArticle = &x 134 | } 135 | return 136 | } 137 | 138 | func relatedChapters(p *model.Article) { 139 | if p.IsBook { 140 | chapters, _, _ := solitudes.System.SafeCache.Do(solitudes.CacheKeyPrefixRelatedChapters+p.ID, func() (interface{}, error) { 141 | return innerRelatedChapters(p.ID), nil 142 | }) 143 | if chapters != nil { 144 | x := chapters.([]*model.Article) 145 | p.Chapters = x 146 | } 147 | } 148 | } 149 | 150 | func innerRelatedChapters(pid string) (ps []*model.Article) { 151 | solitudes.System.DB.Order("created_at ASC").Find(&ps, "book_refer=?", pid) 152 | for i := 0; i < len(ps); i++ { 153 | if ps[i].IsBook { 154 | ps[i].Chapters = innerRelatedChapters(ps[i].ID) 155 | } 156 | } 157 | return 158 | } 159 | 160 | func relatedBook(p *model.Article) { 161 | if p.BookRefer != nil { 162 | book, err, _ := solitudes.System.SafeCache.Do(solitudes.CacheKeyPrefixRelatedArticle+*p.BookRefer, func() (interface{}, error) { 163 | var book model.Article 164 | var err error 165 | if err = solitudes.System.DB.Take(&book, "id = ?", p.BookRefer).Error; err != nil { 166 | return nil, err 167 | } 168 | return book, err 169 | }) 170 | if err == nil { 171 | x := book.(model.Article) 172 | p.Book = &x 173 | } 174 | } 175 | } 176 | 177 | func relatedChildComments(a *model.Article, cm []*model.Comment, root bool) { 178 | if root { 179 | var idMaptoComment = make(map[string]*model.Comment) 180 | var idArray []string 181 | // map to index 182 | for i := 0; i < len(cm); i++ { 183 | idMaptoComment[cm[i].ID] = cm[i] 184 | idArray = append(idArray, cm[i].ID) 185 | } 186 | var cms []*model.Comment 187 | solitudes.System.DB.Raw(`WITH RECURSIVE cs AS (SELECT comments.* FROM comments WHERE comments.reply_to in (?) union ALL 188 | SELECT comments.* FROM comments, cs WHERE comments.reply_to = cs.id) 189 | SELECT * FROM cs ORDER BY created_at;`, idArray).Scan(&cms) 190 | // map to index 191 | for i := 0; i < len(cms); i++ { 192 | if cms[i].ReplyTo != nil { 193 | idMaptoComment[cms[i].ID] = cms[i] 194 | } 195 | } 196 | // set child comments 197 | for i := 0; i < len(cms); i++ { 198 | if _, has := idMaptoComment[*cms[i].ReplyTo]; has { 199 | idMaptoComment[*cms[i].ReplyTo].ChildComments = 200 | append(idMaptoComment[*cms[i].ReplyTo].ChildComments, cms[i]) 201 | } 202 | } 203 | } 204 | for i := 0; i < len(cm); i++ { 205 | cm[i].Article = a 206 | if len(cm[i].ChildComments) > 0 { 207 | relatedChildComments(a, cm[i].ChildComments, false) 208 | continue 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /router/archive.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/gofiber/fiber/v2" 10 | "github.com/gorilla/feeds" 11 | "github.com/naiba/solitudes/pkg/pagination" 12 | 13 | "github.com/naiba/solitudes" 14 | "github.com/naiba/solitudes/internal/model" 15 | "github.com/naiba/solitudes/pkg/translator" 16 | ) 17 | 18 | func tagsCloud(c *fiber.Ctx) error { 19 | var tags []string 20 | var counts []int 21 | rows, err := solitudes.System.DB.Raw(`select count(*), unnest(articles.tags) t from articles group by t order by count desc`).Rows() 22 | if err == nil { 23 | defer rows.Close() 24 | for rows.Next() { 25 | var line string 26 | var count int 27 | rows.Scan(&count, &line) 28 | tags = append(tags, line) 29 | counts = append(counts, count) 30 | } 31 | } 32 | c.Status(http.StatusOK).Render("default/tags", injectSiteData(c, fiber.Map{ 33 | "title": c.Locals(solitudes.CtxTranslator).(*translator.Translator).T("tags_cloud"), 34 | "tags": tags, 35 | "counts": counts, 36 | })) 37 | return nil 38 | } 39 | 40 | func archive(c *fiber.Ctx) error { 41 | var page int64 42 | page, _ = strconv.ParseInt(c.Params("page"), 10, 64) 43 | var articles []model.Article 44 | pg := pagination.Paging(&pagination.Param{ 45 | DB: solitudes.System.DB.Where("array_length(tags, 1) is null").Or("NOT tags @> ARRAY[?]::varchar[]", "Topic"), 46 | Page: int(page), 47 | Limit: 20, 48 | OrderBy: []string{"created_at DESC"}, 49 | }, &articles) 50 | for i := 0; i < len(articles); i++ { 51 | articles[i].RelatedCount(solitudes.System.DB, solitudes.System.Pool, checkPoolSubmit) 52 | // 如果存在 Topic tag,加载前 3 条评论 53 | if articles[i].IsTopic() { 54 | pagination.Paging(&pagination.Param{ 55 | DB: solitudes.System.DB.Where("reply_to is null and article_id = ?", articles[i].ID), 56 | Limit: 5, 57 | OrderBy: []string{"created_at DESC"}, 58 | }, &articles[i].Comments) 59 | } 60 | } 61 | c.Status(http.StatusOK).Render("default/archive", injectSiteData(c, fiber.Map{ 62 | "title": c.Locals(solitudes.CtxTranslator).(*translator.Translator).T("archive"), 63 | "what": "archive", 64 | "articles": listArticleByYear(articles), 65 | "page": pg, 66 | })) 67 | return nil 68 | } 69 | 70 | func book(c *fiber.Ctx) error { 71 | var page int64 72 | page, _ = strconv.ParseInt(c.Params("page"), 10, 64) 73 | var articles []model.Article 74 | pg := pagination.Paging(&pagination.Param{ 75 | DB: solitudes.System.DB.Where("is_book is true"), 76 | Page: int(page), 77 | Limit: 20, 78 | OrderBy: []string{"created_at DESC"}, 79 | }, &articles) 80 | for i := 0; i < len(articles); i++ { 81 | articles[i].RelatedCount(solitudes.System.DB, solitudes.System.Pool, checkPoolSubmit) 82 | } 83 | c.Status(http.StatusOK).Render("default/archive", injectSiteData(c, fiber.Map{ 84 | "title": c.Locals(solitudes.CtxTranslator).(*translator.Translator).T("books"), 85 | "what": "books", 86 | "articles": listArticleByYear(articles), 87 | "page": pg, 88 | })) 89 | return nil 90 | } 91 | 92 | func feedHandler(c *fiber.Ctx) error { 93 | if c.Params("format") == "" { 94 | c.Status(http.StatusBadRequest).JSON(map[string]interface{}{ 95 | "message": "please spec a feed format", 96 | "supportedFormat": []string{"json", "rss", "atom"}, 97 | "feedLink": "https://" + solitudes.System.Config.Site.Domain + "/feed/:format", 98 | }) 99 | return nil 100 | } 101 | feed := &feeds.Feed{ 102 | Title: solitudes.System.Config.Site.SpaceName, 103 | Link: &feeds.Link{Href: "https://" + solitudes.System.Config.Site.Domain}, 104 | Description: solitudes.System.Config.Site.SpaceDesc, 105 | Author: &feeds.Author{Name: solitudes.System.Config.User.Nickname, Email: solitudes.System.Config.User.Email}, 106 | Updated: time.Now(), 107 | } 108 | var articles []model.Article 109 | solitudes.System.DB.Order("created_at DESC").Limit(20).Find(&articles) 110 | for i := 0; i < len(articles); i++ { 111 | // 检查私有博文 112 | if articles[i].IsPrivate && !c.Locals(solitudes.CtxAuthorized).(bool) { 113 | articles[i].Content = "Private Article" 114 | } 115 | 116 | feed.Items = append(feed.Items, &feeds.Item{ 117 | Title: articles[i].Title, 118 | Link: &feeds.Link{Href: "https://" + solitudes.System.Config.Site.Domain + "/" + articles[i].Slug + "/v" + strconv.Itoa(int(articles[i].Version))}, 119 | Author: &feeds.Author{Name: solitudes.System.Config.User.Nickname, Email: solitudes.System.Config.User.Email}, 120 | Content: luteEngine.MarkdownStr(articles[i].GetIndexID(), articles[i].Content), 121 | Created: articles[i].CreatedAt, 122 | Updated: articles[i].UpdatedAt, 123 | }) 124 | } 125 | switch c.Params("format") { 126 | case "atom": 127 | atom, err := feed.ToAtom() 128 | if err != nil { 129 | return err 130 | } 131 | c.Set("Content-Type", "application/xml") 132 | c.Status(http.StatusOK).WriteString(atom) 133 | case "rss": 134 | rss, err := feed.ToRss() 135 | if err != nil { 136 | return err 137 | } 138 | c.Set("Content-Type", "application/xml") 139 | c.Status(http.StatusOK).WriteString(rss) 140 | case "json": 141 | json, err := feed.ToJSON() 142 | if err != nil { 143 | return err 144 | } 145 | c.Set("Content-Type", "application/json") 146 | c.Status(http.StatusOK).WriteString(json) 147 | default: 148 | c.Status(http.StatusOK).WriteString("Unknown type") 149 | } 150 | return nil 151 | } 152 | 153 | func tags(c *fiber.Ctx) error { 154 | var page int64 155 | page, _ = strconv.ParseInt(c.Params("page"), 10, 64) 156 | var articles []model.Article 157 | tag, _ := url.QueryUnescape(c.Params("tag")) 158 | if tag == "" { 159 | page404(c) 160 | return nil 161 | } 162 | pg := pagination.Paging(&pagination.Param{ 163 | DB: solitudes.System.DB.Where("tags @> ARRAY[?]::varchar[]", tag), 164 | Page: int(page), 165 | Limit: 20, 166 | OrderBy: []string{"created_at DESC"}, 167 | }, &articles) 168 | for i := 0; i < len(articles); i++ { 169 | articles[i].RelatedCount(solitudes.System.DB, solitudes.System.Pool, checkPoolSubmit) 170 | // 如果存在 Topic tag,加载前 3 条评论 171 | if articles[i].IsTopic() { 172 | pagination.Paging(&pagination.Param{ 173 | DB: solitudes.System.DB.Where("reply_to is null and article_id = ?", articles[i].ID), 174 | Limit: 5, 175 | OrderBy: []string{"created_at DESC"}, 176 | }, &articles[i].Comments) 177 | } 178 | } 179 | c.Status(http.StatusOK).Render("default/archive", injectSiteData(c, fiber.Map{ 180 | "title": c.Locals(solitudes.CtxTranslator).(*translator.Translator).T("articles_in", tag), 181 | "what": "tags", 182 | "articles": listArticleByYear(articles), 183 | "page": pg, 184 | })) 185 | return nil 186 | } 187 | 188 | func listArticleByYear(as []model.Article) [][]model.Article { 189 | var listed [][]model.Article 190 | var lastYear int 191 | var listItem []model.Article 192 | for i := 0; i < len(as); i++ { 193 | currentYear := as[i].CreatedAt.Year() 194 | if currentYear != lastYear { 195 | if len(listItem) > 0 { 196 | listed = append(listed, listItem) 197 | listItem = make([]model.Article, 0) 198 | } 199 | lastYear = currentYear 200 | } 201 | listItem = append(listItem, as[i]) 202 | } 203 | if len(listItem) > 0 { 204 | listed = append(listed, listItem) 205 | } 206 | return listed 207 | } 208 | -------------------------------------------------------------------------------- /resource/translation/zh/admin.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "locale": "zh", 4 | "key": "home", 5 | "trans": "首页" 6 | }, 7 | { 8 | "locale": "zh", 9 | "key": "login", 10 | "trans": "登录" 11 | }, 12 | { 13 | "locale": "zh", 14 | "key": "remember_me", 15 | "trans": "记住我" 16 | }, 17 | { 18 | "locale": "zh", 19 | "key": "sign_in", 20 | "trans": "登录" 21 | }, 22 | { 23 | "locale": "zh", 24 | "key": "password", 25 | "trans": "密码" 26 | }, 27 | { 28 | "locale": "zh", 29 | "key": "manage_articles", 30 | "trans": "管理文章" 31 | }, 32 | { 33 | "locale": "zh", 34 | "key": "manage_tags", 35 | "trans": "管理标签" 36 | }, 37 | { 38 | "locale": "zh", 39 | "key": "tags", 40 | "trans": "标签" 41 | }, 42 | { 43 | "locale": "zh", 44 | "key": "title", 45 | "trans": "标题" 46 | }, 47 | { 48 | "locale": "zh", 49 | "key": "read", 50 | "trans": "阅读" 51 | }, 52 | { 53 | "locale": "zh", 54 | "key": "slug", 55 | "trans": "链接" 56 | }, 57 | { 58 | "locale": "zh", 59 | "key": "created_at", 60 | "trans": "发布时间" 61 | }, 62 | { 63 | "locale": "zh", 64 | "key": "manage", 65 | "trans": "管理" 66 | }, 67 | { 68 | "locale": "zh", 69 | "key": "edit", 70 | "trans": "编辑" 71 | }, 72 | { 73 | "locale": "zh", 74 | "key": "delete", 75 | "trans": "删除" 76 | }, 77 | { 78 | "locale": "zh", 79 | "key": "previous", 80 | "trans": "上一页" 81 | }, 82 | { 83 | "locale": "zh", 84 | "key": "next", 85 | "trans": "下一页" 86 | }, 87 | { 88 | "locale": "zh", 89 | "key": "manage_comments", 90 | "trans": "管理评论" 91 | }, 92 | { 93 | "locale": "zh", 94 | "key": "content", 95 | "trans": "内容" 96 | }, 97 | { 98 | "locale": "zh", 99 | "key": "author", 100 | "trans": "作者" 101 | }, 102 | { 103 | "locale": "zh", 104 | "key": "article", 105 | "trans": "文章" 106 | }, 107 | { 108 | "locale": "zh", 109 | "key": "articles", 110 | "trans": "文章" 111 | }, 112 | { 113 | "locale": "zh", 114 | "key": "version", 115 | "trans": "版本" 116 | }, 117 | { 118 | "locale": "zh", 119 | "key": "sign_out", 120 | "trans": "注销登录" 121 | }, 122 | { 123 | "locale": "zh", 124 | "key": "publish", 125 | "trans": "发布" 126 | }, 127 | { 128 | "locale": "zh", 129 | "key": "media", 130 | "trans": "文件" 131 | }, 132 | { 133 | "locale": "zh", 134 | "key": "manage_media", 135 | "trans": "管理文件" 136 | }, 137 | { 138 | "locale": "zh", 139 | "key": "dashboard", 140 | "trans": "仪表盘" 141 | }, 142 | { 143 | "locale": "zh", 144 | "key": "days_left_from_the_last_post", 145 | "trans": "距离上次发布文章的天数" 146 | }, 147 | { 148 | "locale": "zh", 149 | "key": "days_left_from_the_last_comment", 150 | "trans": "距离上次收到评论的天数" 151 | }, 152 | { 153 | "locale": "zh", 154 | "key": "labels", 155 | "trans": "标签" 156 | }, 157 | { 158 | "locale": "zh", 159 | "key": "memory_usage", 160 | "trans": "内存占用" 161 | }, 162 | { 163 | "locale": "zh", 164 | "key": "gc_num", 165 | "trans": "内存回收次数" 166 | }, 167 | { 168 | "locale": "zh", 169 | "key": "routine_num", 170 | "trans": "运行中协程数量" 171 | }, 172 | { 173 | "locale": "zh", 174 | "key": "rebuild_fulltext_search_data", 175 | "trans": "重建全文搜索数据" 176 | }, 177 | { 178 | "locale": "zh", 179 | "key": "filename", 180 | "trans": "文件名" 181 | }, 182 | { 183 | "locale": "zh", 184 | "key": "uploaded_at", 185 | "trans": "上传时间" 186 | }, 187 | { 188 | "locale": "zh", 189 | "key": "publish_article", 190 | "trans": "发布文章" 191 | }, 192 | { 193 | "locale": "zh", 194 | "key": "book_refer", 195 | "trans": "专栏ID" 196 | }, 197 | { 198 | "locale": "zh", 199 | "key": "its_a_book", 200 | "trans": "这是专栏" 201 | }, 202 | { 203 | "locale": "zh", 204 | "key": "new_version", 205 | "trans": "大更新" 206 | }, 207 | { 208 | "locale": "zh", 209 | "key": "site_settings", 210 | "trans": "配置" 211 | }, 212 | { 213 | "locale": "zh", 214 | "key": "site_title", 215 | "trans": "站点标题" 216 | }, 217 | { 218 | "locale": "zh", 219 | "key": "site_desc", 220 | "trans": "站点说明" 221 | }, 222 | { 223 | "locale": "zh", 224 | "key": "site_tg_bot_token", 225 | "trans": "TG Bot Token" 226 | }, 227 | { 228 | "locale": "zh", 229 | "key": "site_tg_chat_id", 230 | "trans": "TG Chat ID" 231 | }, 232 | { 233 | "locale": "zh", 234 | "key": "site_mail_server", 235 | "trans": "发信服务器" 236 | }, 237 | { 238 | "locale": "zh", 239 | "key": "site_mail_port", 240 | "trans": "服务器端口" 241 | }, 242 | { 243 | "locale": "zh", 244 | "key": "site_mail_user", 245 | "trans": "发信用户名" 246 | }, 247 | { 248 | "locale": "zh", 249 | "key": "site_mail_pass", 250 | "trans": "发信密码" 251 | }, 252 | { 253 | "locale": "zh", 254 | "key": "site_mail_ssl", 255 | "trans": "SMTP SSL" 256 | }, 257 | { 258 | "locale": "zh", 259 | "key": "site_home_top_content", 260 | "trans": "首页顶部内容" 261 | }, 262 | { 263 | "locale": "zh", 264 | "key": "site_home_bottom_content", 265 | "trans": "首页底部内容" 266 | }, 267 | { 268 | "locale": "zh", 269 | "key": "site_theme", 270 | "trans": "站点主题" 271 | }, 272 | { 273 | "locale": "zh", 274 | "key": "site_akismet", 275 | "trans": "Akismet" 276 | }, 277 | { 278 | "locale": "zh", 279 | "key": "site_domain", 280 | "trans": "站点域名" 281 | }, 282 | { 283 | "locale": "zh", 284 | "key": "site_header_menus", 285 | "trans": "顶部菜单" 286 | }, 287 | { 288 | "locale": "zh", 289 | "key": "site_footer_menus", 290 | "trans": "页脚菜单" 291 | }, 292 | { 293 | "locale": "zh", 294 | "key": "site_custom_code", 295 | "trans": "自定义代码 (HTML, 303 | {{template "default/footer" .}} 304 | {{end}} --------------------------------------------------------------------------------