Repository: mindoc-org/mindoc
Branch: master
Commit: bec17630274e
Files: 1184
Total size: 46.9 MB
Directory structure:
gitextract_8kp_8qo8/
├── .gitattributes
├── .github/
│ ├── ISSUE_TEMPLATE
│ └── workflows/
│ └── build.yml
├── .gitignore
├── .travis.yml
├── Dockerfile
├── LICENSE.md
├── README.md
├── appveyor.yml
├── build_amd64.sh
├── build_musl_amd64.sh
├── cache/
│ ├── cache.go
│ └── cache_null.go
├── commands/
│ ├── command.go
│ ├── daemon/
│ │ └── daemon.go
│ ├── install.go
│ ├── migrate/
│ │ ├── migrate.go
│ │ └── migrate_v03.go
│ └── update.go
├── conf/
│ ├── app.conf.example
│ ├── enumerate.go
│ ├── lang/
│ │ ├── en-us.ini
│ │ ├── ru-ru.ini
│ │ └── zh-cn.ini
│ ├── mail.go
│ └── workweixin.go
├── controllers/
│ ├── AccountController.go
│ ├── BaseController.go
│ ├── BlogController.go
│ ├── BookController.go
│ ├── BookMemberController.go
│ ├── CommentController.go
│ ├── DocumentController.go
│ ├── ErrorController.go
│ ├── HomeController.go
│ ├── ItemsetsController.go
│ ├── LabelController.go
│ ├── ManagerController.go
│ ├── SearchController.go
│ ├── SettingController.go
│ ├── TemplateController.go
│ └── const.go
├── converter/
│ ├── converter.go
│ └── util.go
├── database/
│ └── clean.py
├── dev-win-build.cmd
├── docker-compose.yml
├── go.mod
├── go.sum
├── graphics/
│ ├── copy.go
│ └── file.go
├── lib/
│ └── time/
│ ├── README
│ └── update.bash
├── mail/
│ ├── smtp.go
│ ├── smtp_test.go
│ └── util.go
├── main.go
├── mcp/
│ ├── handler.go
│ ├── mcp.go
│ └── middleware.go
├── models/
│ ├── AttachmentModel.go
│ ├── AttachmentResult.go
│ ├── Auth2Account.go
│ ├── Base.go
│ ├── Blog.go
│ ├── BlogResult.go
│ ├── BookModel.go
│ ├── BookResult.go
│ ├── CommentModel.go
│ ├── ContentReverseIndex.go
│ ├── ConvertBookResult.go
│ ├── Dashboard.go
│ ├── DocumentHistory.go
│ ├── DocumentModel.go
│ ├── DocumentSearchResult.go
│ ├── DocumentTree.go
│ ├── Errors.go
│ ├── Itemsets.go
│ ├── LabelModel.go
│ ├── Logs.go
│ ├── Member.go
│ ├── MemberResult.go
│ ├── MemberToken.go
│ ├── Migrations.go
│ ├── Options.go
│ ├── Relationship.go
│ ├── Team.go
│ ├── TeamMember.go
│ ├── TeamRelationship.go
│ ├── Template.go
│ ├── comment_result.go
│ └── comment_vote.go
├── routers/
│ ├── filter.go
│ └── router.go
├── simsun.ttc
├── start.sh
├── static/
│ ├── bootstrap/
│ │ ├── css/
│ │ │ ├── bootstrap-theme.css
│ │ │ └── bootstrap.css
│ │ ├── js/
│ │ │ ├── bootstrap.js
│ │ │ └── npm.js
│ │ └── plugins/
│ │ ├── bootstrap-fileinput/
│ │ │ └── 4.4.7/
│ │ │ ├── css/
│ │ │ │ ├── fileinput-rtl.css
│ │ │ │ └── fileinput.css
│ │ │ ├── js/
│ │ │ │ ├── fileinput.js
│ │ │ │ ├── locales/
│ │ │ │ │ ├── LANG.js
│ │ │ │ │ ├── ar.js
│ │ │ │ │ ├── az.js
│ │ │ │ │ ├── bg.js
│ │ │ │ │ ├── ca.js
│ │ │ │ │ ├── cr.js
│ │ │ │ │ ├── cs.js
│ │ │ │ │ ├── da.js
│ │ │ │ │ ├── de.js
│ │ │ │ │ ├── el.js
│ │ │ │ │ ├── es.js
│ │ │ │ │ ├── et.js
│ │ │ │ │ ├── fa.js
│ │ │ │ │ ├── fi.js
│ │ │ │ │ ├── fr.js
│ │ │ │ │ ├── gl.js
│ │ │ │ │ ├── hu.js
│ │ │ │ │ ├── id.js
│ │ │ │ │ ├── it.js
│ │ │ │ │ ├── ja.js
│ │ │ │ │ ├── ka.js
│ │ │ │ │ ├── kr.js
│ │ │ │ │ ├── kz.js
│ │ │ │ │ ├── lt.js
│ │ │ │ │ ├── nl.js
│ │ │ │ │ ├── no.js
│ │ │ │ │ ├── pl.js
│ │ │ │ │ ├── pt-BR.js
│ │ │ │ │ ├── pt.js
│ │ │ │ │ ├── ro.js
│ │ │ │ │ ├── ru.js
│ │ │ │ │ ├── sk.js
│ │ │ │ │ ├── sl.js
│ │ │ │ │ ├── sv.js
│ │ │ │ │ ├── th.js
│ │ │ │ │ ├── tr.js
│ │ │ │ │ ├── uk.js
│ │ │ │ │ ├── vi.js
│ │ │ │ │ ├── zh-TW.js
│ │ │ │ │ └── zh.js
│ │ │ │ └── plugins/
│ │ │ │ ├── piexif.js
│ │ │ │ ├── purify.js
│ │ │ │ └── sortable.js
│ │ │ └── themes/
│ │ │ ├── explorer/
│ │ │ │ ├── theme.css
│ │ │ │ └── theme.js
│ │ │ ├── explorer-fa/
│ │ │ │ ├── theme.css
│ │ │ │ └── theme.js
│ │ │ ├── fa/
│ │ │ │ └── theme.js
│ │ │ └── gly/
│ │ │ └── theme.js
│ │ ├── bootstrap-switch/
│ │ │ ├── css/
│ │ │ │ ├── bootstrap2/
│ │ │ │ │ └── bootstrap-switch.css
│ │ │ │ └── bootstrap3/
│ │ │ │ └── bootstrap-switch.css
│ │ │ └── js/
│ │ │ └── bootstrap-switch.js
│ │ ├── bootstrap-wysiwyg/
│ │ │ ├── bootstrap-wysiwyg.js
│ │ │ └── external/
│ │ │ ├── google-code-prettify/
│ │ │ │ ├── lang-apollo.js
│ │ │ │ ├── lang-basic.js
│ │ │ │ ├── lang-clj.js
│ │ │ │ ├── lang-css.js
│ │ │ │ ├── lang-dart.js
│ │ │ │ ├── lang-erlang.js
│ │ │ │ ├── lang-go.js
│ │ │ │ ├── lang-hs.js
│ │ │ │ ├── lang-lisp.js
│ │ │ │ ├── lang-llvm.js
│ │ │ │ ├── lang-lua.js
│ │ │ │ ├── lang-matlab.js
│ │ │ │ ├── lang-ml.js
│ │ │ │ ├── lang-mumps.js
│ │ │ │ ├── lang-n.js
│ │ │ │ ├── lang-pascal.js
│ │ │ │ ├── lang-proto.js
│ │ │ │ ├── lang-r.js
│ │ │ │ ├── lang-rd.js
│ │ │ │ ├── lang-scala.js
│ │ │ │ ├── lang-sql.js
│ │ │ │ ├── lang-tcl.js
│ │ │ │ ├── lang-tex.js
│ │ │ │ ├── lang-vb.js
│ │ │ │ ├── lang-vhdl.js
│ │ │ │ ├── lang-wiki.js
│ │ │ │ ├── lang-xq.js
│ │ │ │ ├── lang-yaml.js
│ │ │ │ ├── prettify.css
│ │ │ │ ├── prettify.js
│ │ │ │ └── run_prettify.js
│ │ │ └── jquery.hotkeys.js
│ │ └── tagsinput/
│ │ ├── bootstrap-tagsinput.css
│ │ ├── bootstrap-tagsinput.js
│ │ └── bootstrap-tagsinput.less
│ ├── bootstrap-paginator/
│ │ └── bootstrap-paginator.js
│ ├── cherry/
│ │ ├── addons/
│ │ │ ├── cherry-code-block-mermaid-plugin.d.ts
│ │ │ ├── cherry-code-block-mermaid-plugin.js
│ │ │ ├── cherry-code-block-plantuml-plugin.d.ts
│ │ │ └── cherry-code-block-plantuml-plugin.js
│ │ ├── cherry-markdown.css
│ │ ├── cherry-markdown.js
│ │ ├── drawio-demo.js
│ │ ├── drawio_demo/
│ │ │ ├── Actions.js
│ │ │ ├── Dialogs.js
│ │ │ ├── Editor.js
│ │ │ ├── EditorUi.js
│ │ │ ├── Format.js
│ │ │ ├── Graph.js
│ │ │ ├── Init.js
│ │ │ ├── Menus.js
│ │ │ ├── Shapes.js
│ │ │ ├── Sidebar.js
│ │ │ ├── Toolbar.js
│ │ │ ├── atlas.css
│ │ │ ├── dark-default.xml
│ │ │ ├── dark.css
│ │ │ ├── default-old.xml
│ │ │ ├── default.xml
│ │ │ ├── drawio-demo.js
│ │ │ ├── font/
│ │ │ │ └── graph.iconfont.less
│ │ │ ├── grapheditor.css
│ │ │ ├── image/
│ │ │ │ └── stencils/
│ │ │ │ ├── arrows.xml
│ │ │ │ ├── basic.xml
│ │ │ │ ├── bpmn.xml
│ │ │ │ └── flowchart.xml
│ │ │ ├── jscolor/
│ │ │ │ └── jscolor.js
│ │ │ ├── lib/
│ │ │ │ └── base64.js
│ │ │ ├── resources/
│ │ │ │ ├── en.txt
│ │ │ │ └── zh.txt
│ │ │ └── src/
│ │ │ ├── css/
│ │ │ │ ├── common.css
│ │ │ │ └── explorer.css
│ │ │ ├── grapheditor.less
│ │ │ ├── js/
│ │ │ │ ├── editor/
│ │ │ │ │ ├── mxDefaultKeyHandler.js
│ │ │ │ │ ├── mxDefaultPopupMenu.js
│ │ │ │ │ ├── mxDefaultToolbar.js
│ │ │ │ │ └── mxEditor.js
│ │ │ │ ├── handler/
│ │ │ │ │ ├── mxCellHighlight.js
│ │ │ │ │ ├── mxCellMarker.js
│ │ │ │ │ ├── mxCellTracker.js
│ │ │ │ │ ├── mxConnectionHandler.js
│ │ │ │ │ ├── mxConstraintHandler.js
│ │ │ │ │ ├── mxEdgeHandler.js
│ │ │ │ │ ├── mxEdgeSegmentHandler.js
│ │ │ │ │ ├── mxElbowEdgeHandler.js
│ │ │ │ │ ├── mxGraphHandler.js
│ │ │ │ │ ├── mxHandle.js
│ │ │ │ │ ├── mxKeyHandler.js
│ │ │ │ │ ├── mxPanningHandler.js
│ │ │ │ │ ├── mxPopupMenuHandler.js
│ │ │ │ │ ├── mxRubberband.js
│ │ │ │ │ ├── mxSelectionCellsHandler.js
│ │ │ │ │ ├── mxTooltipHandler.js
│ │ │ │ │ └── mxVertexHandler.js
│ │ │ │ ├── index.txt
│ │ │ │ ├── io/
│ │ │ │ │ ├── mxCellCodec.js
│ │ │ │ │ ├── mxChildChangeCodec.js
│ │ │ │ │ ├── mxCodec.js
│ │ │ │ │ ├── mxCodecRegistry.js
│ │ │ │ │ ├── mxDefaultKeyHandlerCodec.js
│ │ │ │ │ ├── mxDefaultPopupMenuCodec.js
│ │ │ │ │ ├── mxDefaultToolbarCodec.js
│ │ │ │ │ ├── mxEditorCodec.js
│ │ │ │ │ ├── mxGenericChangeCodec.js
│ │ │ │ │ ├── mxGraphCodec.js
│ │ │ │ │ ├── mxGraphViewCodec.js
│ │ │ │ │ ├── mxModelCodec.js
│ │ │ │ │ ├── mxObjectCodec.js
│ │ │ │ │ ├── mxRootChangeCodec.js
│ │ │ │ │ ├── mxStylesheetCodec.js
│ │ │ │ │ └── mxTerminalChangeCodec.js
│ │ │ │ ├── layout/
│ │ │ │ │ ├── hierarchical/
│ │ │ │ │ │ ├── model/
│ │ │ │ │ │ │ ├── mxGraphAbstractHierarchyCell.js
│ │ │ │ │ │ │ ├── mxGraphHierarchyEdge.js
│ │ │ │ │ │ │ ├── mxGraphHierarchyModel.js
│ │ │ │ │ │ │ ├── mxGraphHierarchyNode.js
│ │ │ │ │ │ │ └── mxSwimlaneModel.js
│ │ │ │ │ │ ├── mxHierarchicalLayout.js
│ │ │ │ │ │ ├── mxSwimlaneLayout.js
│ │ │ │ │ │ └── stage/
│ │ │ │ │ │ ├── mxCoordinateAssignment.js
│ │ │ │ │ │ ├── mxHierarchicalLayoutStage.js
│ │ │ │ │ │ ├── mxMedianHybridCrossingReduction.js
│ │ │ │ │ │ ├── mxMinimumCycleRemover.js
│ │ │ │ │ │ └── mxSwimlaneOrdering.js
│ │ │ │ │ ├── mxCircleLayout.js
│ │ │ │ │ ├── mxCompactTreeLayout.js
│ │ │ │ │ ├── mxCompositeLayout.js
│ │ │ │ │ ├── mxEdgeLabelLayout.js
│ │ │ │ │ ├── mxFastOrganicLayout.js
│ │ │ │ │ ├── mxGraphLayout.js
│ │ │ │ │ ├── mxParallelEdgeLayout.js
│ │ │ │ │ ├── mxPartitionLayout.js
│ │ │ │ │ ├── mxRadialTreeLayout.js
│ │ │ │ │ └── mxStackLayout.js
│ │ │ │ ├── model/
│ │ │ │ │ ├── mxCell.js
│ │ │ │ │ ├── mxCellPath.js
│ │ │ │ │ ├── mxGeometry.js
│ │ │ │ │ └── mxGraphModel.js
│ │ │ │ ├── mxClient.js
│ │ │ │ ├── shape/
│ │ │ │ │ ├── mxActor.js
│ │ │ │ │ ├── mxArrow.js
│ │ │ │ │ ├── mxArrowConnector.js
│ │ │ │ │ ├── mxCloud.js
│ │ │ │ │ ├── mxConnector.js
│ │ │ │ │ ├── mxCylinder.js
│ │ │ │ │ ├── mxDoubleEllipse.js
│ │ │ │ │ ├── mxEllipse.js
│ │ │ │ │ ├── mxHexagon.js
│ │ │ │ │ ├── mxImageShape.js
│ │ │ │ │ ├── mxLabel.js
│ │ │ │ │ ├── mxLine.js
│ │ │ │ │ ├── mxMarker.js
│ │ │ │ │ ├── mxPolyline.js
│ │ │ │ │ ├── mxRectangleShape.js
│ │ │ │ │ ├── mxRhombus.js
│ │ │ │ │ ├── mxShape.js
│ │ │ │ │ ├── mxStencil.js
│ │ │ │ │ ├── mxStencilRegistry.js
│ │ │ │ │ ├── mxSwimlane.js
│ │ │ │ │ ├── mxText.js
│ │ │ │ │ └── mxTriangle.js
│ │ │ │ ├── util/
│ │ │ │ │ ├── mxAbstractCanvas2D.js
│ │ │ │ │ ├── mxAnimation.js
│ │ │ │ │ ├── mxAutoSaveManager.js
│ │ │ │ │ ├── mxClipboard.js
│ │ │ │ │ ├── mxConstants.js
│ │ │ │ │ ├── mxDictionary.js
│ │ │ │ │ ├── mxDivResizer.js
│ │ │ │ │ ├── mxDragSource.js
│ │ │ │ │ ├── mxEffects.js
│ │ │ │ │ ├── mxEvent.js
│ │ │ │ │ ├── mxEventObject.js
│ │ │ │ │ ├── mxEventSource.js
│ │ │ │ │ ├── mxForm.js
│ │ │ │ │ ├── mxGuide.js
│ │ │ │ │ ├── mxImage.js
│ │ │ │ │ ├── mxImageBundle.js
│ │ │ │ │ ├── mxImageExport.js
│ │ │ │ │ ├── mxLog.js
│ │ │ │ │ ├── mxMorphing.js
│ │ │ │ │ ├── mxMouseEvent.js
│ │ │ │ │ ├── mxObjectIdentity.js
│ │ │ │ │ ├── mxPanningManager.js
│ │ │ │ │ ├── mxPoint.js
│ │ │ │ │ ├── mxPopupMenu.js
│ │ │ │ │ ├── mxRectangle.js
│ │ │ │ │ ├── mxResources.js
│ │ │ │ │ ├── mxSvgCanvas2D.js
│ │ │ │ │ ├── mxToolbar.js
│ │ │ │ │ ├── mxUndoManager.js
│ │ │ │ │ ├── mxUndoableEdit.js
│ │ │ │ │ ├── mxUrlConverter.js
│ │ │ │ │ ├── mxUtils.js
│ │ │ │ │ ├── mxVmlCanvas2D.js
│ │ │ │ │ ├── mxWindow.js
│ │ │ │ │ ├── mxXmlCanvas2D.js
│ │ │ │ │ └── mxXmlRequest.js
│ │ │ │ └── view/
│ │ │ │ ├── mxCellEditor.js
│ │ │ │ ├── mxCellOverlay.js
│ │ │ │ ├── mxCellRenderer.js
│ │ │ │ ├── mxCellState.js
│ │ │ │ ├── mxCellStatePreview.js
│ │ │ │ ├── mxConnectionConstraint.js
│ │ │ │ ├── mxEdgeStyle.js
│ │ │ │ ├── mxGraph.js
│ │ │ │ ├── mxGraphSelectionModel.js
│ │ │ │ ├── mxGraphView.js
│ │ │ │ ├── mxLayoutManager.js
│ │ │ │ ├── mxMultiplicity.js
│ │ │ │ ├── mxOutline.js
│ │ │ │ ├── mxPerimeter.js
│ │ │ │ ├── mxPrintPreview.js
│ │ │ │ ├── mxStyleRegistry.js
│ │ │ │ ├── mxStylesheet.js
│ │ │ │ ├── mxSwimlaneManager.js
│ │ │ │ └── mxTemporaryCellStates.js
│ │ │ └── resources/
│ │ │ ├── editor.txt
│ │ │ ├── editor_de.txt
│ │ │ ├── editor_zh.txt
│ │ │ ├── graph.txt
│ │ │ ├── graph_de.txt
│ │ │ └── graph_zh.txt
│ │ ├── drawio_demo.html
│ │ ├── mxgraph/
│ │ │ ├── css/
│ │ │ │ ├── common.css
│ │ │ │ └── explorer.css
│ │ │ └── mxClient.js
│ │ └── pinyin/
│ │ ├── README.md
│ │ ├── hanziPinyin.js
│ │ ├── hanziPinyinWithoutYin.js
│ │ ├── pinyin.js
│ │ └── pinyin_dist.js
│ ├── cropper/
│ │ └── 2.3.4/
│ │ ├── cropper.css
│ │ └── cropper.js
│ ├── css/
│ │ ├── export.css
│ │ ├── jstree.css
│ │ ├── kancloud.css
│ │ ├── main.css
│ │ ├── markdown.css
│ │ ├── markdown.preview.css
│ │ └── print.css
│ ├── editor.md/
│ │ ├── css/
│ │ │ ├── editormd.css
│ │ │ ├── editormd.logo.css
│ │ │ └── editormd.preview.css
│ │ ├── editormd.amd.js
│ │ ├── editormd.js
│ │ ├── fonts/
│ │ │ └── FontAwesome.otf
│ │ ├── languages/
│ │ │ ├── en.js
│ │ │ └── zh-tw.js
│ │ ├── lib/
│ │ │ ├── codemirror/
│ │ │ │ ├── AUTHORS
│ │ │ │ ├── LICENSE
│ │ │ │ ├── README.md
│ │ │ │ ├── addon/
│ │ │ │ │ ├── comment/
│ │ │ │ │ │ ├── comment.js
│ │ │ │ │ │ └── continuecomment.js
│ │ │ │ │ ├── dialog/
│ │ │ │ │ │ ├── dialog.css
│ │ │ │ │ │ └── dialog.js
│ │ │ │ │ ├── display/
│ │ │ │ │ │ ├── fullscreen.css
│ │ │ │ │ │ ├── fullscreen.js
│ │ │ │ │ │ ├── panel.js
│ │ │ │ │ │ ├── placeholder.js
│ │ │ │ │ │ └── rulers.js
│ │ │ │ │ ├── edit/
│ │ │ │ │ │ ├── closebrackets.js
│ │ │ │ │ │ ├── closetag.js
│ │ │ │ │ │ ├── continuelist.js
│ │ │ │ │ │ ├── matchbrackets.js
│ │ │ │ │ │ ├── matchtags.js
│ │ │ │ │ │ └── trailingspace.js
│ │ │ │ │ ├── fold/
│ │ │ │ │ │ ├── brace-fold.js
│ │ │ │ │ │ ├── comment-fold.js
│ │ │ │ │ │ ├── foldcode.js
│ │ │ │ │ │ ├── foldgutter.css
│ │ │ │ │ │ ├── foldgutter.js
│ │ │ │ │ │ ├── indent-fold.js
│ │ │ │ │ │ ├── markdown-fold.js
│ │ │ │ │ │ └── xml-fold.js
│ │ │ │ │ ├── hint/
│ │ │ │ │ │ ├── anyword-hint.js
│ │ │ │ │ │ ├── css-hint.js
│ │ │ │ │ │ ├── html-hint.js
│ │ │ │ │ │ ├── javascript-hint.js
│ │ │ │ │ │ ├── show-hint.css
│ │ │ │ │ │ ├── show-hint.js
│ │ │ │ │ │ ├── sql-hint.js
│ │ │ │ │ │ └── xml-hint.js
│ │ │ │ │ ├── lint/
│ │ │ │ │ │ ├── coffeescript-lint.js
│ │ │ │ │ │ ├── css-lint.js
│ │ │ │ │ │ ├── javascript-lint.js
│ │ │ │ │ │ ├── json-lint.js
│ │ │ │ │ │ ├── lint.css
│ │ │ │ │ │ ├── lint.js
│ │ │ │ │ │ └── yaml-lint.js
│ │ │ │ │ ├── merge/
│ │ │ │ │ │ ├── merge.css
│ │ │ │ │ │ └── merge.js
│ │ │ │ │ ├── mode/
│ │ │ │ │ │ ├── loadmode.js
│ │ │ │ │ │ ├── multiplex.js
│ │ │ │ │ │ ├── multiplex_test.js
│ │ │ │ │ │ ├── overlay.js
│ │ │ │ │ │ └── simple.js
│ │ │ │ │ ├── runmode/
│ │ │ │ │ │ ├── colorize.js
│ │ │ │ │ │ ├── runmode-standalone.js
│ │ │ │ │ │ ├── runmode.js
│ │ │ │ │ │ └── runmode.node.js
│ │ │ │ │ ├── scroll/
│ │ │ │ │ │ ├── annotatescrollbar.js
│ │ │ │ │ │ ├── scrollpastend.js
│ │ │ │ │ │ ├── simplescrollbars.css
│ │ │ │ │ │ └── simplescrollbars.js
│ │ │ │ │ ├── search/
│ │ │ │ │ │ ├── match-highlighter.js
│ │ │ │ │ │ ├── matchesonscrollbar.css
│ │ │ │ │ │ ├── matchesonscrollbar.js
│ │ │ │ │ │ ├── search.js
│ │ │ │ │ │ └── searchcursor.js
│ │ │ │ │ ├── selection/
│ │ │ │ │ │ ├── active-line.js
│ │ │ │ │ │ ├── mark-selection.js
│ │ │ │ │ │ └── selection-pointer.js
│ │ │ │ │ ├── tern/
│ │ │ │ │ │ ├── tern.css
│ │ │ │ │ │ ├── tern.js
│ │ │ │ │ │ └── worker.js
│ │ │ │ │ └── wrap/
│ │ │ │ │ └── hardwrap.js
│ │ │ │ ├── bower.json
│ │ │ │ ├── lib/
│ │ │ │ │ ├── codemirror.css
│ │ │ │ │ └── codemirror.js
│ │ │ │ ├── mode/
│ │ │ │ │ ├── apl/
│ │ │ │ │ │ ├── apl.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── asterisk/
│ │ │ │ │ │ ├── asterisk.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── clike/
│ │ │ │ │ │ ├── clike.js
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── scala.html
│ │ │ │ │ ├── clojure/
│ │ │ │ │ │ ├── clojure.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── cobol/
│ │ │ │ │ │ ├── cobol.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── coffeescript/
│ │ │ │ │ │ ├── coffeescript.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── commonlisp/
│ │ │ │ │ │ ├── commonlisp.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── css/
│ │ │ │ │ │ ├── css.js
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ ├── less.html
│ │ │ │ │ │ ├── less_test.js
│ │ │ │ │ │ ├── scss.html
│ │ │ │ │ │ ├── scss_test.js
│ │ │ │ │ │ └── test.js
│ │ │ │ │ ├── cypher/
│ │ │ │ │ │ ├── cypher.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── d/
│ │ │ │ │ │ ├── d.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── dart/
│ │ │ │ │ │ ├── dart.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── diff/
│ │ │ │ │ │ ├── diff.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── django/
│ │ │ │ │ │ ├── django.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── dockerfile/
│ │ │ │ │ │ ├── dockerfile.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── dtd/
│ │ │ │ │ │ ├── dtd.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── dylan/
│ │ │ │ │ │ ├── dylan.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── ebnf/
│ │ │ │ │ │ ├── ebnf.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── ecl/
│ │ │ │ │ │ ├── ecl.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── eiffel/
│ │ │ │ │ │ ├── eiffel.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── erlang/
│ │ │ │ │ │ ├── erlang.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── forth/
│ │ │ │ │ │ ├── forth.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── fortran/
│ │ │ │ │ │ ├── fortran.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── gas/
│ │ │ │ │ │ ├── gas.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── gfm/
│ │ │ │ │ │ ├── gfm.js
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── test.js
│ │ │ │ │ ├── gherkin/
│ │ │ │ │ │ ├── gherkin.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── go/
│ │ │ │ │ │ ├── go.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── groovy/
│ │ │ │ │ │ ├── groovy.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── haml/
│ │ │ │ │ │ ├── haml.js
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── test.js
│ │ │ │ │ ├── haskell/
│ │ │ │ │ │ ├── haskell.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── haxe/
│ │ │ │ │ │ ├── haxe.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── htmlembedded/
│ │ │ │ │ │ ├── htmlembedded.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── htmlmixed/
│ │ │ │ │ │ ├── htmlmixed.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── http/
│ │ │ │ │ │ ├── http.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── idl/
│ │ │ │ │ │ ├── idl.js
│ │ │ │ │ │ └── index.html
│ │ │ │ │ ├── index.html
│ │ │ │ │ ├── jade/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── jade.js
│ │ │ │ │ ├── javascript/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ ├── javascript.js
│ │ │ │ │ │ ├── json-ld.html
│ │ │ │ │ │ ├── test.js
│ │ │ │ │ │ └── typescript.html
│ │ │ │ │ ├── jinja2/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── jinja2.js
│ │ │ │ │ ├── julia/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── julia.js
│ │ │ │ │ ├── kotlin/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── kotlin.js
│ │ │ │ │ ├── livescript/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── livescript.js
│ │ │ │ │ ├── lua/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── lua.js
│ │ │ │ │ ├── markdown/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ ├── markdown.js
│ │ │ │ │ │ └── test.js
│ │ │ │ │ ├── meta.js
│ │ │ │ │ ├── mirc/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── mirc.js
│ │ │ │ │ ├── mllike/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── mllike.js
│ │ │ │ │ ├── modelica/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── modelica.js
│ │ │ │ │ ├── nginx/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── nginx.js
│ │ │ │ │ ├── ntriples/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── ntriples.js
│ │ │ │ │ ├── octave/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── octave.js
│ │ │ │ │ ├── pascal/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── pascal.js
│ │ │ │ │ ├── pegjs/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── pegjs.js
│ │ │ │ │ ├── perl/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── perl.js
│ │ │ │ │ ├── php/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ ├── php.js
│ │ │ │ │ │ └── test.js
│ │ │ │ │ ├── pig/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── pig.js
│ │ │ │ │ ├── properties/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── properties.js
│ │ │ │ │ ├── puppet/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── puppet.js
│ │ │ │ │ ├── python/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── python.js
│ │ │ │ │ ├── q/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── q.js
│ │ │ │ │ ├── r/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── r.js
│ │ │ │ │ ├── rpm/
│ │ │ │ │ │ ├── changes/
│ │ │ │ │ │ │ └── index.html
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── rpm.js
│ │ │ │ │ ├── rst/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── rst.js
│ │ │ │ │ ├── ruby/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ ├── ruby.js
│ │ │ │ │ │ └── test.js
│ │ │ │ │ ├── rust/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── rust.js
│ │ │ │ │ ├── sass/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── sass.js
│ │ │ │ │ ├── scheme/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── scheme.js
│ │ │ │ │ ├── shell/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ ├── shell.js
│ │ │ │ │ │ └── test.js
│ │ │ │ │ ├── sieve/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── sieve.js
│ │ │ │ │ ├── slim/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ ├── slim.js
│ │ │ │ │ │ └── test.js
│ │ │ │ │ ├── smalltalk/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── smalltalk.js
│ │ │ │ │ ├── smarty/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── smarty.js
│ │ │ │ │ ├── smartymixed/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── smartymixed.js
│ │ │ │ │ ├── solr/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── solr.js
│ │ │ │ │ ├── soy/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── soy.js
│ │ │ │ │ ├── sparql/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── sparql.js
│ │ │ │ │ ├── spreadsheet/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── spreadsheet.js
│ │ │ │ │ ├── sql/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── sql.js
│ │ │ │ │ ├── stex/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ ├── stex.js
│ │ │ │ │ │ └── test.js
│ │ │ │ │ ├── stylus/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── stylus.js
│ │ │ │ │ ├── tcl/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── tcl.js
│ │ │ │ │ ├── textile/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ ├── test.js
│ │ │ │ │ │ └── textile.js
│ │ │ │ │ ├── tiddlywiki/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ ├── tiddlywiki.css
│ │ │ │ │ │ └── tiddlywiki.js
│ │ │ │ │ ├── tiki/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ ├── tiki.css
│ │ │ │ │ │ └── tiki.js
│ │ │ │ │ ├── toml/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── toml.js
│ │ │ │ │ ├── tornado/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── tornado.js
│ │ │ │ │ ├── turtle/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── turtle.js
│ │ │ │ │ ├── vb/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── vb.js
│ │ │ │ │ ├── vbscript/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── vbscript.js
│ │ │ │ │ ├── velocity/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── velocity.js
│ │ │ │ │ ├── verilog/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ ├── test.js
│ │ │ │ │ │ └── verilog.js
│ │ │ │ │ ├── xml/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ ├── test.js
│ │ │ │ │ │ └── xml.js
│ │ │ │ │ ├── xquery/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ ├── test.js
│ │ │ │ │ │ └── xquery.js
│ │ │ │ │ ├── yaml/
│ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ └── yaml.js
│ │ │ │ │ └── z80/
│ │ │ │ │ ├── index.html
│ │ │ │ │ └── z80.js
│ │ │ │ ├── package.json
│ │ │ │ └── theme/
│ │ │ │ ├── 3024-day.css
│ │ │ │ ├── 3024-night.css
│ │ │ │ ├── ambiance-mobile.css
│ │ │ │ ├── ambiance.css
│ │ │ │ ├── base16-dark.css
│ │ │ │ ├── base16-light.css
│ │ │ │ ├── blackboard.css
│ │ │ │ ├── cobalt.css
│ │ │ │ ├── colorforth.css
│ │ │ │ ├── eclipse.css
│ │ │ │ ├── elegant.css
│ │ │ │ ├── erlang-dark.css
│ │ │ │ ├── lesser-dark.css
│ │ │ │ ├── mbo.css
│ │ │ │ ├── mdn-like.css
│ │ │ │ ├── midnight.css
│ │ │ │ ├── monokai.css
│ │ │ │ ├── neat.css
│ │ │ │ ├── neo.css
│ │ │ │ ├── night.css
│ │ │ │ ├── paraiso-dark.css
│ │ │ │ ├── paraiso-light.css
│ │ │ │ ├── pastel-on-dark.css
│ │ │ │ ├── rubyblue.css
│ │ │ │ ├── solarized.css
│ │ │ │ ├── the-matrix.css
│ │ │ │ ├── tomorrow-night-bright.css
│ │ │ │ ├── tomorrow-night-eighties.css
│ │ │ │ ├── twilight.css
│ │ │ │ ├── vibrant-ink.css
│ │ │ │ ├── xq-dark.css
│ │ │ │ ├── xq-light.css
│ │ │ │ └── zenburn.css
│ │ │ ├── highlight/
│ │ │ │ ├── highlight.js
│ │ │ │ ├── languages/
│ │ │ │ │ ├── 1c.js
│ │ │ │ │ ├── abnf.js
│ │ │ │ │ ├── accesslog.js
│ │ │ │ │ ├── actionscript.js
│ │ │ │ │ ├── ada.js
│ │ │ │ │ ├── apache.js
│ │ │ │ │ ├── applescript.js
│ │ │ │ │ ├── arduino.js
│ │ │ │ │ ├── armasm.js
│ │ │ │ │ ├── asciidoc.js
│ │ │ │ │ ├── aspectj.js
│ │ │ │ │ ├── autohotkey.js
│ │ │ │ │ ├── autoit.js
│ │ │ │ │ ├── avrasm.js
│ │ │ │ │ ├── awk.js
│ │ │ │ │ ├── axapta.js
│ │ │ │ │ ├── bash.js
│ │ │ │ │ ├── basic.js
│ │ │ │ │ ├── bnf.js
│ │ │ │ │ ├── brainfuck.js
│ │ │ │ │ ├── cal.js
│ │ │ │ │ ├── capnproto.js
│ │ │ │ │ ├── ceylon.js
│ │ │ │ │ ├── clean.js
│ │ │ │ │ ├── clojure-repl.js
│ │ │ │ │ ├── clojure.js
│ │ │ │ │ ├── cmake.js
│ │ │ │ │ ├── coffeescript.js
│ │ │ │ │ ├── coq.js
│ │ │ │ │ ├── cos.js
│ │ │ │ │ ├── cpp.js
│ │ │ │ │ ├── crmsh.js
│ │ │ │ │ ├── crystal.js
│ │ │ │ │ ├── cs.js
│ │ │ │ │ ├── csp.js
│ │ │ │ │ ├── css.js
│ │ │ │ │ ├── d.js
│ │ │ │ │ ├── dart.js
│ │ │ │ │ ├── delphi.js
│ │ │ │ │ ├── diff.js
│ │ │ │ │ ├── django.js
│ │ │ │ │ ├── dns.js
│ │ │ │ │ ├── dockerfile.js
│ │ │ │ │ ├── dos.js
│ │ │ │ │ ├── dsconfig.js
│ │ │ │ │ ├── dts.js
│ │ │ │ │ ├── dust.js
│ │ │ │ │ ├── ebnf.js
│ │ │ │ │ ├── elixir.js
│ │ │ │ │ ├── elm.js
│ │ │ │ │ ├── erb.js
│ │ │ │ │ ├── erlang-repl.js
│ │ │ │ │ ├── erlang.js
│ │ │ │ │ ├── excel.js
│ │ │ │ │ ├── fix.js
│ │ │ │ │ ├── flix.js
│ │ │ │ │ ├── fortran.js
│ │ │ │ │ ├── fsharp.js
│ │ │ │ │ ├── gams.js
│ │ │ │ │ ├── gauss.js
│ │ │ │ │ ├── gcode.js
│ │ │ │ │ ├── gherkin.js
│ │ │ │ │ ├── glsl.js
│ │ │ │ │ ├── go.js
│ │ │ │ │ ├── golo.js
│ │ │ │ │ ├── gradle.js
│ │ │ │ │ ├── groovy.js
│ │ │ │ │ ├── haml.js
│ │ │ │ │ ├── handlebars.js
│ │ │ │ │ ├── haskell.js
│ │ │ │ │ ├── haxe.js
│ │ │ │ │ ├── hsp.js
│ │ │ │ │ ├── htmlbars.js
│ │ │ │ │ ├── http.js
│ │ │ │ │ ├── inform7.js
│ │ │ │ │ ├── ini.js
│ │ │ │ │ ├── irpf90.js
│ │ │ │ │ ├── java.js
│ │ │ │ │ ├── javascript.js
│ │ │ │ │ ├── json.js
│ │ │ │ │ ├── julia.js
│ │ │ │ │ ├── kotlin.js
│ │ │ │ │ ├── lasso.js
│ │ │ │ │ ├── ldif.js
│ │ │ │ │ ├── less.js
│ │ │ │ │ ├── lisp.js
│ │ │ │ │ ├── livecodeserver.js
│ │ │ │ │ ├── livescript.js
│ │ │ │ │ ├── lsl.js
│ │ │ │ │ ├── lua.js
│ │ │ │ │ ├── makefile.js
│ │ │ │ │ ├── markdown.js
│ │ │ │ │ ├── mathematica.js
│ │ │ │ │ ├── matlab.js
│ │ │ │ │ ├── maxima.js
│ │ │ │ │ ├── mel.js
│ │ │ │ │ ├── mercury.js
│ │ │ │ │ ├── mipsasm.js
│ │ │ │ │ ├── mizar.js
│ │ │ │ │ ├── mojolicious.js
│ │ │ │ │ ├── monkey.js
│ │ │ │ │ ├── moonscript.js
│ │ │ │ │ ├── nginx.js
│ │ │ │ │ ├── nimrod.js
│ │ │ │ │ ├── nix.js
│ │ │ │ │ ├── nsis.js
│ │ │ │ │ ├── objectivec.js
│ │ │ │ │ ├── ocaml.js
│ │ │ │ │ ├── openscad.js
│ │ │ │ │ ├── oxygene.js
│ │ │ │ │ ├── parser3.js
│ │ │ │ │ ├── perl.js
│ │ │ │ │ ├── pf.js
│ │ │ │ │ ├── php.js
│ │ │ │ │ ├── pony.js
│ │ │ │ │ ├── powershell.js
│ │ │ │ │ ├── processing.js
│ │ │ │ │ ├── profile.js
│ │ │ │ │ ├── prolog.js
│ │ │ │ │ ├── protobuf.js
│ │ │ │ │ ├── puppet.js
│ │ │ │ │ ├── purebasic.js
│ │ │ │ │ ├── python.js
│ │ │ │ │ ├── q.js
│ │ │ │ │ ├── qml.js
│ │ │ │ │ ├── r.js
│ │ │ │ │ ├── rib.js
│ │ │ │ │ ├── roboconf.js
│ │ │ │ │ ├── rsl.js
│ │ │ │ │ ├── ruby.js
│ │ │ │ │ ├── ruleslanguage.js
│ │ │ │ │ ├── rust.js
│ │ │ │ │ ├── scala.js
│ │ │ │ │ ├── scheme.js
│ │ │ │ │ ├── scilab.js
│ │ │ │ │ ├── scss.js
│ │ │ │ │ ├── smali.js
│ │ │ │ │ ├── smalltalk.js
│ │ │ │ │ ├── sml.js
│ │ │ │ │ ├── sqf.js
│ │ │ │ │ ├── sql.js
│ │ │ │ │ ├── stan.js
│ │ │ │ │ ├── stata.js
│ │ │ │ │ ├── step21.js
│ │ │ │ │ ├── stylus.js
│ │ │ │ │ ├── subunit.js
│ │ │ │ │ ├── swift.js
│ │ │ │ │ ├── taggerscript.js
│ │ │ │ │ ├── tap.js
│ │ │ │ │ ├── tcl.js
│ │ │ │ │ ├── tex.js
│ │ │ │ │ ├── thrift.js
│ │ │ │ │ ├── tp.js
│ │ │ │ │ ├── twig.js
│ │ │ │ │ ├── typescript.js
│ │ │ │ │ ├── vala.js
│ │ │ │ │ ├── vbnet.js
│ │ │ │ │ ├── vbscript-html.js
│ │ │ │ │ ├── vbscript.js
│ │ │ │ │ ├── verilog.js
│ │ │ │ │ ├── vhdl.js
│ │ │ │ │ ├── vim.js
│ │ │ │ │ ├── x86asm.js
│ │ │ │ │ ├── xl.js
│ │ │ │ │ ├── xml.js
│ │ │ │ │ ├── xquery.js
│ │ │ │ │ ├── yaml.js
│ │ │ │ │ └── zephir.js
│ │ │ │ └── styles/
│ │ │ │ ├── agate.css
│ │ │ │ ├── androidstudio.css
│ │ │ │ ├── arduino-light.css
│ │ │ │ ├── arta.css
│ │ │ │ ├── ascetic.css
│ │ │ │ ├── atelier-cave-dark.css
│ │ │ │ ├── atelier-cave-light.css
│ │ │ │ ├── atelier-dune-dark.css
│ │ │ │ ├── atelier-dune-light.css
│ │ │ │ ├── atelier-estuary-dark.css
│ │ │ │ ├── atelier-estuary-light.css
│ │ │ │ ├── atelier-forest-dark.css
│ │ │ │ ├── atelier-forest-light.css
│ │ │ │ ├── atelier-heath-dark.css
│ │ │ │ ├── atelier-heath-light.css
│ │ │ │ ├── atelier-lakeside-dark.css
│ │ │ │ ├── atelier-lakeside-light.css
│ │ │ │ ├── atelier-plateau-dark.css
│ │ │ │ ├── atelier-plateau-light.css
│ │ │ │ ├── atelier-savanna-dark.css
│ │ │ │ ├── atelier-savanna-light.css
│ │ │ │ ├── atelier-seaside-dark.css
│ │ │ │ ├── atelier-seaside-light.css
│ │ │ │ ├── atelier-sulphurpool-dark.css
│ │ │ │ ├── atelier-sulphurpool-light.css
│ │ │ │ ├── atom-one-dark.css
│ │ │ │ ├── atom-one-light.css
│ │ │ │ ├── brown-paper.css
│ │ │ │ ├── codepen-embed.css
│ │ │ │ ├── color-brewer.css
│ │ │ │ ├── darcula.css
│ │ │ │ ├── dark.css
│ │ │ │ ├── darkula.css
│ │ │ │ ├── default.css
│ │ │ │ ├── docco.css
│ │ │ │ ├── dracula.css
│ │ │ │ ├── far.css
│ │ │ │ ├── foundation.css
│ │ │ │ ├── github-gist.css
│ │ │ │ ├── github.css
│ │ │ │ ├── googlecode.css
│ │ │ │ ├── grayscale.css
│ │ │ │ ├── gruvbox-dark.css
│ │ │ │ ├── gruvbox-light.css
│ │ │ │ ├── hopscotch.css
│ │ │ │ ├── hybrid.css
│ │ │ │ ├── idea.css
│ │ │ │ ├── ir-black.css
│ │ │ │ ├── kimbie.dark.css
│ │ │ │ ├── kimbie.light.css
│ │ │ │ ├── magula.css
│ │ │ │ ├── mono-blue.css
│ │ │ │ ├── monokai-sublime.css
│ │ │ │ ├── monokai.css
│ │ │ │ ├── obsidian.css
│ │ │ │ ├── ocean.css
│ │ │ │ ├── paraiso-dark.css
│ │ │ │ ├── paraiso-light.css
│ │ │ │ ├── pojoaque.css
│ │ │ │ ├── purebasic.css
│ │ │ │ ├── qtcreator_dark.css
│ │ │ │ ├── qtcreator_light.css
│ │ │ │ ├── railscasts.css
│ │ │ │ ├── rainbow.css
│ │ │ │ ├── school-book.css
│ │ │ │ ├── solarized-dark.css
│ │ │ │ ├── solarized-light.css
│ │ │ │ ├── sunburst.css
│ │ │ │ ├── tomorrow-night-blue.css
│ │ │ │ ├── tomorrow-night-bright.css
│ │ │ │ ├── tomorrow-night-eighties.css
│ │ │ │ ├── tomorrow-night.css
│ │ │ │ ├── tomorrow.css
│ │ │ │ ├── vs.css
│ │ │ │ ├── xcode.css
│ │ │ │ ├── xt256.css
│ │ │ │ └── zenburn.css
│ │ │ ├── marked.js
│ │ │ ├── mermaid/
│ │ │ │ └── mermaid.js
│ │ │ ├── mindmap/
│ │ │ │ ├── d3@5.js
│ │ │ │ ├── transform.js
│ │ │ │ └── view.js
│ │ │ └── sequence/
│ │ │ ├── sequence-diagram-min.css
│ │ │ ├── sequence-diagram-min.js
│ │ │ ├── sequence-diagram-raphael-min.js
│ │ │ ├── sequence-diagram-raphael.js
│ │ │ ├── sequence-diagram-snap-min.js
│ │ │ ├── snap.svg-min.js
│ │ │ ├── underscore-min.js
│ │ │ └── webfont.js
│ │ └── plugins/
│ │ ├── code-block-dialog/
│ │ │ └── code-block-dialog.js
│ │ ├── emoji-dialog/
│ │ │ ├── emoji-dialog.js
│ │ │ └── emoji.json
│ │ ├── file-dialog/
│ │ │ └── file-dialog.js
│ │ ├── goto-line-dialog/
│ │ │ └── goto-line-dialog.js
│ │ ├── help-dialog/
│ │ │ ├── help-dialog.js
│ │ │ └── help.md
│ │ ├── html-entities-dialog/
│ │ │ ├── html-entities-dialog.js
│ │ │ └── html-entities.json
│ │ ├── image-dialog/
│ │ │ └── image-dialog.js
│ │ ├── link-dialog/
│ │ │ └── link-dialog.js
│ │ ├── plugin-template.js
│ │ ├── preformatted-text-dialog/
│ │ │ └── preformatted-text-dialog.js
│ │ ├── reference-link-dialog/
│ │ │ └── reference-link-dialog.js
│ │ ├── table-dialog/
│ │ │ └── table-dialog.js
│ │ └── test-plugin/
│ │ └── test-plugin.js
│ ├── font-awesome/
│ │ ├── css/
│ │ │ └── font-awesome.css
│ │ └── fonts/
│ │ └── FontAwesome.otf
│ ├── fonts/
│ │ ├── lato-100.css
│ │ └── notosans.css
│ ├── jquery/
│ │ └── 1.12.4/
│ │ └── jquery.js
│ ├── js/
│ │ ├── array.js
│ │ ├── blog.js
│ │ ├── cherry_markdown.js
│ │ ├── class2browser.js
│ │ ├── dingtalk-ddlogin.js
│ │ ├── dingtalk-jsapi.js
│ │ ├── editor.js
│ │ ├── froala-editor.js
│ │ ├── html-editor.js
│ │ ├── html-to-markdown.js
│ │ ├── jquery.form.js
│ │ ├── jquery.highlight.js
│ │ ├── kancloud.js
│ │ ├── main.js
│ │ ├── markdown.js
│ │ ├── quill.js
│ │ ├── sort.js
│ │ ├── splitbar.js
│ │ ├── wangEditor-plugins/
│ │ │ ├── attach-menu.js
│ │ │ ├── history-menu.js
│ │ │ ├── release-menu.js
│ │ │ └── save-menu.js
│ │ ├── word-to-html.js
│ │ └── x-frame-bypass-1.0.2.js
│ ├── jstree/
│ │ └── 3.3.4/
│ │ ├── jstree.js
│ │ └── themes/
│ │ ├── default/
│ │ │ └── style.css
│ │ └── default-dark/
│ │ └── style.css
│ ├── katex/
│ │ ├── README.md
│ │ ├── katex.css
│ │ └── katex.js
│ ├── layer/
│ │ ├── layer.js
│ │ ├── mobile/
│ │ │ ├── layer.js
│ │ │ └── need/
│ │ │ └── layer.css
│ │ └── skin/
│ │ └── default/
│ │ └── layer.css
│ ├── mammoth/
│ │ └── mammoth.browser.js
│ ├── mergely/
│ │ ├── editor/
│ │ │ ├── editor.css
│ │ │ ├── editor.js
│ │ │ ├── editor.php
│ │ │ └── lib/
│ │ │ ├── farbtastic/
│ │ │ │ ├── LICENSE.txt
│ │ │ │ ├── farbtastic.css
│ │ │ │ ├── farbtastic.js
│ │ │ │ └── index.html
│ │ │ ├── gatag.js
│ │ │ ├── tipsy/
│ │ │ │ ├── jquery.tipsy.js
│ │ │ │ └── tipsy.css
│ │ │ ├── wicked-ui.css
│ │ │ └── wicked-ui.js
│ │ └── lib/
│ │ ├── codemirror.css
│ │ ├── codemirror.js
│ │ ├── mergely.css
│ │ ├── mergely.js
│ │ └── searchcursor.js
│ ├── nprogress/
│ │ ├── nprogress.css
│ │ └── nprogress.js
│ ├── prettify/
│ │ └── themes/
│ │ └── prettify.css
│ ├── prismjs/
│ │ ├── prismjs.css
│ │ └── prismjs.js
│ ├── quill/
│ │ ├── quill.bubble.css
│ │ ├── quill.core.css
│ │ ├── quill.core.js
│ │ ├── quill.icons.js
│ │ ├── quill.js
│ │ └── quill.snow.css
│ ├── table-editor/
│ │ ├── .gitignore
│ │ ├── dist/
│ │ │ └── index.js
│ │ ├── package.json
│ │ ├── src/
│ │ │ └── main.js
│ │ └── webpack.config.js
│ ├── to-markdown/
│ │ ├── dist/
│ │ │ └── to-markdown.js
│ │ └── lib/
│ │ ├── gfm-converters.js
│ │ ├── html-parser.js
│ │ └── md-converters.js
│ ├── turndown/
│ │ └── turndown.js
│ ├── vuejs/
│ │ ├── vue.common.js
│ │ ├── vue.esm.js
│ │ ├── vue.js
│ │ ├── vue.runtime.common.js
│ │ ├── vue.runtime.esm.js
│ │ └── vue.runtime.js
│ ├── wangEditor/
│ │ ├── wangEditor.d.ts
│ │ └── wangEditor.js
│ └── webuploader/
│ ├── README.md
│ ├── Uploader.swf
│ ├── webuploader.css
│ ├── webuploader.custom.js
│ ├── webuploader.fis.js
│ ├── webuploader.flashonly.js
│ ├── webuploader.html5nodepend.js
│ ├── webuploader.html5only.js
│ ├── webuploader.js
│ ├── webuploader.noimage.js
│ ├── webuploader.nolog.js
│ └── webuploader.withoutimage.js
├── uploads/
│ └── .gitkeep
├── utils/
│ ├── auth2/
│ │ ├── auth2.go
│ │ ├── dingtalk/
│ │ │ └── dingtalk.go
│ │ └── wecom/
│ │ └── wecom.go
│ ├── cryptil/
│ │ └── cryptil.go
│ ├── dingtalk/
│ │ └── dingtalk.go
│ ├── docx2md.go
│ ├── filetil/
│ │ └── filetil.go
│ ├── gob.go
│ ├── gopool/
│ │ └── gopool.go
│ ├── html.go
│ ├── krand.go
│ ├── ldap.go
│ ├── pagination/
│ │ └── pagination.go
│ ├── password.go
│ ├── requests/
│ │ └── requests.go
│ ├── segmenter/
│ │ └── segmenter.go
│ ├── sqltil/
│ │ └── sql.go
│ ├── template_fun.go
│ ├── url.go
│ ├── wkhtmltopdf/
│ │ ├── options.go
│ │ └── wkhtmltopdf.go
│ ├── workweixin/
│ │ └── workweixin.go
│ └── ziptil/
│ └── ziptil.go
└── views/
├── account/
│ ├── auth2_callback.tpl
│ ├── find_password_setp1.tpl
│ ├── find_password_setp2.tpl
│ ├── login.tpl
│ ├── mail_template.tpl
│ └── register.tpl
├── blog/
│ ├── index.tpl
│ ├── index_password.tpl
│ ├── list.tpl
│ ├── manage_edit.tpl
│ ├── manage_list.tpl
│ └── manage_setting.tpl
├── book/
│ ├── dashboard.tpl
│ ├── index.tpl
│ ├── setting.tpl
│ ├── team.tpl
│ └── users.tpl
├── comment/
│ └── index.tpl
├── document/
│ ├── cherry_markdown_edit_template.tpl
│ ├── cherry_read.tpl
│ ├── compare.tpl
│ ├── default_read.tpl
│ ├── document_password.tpl
│ ├── export.tpl
│ ├── froala_edit_template.tpl
│ ├── history.tpl
│ ├── html_edit_template.tpl
│ ├── index.tpl
│ ├── kancloud_read_template.tpl
│ ├── markdown_edit_template.tpl
│ ├── new_html_edit_template.tpl
│ ├── template_api-en.tpl
│ ├── template_api.tpl
│ ├── template_code-en.tpl
│ ├── template_code.tpl
│ ├── template_normal-en.tpl
│ └── template_normal.tpl
├── errors/
│ ├── 403.tpl
│ ├── 404.tpl
│ └── error.tpl
├── home/
│ └── index.tpl
├── items/
│ ├── index.tpl
│ └── list.tpl
├── label/
│ ├── index.tpl
│ └── list.tpl
├── manager/
│ ├── attach_detailed.tpl
│ ├── attach_list.tpl
│ ├── books.tpl
│ ├── comments.tpl
│ ├── config.tpl
│ ├── edit_book.tpl
│ ├── edit_users.tpl
│ ├── index.tpl
│ ├── itemsets.tpl
│ ├── label_list.tpl
│ ├── setting.tpl
│ ├── team.tpl
│ ├── team_book_list.tpl
│ ├── team_member_list.tpl
│ ├── users.tpl
│ └── widgets.tpl
├── search/
│ └── index.tpl
├── setting/
│ ├── index.tpl
│ └── password.tpl
├── template/
│ └── list.tpl
├── template.tpl
└── widgets/
├── footer.tpl
├── header.tpl
└── ie.tpl
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitattributes
================================================
*.js linguist-language=Go
*.css linguist-language=Go
================================================
FILE: .github/ISSUE_TEMPLATE
================================================
请按照一下格式提交issue,谢谢!
1. 你当前使用的是哪个版本的 MinDoc(`godoc_linux_amd64 version`)?
2. 你当前使用的是什么操作系统?
3. 你是如何操作的?
4. 你期望得到什么结果?
5. 当前遇到的是什么结果?
================================================
FILE: .github/workflows/build.yml
================================================
name: Go
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
jobs:
build:
name: ${{ matrix.config.name }}
runs-on: ${{ matrix.config.os }}
outputs:
tag: ${{ steps.git.outputs.tag }}
strategy:
fail-fast: false
matrix:
config:
- {
name: "Windows Latest MSVC",
artifact: "windows",
os: windows-latest
}
- {
name: "Ubuntu Latest GCC",
artifact: "linux",
os: ubuntu-latest
}
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: 1.24.0
- name: Build
run: |
go mod tidy
go build -v
# - name: Test
# run: go test -v ./...
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
path: |
conf/**/*
static/**/*
views/**/*
mindoc.*
name: mindoc-${{ matrix.config.artifact }}-${{ steps.git.outputs.tag }}.7z
================================================
FILE: .gitignore
================================================
# Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
*.a
*.so
.DS_Store
# Folders
_obj
_test
# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*
_testmain.go
*.exe
*.exe~
mindoc
mindoc_linux_amd64
mindoc_linux_musl_amd64
database/mindoc.db
*.test
*.prof
.idea
.vscode
/conf/app.conf
/vendor
/runtime
/uploads/*.*
!/uploads/.gitkeep
calibre-*.txz
================================================
FILE: .travis.yml
================================================
os: linux
dist: focal
language: go
go:
- "1.18.1"
arch:
- amd64
env:
- GO111MODULE=on CGO_ENABLED=1
install:
- go mod tidy -v
before_install:
- whereis gcc
- go env
script:
- go build -o mindoc_linux_amd64 -ldflags "-w"
- cp conf/app.conf.example conf/app.conf
- ./mindoc_linux_amd64 version
- rm conf/app.conf
before_deploy:
- go mod tidy -v && GOARCH=amd64 GOOS=linux go build -v -o mindoc_linux_amd64 -ldflags="-w -X 'github.com/mindoc-org/mindoc/conf.VERSION=$TRAVIS_TAG' -X 'github.com/mindoc-org/mindoc/conf.BUILD_TIME=`date`' -X 'conf.GO_VERSION=`go version`'"
# remove files
- rm appveyor.yml docker-compose.yml Dockerfile .travis.yml .gitattributes .gitignore go.mod go.sum main.go README.md simsun.ttc start.sh sync_host.sh build_amd64.sh build_musl_amd64.sh
# remove dirs
- rm -rf cache commands controllers converter .git .github graphics mail models routers utils runtime
- ls -alh
- cp conf/app.conf.example conf/app.conf
- zip -r mindoc_linux_amd64.zip conf static uploads views lib mindoc_linux_amd64 LICENSE.md
deploy:
provider: releases
token: $CI_USER_TOKEN
cleanup: true
overwrite: true
file:
- mindoc_linux_amd64.zip
on:
tags: true
branch: master
================================================
FILE: Dockerfile
================================================
FROM golang:bookworm AS build
ARG TAG=0.0.1
# 编译-环境变量
ENV GO111MODULE=on
ENV GOPROXY=https://goproxy.cn,direct
ENV CGO_ENABLED=1
ENV GOARCH=amd64
ENV GOOS=linux
# 工作目录
ADD . /go/src/github.com/mindoc-org/mindoc
WORKDIR /go/src/github.com/mindoc-org/mindoc
# 编译
RUN go env
RUN go mod tidy -v
RUN go build -v -o mindoc_linux_amd64 -ldflags "-w -s -X 'main.VERSION=$TAG' -X 'main.BUILD_TIME=`date`' -X 'main.GO_VERSION=`go version`'"
RUN cp conf/app.conf.example conf/app.conf
# 清理不需要的文件
RUN rm appveyor.yml docker-compose.yml Dockerfile .travis.yml .gitattributes .gitignore go.mod go.sum main.go README.md simsun.ttc start.sh conf/*.go
RUN rm -rf cache commands controllers converter .git .github graphics mail models routers utils
# 测试编译的mindoc是否ok
RUN ./mindoc_linux_amd64 version
# 必要的文件复制
ADD simsun.ttc /usr/share/fonts/win/
ADD start.sh /go/src/github.com/mindoc-org/mindoc
# upgrade to the latest
FROM ubuntu:latest
# 切换默认shell为bash
SHELL ["/bin/bash", "-c"]
WORKDIR /mindoc
# 文件复制
COPY --from=build /usr/share/fonts/win/simsun.ttc /usr/share/fonts/win/
COPY --from=build /go/src/github.com/mindoc-org/mindoc/mindoc_linux_amd64 /mindoc/
COPY --from=build /go/src/github.com/mindoc-org/mindoc/start.sh /mindoc/
COPY --from=build /go/src/github.com/mindoc-org/mindoc/LICENSE.md /mindoc/
# 文件夹复制
COPY --from=build /go/src/github.com/mindoc-org/mindoc/lib /mindoc/lib
COPY --from=build /go/src/github.com/mindoc-org/mindoc/conf /mindoc/__default_assets__/conf
COPY --from=build /go/src/github.com/mindoc-org/mindoc/static /mindoc/__default_assets__/static
COPY --from=build /go/src/github.com/mindoc-org/mindoc/views /mindoc/__default_assets__/views
COPY --from=build /go/src/github.com/mindoc-org/mindoc/uploads /mindoc/__default_assets__/uploads
RUN chmod a+r /usr/share/fonts/win/simsun.ttc
RUN sed -i "s/archive.ubuntu.com/mirrors.aliyun.com/g" /etc/apt/sources.list /etc/apt/sources.list.d/*
# 更新软件包信息
RUN apt-get update
# 时区设置(如果不设置, calibre依赖的tzdata在安装过程中会要求选择时区)
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# tzdata的前端类型默认为readline(Shell情况下)或dialog(支持GUI的情况下)
ARG DEBIAN_FRONTEND=noninteractive
# 安装时区信息
RUN apt install -y --no-install-recommends tzdata
# 重新配置tzdata软件包,使得时区设置生效
RUN dpkg-reconfigure --frontend noninteractive tzdata
# 安装文泉驿字体
# 安装中文语言包
RUN apt install -y fonts-wqy-microhei fonts-wqy-zenhei locales language-pack-zh-hans-base
# 设置默认编码
RUN locale-gen "zh_CN.UTF-8"
RUN update-locale LANG=zh_CN.UTF-8
ENV LANG=zh_CN.UTF-8
ENV LANGUAGE=zh_CN:en
ENV LC_ALL=zh_CN.UTF-8
# 安装必要依赖、下载、解压 calibre 并清理缓存
RUN apt-get install -y --no-install-recommends \
libglx0 libegl1 libnss3 libxcomposite1 libxkbcommon0 libxdamage1 libxrandr-dev libopengl0 libxtst6 libasound2t64 libxkbfile1\
wget xz-utils && \
mkdir -p /tmp/calibre-cache /opt/calibre && \
wget -O /tmp/calibre-cache/calibre-x86_64.txz -c https://download.calibre-ebook.com/7.26.0/calibre-7.26.0-x86_64.txz --no-check-certificate && \
tar xJof /tmp/calibre-cache/calibre-x86_64.txz -C /opt/calibre && \
rm -rf /tmp/calibre-cache && \
apt-get clean && rm -rf /var/lib/apt/lists/*
# 设置环境变量
ENV PATH="/opt/calibre:$PATH" \
QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox" \
QT_QPA_PLATFORM="offscreen"
# 测试 calibre 是否可正常使用
RUN ebook-convert --version
# refer: https://docs.docker.com/engine/reference/builder/#volume
VOLUME ["/mindoc/conf","/mindoc/static","/mindoc/views","/mindoc/uploads","/mindoc/runtime","/mindoc/database"]
# refer: https://docs.docker.com/engine/reference/builder/#expose
EXPOSE 8181/tcp
ENV ZONEINFO=/mindoc/lib/time/zoneinfo.zip
RUN chmod +x /mindoc/start.sh
ENTRYPOINT ["/bin/bash", "/mindoc/start.sh"]
# https://docs.docker.com/engine/reference/commandline/build/#options
# docker build --progress plain --rm --build-arg TAG=2.1 --tag gsw945/mindoc:2.1 .
# https://docs.docker.com/engine/reference/commandline/run/#options
# set MINDOC=//d/mindoc # windows
# export MINDOC=/home/ubuntu/mindoc-docker # linux
# docker run -d --name=mindoc --restart=always -v /www/mindoc/uploads:/mindoc/uploads -v /www/mindoc/database:/mindoc/database -v /www/mindoc/conf:/mindoc/conf -e MINDOC_DB_ADAPTER=sqlite3 -e MINDOC_DB_DATABASE=./database/mindoc.db -e MINDOC_CACHE=true -e MINDOC_CACHE_PROVIDER=file -p 8181:8181 mindoc-org/mindoc:v2.1
================================================
FILE: LICENSE.md
================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================
FILE: README.md
================================================
# MinDoc 简介
[](https://travis-ci.com/mindoc-org/mindoc)
[](https://ci.appveyor.com/project/mindoc-org/mindoc)
MinDoc 是一款针对IT团队开发的简单好用的文档管理系统。
MinDoc 的前身是 [SmartWiki](https://github.com/lifei6671/SmartWiki) 文档系统。SmartWiki 是基于 PHP 框架 laravel 开发的一款文档管理系统。因 PHP 的部署对普通用户来说太复杂,所以改用 Golang 开发。可以方便用户部署和实用。
开发缘起是公司IT部门需要一款简单实用的项目接口文档管理和分享的系统。其功能和界面源于 kancloud 。
可以用来储存日常接口文档,数据库字典,手册说明等文档。内置项目管理,用户管理,权限管理等功能,能够满足大部分中小团队的文档管理需求。
##### 演示站点&文档:
- https://demo.mindoc.cn/docs/mindochelp
- https://www.iminho.me/wiki/docs/mindoc/
- https://doc.gsw945.com/docs/mindoc-docs/
---
### 开发&维护&使用 悉知
- 感谢作者 [lifei6671](https://github.com/lifei6671) 创造了MinDoc,并持续维护了很久。
- 作者因工作等原因,精力有限,无法花费足够的时间来持续维护mindoc,已于北京时间2021年3月23日将mindoc交给社区(github组织[mindoc-org](https://github.com/mindoc-org))维护,期待热心开发者加入[mindoc-org](https://github.com/mindoc-org)一起来维护MinDoc。
- 遇到问题请提 [Issues](https://github.com/mindoc-org/mindoc/issues ),欢迎使用者和贡献者加入QQ群 `1051164153`
- 对开发感兴趣请关注 [Development](https://github.com/mindoc-org/mindoc/projects/1):
- [Todo List](https://github.com/mindoc-org/mindoc/projects/1#column-13554511)
- [Work in progress](https://github.com/mindoc-org/mindoc/projects/1#column-13554512)
- [Review in progress](https://github.com/mindoc-org/mindoc/projects/1#column-13554513)
- Mindoc基于 [beeego](https://github.com/beego/beego) 开发,beego文档地址: https://github.com/beego/beego-doc/tree/main/docs/zh
- :warning: **特别声明**:
- 原作者 [lifei6671](https://github.com/lifei6671) 已于 2021-08-06 删除了个人捐赠信息,参见: [1a179179c1fe4d0d4db95e0b757d863aee5bf395](https://github.com/mindoc-org/mindoc/commit/1a179179c1fe4d0d4db95e0b757d863aee5bf395)
- 截止目前(2023-03-27),[mindoc-org](https://github.com/mindoc-org) 暂未发布任何捐赠信息,请勿轻信
---
# 安装与使用
~~如果你的服务器上没有安装golang程序请手动设置一个环境变量如下:键名为 ZONEINFO,值为MinDoc跟目录下的/lib/time/zoneinfo.zip 。~~
更多信息请查看手册: [MinDoc 使用手册](https://demo.mindoc.cn/docs/mindochelp/mindoc-summary)
对于没有Golang使用经验的用户,可以从 [https://github.com/mindoc-org/mindoc/releases](https://github.com/mindoc-org/mindoc/releases) 这里下载编译完的程序。
如果有Golang开发经验,建议通过编译安装,要求golang版本不小于1.23.0(需支持`CGO`、`go mod`和`import _ "time/tzdata"`)(推荐Go版本为1.23.x)。
> 注意: CentOS7上GLibC版本低,常规编译版本不能使用。需要自行源码编译,或使用使用musl编译版本。
## 常规编译
```bash
# 克隆源码
git clone https://github.com/mindoc-org/mindoc.git
# go包安装
go mod tidy -v
# 编译(sqlite需要CGO支持)
go build -ldflags "-w" -o mindoc main.go
# 数据库初始化(此步骤执行之前,需配置`conf/app.conf`)
./mindoc install
# 执行
./mindoc
# 开发阶段运行
bee run
```
## 旧版本运行 可更新部分数据库配置
```base
./mindoc update
```
MinDoc 如果使用MySQL储存数据,则编码必须是`utf8mb4_general_ci`。请在安装前,把数据库配置填充到项目目录下的 `conf/app.conf` 中。
如果使用 `SQLite` 数据库,则直接在配置文件中配置数据库路径即可.
如果conf目录下不存在 `app.conf` 请重命名 `app.conf.example` 为 `app.conf`。
**默认程序会自动初始化一个超级管理员用户:admin 密码:123456 。请登录后重新设置密码。**
## Linux系统中不依赖gLibC的编译方式
### 安装 musl-gcc
```bash
# 手动安装
wget -c http://musl.libc.org/releases/musl-1.2.2.tar.gz
tar -xvf musl-1.2.2.tar.gz
cd musl-1.2.2
./configure
make
sudo make install
# apt 安装
sudo apt install musl-tools
```
### 使用 musl-gcc 编译 mindoc
```bash
go mod tidy -v
export GOARCH=amd64
export GOOS=linux
# 设置使用musl-gcc
export CC=/usr/local/musl/bin/musl-gcc
# 设置版本
export TRAVIS_TAG=temp-musl-v`date +%y%m%d`
go build -v -o mindoc_linux_musl_amd64 -ldflags="-linkmode external -extldflags '-static' -w -X 'github.com/mindoc-org/mindoc/conf.VERSION=$TRAVIS_TAG' -X 'github.com/mindoc-org/mindoc/conf.BUILD_TIME=`date`' -X 'github.com/mindoc-org/mindoc/conf.GO_VERSION=`go version`'"
# 验证
./mindoc_linux_musl_amd64 version
```
## Windows 上后台运行
使用 [mindoc-daemon](https://github.com/mindoc-org/mindoc-daemon)
```ini
#邮件配置-示例
#是否启用邮件
enable_mail=true
#smtp服务器的账号
smtp_user_name=admin@iminho.me
#smtp服务器的地址
smtp_host=smtp.ym.163.com
#密码
smtp_password=1q2w3e__ABC
#端口号
smtp_port=25
#邮件发送人的地址
form_user_name=admin@iminho.me
#邮件有效期30分钟
mail_expired=30
```
# 使用Docker部署
如果是Docker用户,可参考项目内置的Dockerfile文件自行编译镜像(编译命令见Dockerfile文件底部注释,仅供参考)。
在启动镜像时需要提供如下的常用环境变量(全部支持的环境变量请参考: [`conf/app.conf.example`](https://github.com/mindoc-org/mindoc/blob/master/conf/app.conf.example)):
```ini
DB_ADAPTER 指定DB类型(默认为sqlite)
MYSQL_PORT_3306_TCP_ADDR MySQL地址
MYSQL_PORT_3306_TCP_PORT MySQL端口号
MYSQL_INSTANCE_NAME MySQL数据库名称
MYSQL_USERNAME MySQL账号
MYSQL_PASSWORD MySQL密码
HTTP_PORT 程序监听的端口号
MINDOC_ENABLE_EXPORT 开启导出(默认为false)
```
#### 举个栗子-当前(公开)镜像(信息页面: https://cr.console.aliyun.com/images/cn-hangzhou/mindoc-org/mindoc/detail , 需要登录阿里云账号才可访问列表)
##### Windows
```bash
set MINDOC=//d/mindoc
docker run -it --name=mindoc --restart=always -v "%MINDOC%/conf":"/mindoc/conf" -p 8181:8181 -e MINDOC_ENABLE_EXPORT=true -d registry.cn-hangzhou.aliyuncs.com/mindoc-org/mindoc:v2.2-beta.2
```
##### Linux、Mac
```bash
export MINDOC=/home/ubuntu/mindoc-docker
docker run -it --name=mindoc --restart=always -v "${MINDOC}/conf":"/mindoc/conf" -p 8181:8181 -e MINDOC_ENABLE_EXPORT=true -d registry.cn-hangzhou.aliyuncs.com/mindoc-org/mindoc:v2.2-beta.2
```
##### 举个栗子-更多环境变量示例(镜像已过期,仅供参考,请以当前镜像为准)
```bash
docker run -p 8181:8181 --name mindoc -e DB_ADAPTER=mysql -e MYSQL_PORT_3306_TCP_ADDR=10.xxx.xxx.xxx -e MYSQL_PORT_3306_TCP_PORT=3306 -e MYSQL_INSTANCE_NAME=mindoc -e MYSQL_USERNAME=root -e MYSQL_PASSWORD=123456 -e httpport=8181 -d daocloud.io/lifei6671/mindoc:latest
```
#### dockerfile内容参考
- [无需代理直接加速各种 GitHub 资源拉取 | 国内镜像赋能 | 助力开发](https://blog.frytea.com/archives/504/)
- [阿里云 - Ubuntu 镜像](https://developer.aliyun.com/mirror/ubuntu)
### docker-compose 一键安装
1. 修改配置文件
修改`docker-compose.yml`中的配置信息,主要修改`volumes`节点,将宿主机的两个目录映射到容器内。
`environment`节点,配置自己的环境变量。
2. 一键完成所有环境搭建
> docker-compose up -d
3. 浏览器访问
> http://localhost:8181/
整个部署完成了
4. 常用命令参考
- 启动
> docker-compose up -d
- 停止
> docker-compose stop
- 重启
> docker-compose restart
- 停止删除容器,释放所有资源
> docker-compose down
- 删除并重新创建
> docker-compose -f docker-compose.yml down && docker-compose up -d
>
> 更多 docker-compose 的使用相关的内容 请查看官网文档或百度
#### MCP服务器对接指导
1. 请在配置文件中启用MCP服务器功能
在配置文件`app.conf`中添加或修改为如下内容:
```
# MCP Server 功能
enable_mcp_server="${MINDOC_ENABLE_MCP_SERVER||true}"
mcp_api_key="${MINDOC_MCP_API_KEY||demo-mcp-api-key}"
```
说明:
`enable_mcp_server`为是否启用MCP服务器功能,默认为true。
`mcp_api_key` 为MCP服务器的API密钥,示例配置中默认为`demo-mcp-api-key`,可根据需求自行修改。
2. 在Dify等AI应用或其他可调用MCP服务器的项目配置中添加如下Mindoc配置
```json
{
"mindoc": {
"transport": "streamable_http",
"url": "http://127.0.0.1:8181/mcp/?api_key=demo-mcp-api-key",
"headers":{},
"timeout":600
}
}
```
说明:
`transport`为传输方式,目前支持`streamable_http`。
`url`为Mindoc的MCP服务地址,示例配置中Endpoint默认为`http://127.0.0.1:8181`,默认的API密钥为`demo-mcp-api-key`,可自行修改为对接时项目实际使用的Endpoint和API密钥。
# 项目截图
**创建项目**

**项目列表**

**项目概述**

**项目成员**

**项目设置**

**基于Editor.md开发的Markdown编辑器**

**基于wangEditor开发的富文本编辑器**

**基于cherryMarkdown开发的编辑器**

**项目预览**

**超级管理员后台**

# 使用的技术(TODO: 最新技术栈整理中,使用的第三方库升级中)
- [Beego](https://github.com/beego/beego) ~~1.10.0~~
- MySQL 5.6
- [editor.md](https://github.com/pandao/editor.md) Markdown 编辑器
- [cherry-markdown](https://github.com/Tencent/cherry-markdown) Cherry Markdown Writer
- [Bootstrap](https://github.com/twbs/bootstrap) 3.2
- [jQuery](https://github.com/jquery/jquery) 库
- [WebUploader](https://github.com/fex-team/webuploader) 文件上传框架
- [NProgress](https://github.com/rstacruz/nprogress) 库
- [jsTree](https://github.com/vakata/jstree) 树状结构库
- [Font Awesome](https://github.com/FortAwesome/Font-Awesome) 字体库
- [Cropper](https://github.com/fengyuanchen/cropper) 图片剪裁库
- [layer](https://github.com/sentsin/layer) 弹出层框架
- [highlight.js](https://github.com/highlightjs/highlight.js) 代码高亮库
- ~~to-markdown~~[Turndown](https://github.com/domchristie/turndown) HTML转Markdown库
- ~~quill 富文本编辑器~~
- [wangEditor](https://github.com/wangeditor-team/wangEditor) 富文本编辑器
- 参考
- [wangEditor v4.7 富文本编辑器教程](https://www.bookstack.cn/books/wangeditor-4.7-zh)
- [扩展菜单注册太过繁琐 #2493](https://github.com/wangeditor-team/wangEditor/issues/2493)
- 工具: `https://babeljs.io/repl` + `@babel/plugin-transform-classes`
- [Vue.js](https://github.com/vuejs/vue) 框架
- [MCP-Go](https://github.com/mark3labs/mcp-go)
# 主要功能
- 项目管理,可以对项目进行编辑更改,成员添加, 项目排序等。
- 文档管理,添加和删除文档等。
- 评论管理,可以管理文档评论和自己发布的评论。
- 用户管理,添加和禁用用户,个人资料更改等。
- 用户权限管理 , 实现用户角色的变更。
- 项目加密,可以设置项目公开状态,私有项目需要通过Token访问。
- 站点配置,多语言切换, 可开启匿名访问、验证码等。
# 参与开发
我们欢迎您在 MinDoc 项目的 GitHub 上报告 issue 或者 pull request。
如果您还不熟悉GitHub的Fork and Pull开发模式,您可以阅读GitHub的文档(https://help.github.com/articles/using-pull-requests) 获得更多的信息。
# 关于作者[lifei6671](https://github.com/lifei6671)
一个不纯粹的PHPer,一个不自由的 gopher 。
# 部署补充
- 若内网部署,draw.io无法使用外网,则需要用tomcat运行war包,见(https://github.com/jgraph/drawio) 从release下载,之后修改markdown.js的TODO行对应的链接即可
- 为了护眼,简单增加了编辑界面的主题切换,见editormd.js和markdown_edit_template.tpl
- (需重新编译项)为了对已删除文档/文档引用图片删除文字后,对悬空无引用的图片/附件进行清理,增加了清理接口,需重新编译
- 编译后除二进制文件外还需更新三个文件: conf/lang/en-us.ini,zh-cn.ini; attach_list.tpl
- 若不想重新编译,也可通过database/clean.py,手动执行对无引用图片/附件的文件清理和数据库记录双向清理。
- 若采用nginx二级部署,以yourpath/为例,需修改
- conf/app.conf修改:`baseurl="/yourpath"`
- static/js/kancloud.js文件中`url: "/comment/xxxxx` => `url: "/yourpath" + "/comment/xxxxx`, 共两处
- nginx端口代理示例:
```
增加
location /yourpath/ {
rewrite ^/yourpath/(.*) /$1 break;
proxy_pass http://127.0.0.1:8181;
}
```
注意使用的是127.0.0.1,根据自身选择替换,如果nginx是docker部署,则还需要在docker中托管运行mindoc,具体参考如下配置:
- docker-compose代理示例(docker-nginx代理运行mindoc)
```
version: '3'
services:
mynginx:
image: nginx:latest
ports:
- "8880:80"
command:
- bash
- -c
- |
service nginx start
cd /src/mindoc/ && ./mindoc
volumes:
- ..:/src
- ./nginx:/etc/nginx/conf.d
```
目录结构
```
onefolder
|
- docker
|
- docker-compose.yml
- nginx
|
- mynginx.conf
- mindoc
|
- database/
- conf/
- ...
```
================================================
FILE: appveyor.yml
================================================
version: 1.0.{build}
branches:
only:
- master
image: Visual Studio 2022
clone_folder: c:\gopath\src\github.com\mindoc-org\mindoc
init:
- cmd: >-
if [%tbs_arch%]==[x86] SET PATH=C:\msys64\mingw32\bin;%PATH%
if [%tbs_arch%]==[x64] SET PATH=C:\msys64\mingw64\bin;%PATH%
SET PATH=%GOPATH%\bin;%GOBIN%;%PATH%
FOR /f "delims=" %%i IN ('go version') DO (SET GO_VERSION=%%i)
git config --global --add safe.directory /cygdrive/c/gopath/src/github.com/mindoc-org/mindoc
environment:
GOPATH: c:\gopath
GOBIN: c:\gobin
GO111MODULE: on
CGO_ENABLED: 1
matrix:
- tbs_arch: x86
GOARCH: 386
job_name: job_x86
- tbs_arch: x64
GOARCH: amd64
job_name: job_x64
install:
- cmd: >-
echo %PATH%
echo %GO_VERSION%
go env
where gcc
where g++
build_script:
- cmd: >-
cd c:\gopath\src\github.com\mindoc-org\mindoc
go mod tidy -v
go build -v -o "mindoc_windows_%GOARCH%.exe" -ldflags="-w -X github.com/mindoc-org/mindoc/conf.VERSION=%APPVEYOR_REPO_TAG_NAME% -X 'github.com/mindoc-org/mindoc/conf.BUILD_TIME=%date% %time%' -X 'github.com/mindoc-org/mindoc/conf.GO_VERSION=%GO_VERSION%'"
7z a -t7z -r mindoc_windows_%GOARCH%.7z conf/*.conf* conf/lang/* static/* mindoc_windows_%GOARCH%.exe views/* uploads/* lib/* LICENSE.md
test_script:
- cmd: >-
cd c:\gopath\src\github.com\mindoc-org\mindoc
pwsh -NoProfile -ExecutionPolicy Bypass -Command "& {Copy-Item -Force -Path 'conf\app.conf.example' -Destination 'conf\app.conf'}"
mindoc_windows_%GOARCH%.exe version
artifacts:
- path: mindoc_windows_*.7z
deploy: off
================================================
FILE: build_amd64.sh
================================================
rm mindoc_linux_amd64 mindoc_linux_musl_amd64
rm -rf ../mindoc_linux_amd64/
export GOARCH=amd64
export GOOS=linux
export CC=/usr/bin/gcc
export TRAVIS_TAG=v2.1-beta.6
go mod tidy -v
go build -v -o mindoc_linux_amd64 -ldflags="-linkmode external -extldflags '-static' -w -X 'github.com/mindoc-org/mindoc/conf.VERSION=$TRAVIS_TAG' -X 'github.com/mindoc-org/mindoc/conf.BUILD_TIME=`date`' -X 'github.com/mindoc-org/mindoc/conf.GO_VERSION=`go version`'"
./mindoc_linux_amd64 version
mkdir ../mindoc_linux_amd64
cp -r * ../mindoc_linux_amd64
cd ../mindoc_linux_amd64
rm -rf cache commands controllers converter .git .github graphics mail models routers utils runtime conf/*.go
rm appveyor.yml docker-compose.yml Dockerfile .travis.yml .gitattributes .gitignore go.mod go.sum main.go README.md simsun.ttc start.sh sync_host.sh build_amd64.sh build_musl_amd64.sh
zip -r mindoc_linux_amd64.zip conf static uploads views lib mindoc_linux_amd64 LICENSE.md
mv ./mindoc_linux_amd64.zip ../
================================================
FILE: build_musl_amd64.sh
================================================
rm mindoc_linux_musl_amd64 mindoc_linux_amd64
rm -rf ../mindoc_linux_musl_amd64/
export GOARCH=amd64
export GOOS=linux
export CC=/usr/local/musl/bin/musl-gcc
export TRAVIS_TAG=v2.1-beta.6
go mod tidy -v
go build -v -o mindoc_linux_musl_amd64 -ldflags="-linkmode external -extldflags '-static' -w -X 'github.com/mindoc-org/mindoc/conf.VERSION=$TRAVIS_TAG' -X 'github.com/mindoc-org/mindoc/conf.BUILD_TIME=`date`' -X 'github.com/mindoc-org/mindoc/conf.GO_VERSION=`go version`'"
./mindoc_linux_musl_amd64 version
mkdir ../mindoc_linux_musl_amd64
cp -r * ../mindoc_linux_musl_amd64
cd ../mindoc_linux_musl_amd64
rm -rf cache commands controllers converter .git .github graphics mail models routers utils runtime conf/*.go
rm appveyor.yml docker-compose.yml Dockerfile .travis.yml .gitattributes .gitignore go.mod go.sum main.go README.md simsun.ttc start.sh sync_host.sh build_amd64.sh build_musl_amd64.sh
zip -r mindoc_linux_musl_amd64.zip conf static uploads views lib mindoc_linux_musl_amd64 LICENSE.md
mv ./mindoc_linux_musl_amd64.zip ../
================================================
FILE: cache/cache.go
================================================
package cache
import (
"bytes"
"context"
"encoding/gob"
"errors"
"time"
"github.com/beego/beego/v2/client/cache"
"github.com/beego/beego/v2/core/logs"
)
var bm cache.Cache
var nilctx = context.TODO()
func Get(key string, e interface{}) error {
val, err := bm.Get(nilctx, key)
if err != nil {
return errors.New("get cache error:" + err.Error())
}
if val == nil {
return errors.New("cache does not exist")
}
if b, ok := val.([]byte); ok {
buf := bytes.NewBuffer(b)
decoder := gob.NewDecoder(buf)
err := decoder.Decode(e)
if err != nil {
logs.Error("反序列化对象失败 ->", err)
}
return err
} else if s, ok := val.(string); ok && s != "" {
buf := bytes.NewBufferString(s)
decoder := gob.NewDecoder(buf)
err := decoder.Decode(e)
if err != nil {
logs.Error("反序列化对象失败 ->", err)
}
return err
}
return errors.New("value is not []byte or string")
}
func Put(key string, val interface{}, timeout time.Duration) error {
var buf bytes.Buffer
encoder := gob.NewEncoder(&buf)
err := encoder.Encode(val)
if err != nil {
logs.Error("序列化对象失败 ->", err)
return err
}
return bm.Put(nilctx, key, buf.String(), timeout)
}
func Delete(key string) error {
return bm.Delete(nilctx, key)
}
func Incr(key string) error {
return bm.Incr(nilctx, key)
}
func Decr(key string) error {
return bm.Decr(nilctx, key)
}
func IsExist(key string) (bool, error) {
return bm.IsExist(nilctx, key)
}
func ClearAll() error {
return bm.ClearAll(nilctx)
}
func StartAndGC(config string) error {
return bm.StartAndGC(config)
}
//Init will initialize cache
func Init(c cache.Cache) {
bm = c
}
================================================
FILE: cache/cache_null.go
================================================
package cache
import (
"context"
"time"
)
type NullCache struct {
}
func (bm *NullCache) Get(ctx context.Context, key string) (interface{}, error) {
return nil, nil
}
func (bm *NullCache)GetMulti(ctx context.Context, keys []string) ([]interface{}, error) {
return nil, nil
}
func (bm *NullCache)Put(ctx context.Context,key string, val interface{}, timeout time.Duration) error {
return nil
}
func (bm *NullCache)Delete(ctx context.Context,key string) error {
return nil
}
func (bm *NullCache)Incr(ctx context.Context,key string) error {
return nil
}
func (bm *NullCache)Decr(ctx context.Context,key string) error {
return nil
}
func (bm *NullCache)IsExist(ctx context.Context,key string) (bool, error) {
return false, nil
}
func (bm *NullCache)ClearAll(ctx context.Context) error{
return nil
}
func (bm *NullCache)StartAndGC(config string) error {
return nil
}
================================================
FILE: commands/command.go
================================================
package commands
import (
"encoding/gob"
"flag"
"fmt"
"log"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
_ "time/tzdata"
"bytes"
"encoding/json"
"net/http"
beegoCache "github.com/beego/beego/v2/client/cache"
_ "github.com/beego/beego/v2/client/cache/memcache"
"github.com/beego/beego/v2/client/orm"
"github.com/beego/beego/v2/core/logs"
"github.com/beego/beego/v2/server/web"
"github.com/beego/i18n"
"github.com/howeyc/fsnotify"
_ "github.com/lib/pq"
"github.com/lifei6671/gocaptcha"
"github.com/mindoc-org/mindoc/cache"
"github.com/mindoc-org/mindoc/conf"
"github.com/mindoc-org/mindoc/models"
"github.com/mindoc-org/mindoc/utils/filetil"
)
// RegisterDataBase 注册数据库
func RegisterDataBase() {
logs.Info("正在初始化数据库配置.")
dbadapter, _ := web.AppConfig.String("db_adapter")
orm.DefaultTimeLoc = time.Local
orm.DefaultRowsLimit = -1
if strings.EqualFold(dbadapter, "mysql") {
host, _ := web.AppConfig.String("db_host")
database, _ := web.AppConfig.String("db_database")
username, _ := web.AppConfig.String("db_username")
password, _ := web.AppConfig.String("db_password")
timezone, _ := web.AppConfig.String("timezone")
location, err := time.LoadLocation(timezone)
if err == nil {
orm.DefaultTimeLoc = location
} else {
logs.Error("加载时区配置信息失败,请检查是否存在 ZONEINFO 环境变量->", err)
}
port, _ := web.AppConfig.String("db_port")
dataSource := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=true&loc=%s", username, password, host, port, database, url.QueryEscape(timezone))
if err := orm.RegisterDataBase("default", "mysql", dataSource); err != nil {
logs.Error("注册默认数据库失败->", err)
os.Exit(1)
}
} else if strings.EqualFold(dbadapter, "sqlite3") {
database, _ := web.AppConfig.String("db_database")
if strings.HasPrefix(database, "./") {
database = filepath.Join(conf.WorkingDirectory, string(database[1:]))
}
if p, err := filepath.Abs(database); err == nil {
database = p
}
dbPath := filepath.Dir(database)
if _, err := os.Stat(dbPath); err != nil && os.IsNotExist(err) {
_ = os.MkdirAll(dbPath, 0777)
}
err := orm.RegisterDataBase("default", "sqlite3", database)
if err != nil {
logs.Error("注册默认数据库失败->", err)
}
} else if strings.EqualFold(dbadapter, "postgres") {
host, _ := web.AppConfig.String("db_host")
database, _ := web.AppConfig.String("db_database")
username, _ := web.AppConfig.String("db_username")
password, _ := web.AppConfig.String("db_password")
sslmode, _ := web.AppConfig.String("db_sslmode")
timezone, _ := web.AppConfig.String("timezone")
location, err := time.LoadLocation(timezone)
if err == nil {
orm.DefaultTimeLoc = location
} else {
logs.Error("加载时区配置信息失败,请检查是否存在 ZONEINFO 环境变量->", err)
}
port, _ := web.AppConfig.String("db_port")
dataSource := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s", username, password, host, port, database, sslmode)
if err := orm.RegisterDataBase("default", "postgres", dataSource); err != nil {
logs.Error("注册默认数据库失败->", err)
os.Exit(1)
}
} else {
logs.Error("不支持的数据库类型.")
os.Exit(1)
}
logs.Info("数据库初始化完成.")
}
// RegisterModel 注册Model
func RegisterModel() {
orm.RegisterModelWithPrefix(conf.GetDatabasePrefix(),
new(models.Member),
new(models.Book),
new(models.Relationship),
new(models.Option),
new(models.Document),
new(models.Attachment),
new(models.Logger),
new(models.MemberToken),
new(models.DocumentHistory),
new(models.Migration),
new(models.Label),
new(models.Blog),
new(models.Template),
new(models.Team),
new(models.TeamMember),
new(models.TeamRelationship),
new(models.Itemsets),
new(models.Comment),
new(models.WorkWeixinAccount),
new(models.DingTalkAccount),
new(models.ContentReverseIndex),
)
gob.Register(models.Blog{})
gob.Register(models.Document{})
gob.Register(models.Template{})
//migrate.RegisterMigration()
err := orm.RunSyncdb("default", false, true)
if err != nil {
logs.Error("注册Model失败 ->", err)
os.Exit(1)
}
}
// RegisterLogger 注册日志
func RegisterLogger(log string) {
logs.Reset()
logs.SetLogFuncCall(true)
_ = logs.SetLogger("console")
logs.EnableFuncCallDepth(true)
if web.AppConfig.DefaultBool("log_is_async", true) {
logs.Async(1e3)
}
if log == "" {
logPath, err := filepath.Abs(web.AppConfig.DefaultString("log_path", conf.WorkingDir("runtime", "logs")))
if err == nil {
log = logPath
} else {
log = conf.WorkingDir("runtime", "logs")
}
}
logPath := filepath.Join(log, "log.log")
if _, err := os.Stat(log); os.IsNotExist(err) {
_ = os.MkdirAll(log, 0755)
}
config := make(map[string]interface{}, 1)
config["filename"] = logPath
config["perm"] = "0755"
config["rotate"] = true
if maxLines := web.AppConfig.DefaultInt("log_maxlines", 1000000); maxLines > 0 {
config["maxLines"] = maxLines
}
if maxSize := web.AppConfig.DefaultInt("log_maxsize", 1<<28); maxSize > 0 {
config["maxsize"] = maxSize
}
if !web.AppConfig.DefaultBool("log_daily", true) {
config["daily"] = false
}
if maxDays := web.AppConfig.DefaultInt("log_maxdays", 7); maxDays > 0 {
config["maxdays"] = maxDays
}
if level := web.AppConfig.DefaultString("log_level", "Trace"); level != "" {
switch level {
case "Emergency":
config["level"] = logs.LevelEmergency
case "Alert":
config["level"] = logs.LevelAlert
case "Critical":
config["level"] = logs.LevelCritical
case "Error":
config["level"] = logs.LevelError
case "Warning":
config["level"] = logs.LevelWarning
case "Notice":
config["level"] = logs.LevelNotice
case "Informational":
config["level"] = logs.LevelInformational
case "Debug":
config["level"] = logs.LevelDebug
}
}
b, err := json.Marshal(config)
if err != nil {
logs.Error("初始化文件日志时出错 ->", err)
_ = logs.SetLogger("file", `{"filename":"`+logPath+`"}`)
} else {
_ = logs.SetLogger(logs.AdapterFile, string(b))
}
logs.SetLogFuncCall(true)
}
// RunCommand 注册orm命令行工具
func RegisterCommand() {
if len(os.Args) >= 2 && os.Args[1] == "install" {
ResolveCommand(os.Args[2:])
Install()
} else if len(os.Args) >= 2 && os.Args[1] == "version" {
CheckUpdate()
os.Exit(0)
} else if len(os.Args) >= 2 && os.Args[1] == "update" {
Update()
os.Exit(0)
}
}
// 注册模板函数
func RegisterFunction() {
err := web.AddFuncMap("config", models.GetOptionValue)
if err != nil {
logs.Error("注册函数 config 出错 ->", err)
os.Exit(-1)
}
err = web.AddFuncMap("cdn", func(p string) string {
cdn := web.AppConfig.DefaultString("cdn", "")
if strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") {
return p
}
//如果没有设置cdn,则使用baseURL拼接
if cdn == "" {
baseUrl := web.AppConfig.DefaultString("baseurl", "")
if strings.HasPrefix(p, "/") && strings.HasSuffix(baseUrl, "/") {
return baseUrl + p[1:]
}
if !strings.HasPrefix(p, "/") && !strings.HasSuffix(baseUrl, "/") {
return baseUrl + "/" + p
}
return baseUrl + p
}
if strings.HasPrefix(p, "/") && strings.HasSuffix(cdn, "/") {
return cdn + string(p[1:])
}
if !strings.HasPrefix(p, "/") && !strings.HasSuffix(cdn, "/") {
return cdn + "/" + p
}
return cdn + p
})
if err != nil {
logs.Error("注册函数 cdn 出错 ->", err)
os.Exit(-1)
}
err = web.AddFuncMap("cdnjs", conf.URLForWithCdnJs)
if err != nil {
logs.Error("注册函数 cdnjs 出错 ->", err)
os.Exit(-1)
}
err = web.AddFuncMap("cdncss", conf.URLForWithCdnCss)
if err != nil {
logs.Error("注册函数 cdncss 出错 ->", err)
os.Exit(-1)
}
err = web.AddFuncMap("cdnimg", conf.URLForWithCdnImage)
if err != nil {
logs.Error("注册函数 cdnimg 出错 ->", err)
os.Exit(-1)
}
//重写url生成,支持配置域名以及域名前缀
err = web.AddFuncMap("urlfor", conf.URLFor)
if err != nil {
logs.Error("注册函数 urlfor 出错 ->", err)
os.Exit(-1)
}
//读取配置值(未作任何转换)
err = web.AddFuncMap("conf", conf.CONF)
if err != nil {
logs.Error("注册函数 conf 出错 ->", err)
os.Exit(-1)
}
err = web.AddFuncMap("date_format", func(t time.Time, format string) string {
return t.Local().Format(format)
})
if err != nil {
logs.Error("注册函数 date_format 出错 ->", err)
os.Exit(-1)
}
err = web.AddFuncMap("i18n", i18n.Tr)
if err != nil {
logs.Error("注册函数 i18n 出错 ->", err)
os.Exit(-1)
}
i18nList, err := web.AppConfig.String("i18n_list")
if err != nil {
logs.Error("error : failed to read i18n_list config ->", err)
i18nList = ""
}
if i18nList == "" { // 之所以分开判断是因为读取出的配置也可能是空串
logs.Error("error : config `i18n_list` is empty, please add config item like format: `i18n_list=zh-CN:简体中文|en-US:English`")
i18nList = "zh-cn:简体中文|en-us:English|ru-ru:Русский" // 没有配置时给个默认配置,避免啥语言都没有
}
langs := strings.Split(i18nList, "|")
i18nMap := make(map[string]string)
for _, langItem := range langs {
langItemSplit := strings.Split(langItem, ":")
if len(langItemSplit) < 2 {
logs.Error("error: language config value `" + langItem + "` for `i18n_list` format error")
continue
}
lang := langItemSplit[0]
i18nMap[lang] = langItemSplit[1]
if err := i18n.SetMessage(lang, "conf/lang/"+lang+".ini"); err != nil {
logs.Error("Fail to set message file: " + err.Error())
return
}
}
i18nMapBytes, err := json.Marshal(i18nMap)
if err != nil {
logs.Error("error: Fail to marshal i18n map, " + err.Error())
i18nMapBytes = []byte("{}")
}
err = web.AppConfig.Set("i18n_map", string(i18nMapBytes))
if err != nil {
logs.Error("error: Fail to set i18n_map, " + err.Error())
}
}
// 解析命令
func ResolveCommand(args []string) {
flagSet := flag.NewFlagSet("MinDoc command: ", flag.ExitOnError)
flagSet.StringVar(&conf.ConfigurationFile, "config", "", "MinDoc configuration file.")
flagSet.StringVar(&conf.WorkingDirectory, "dir", "", "MinDoc working directory.")
flagSet.StringVar(&conf.LogFile, "log", "", "MinDoc log file path.")
if err := flagSet.Parse(args); err != nil {
log.Fatal("解析命令失败 ->", err)
}
if conf.WorkingDirectory == "" {
if p, err := filepath.Abs(os.Args[0]); err == nil {
conf.WorkingDirectory = filepath.Dir(p)
}
}
if conf.ConfigurationFile == "" {
conf.ConfigurationFile = conf.WorkingDir("conf", "app.conf")
config := conf.WorkingDir("conf", "app.conf.example")
if !filetil.FileExists(conf.ConfigurationFile) && filetil.FileExists(config) {
_ = filetil.CopyFile(conf.ConfigurationFile, config)
}
}
if err := gocaptcha.ReadFonts(conf.WorkingDir("static", "fonts"), ".ttf"); err != nil {
log.Fatal("读取字体文件时出错 -> ", err)
}
if err := web.LoadAppConfig("ini", conf.ConfigurationFile); err != nil {
log.Fatal("An error occurred:", err)
}
if conf.LogFile == "" {
logPath, err := filepath.Abs(web.AppConfig.DefaultString("log_path", conf.WorkingDir("runtime", "logs")))
if err == nil {
conf.LogFile = logPath
} else {
conf.LogFile = conf.WorkingDir("runtime", "logs")
}
}
conf.AutoLoadDelay = web.AppConfig.DefaultInt("config_auto_delay", 0)
uploads := conf.WorkingDir("uploads")
_ = os.MkdirAll(uploads, 0666)
web.BConfig.WebConfig.StaticDir["/static"] = filepath.Join(conf.WorkingDirectory, "static")
web.BConfig.WebConfig.StaticDir["/uploads"] = uploads
web.BConfig.WebConfig.ViewsPath = conf.WorkingDir("views")
web.BConfig.WebConfig.Session.SessionCookieSameSite = http.SameSiteDefaultMode
var upload_file_size = conf.GetUploadFileSize()
if upload_file_size > web.BConfig.MaxUploadSize {
web.BConfig.MaxUploadSize = upload_file_size
}
fonts := conf.WorkingDir("static", "fonts")
if !filetil.FileExists(fonts) {
log.Fatal("Font path not exist.")
}
if err := gocaptcha.ReadFonts(filepath.Join(conf.WorkingDirectory, "static", "fonts"), ".ttf"); err != nil {
log.Fatal("读取字体失败 ->", err)
}
RegisterDataBase()
RegisterCache()
RegisterModel()
RegisterLogger(conf.LogFile)
models.InitializeMissingIndexes()
ModifyPassword()
}
// 注册缓存管道
func RegisterCache() {
isOpenCache := web.AppConfig.DefaultBool("cache", false)
if !isOpenCache {
cache.Init(&cache.NullCache{})
return
}
logs.Info("正常初始化缓存配置.")
cacheProvider, _ := web.AppConfig.String("cache_provider")
if cacheProvider == "file" {
cacheFilePath := web.AppConfig.DefaultString("cache_file_path", "./runtime/cache/")
if strings.HasPrefix(cacheFilePath, "./") {
cacheFilePath = filepath.Join(conf.WorkingDirectory, string(cacheFilePath[1:]))
}
fileCache := beegoCache.NewFileCache()
fileConfig := make(map[string]string, 0)
fileConfig["CachePath"] = cacheFilePath
fileConfig["DirectoryLevel"] = web.AppConfig.DefaultString("cache_file_dir_level", "2")
fileConfig["EmbedExpiry"] = web.AppConfig.DefaultString("cache_file_expiry", "120")
fileConfig["FileSuffix"] = web.AppConfig.DefaultString("cache_file_suffix", ".bin")
bc, err := json.Marshal(&fileConfig)
if err != nil {
logs.Error("初始化file缓存失败:", err)
os.Exit(1)
}
_ = fileCache.StartAndGC(string(bc))
cache.Init(fileCache)
} else if cacheProvider == "memory" {
cacheInterval := web.AppConfig.DefaultInt("cache_memory_interval", 60)
memory := beegoCache.NewMemoryCache()
beegoCache.DefaultEvery = cacheInterval
cache.Init(memory)
} else if cacheProvider == "redis" {
var redisConfig struct {
Conn string `json:"conn"`
Password string `json:"password"`
DbNum string `json:"dbNum"`
Key string `json:"key"`
}
//设置Redis前缀
if key := web.AppConfig.DefaultString("cache_redis_prefix", ""); key != "" {
redisConfig.Key = key // 设置Redis前缀,替代原来的 redis.DefaultKey
}
redisConfig.DbNum = "0"
redisConfig.Conn = web.AppConfig.DefaultString("cache_redis_host", "")
if pwd := web.AppConfig.DefaultString("cache_redis_password", ""); pwd != "" {
redisConfig.Password = pwd
}
if dbNum := web.AppConfig.DefaultInt("cache_redis_db", 0); dbNum > 0 {
redisConfig.DbNum = strconv.Itoa(dbNum)
}
bc, err := json.Marshal(&redisConfig)
if err != nil {
logs.Error("初始化Redis缓存失败:", err)
os.Exit(1)
}
redisCache, err := beegoCache.NewCache("redis", string(bc))
if err != nil {
logs.Error("初始化Redis缓存失败:", err)
os.Exit(1)
}
cache.Init(redisCache)
} else if cacheProvider == "memcache" {
var memcacheConfig struct {
Conn string `json:"conn"`
}
memcacheConfig.Conn = web.AppConfig.DefaultString("cache_memcache_host", "")
bc, err := json.Marshal(&memcacheConfig)
if err != nil {
logs.Error("初始化 Memcache 缓存失败 ->", err)
os.Exit(1)
}
memcache, err := beegoCache.NewCache("memcache", string(bc))
if err != nil {
logs.Error("初始化 Memcache 缓存失败 ->", err)
os.Exit(1)
}
cache.Init(memcache)
} else {
cache.Init(&cache.NullCache{})
logs.Warn("不支持的缓存管道,缓存将禁用 ->", cacheProvider)
return
}
logs.Info("缓存初始化完成.")
}
// 自动加载配置文件.修改了监听端口号和数据库配置无法自动生效.
func RegisterAutoLoadConfig() {
if conf.AutoLoadDelay > 0 {
watcher, err := fsnotify.NewWatcher()
if err != nil {
logs.Error("创建配置文件监控器失败 ->", err)
}
go func() {
for {
select {
case ev := <-watcher.Event:
//如果是修改了配置文件
if ev.IsModify() {
if err := web.LoadAppConfig("ini", conf.ConfigurationFile); err != nil {
logs.Error("An error occurred ->", err)
continue
}
RegisterCache()
RegisterLogger("")
logs.Info("配置文件已加载 ->", conf.ConfigurationFile)
} else if ev.IsRename() {
_ = watcher.WatchFlags(conf.ConfigurationFile, fsnotify.FSN_MODIFY|fsnotify.FSN_RENAME)
}
logs.Info(ev.String())
case err := <-watcher.Error:
logs.Error("配置文件监控器错误 ->", err)
}
}
}()
err = watcher.WatchFlags(conf.ConfigurationFile, fsnotify.FSN_MODIFY|fsnotify.FSN_RENAME)
if err != nil {
logs.Error("监控配置文件失败 ->", err)
}
}
}
// 注册错误处理方法.
func RegisterError() {
web.ErrorHandler("404", func(writer http.ResponseWriter, request *http.Request) {
var buf bytes.Buffer
data := make(map[string]interface{})
data["ErrorCode"] = 404
data["ErrorMessage"] = "页面未找到或已删除"
if err := web.ExecuteViewPathTemplate(&buf, "errors/error.tpl", web.BConfig.WebConfig.ViewsPath, data); err == nil {
_, _ = fmt.Fprint(writer, buf.String())
} else {
_, _ = fmt.Fprint(writer, data["ErrorMessage"])
}
})
web.ErrorHandler("401", func(writer http.ResponseWriter, request *http.Request) {
var buf bytes.Buffer
data := make(map[string]interface{})
data["ErrorCode"] = 401
data["ErrorMessage"] = "请与 Web 服务器的管理员联系,以确认您是否具有访问所请求资源的权限。"
if err := web.ExecuteViewPathTemplate(&buf, "errors/error.tpl", web.BConfig.WebConfig.ViewsPath, data); err == nil {
_, _ = fmt.Fprint(writer, buf.String())
} else {
_, _ = fmt.Fprint(writer, data["ErrorMessage"])
}
})
}
func init() {
if configPath, err := filepath.Abs(conf.ConfigurationFile); err == nil {
conf.ConfigurationFile = configPath
}
if err := gocaptcha.ReadFonts(conf.WorkingDir("static", "fonts"), ".ttf"); err != nil {
log.Fatal("读取字体文件失败 ->", err)
}
gob.Register(models.Member{})
if p, err := filepath.Abs(os.Args[0]); err == nil {
conf.WorkingDirectory = filepath.Dir(p)
}
}
================================================
FILE: commands/daemon/daemon.go
================================================
package daemon
import (
"fmt"
"os"
"path/filepath"
"github.com/beego/beego/v2/core/logs"
"github.com/beego/beego/v2/server/web"
"github.com/kardianos/service"
"github.com/mindoc-org/mindoc/commands"
"github.com/mindoc-org/mindoc/conf"
"github.com/mindoc-org/mindoc/controllers"
)
type Daemon struct {
config *service.Config
errs chan error
}
func NewDaemon() *Daemon {
config := &service.Config{
Name: "mindocd", //服务显示名称
DisplayName: "MinDoc service", //服务名称
Description: "A document online management program.", //服务描述
WorkingDirectory: conf.WorkingDirectory,
Arguments: os.Args[1:],
}
return &Daemon{
config: config,
errs: make(chan error, 100),
}
}
func (d *Daemon) Config() *service.Config {
return d.config
}
func (d *Daemon) Start(s service.Service) error {
go d.Run()
return nil
}
func (d *Daemon) Run() {
commands.ResolveCommand(d.config.Arguments)
commands.RegisterFunction()
commands.RegisterAutoLoadConfig()
commands.RegisterError()
web.ErrorController(&controllers.ErrorController{})
f, err := filepath.Abs(os.Args[0])
if err != nil {
f = os.Args[0]
}
fmt.Printf("MinDoc version => %s\nbuild time => %s\nstart directory => %s\n%s\n", conf.VERSION, conf.BUILD_TIME, f, conf.GO_VERSION)
web.Run()
}
func (d *Daemon) Stop(s service.Service) error {
if service.Interactive() {
os.Exit(0)
}
return nil
}
func Install() {
d := NewDaemon()
d.config.Arguments = os.Args[3:]
s, err := service.New(d, d.config)
if err != nil {
logs.Error("Create service error => ", err)
os.Exit(1)
}
err = s.Install()
if err != nil {
logs.Error("Install service error:", err)
os.Exit(1)
} else {
logs.Info("Service installed!")
}
os.Exit(0)
}
func Uninstall() {
d := NewDaemon()
s, err := service.New(d, d.config)
if err != nil {
logs.Error("Create service error => ", err)
os.Exit(1)
}
err = s.Uninstall()
if err != nil {
logs.Error("Install service error:", err)
os.Exit(1)
} else {
logs.Info("Service uninstalled!")
}
os.Exit(0)
}
func Restart() {
d := NewDaemon()
s, err := service.New(d, d.config)
if err != nil {
logs.Error("Create service error => ", err)
os.Exit(1)
}
err = s.Restart()
if err != nil {
logs.Error("Install service error:", err)
os.Exit(1)
} else {
logs.Info("Service Restart!")
}
os.Exit(0)
}
================================================
FILE: commands/install.go
================================================
package commands
import (
"errors"
"fmt"
"os"
"time"
"flag"
"github.com/beego/beego/v2/client/orm"
"github.com/beego/beego/v2/core/logs"
"github.com/beego/beego/v2/server/web"
"github.com/beego/i18n"
"github.com/mindoc-org/mindoc/conf"
"github.com/mindoc-org/mindoc/models"
"github.com/mindoc-org/mindoc/utils"
)
//系统安装.
func Install() {
fmt.Println("Initializing...")
err := orm.RunSyncdb("default", false, true)
if err == nil {
initialization()
} else {
panic(err.Error())
}
fmt.Println("Install Successfully!")
os.Exit(0)
}
func Version() {
if len(os.Args) >= 2 && os.Args[1] == "version" {
fmt.Println(conf.VERSION)
os.Exit(0)
}
}
//修改用户密码
func ModifyPassword() {
var account, password string
//账号和密码需要解析参数后才能获取
if len(os.Args) >= 2 && os.Args[1] == "password" {
flagSet := flag.NewFlagSet("MinDoc command: ", flag.ExitOnError)
flagSet.StringVar(&account, "account", "", "用户账号.")
flagSet.StringVar(&password, "password", "", "用户密码.")
if err := flagSet.Parse(os.Args[2:]); err != nil {
logs.Error("解析参数失败 -> ", err)
os.Exit(1)
}
if len(os.Args) < 2 {
fmt.Println("Parameter error.")
os.Exit(1)
}
if account == "" {
fmt.Println("Account cannot be empty.")
os.Exit(1)
}
if password == "" {
fmt.Println("Password cannot be empty.")
os.Exit(1)
}
member, err := models.NewMember().FindByAccount(account)
if err != nil {
fmt.Println("Failed to change password:", err)
os.Exit(1)
}
pwd, err := utils.PasswordHash(password)
if err != nil {
fmt.Println("Failed to change password:", err)
os.Exit(1)
}
member.Password = pwd
err = member.Update("password")
if err != nil {
fmt.Println("Failed to change password:", err)
os.Exit(1)
}
fmt.Println("Successfully modified.")
os.Exit(0)
}
}
//初始化数据
func initialization() {
err := models.NewOption().Init()
if err != nil {
panic(err.Error())
}
lang, _ := web.AppConfig.String("default_lang")
err = i18n.SetMessage(lang, "conf/lang/"+lang+".ini")
if err != nil {
panic(fmt.Errorf("initialize locale error: %s", err))
}
member, err := models.NewMember().FindByFieldFirst("account", "admin")
if errors.Is(err, orm.ErrNoRows) {
// create admin user
logs.Info("creating admin user")
member.Account = "admin"
member.Avatar = conf.URLForWithCdnImage("/static/images/headimgurl.jpg")
member.Password = "123456"
member.AuthMethod = "local"
member.Role = conf.MemberSuperRole
member.Email = "admin@iminho.me"
if err := member.Add(); err != nil {
panic("Member.Add => " + err.Error())
}
// create demo book
logs.Info("creating demo book")
book := models.NewBook()
book.MemberId = member.MemberId
book.BookName = i18n.Tr(lang, "init.default_proj_name") //"MinDoc演示项目"
book.Status = 0
book.ItemId = 1
book.Description = i18n.Tr(lang, "init.default_proj_desc") //"这是一个MinDoc演示项目,该项目是由系统初始化时自动创建。"
book.CommentCount = 0
book.PrivatelyOwned = 0
book.CommentStatus = "closed"
book.Identify = "mindoc"
book.DocCount = 0
book.CommentCount = 0
book.Version = time.Now().Unix()
book.Cover = conf.GetDefaultCover()
book.Editor = "markdown"
book.Theme = "default"
if err := book.Insert(lang); err != nil {
panic("初始化项目失败 -> " + err.Error())
}
} else if err != nil {
panic(fmt.Errorf("occur errors when initialize: %s", err))
}
if !models.NewItemsets().Exist(1) {
item := models.NewItemsets()
item.ItemName = i18n.Tr(lang, "init.default_proj_space") //"默认项目空间"
item.MemberId = 1
if err := item.Save(); err != nil {
panic("初始化项目空间失败 -> " + err.Error())
}
}
}
================================================
FILE: commands/migrate/migrate.go
================================================
// Copyright 2013 bee authors
//
// Licensed under the Apache License, Version 2.0 (the "License"): you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
package migrate
import (
"os"
"container/list"
"fmt"
"log"
"github.com/beego/beego/v2/client/orm"
"github.com/beego/beego/v2/server/web"
"github.com/mindoc-org/mindoc/models"
)
var (
migrationList = &migrationCache{}
)
type MigrationDatabase interface {
//获取当前的版本
Version() int64
//校验当前是否可更新
ValidUpdate(version int64) error
//校验并备份表结构
ValidForBackupTableSchema() error
//校验并更新表结构
ValidForUpdateTableSchema() error
//恢复旧数据
MigrationOldTableData() error
//插入新数据
MigrationNewTableData() error
//增加迁移记录
AddMigrationRecord(version int64) error
//最后的清理工作
MigrationCleanup() error
//回滚本次迁移
RollbackMigration() error
}
type migrationCache struct {
items *list.List
}
func RunMigration() {
if len(os.Args) >= 2 && os.Args[1] == "migrate" {
migrate, err := models.NewMigration().FindFirst()
if err != nil {
//log.Fatalf("migrations table %s", err)
migrate = models.NewMigration()
}
fmt.Println("Start migration databae... ")
for el := migrationList.items.Front(); el != nil; el = el.Next() {
//如果存在比当前版本大的版本,则依次升级
if item, ok := el.Value.(MigrationDatabase); ok && item.Version() > migrate.Version {
err := item.ValidUpdate(migrate.Version)
if err != nil {
log.Fatal(err)
}
err = item.ValidForBackupTableSchema()
if err != nil {
item.RollbackMigration()
log.Fatal(err)
}
err = item.ValidForUpdateTableSchema()
if err != nil {
item.RollbackMigration()
log.Fatal(err)
}
err = item.MigrationOldTableData()
if err != nil {
item.RollbackMigration()
log.Fatal(err)
}
err = item.MigrationNewTableData()
if err != nil {
item.RollbackMigration()
log.Fatal(err)
}
err = item.AddMigrationRecord(item.Version())
if err != nil {
item.RollbackMigration()
log.Fatal(err)
}
err = item.MigrationCleanup()
if err != nil {
item.RollbackMigration()
log.Fatal(err)
}
}
}
fmt.Println("Migration successfull.")
os.Exit(0)
}
}
//导出数据库的表结构
func ExportDatabaseTable() ([]string, error) {
dbadapter, _ := web.AppConfig.String("db_adapter")
dbdatabase, _ := web.AppConfig.String("db_database")
tables := make([]string, 0)
o := orm.NewOrm()
switch dbadapter {
case "mysql":
{
var lists []orm.Params
_, err := o.Raw(fmt.Sprintf("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = '%s'", dbdatabase)).Values(&lists)
if err != nil {
return tables, err
}
for _, table := range lists {
var results []orm.Params
_, err = o.Raw(fmt.Sprintf("show create table %s", table["TABLE_NAME"])).Values(&results)
if err != nil {
return tables, err
}
tables = append(tables, results[0]["Create Table"].(string))
}
break
}
case "sqlite3":
{
var results []orm.Params
_, err := o.Raw("SELECT sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY rootpage ASC").Values(&results)
if err != nil {
return tables, err
}
for _, item := range results {
if sql, ok := item["sql"]; ok {
tables = append(tables, sql.(string))
}
}
break
}
}
return tables, nil
}
func RegisterMigration() {
migrationList.items = list.New()
migrationList.items.PushBack(NewMigrationVersion03())
}
================================================
FILE: commands/migrate/migrate_v03.go
================================================
package migrate
import (
"errors"
"fmt"
"strings"
"time"
"github.com/beego/beego/v2/client/orm"
"github.com/mindoc-org/mindoc/models"
)
type MigrationVersion03 struct {
isValid bool
tables []string
}
func NewMigrationVersion03() *MigrationVersion03 {
return &MigrationVersion03{isValid: false, tables: make([]string, 0)}
}
func (m *MigrationVersion03) Version() int64 {
return 201705271114
}
func (m *MigrationVersion03) ValidUpdate(version int64) error {
if m.Version() > version {
m.isValid = true
return nil
}
m.isValid = false
return errors.New("The target version is higher than the current version.")
}
func (m *MigrationVersion03) ValidForBackupTableSchema() error {
if !m.isValid {
return errors.New("The current version failed to verify.")
}
var err error
m.tables, err = ExportDatabaseTable()
return err
}
func (m *MigrationVersion03) ValidForUpdateTableSchema() error {
if !m.isValid {
return errors.New("The current version failed to verify.")
}
err := orm.RunSyncdb("default", false, true)
if err != nil {
return err
}
//_,err = o.Raw("ALTER TABLE md_members ADD auth_method VARCHAR(50) DEFAULT 'local' NULL").Exec()
return err
}
func (m *MigrationVersion03) MigrationOldTableData() error {
if !m.isValid {
return errors.New("The current version failed to verify.")
}
return nil
}
func (m *MigrationVersion03) MigrationNewTableData() error {
if !m.isValid {
return errors.New("The current version failed to verify.")
}
o := orm.NewOrm()
_, err := o.Raw("UPDATE md_members SET auth_method = 'local'").Exec()
if err != nil {
return err
}
_, err = o.Raw("INSERT INTO md_options (option_title, option_name, option_value) SELECT '是否启用文档历史','ENABLE_DOCUMENT_HISTORY','true' WHERE NOT exists(SELECT * FROM md_options WHERE option_name = 'ENABLE_DOCUMENT_HISTORY');").Exec()
if err != nil {
return err
}
return nil
}
func (m *MigrationVersion03) AddMigrationRecord(version int64) error {
o := orm.NewOrm()
tables, err := ExportDatabaseTable()
if err != nil {
return err
}
migration := models.NewMigration()
migration.Version = version
migration.Status = "update"
migration.CreateTime = time.Now()
migration.Name = fmt.Sprintf("update_%d", version)
migration.Statements = strings.Join(tables, "\r\n")
_, err = o.Insert(migration)
return err
}
func (m *MigrationVersion03) MigrationCleanup() error {
return nil
}
func (m *MigrationVersion03) RollbackMigration() error {
if !m.isValid {
return errors.New("The current version failed to verify.")
}
o := orm.NewOrm()
_, err := o.Raw("ALTER TABLE md_members DROP COLUMN auth_method").Exec()
if err != nil {
return err
}
_, err = o.Raw("DROP TABLE md_document_history").Exec()
if err != nil {
return err
}
_, err = o.Raw("DELETE md_options WHERE option_name = 'ENABLE_DOCUMENT_HISTORY'").Exec()
if err != nil {
return err
}
return nil
}
================================================
FILE: commands/update.go
================================================
package commands
import (
"encoding/json"
"fmt"
"github.com/mindoc-org/mindoc/models"
"io/ioutil"
"net/http"
"os"
"github.com/beego/beego/v2/client/orm"
"github.com/beego/beego/v2/core/logs"
"github.com/mindoc-org/mindoc/conf"
)
// 检查最新版本.
func CheckUpdate() {
fmt.Println("MinDoc current version => ", conf.VERSION)
resp, err := http.Get("https://api.github.com/repos/mindoc-org/mindoc/tags")
if err != nil {
logs.Error("CheckUpdate => ", err)
os.Exit(1)
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
logs.Error("CheckUpdate => ", err)
os.Exit(1)
}
var result []*struct {
Name string `json:"name"`
}
err = json.Unmarshal(body, &result)
if err != nil {
logs.Error("CheckUpdate => ", err)
os.Exit(0)
}
if len(result) > 0 {
fmt.Println("MinDoc last version => ", result[0].Name)
}
os.Exit(0)
}
func Update() {
fmt.Println("Update...")
RegisterDataBase()
RegisterModel()
err := orm.RunSyncdb("default", false, true)
if err == nil {
UpdateInitialization()
} else {
panic(err.Error())
}
fmt.Println("Update Successfully!")
os.Exit(0)
}
func UpdateInitialization() {
err := models.NewOption().Update()
if err != nil {
panic(err.Error())
}
}
================================================
FILE: conf/app.conf.example
================================================
appname = mindoc
#默认监听的网卡,为空则监听所有
httpaddr="${MINDOC_ADDR}"
httpport = "${MINDOC_PORT||8181}"
runmode = "${MINDOC_RUN_MODE||dev}"
sessionon = true
sessionname = mindoc_id
copyrequestbody = true
enablexsrf = "${MINDOC_ENABLE_XSRF||false}"
enable_iframe = "${MINDOC_ENABLE_IFRAME||false}"
#系统完整URL(http://doc.iminho.me),如果该项不设置,会从请求头中获取地址。
baseurl="${MINDOC_BASE_URL}"
#########代码高亮样式################
#样式演示地址:https://highlightjs.org/static/demo/
highlight_style="${MINDOC_HIGHLIGHT_STYLE||github}"
########配置文件自动加载##################
#大于0时系统会自动检测配置文件是否变动,变动后自动加载并生效,单位是秒。监听端口和数据库配置无效
config_auto_delay="${MINDOC_CONFIG_AUTO_DELAY||20}"
#发布pdf时候的默认发布者(项目填写了公司名称以公司名称为准)
publisher_def =
########Session储存方式##############
#默认Session生成Key的秘钥
beegoserversessionkey=NY1B$28pms12JM&c
sessionprovider="${MINDOC_SESSION_PROVIDER||file}"
sessionproviderconfig="${MINDOC_SESSION_PROVIDER_CONFIG||./runtime/session}"
#默认的过期时间
sessiongcmaxlifetime="${MINDOC_SESSION_MAX_LIFETIME||3600}"
#以文件方式储存
#sessionprovider=file
#sessionproviderconfig=./runtime/session
#以redis方式储存
#sessionprovider=redis
#sessionproviderconfig=127.0.0.1:6379
#以memcache方式储存
#sessionprovider=memcache
#sessionproviderconfig=127.0.0.1:11211
#以内存方式托管Session
#sessionprovider=memory
#时区设置
timezone = Asia/Shanghai
####################MySQL 数据库配置###########################
#支持MySQL,sqlite3,postgres三种数据库,如果是sqlite3 则 db_database 标识数据库的物理目录
db_adapter="${MINDOC_DB_ADAPTER||sqlite3}"
db_host="${MINDOC_DB_HOST||127.0.0.1}"
db_port="${MINDOC_DB_PORT||3306}"
db_database="${MINDOC_DB_DATABASE||./database/mindoc.db}"
db_username="${MINDOC_DB_USERNAME||root}"
db_password="${MINDOC_DB_PASSWORD||123456}"
#是否使用SSL,支持posgres,可选的值有:
#disable - No SSL
#require - Always SSL (skip verification)
#verify-ca - Always SSL (verify that the certificate presented by the server was signed by a trusted CA)
#verify-full - Always SSL (verify that the certification presented by the server was signed by a trusted CA and the server host name matches the one in the certificate)
db_sslmode="${MINDOC_DB_SSLMODE||disable}"
####################sqlite3 数据库配置###########################
#db_adapter=sqlite3
#db_database=./database/mindoc.db
#项目默认封面
cover=/static/images/book.jpg
#默认头像
avatar=/static/images/headimgurl.jpg
#默认阅读令牌长度
token_size=12
#上传文件的后缀,如果不限制后缀可以设置为 *
upload_file_ext=txt|doc|docx|xls|xlsx|ppt|pptx|pdf|7z|rar|jpg|jpeg|png|gif|mp4|webm|avi
#上传的文件大小限制
# - 如果不填写, 则默认1GB,如果希望超过1GB,必须带单位
# - 如果填写,单位可以是 TB、GB、MB、KB,不带单位表示字节
upload_file_size=10MB
####################邮件配置######################
#是否启用邮件
enable_mail="${MINDOC_ENABLE_MAIL||false}"
#每小时限制指定邮箱邮件发送次数
mail_number="${MINDOC_MAIL_NUMBER||5}"
#smtp服务用户名
smtp_user_name="${MINDOC_SMTP_USER_NAME||admin@iminho.me}"
#smtp服务器地址
smtp_host="${MINDOC_SMTP_HOST||smtp.163.com}""
#smtp密码
smtp_password="${MINDOC_SMTP_PASSWORD}"
#端口号
smtp_port="${MINDOC_SMTP_PORT||25}""
#发送邮件的显示名称
form_user_name="${MINDOC_FORM_USERNAME||admin@iminho.me}"
#邮件有效期30分钟
mail_expired="${MINDOC_EXPIRED||30}"
#加密类型NONE 无认证、SSL 加密、LOGIN 普通用户登录
secure="${MINDOC_MAIL_SECURE||LOGIN}"
###############配置导出项目###################
enable_export="${MINDOC_ENABLE_EXPORT||false}"
#同一个项目同时运行导出程序的并行数量,取值1-4之间,取值越大导出速度越快,越占用资源
export_process_num="${MINDOC_EXPORT_PROCESS_NUM||1}"
#并发导出的项目限制,指同一时间限制的导出项目数量,如果为0则不限制。设置的越大,越占用资源
export_limit_num="${MINDOC_EXPORT_LIMIT_NUM||5}"
#指同时等待导出的任务数量
export_queue_limit_num="${MINDOC_EXPORT_QUEUE_LIMIT_NUM||100}"
#导出项目的缓存目录配置
export_output_path="${MINDOC_EXPORT_OUTPUT_PATH||./runtime/cache}"
################百度地图密钥#################
baidumapkey=
################Active Directory/LDAP################
#是否启用ldap
ldap_enable=${MINDOC_LDAP_ENABLE||false}
#ldap协议(ldap/ldaps)
ldap_scheme="${MINDOC_LDAP_SCHEME||ldap}"
#ldap主机名
ldap_host="${MINDOC_LDAP_HOST||127.0.0.1}"
#ldap端口
ldap_port=${MINDOC_LDAP_PORT||389}
#ldap内哪个属性作为用户名
ldap_account="${MINDOC_LDAP_ACCOUNT||sAMAccountName}"
#ldap内哪个属性作为邮箱
ldap_mail="${MINDOC_LDAP_MAIL||mail}"
#搜索范围
ldap_base="${MINDOC_LDAP_BASE||dc=example,dc=com}"
#第一次绑定ldap用户dn
ldap_user="${MINDOC_LDAP_USER||cn=ldap helper,ou=example.com,dc=example,dc=com}"
#第一次绑定ldap用户密码
ldap_password="${MINDOC_LDAP_PASSWORD||xxx}"
#自动注册用户角色:0 超级管理员 /1 管理员/ 2 普通用户/ 3 只读用户
ldap_user_role=${MINDOC_LDAP_USER_ROLE||2}
#ldap搜索filter规则,AD服务器: objectClass=User, openldap服务器: objectClass=posixAccount ,也可以定义为其他属性,如: title=mindoc
ldap_filter="${MINDOC_LDAP_FILTER||objectClass=posixAccount}"
############# HTTP自定义接口登录 ################
http_login_url=
#md5计算的秘钥
http_login_secret=hzsp*THJUqwbCU%s
##################################
###############配置CDN加速##################
cdn="${MINDOC_CDN_URL}"
cdnjs="${MINDOC_CDN_JS_URL}"
cdncss="${MINDOC_CDN_CSS_URL}"
cdnimg="${MINDOC_CDN_IMG_URL}"
######################缓存配置###############################
#是否开启缓存,true 开启/false 不开启
cache="${MINDOC_CACHE||false}"
#缓存方式:memory/memcache/redis/file
cache_provider="${MINDOC_CACHE_PROVIDER||file}"
#当配置缓存方式为memory时,内存回收时间,单位是秒
cache_memory_interval="${MINDOC_CACHE_MEMORY_INTERVAL||120}"
#当缓存方式配置为file时,缓存的储存目录
cache_file_path="${MINDOC_CACHE_FILE_PATH||./runtime/cache/}"
#缓存文件后缀
cache_file_suffix="${MINDOC_CACHE_FILE_SUFFIX||.bin}"
#文件缓存目录层级
cache_file_dir_level="${MINDOC_CACHE_FILE_DIR_LEVEL||2}"
#文件缓存的默认过期时间
cache_file_expiry="${MINDOC_CACHE_FILE_EXPIRY||3600}"
#memcache缓存服务器地址
cache_memcache_host="${MINDOC_CACHE_MEMCACHE_HOST||127.0.0.1:11211}"
#redis服务器地址
cache_redis_host="${MINDOC_CACHE_REDIS_HOST||127.0.0.1:6379}"
#redis数据库索引
cache_redis_db="${MINDOC_CACHE_REDIS_DB||0}"
#redis服务器密码
cache_redis_password="${MINDOC_CACHE_REDIS_PASSWORD}"
#缓存键的前缀
cache_redis_prefix="${MINDOC_CACHE_REDIS_PREFIX||mindoc::cache}"
#########日志储存配置##############
#日志保存路径,在linux上,自动创建的日志文件请不要删除,否则将无法写入日志
log_path="${MINDOC_LOG_PATH||./runtime/logs}"
#每个文件保存的最大行数,默认值 1000000
log_maxlines="${MINDOC_LOG_MAX_LINES||1000000}"
# 每个文件保存的最大尺寸,默认值是 1 << 28, //256 MB
log_maxsize="${MINDOC_LOG_MAX_SIZE}"
# 是否按照每天 logrotate,默认是 true
log_daily="${MINDOC_LOG_DAILY||true}"
# 文件最多保存多少天,默认保存 7 天
log_maxdays="${MINDOC_LOG_MAX_DAYS||30}"
# 日志保存的时候的级别,默认是 Trace 级别,可选值: Emergency/Alert/Critical/Error/Warning/Notice/Informational/Debug/Trace
log_level="${MINDOC_LOG_LEVEL||Alert}"
# 是否异步生成日志,默认是 true
log_is_async="${MINDOC_LOG_IS_ASYNC||TRUE}"
##########钉钉应用相关配置##############
# 企业钉钉ID
dingtalk_corpid="${MINDOC_DINGTALK_CORPID}"
# 钉钉AppKey
dingtalk_app_key="${MINDOC_DINGTALK_APPKEY}"
# 钉钉AppSecret
dingtalk_app_secret="${MINDOC_DINGTALK_APPSECRET}"
########企业微信登录配置##############
# 企业ID
workweixin_corpid="${MINDOC_WORKWEIXIN_CORPID}"
# 应用ID
workweixin_agentid="${MINDOC_WORKWEIXIN_AGENTID}"
# 应用密钥
workweixin_secret="${MINDOC_WORKWEIXIN_SECRET}"
# i18n config
i18n_list=zh-cn:简体中文|en-us:English|ru-ru:Русский
default_lang="zh-cn"
# MCP Server 功能
enable_mcp_server="${MINDOC_ENABLE_MCP_SERVER||false}"
mcp_api_key="${MINDOC_MCP_API_KEY||demo-mcp-api-key}"
================================================
FILE: conf/enumerate.go
================================================
// package conf 为配置相关.
package conf
import (
"strings"
"fmt"
"os"
"path/filepath"
"strconv"
"github.com/beego/beego/v2/server/web"
)
// 登录用户的Session名
const LoginSessionName = "LoginSessionName"
const CaptchaSessionName = "__captcha__"
const RegexpEmail = "^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
// 允许用户名中出现点号
const RegexpAccount = `^[a-zA-Z0-9][a-zA-Z0-9\.-]{2,50}$`
// PageSize 默认分页条数.
const PageSize = 10
// 用户权限
const (
// 超级管理员.
MemberSuperRole SystemRole = iota
//普通管理员.
MemberAdminRole
//普通用户.
MemberGeneralRole
//只读用户.
MemberReaderRole
)
// 系统角色
type SystemRole int
const (
// 创始人.
BookFounder BookRole = iota
//管理者
BookAdmin
//编辑者.
BookEditor
//观察者
BookObserver
//未指定关系
BookRoleNoSpecific
)
// 项目角色
type BookRole int
const (
LoggerOperate = "operate"
LoggerSystem = "system"
LoggerException = "exception"
LoggerDocument = "document"
)
const (
//本地账户校验
AuthMethodLocal = "local"
//LDAP用户校验
AuthMethodLDAP = "ldap"
)
var (
VERSION string
BUILD_TIME string
GO_VERSION string
)
var (
ConfigurationFile = "./conf/app.conf"
WorkingDirectory = "./"
LogFile = "./runtime/logs"
BaseUrl = ""
AutoLoadDelay = 0
)
// app_key
func GetAppKey() string {
return web.AppConfig.DefaultString("app_key", "mindoc")
}
func GetDatabasePrefix() string {
return web.AppConfig.DefaultString("db_prefix", "md_")
}
// 获取默认头像
func GetDefaultAvatar() string {
return URLForWithCdnImage(web.AppConfig.DefaultString("avatar", "/static/images/headimgurl.jpg"))
}
// 获取阅读令牌长度.
func GetTokenSize() int {
return web.AppConfig.DefaultInt("token_size", 12)
}
// 获取默认文档封面.
func GetDefaultCover() string {
return URLForWithCdnImage(web.AppConfig.DefaultString("cover", "/static/images/book.jpg"))
}
// 获取允许的上传文件的类型.
func GetUploadFileExt() []string {
ext := web.AppConfig.DefaultString("upload_file_ext", "png|jpg|jpeg|gif|txt|doc|docx|pdf|mp4")
temp := strings.Split(ext, "|")
exts := make([]string, len(temp))
i := 0
for _, item := range temp {
if item != "" {
exts[i] = item
i++
}
}
return exts
}
// 获取上传文件允许的最大值
func GetUploadFileSize() int64 {
size := web.AppConfig.DefaultString("upload_file_size", "0")
if strings.HasSuffix(size, "TB") {
if s, e := strconv.ParseInt(size[0:len(size)-2], 10, 64); e == nil {
return s * 1024 * 1024 * 1024 * 1024
}
}
if strings.HasSuffix(size, "GB") {
if s, e := strconv.ParseInt(size[0:len(size)-2], 10, 64); e == nil {
return s * 1024 * 1024 * 1024
}
}
if strings.HasSuffix(size, "MB") {
if s, e := strconv.ParseInt(size[0:len(size)-2], 10, 64); e == nil {
return s * 1024 * 1024
}
}
if strings.HasSuffix(size, "KB") {
if s, e := strconv.ParseInt(size[0:len(size)-2], 10, 64); e == nil {
return s * 1024
}
}
if s, e := strconv.ParseInt(size, 10, 64); e == nil {
return s
}
return 0
}
// 是否启用导出
func GetEnableExport() bool {
return web.AppConfig.DefaultBool("enable_export", true)
}
// 是否启用iframe
func GetEnableIframe() bool {
return web.AppConfig.DefaultBool("enable_iframe", false)
}
// 同一项目导出线程的并发数
func GetExportProcessNum() int {
exportProcessNum := web.AppConfig.DefaultInt("export_process_num", 1)
if exportProcessNum <= 0 || exportProcessNum > 4 {
exportProcessNum = 1
}
return exportProcessNum
}
// 导出项目队列的并发数量
func GetExportLimitNum() int {
exportLimitNum := web.AppConfig.DefaultInt("export_limit_num", 1)
if exportLimitNum < 0 {
exportLimitNum = 1
}
return exportLimitNum
}
// 等待导出队列的长度
func GetExportQueueLimitNum() int {
exportQueueLimitNum := web.AppConfig.DefaultInt("export_queue_limit_num", 10)
if exportQueueLimitNum <= 0 {
exportQueueLimitNum = 100
}
return exportQueueLimitNum
}
// 默认导出项目的缓存目录
func GetExportOutputPath() string {
exportOutputPath := filepath.Join(web.AppConfig.DefaultString("export_output_path", filepath.Join(WorkingDirectory, "cache")), "books")
return exportOutputPath
}
// 判断是否是允许上传的文件类型.
func IsAllowUploadFileExt(ext string) bool {
if strings.HasPrefix(ext, ".") {
ext = string(ext[1:])
}
exts := GetUploadFileExt()
for _, item := range exts {
if item == "*" {
return true
}
if strings.EqualFold(item, ext) {
return true
}
}
return false
}
// 读取配置文件值
func CONF(key string, value ...string) string {
defaultValue := ""
if len(value) > 0 {
defaultValue = value[0]
}
return web.AppConfig.DefaultString(key, defaultValue)
}
// 重写生成URL的方法,加上完整的域名
func URLFor(endpoint string, values ...interface{}) string {
baseUrl := web.AppConfig.DefaultString("baseurl", "")
pathUrl := web.URLFor(endpoint, values...)
if baseUrl == "" {
baseUrl = BaseUrl
}
if strings.HasPrefix(pathUrl, "http://") {
return pathUrl
}
if strings.HasPrefix(pathUrl, "/") && strings.HasSuffix(baseUrl, "/") {
return baseUrl + pathUrl[1:]
}
if !strings.HasPrefix(pathUrl, "/") && !strings.HasSuffix(baseUrl, "/") {
return baseUrl + "/" + pathUrl
}
return baseUrl + web.URLFor(endpoint, values...)
}
func URLForNotHost(endpoint string, values ...interface{}) string {
baseUrl := web.AppConfig.DefaultString("baseurl", "")
pathUrl := web.URLFor(endpoint, values...)
if baseUrl == "" {
baseUrl = "/"
}
if strings.HasPrefix(pathUrl, "http://") {
return pathUrl
}
if strings.HasPrefix(pathUrl, "/") && strings.HasSuffix(baseUrl, "/") {
return baseUrl + pathUrl[1:]
}
if !strings.HasPrefix(pathUrl, "/") && !strings.HasSuffix(baseUrl, "/") {
return baseUrl + "/" + pathUrl
}
return baseUrl + web.URLFor(endpoint, values...)
}
func URLForWithCdnImage(p string) string {
if strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") {
return p
}
cdn := web.AppConfig.DefaultString("cdnimg", "")
//如果没有设置cdn,则使用baseURL拼接
if cdn == "" {
baseUrl := web.AppConfig.DefaultString("baseurl", "/")
if strings.HasPrefix(p, "/") && strings.HasSuffix(baseUrl, "/") {
return baseUrl + p[1:]
}
if !strings.HasPrefix(p, "/") && !strings.HasSuffix(baseUrl, "/") {
return baseUrl + "/" + p
}
return baseUrl + p
}
if strings.HasPrefix(p, "/") && strings.HasSuffix(cdn, "/") {
return cdn + string(p[1:])
}
if !strings.HasPrefix(p, "/") && !strings.HasSuffix(cdn, "/") {
return cdn + "/" + p
}
return cdn + p
}
func URLForWithCdnCss(p string, v ...string) string {
cdn := web.AppConfig.DefaultString("cdncss", "")
if strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") {
return p
}
filePath := WorkingDir(p)
if f, err := os.Stat(filePath); err == nil && !strings.Contains(p, "?") && len(v) > 0 && v[0] == "version" {
p = p + fmt.Sprintf("?v=%s", f.ModTime().Format("20060102150405"))
}
//如果没有设置cdn,则使用baseURL拼接
if cdn == "" {
baseUrl := web.AppConfig.DefaultString("baseurl", "/")
if strings.HasPrefix(p, "/") && strings.HasSuffix(baseUrl, "/") {
return baseUrl + p[1:]
}
if !strings.HasPrefix(p, "/") && !strings.HasSuffix(baseUrl, "/") {
return baseUrl + "/" + p
}
return baseUrl + p
}
if strings.HasPrefix(p, "/") && strings.HasSuffix(cdn, "/") {
return cdn + string(p[1:])
}
if !strings.HasPrefix(p, "/") && !strings.HasSuffix(cdn, "/") {
return cdn + "/" + p
}
return cdn + p
}
func URLForWithCdnJs(p string, v ...string) string {
cdn := web.AppConfig.DefaultString("cdnjs", "")
if strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") {
return p
}
filePath := WorkingDir(p)
if f, err := os.Stat(filePath); err == nil && !strings.Contains(p, "?") && len(v) > 0 && v[0] == "version" {
p = p + fmt.Sprintf("?v=%s", f.ModTime().Format("20060102150405"))
}
//如果没有设置cdn,则使用baseURL拼接
if cdn == "" {
baseUrl := web.AppConfig.DefaultString("baseurl", "/")
if strings.HasPrefix(p, "/") && strings.HasSuffix(baseUrl, "/") {
return baseUrl + p[1:]
}
if !strings.HasPrefix(p, "/") && !strings.HasSuffix(baseUrl, "/") {
return baseUrl + "/" + p
}
return baseUrl + p
}
if strings.HasPrefix(p, "/") && strings.HasSuffix(cdn, "/") {
return cdn + string(p[1:])
}
if !strings.HasPrefix(p, "/") && !strings.HasSuffix(cdn, "/") {
return cdn + "/" + p
}
return cdn + p
}
func WorkingDir(elem ...string) string {
elems := append([]string{WorkingDirectory}, elem...)
return filepath.Join(elems...)
}
func init() {
if p, err := filepath.Abs("./conf/app.conf"); err == nil {
ConfigurationFile = p
}
if p, err := filepath.Abs("./"); err == nil {
WorkingDirectory = p
}
if p, err := filepath.Abs("./runtime/logs"); err == nil {
LogFile = p
}
}
================================================
FILE: conf/lang/en-us.ini
================================================
[common]
title = mindoc
home = Home
blog = Blog
project_space = Project Space
person_center = Personal Center
my_project = My Project
my_blog = My Article
manage = Management
login = Log In
logout = Log Out
official_website = Official Website
feedback = Feedback
source_code = Source Code
manual = Manual
username = Username
account = Account
email = Email
password = Password
role = Role
captcha = Captcha
keep_login = Stay signed in
forgot_password = Forgot password?
register = Create New Account
third_party_login = Third Party Login
dingtalk_login = DingTalk Login
wecom_login = WeCom Login
account_recovery = Account recovery
new_password = New password
confirm_password = Confirm password
new_account = Create New Account
setting = Setting
save = Save
edit = Edit
delete = Delete
cancel = Cancel
create = Create
confirm_delete = Confirm
upload_lang = en
js_lang = en
remove = Remove
operate = Operate
confirm = Confirm
creator = Creator
administrator = Administrator
editor = Editor
observer = Observer
back = Back
detail = Detail
admin_right = Reading, writing and management
editor_right = Reading and writing
observer_right = Reading only
yes = yes
no = no
read = Read
generate = Generate
clean = Clean
[init]
default_proj_name = MinDoc Demo Project
default_proj_desc = This is a MinDoc demo project, which is automatically created when the system is initialized.
default_proj_space = Default Project Space
blank_doc = Blank document
[message]
tips = Tips
page_not_existed = The page does not exist
no_permission = No enough permissions
keyword_placeholder = input keyword please...
wrong_account_password = Incorrect username or password
wrong_password = Wrong password
click_to_change = Click to change one
logging_in = logging in...
need_relogin = Relogin please.
return_account_login = Return account password login
no_account_yet = No account yet?
has_account = Already have an account?
account_empty = Account cannot be empty
email_empty = Email cannot be empty
password_empty = Password cannot be empty
captcha_empty = Captcha cannot be empty
system_error = System error
processing = Processing...
email_sent = The email is sent successfully, please log in to check it.
confirm_password_empty = Confirm password cannot be empty
incorrect_confirm_password = Incorrect confirm password
illegal_request = Illegal request
account_or_password_empty = Account or Password cannot be empty
captcha_wrong = Incorrect captcha
password_length_invalid = The password cannot be empty and must be between 6-50 characters
mail_expired = Mail has expired
captcha_expired = The verification code has expired, please try again.
user_not_existed = User does not exist
readusr_only_observer = Read only users can only be set as observers
email_not_exist = Email does not exist
failed_save_password = Failed to save password
mail_service_not_enable = Mail service is not enabled
account_disable = Account has been disabled
failed_send_mail = Failed to send mail
sent_too_many_times = Send too many times, please try again later
account_not_support_retrieval = The current user does not support password retrieval
username_invalid_format = The account number can only be composed of English alphanumerics and 3-50 characters
email_invalid_format = Email format is incorrect
account_existed = Username already existed
failed_register = Registration failed, please contact the system administrator
failed_obtain_user_info = Failed to obtain identity information
dingtalk_auto_login_not_enable = DingTalk automatic login function is not enabled
failed_auto_login = Automatic login failed
no_project = No Project
item_not_exist = Item does not exist or has been deleted
item_not_exist_or_no_permit = Item does not exist or has insufficient permissions
doc_not_exist = Document does not exist or has been deleted
doc_not_exist_or_no_permit = Document does not exist or has insufficient permissions
unknown_exception = Unknown Exception
no_data = No Data
project_must_belong_space = Project must belong to a project space, and the administrator can manage it
project_title_placeholder = Title (limit in 30 words)
project_title_tips = Project name cannot exceed 100 characters
project_id_placeholder = Project ID (limit in 30 characters)
project_id_tips = The ID can only contain lowercase letters, numbers, and "-", "." and "_" symbols.
project_desc_placeholder = Project description cannot exceed 500 characters
project_public_desc = (Anyone can access)
project_private_desc = (Only participants or use tokens can access)
project_cover_desc = The Cover can be edit in the settings
confirm_delete_project = Are you sure you want to delete the project?
warning_delete_project = Remove items will not be retrieved.
project_space_empty = Please select project space
project_title_empty = Project title cannot be empty
project_id_empty = Project ID cannot be empty
project_id_existed = Project ID already in use
project_id_error = Project ID error
project_id_length = Project ID must be less than 50 characters
import_file_empty = Please select the file to upload
file_type_placeholder = Please select a Zip file
publish_to_queue = The publish task has been pushed to the task queue and will be executed soon.
team_name_empty = Team name cannot be empty
operate_failed = Operation failed
project_id_desc = The project ID is used to mark the uniqueness of the item and cannot be modified.
history_record_amount_desc = When document history enabled, this value limits the number of history saved per document
corp_id_desc = The footer that appears when the document PDF document is exported
project_desc_desc = The description information is no more than 500 characters, supports markdown syntax
project_desc_tips = The description information is no more than 500 characters
access_pass_desc = The password that needs to be provided to access the project without permit
auto_publish_desc = Enable for each save is automatically published to the latest version
enable_export_desc = Configure the exporter before you start the export, also enable the export function in the profile
enable_share_desc = Sharing is only available for public projects, and private projects do not support sharing
auto_save_desc = Save automatically every 30 seconds
confirm_into_private = Are you sure you want to make your project private?
into_private_notice = Private project need provide access token
confirm_into_public = Are you sure you want to make your project public?
into_public_notice = Public project could be visit by anyone
project_name_empty = Project name cannot be empty
success = Success
failed = Failed
receive_account_empty = The recipient account cannot be empty
receive_account_not_exist = The recipient account not exist
receive_account_disabled = The recipient account disable
cannot_preview = Cannot preview
upload_failed = Upload failed
upload_file_size_limit = The file must be less than 2MB
upload_file_empty = Upload file is empty
uploda_file_type_error = upload file type is wrong
choose_pic_file = Please select a picture
no_doc_in_cur_proj = No documents for the current project
build_doc_tree_error = There was an error building the project document tree
param_error = Parameter error
doc_name_empty = Document name cannot empty
parent_id_not_existed = Parent ID not existed
doc_not_belong_project = The document does not belong to the specified project`
attachment_not_exist = Attachment does not exist
read_file_error = Load file error
confirm_override_doc = The document has been modified, are you sure to override it?
doc_auto_published = The document was automatically published
export_func_disable = The export function is disable
cur_project_export_func_disable = The export function is disable for current Project
cur_project_not_support_md = The Markdown editor is not supported for the current project
export_failed = The export failed, check the system logs
file_converting = The document is being converted in the background, please download it later
unsupport_file_type = Unsupport file type
no_exportable_file = The project has no exportable file
gen_qrcode_failed = Generate QrCode failed
search_result_error = Search error
get_doc_his_failed = Fail to get document history
project_space_not_exist = Project space does not exist
search_placeholder = input keyword please
no_search_result = No search results!
user_exist_in_proj = The user already exists in the project
cannot_change_own_priv = Cannot change own permissions
cannot_delete_self = Cannot delete myself
cannot_handover_myself = Cannot handover to myself
confirm_delete_blog = Confirm delete blog?
delete_blog_tips = Deleted blog cannot be retrieved.
input_proj_id_pls = input project ID please
input_doc_id_pls = input document ID please
blog_digest_tips = blog digest cannot exceed 500 characters
blog_title_empty = blog title cannot be empty
blog_not_exist = Blog does not exist or has been deleted
blog_pwd_incorrect = Access password incorrect
set_pwd_pls = please set password for encrypt blog
unknown_blog_type = unknown blog type
blog_title_tips = blog title cannot exceed 200 characters
ref_doc_prj_not_existed = The Project of reference document not existed
query_ref_doc_error = query reference document failed
ref_doc_not_exist_or_no_permit = reference document does not exist or has insufficient permissions
blog_id_existed = blog id already existed
query_failed = query failed
blog_has_modified = The article has been modified
cur_user_cannot_change_pwd = The current user does not support changing the password
origin_pwd_empty = The origin password cannot be empty
new_pwd_empty = The new password cannot be empty
confirm_pwd_empty = The confirm password cannot be empty
pwd_length = Password must be between 6-18 characters
pwd_length_tips = Password must be between 6-50 characters
wrong_origin_pwd = The origin password incorrect
wrong_confirm_pwd = The confirm passwrod incorrect
same_pwd = The new password must different from the origin
pwd_encrypt_failed = Password encryption failed
team_name_empty = Team name cannot be emtpy
proj_empty = Project cannot be empty
site_name_empty = Site name cannot be empty
proj_space_name_empty = Project space name cannot be empty
proj_space_id_empty = Project space id cannot be empty
proj_space_id_tips = The project space id can only consist of letters and numbers and be between 2-100 characters
project_order_desc = Number only, sort from largest to smallest
project_label_desc = Allows up to 10 labels, use ";" to separate multiple tags
cannot_change_own_status = Cannot change own status
cannot_change_super_status = Cannot change super administrator status
cannot_change_super_priv = Cannot change super administrator permissions
editors_not_compatible = two editors are not compatible
[blog]
author = Author
project_list = Project List
add_project = Add Project
import_project = Import Project
delete_project = Delete Project
project_summary = Project summary
read = Read
edit = Edit
delete = Delete
copy = Copy
view = View
publish = Publish
edit_doc = Edit Document
default_cover = Default Cover
create_time = Create Time
update_time = Update Time
creator = Creator
doc_amount = Number of documents
doc_unit =
project_role = Project Role
last_edit = Last Edited
project_title = Project Title
project_id = Project ID
project_desc = Project description
public = Public
private = Private
public_project = Public Project
private_project = Private Project
summary = Summary
member = Member
team = Team
comment_amount = Number of comments
comment_unit =
member_manage = Member Manage
add_member = Add Member
administrator = Administrator
editor = Editor
observer = Observer
team_manage = Team Manage
add_team = Add Team
team_name = Team name
member_amount = Number of members
join_time = Join Time
project_setting = Project setting
handover_project = Handover
make_public = Into Public
make_private = Into Privete
history_record_amount = Number of history records
corp_id = corp name
text_editor = editor
project_label = Project Label
project_order = Project Order
access_pass = Access Password
access_token = Access Token
auto_publish = Auto publish
enable_export = Enable Export
enable_share = Enable Share
set_first_as_home = Set the first document as the default homepage
auto_save = Auto Save
cover = Cover
click_change_cover = Click to change the cover
change_cover = Change Cover
preview = Preview
choose = Choose
upload = Upload
recipient_account = Recipient
blog_list = Blog List
add_blog = Add Blog
encryption = encryption
encrypt = encrypt
edit_blog = Edit blog
delete_blog = Delete blog
setting_blog = Setting Blog
no_blog = No Blog
blog_setting = Blog Setting
title = Blog Title
type = Blog Type
normal_blog = Normal Blog
link_blog = Link Blog
ref_doc = Reference Document
blog_status = Blog Status
blog_pwd = Blog Password
blog_digest = Blog Digest
posted_on = Posted on
modified_on = Modified on
prev = prev
next = next
no = no
edit_title = Edit Blog
private_blog_tips = Private blog, please enter password to access
print_text = Enable Printing
[doc]
word_to_html = Word to HTML
html_to_markdown = HTML to Markdown
modify_doc = Modify Document
comparison = Comparison
save_merge = Save Merge
prev_diff = Previous difference
next_diff = Next difference
merge_to_left = Merge to left
merge_to_right = Merge to right
exchange_left_right = Exchange left and right
print = Print
download = Download
share = Share
share_project = Share project
share_url = Project URL
contents = Contents
search = Search
expand = Unfold
fold = Fold
close = Close
doc_publish_by = Document is Published by
doc_publish =
edit_doc = Edit Document
backward = backward
save = save
save_as_tpl = save as template
undo = undo
redo = redo
bold = bold
italic = italic
strikethrough = strikethrough
h1 = head 1
h2 = head 2
h3 = head 3
h4 = head 4
h5 = head 5
h6 = head 6
unorder_list = disorder list
order_list = order list
hline = horizontal line
link = link
ref_link = reference link
add_pic = add picture
code = code
code_block = code block
table = add table
quote = quote
gfm_task = GFM task
attachment = attachment
json_to_table = Json converted to table
template = template
draw = draw
close_preview = disable preview
modify_history = modify history
sidebar = sidebar
help = help
publish = publish
document = document
create_doc = create document
attachments = attachments
doc_name = Document name
doc_name_tips = Right-click on the document name of the directory to delete and modify the document name and add subordinate documents
doc_id = Document ID
doc_id_tips = The document ID can only contain lowercase letters, numbers, and "-" and "_" symbols, and can only start with a lowercase letter
expand_desc = (Expand nodes when reading)
fold_desc = (Fold nodes when reading)
empty_contents = Empty contents
empty_contents_desc = Click to expand the subordinate nodes
upload_attachment = Upload attachment
doc_history = Document history
choose_template_type = Choose template type please
normal_tpl = Normal
api_tpl = API
data_dict = Data Dictionary
custom_tpl = Custom
tpl_default_type = Default type
tpl_plain_text = Plain Text
for_api_doc = Used for API Document
code_highlight = support code highlighting
for_data_dict = Used for data dictionary
form_support = Form Support
any_type_doc = Support any type of document
as_global_tpl = Can be set as a global template
tpl_name = Template name
tpl_type = Template type
creator = Creator
create_time = Create time
operation = Operation
global_tpl = Global
global_tpl_desc = (Any Project is available)
project_tpl = Project
project_tpl_desc = (Only the current Project is available)
insert = insert
uploading = Uploading
his_ver = Historic version
update_time = Update time
updater = Updater
version = Version
delete = Delete
recover = Recover
merge = Merge
comparison_title = Document comparison [the left is the historical document, the right is the current document, please merge the documents to the right]
font_size = font size
underscore = underscore
right_intent = right intent
left_intent = left intent
subscript = subscript
superscript = superscript
clean_format = clear format
add_video = add video
formula = formula
font_color = font color
bg_color = background color
input_pwd = input password please
read_pwd = read password
commit = commit
ft_author = Author:
ft_last_editor = Last editor:
ft_create_time = Create time:
ft_update_time = Update time:
view_count = Number of views
changetheme = Switch themes
prev = prev
next = next
[project]
prj_space_list = Project Space List
prj_space_list_of = List of Project Space %s
search_title = Show Items of Project Space "%s"
author = Author
no_project = No Project
prj_amount = Project Count
creator = Creator
create_time = Create time
no_project_space = No Project Space
[search]
title = Search
search_title = Show search result for %s
doc = document
prj = project
blog = blog
from_proj = from project
from_blog = from blog
author = author
update_time = update time
no_result = No search result
[page]
first = first
last = last
prev = prev
next = next
[uc]
user_center = User Center
base_info = Basic Info
change_pwd = Change Password
username = Username
nickname = Nickname
realname = Real name
email = Email
mobile = Mobile
description = Description
description_tips = Description cannot exceed 500 characters
avatar = Avatar
change_avatar = Change avatar
password = Password
origin_pwd = Origin password
new_pwd = New password
confirm_pwd = Confirm password
role = Role
type = Type
status = Status
super_admin = Super administrator
admin = Administrator
user = User
read_usr = Read-Only User
normal = Normal
disable = Disable
enable = Enable
create_user = Create User
edit_user = Edit User
pwd_tips = Please leave it blank if you do not change the password, only local users can change the password
[mgr]
language = Default Language
zh_cn = 简体中文
en_us = English
dashboard_menu = Dashboard
user_menu = User
team_menu = Team
project_menu = Project
project_space_menu = Project Space
comment_menu = Comment
config_menu = Configure
attachment_menu = Attachment
label_menu = Label
dashboard_mgr = Dashboard
user_mgr = User Management
team_mgr = Team Management
project_mgr = Project Management
project_space_mgr = Project Space Management
comment_mgr = Comment Management
config_mgr = Configure Management
config_file = Configure File
attachment_mgr = Attachment Management
label_mgr = Label Management
label_name = Label name
used_quantity = Used Quantity
proj_amount = Number Of Project
blog_amount = Number Of Blog
member_amount = Number Of Member
comment_amount = Number Of Comment
attachment_amount = Number Of Attachment
member_mgr = Member Management
add_member = Add Member
create_team = Create Team
team_name = Team Name
proj = Project
member = Member
edit_team = Edit Team
team_member_mgr = Team Member Management
team_proj = Team Project
add_proj = Add Project
proj_name = Project name
proj_author = Project author
join_time = Join Time
join_proj = Join
file_name = File name
is_exist = Is Exist
exist = Exist
deleted = Deleted
proj_blog_name = Project/Blog name
doc_name = Document name
file_path = File path
download_url = Download URL
file_size = File size
upload_time = Upload time
download = Download
download_title = Download to local
attachment_name = Attachment name
site_name = Site Name
domain_icp = Domain ICP
site_desc = Site Description
site_desc_tips = Description cannot exceed 500 characters
enable_anonymous_access = Enable anonymous access
enable = Enable
disable = Disable
enable_register = Enable Registration
enable_captcha = Enable Captcha
enable_doc_his = Enable Document Historic
proj_space_name = Project space name
proj_space_id = Project space ID
create_proj_space = Create Project Space
edit_proj_space = Edit Project Space
proj_list = Project List
edit_proj = Edit Project
create_time = Create Time
creator = Creator
doc_amount = Number of Document
last_edit = Last Edit
delete_project = Delete Project
================================================
FILE: conf/lang/ru-ru.ini
================================================
[common]
title = MinDoc
home = Домашняя
blog = Блог
project_space = Проекты
person_center = Профиль
my_project = Мои проекты
my_blog = Моя статья
manage = Управление
login = Войти
logout = Выйти
official_website = Официальный сайт
feedback = Обратная связь
source_code = Исходный код
manual = Руководство пользователя
username = Имя пользователя
account = Аккаунт
email = Email
password = Пароль
role = Роль
captcha = Капча
keep_login = Оставайтесь в системе
forgot_password = забыл пароль?
register = Создать новый аккаунт
third_party_login = Вход третьей стороны
dingtalk_login = Войти с помощью DingTalk
wecom_login = Войти через Wecom
account_recovery = Восстановление аккаунта
new_password = Новый пароль
confirm_password = Подтвердите пароль
new_account = Создать новую учетную запись
setting = Настройка
save = Сохранить
edit = Изменить
delete = Удалить
cancel = Отмена
create = Создать
confirm_delete = Подтверждать
upload_lang = ru
js_lang = ru-RU
remove = Удалять
operate = Оперировать
confirm = Подтверждать
creator = Создатель
administrator = Администратор
editor = Редактор
observer = Наблюдатель
back = Назад
detail = Деталь
admin_right = Чтение, письмо и управление
editor_right = Чтение и письмо
observer_right = Только чтение
yes = Да
no = Нет
read = Читать
generate = Генерировать
clean = Чистый
[init]
default_proj_name = Демонстрационный проект MinDoc
default_proj_desc = Это демонстрационный проект MinDoc, который автоматически создается при инициализации системы
default_proj_space = Пространство проекта по умолчанию
blank_doc = Пустой документ
[message]
tips = Советы
page_not_existed = Эта страница не существует
no_permission = доступ запрещен
keyword_placeholder = введите ключевое слово, пожалуйста...
wrong_account_password = Неправильное имя пользователя или пароль
wrong_password = Неправильный пароль
click_to_change = Нажмите, чтобы изменить один
logging_in = Вход в систему...
need_relogin = Пожалуйста, войдите снова
return_account_login = вернуться к входу с учетной записью и паролем
no_account_yet = У вас еще нет аккаунта?
has_account = У вас уже есть аккаунт?
account_empty = Счет не может быть пустым
email_empty = Электронная почта не может быть пустой
password_empty = Пароль не может быть пустым
captcha_empty = Капча не может быть пустой
system_error = Система обнаружила ошибку
processing = Обработка...
email_sent = Письмо успешно отправлено, пожалуйста, проверьте его
confirm_password_empty = Подтвержденный пароль не может быть пустым
incorrect_confirm_password = Неверный подтвержденный пароль
illegal_request = Незаконный запрос
account_or_password_empty = Учетная запись или пароль не могут быть пустыми
captcha_wrong = Неправильная капча
password_length_invalid = Пароль не может быть пустым и должен содержать от 6 до 50 символов
mail_expired = почта просрочена
captcha_expired = Срок действия капчи истек, попробуйте еще раз
user_not_existed = этот пользователь не существует
readusr_only_observer = Толькі для чытання карыстальнікаў можна ўсталяваць толькі як назіральнікі
email_not_exist = этот адрес электронной почты не существует
failed_save_password = Не удалось сохранить пароль
mail_service_not_enable = Служба электронной почты не включена
account_disable = эта учетная запись была отключена
failed_send_mail = Не удалось отправить электронное письмо
sent_too_many_times = Отправьте слишком много раз, попробуйте еще раз позже
account_not_support_retrieval = Текущий пользователь не поддерживает восстановление пароля
username_invalid_format = Имя учетной записи может состоять только из английских букв, цифр и 3–50 символов
email_invalid_format = Неверный формат электронной почты
account_existed = Имя пользователя уже существует
failed_register = Регистрация не удалась, обратитесь к системному администратору
failed_obtain_user_info = Не удалось получить идентификационную информацию
dingtalk_auto_login_not_enable = Функция автоматического входа в DingTalk не включена
failed_auto_login = Автоматический вход не удался
no_project = Нет проекта
item_not_exist = Элемент не существует или был удален
item_not_exist_or_no_permit = Элемент не существует или имеет недостаточно прав
doc_not_exist = Документ не существует или был удален
doc_not_exist_or_no_permit = Документ не существует или имеет недостаточно прав
unknown_exception = Неизвестное исключение
no_data = Нет данных
project_must_belong_space = Каждый проект должен принадлежать к проектному пространству, и его администратор может им управлять
project_title_placeholder = Название (ограничение в 30 слов)
project_title_tips = Название проекта не может превышать 100 символов
project_id_placeholder = Идентификатор проекта (ограничение в 30 символов)
project_id_tips = Идентификатор может содержать только строчные буквы, цифры и символы «-», «.» и «_»
project_desc_placeholder = Описание проекта не может превышать 500 символов
project_public_desc = (Доступ возможен для всех)
project_private_desc = (Доступ возможен только участникам или пользователям токенов)
project_cover_desc = Изображение обложки проекта можно изменить в настройках проекта
confirm_delete_project = Вы уверены, что хотите удалить проект?
warning_delete_project = После удаления проекта его невозможно восстановить
project_space_empty = Пожалуйста, выберите проектное пространство
project_title_empty = Название проекта не может быть пустым
project_id_empty = ID проекта не может быть пустым
project_id_existed = Идентификатор проекта уже используется
project_id_error = Неверный идентификатор проекта
project_id_length = Идентификатор проекта должен быть менее 50 символов
import_file_empty = Пожалуйста, выберите файл для загрузки
file_type_placeholder = Пожалуйста, выберите файл zip/docx
publish_to_queue = Задача публикации помещена в очередь задач и будет выполнена в ближайшее время
team_name_empty = Название команды не может быть пустым
operate_failed = Операция не удалась
project_id_desc = Идентификатор проекта используется для обозначения уникальности элемента и не может быть изменен
history_record_amount_desc = Если включена история документа, это значение ограничивает количество сохраненных историй для каждого документа
corp_id_desc = Нижний колонтитул, который появляется при экспорте документа PDF
project_desc_desc = Описание информации не более 500 символов, поддерживает синтаксис markdown
project_desc_tips = Описательная информация не более 500 символов
access_pass_desc = Пароль, который вам необходимо указать, если у вас нет разрешения на доступ к проекту
auto_publish_desc = После включения каждое сохранение будет автоматически публиковаться в последней версии
enable_export_desc = Перед включением экспорта настройте программу экспорта и одновременно включите функцию экспорта в файле конфигурации
enable_share_desc = Совместное использование доступно только для публичных проектов. Частные проекты не поддерживают совместное использование
auto_save_desc = Сохранять автоматически каждые 30 секунд
confirm_into_private = Вы уверены, что хотите сделать проект приватным?
into_private_notice = Частный проект должен предоставить токен доступа
confirm_into_public = Вы уверены, что хотите сделать свой проект публичным?
into_public_notice = Публичный проект может посетить любой желающий
project_name_empty = Название проекта не может быть пустым
success = Успех
failed = Неуспешный
receive_account_empty = Счет получателя не может быть пустым
receive_account_not_exist = Учетная запись получателя не существует
receive_account_disabled = Отключить учетную запись получателя
cannot_preview = Невозможно просмотреть
upload_failed = Загрузка не удалась
upload_file_size_limit = Файл должен быть меньше 2MB
upload_file_empty = Загруженный файл пуст
uploda_file_type_error = Неправильный тип загруженного файла
choose_pic_file = Пожалуйста, выберите изображение
no_doc_in_cur_proj = Нет документов по текущему проекту
build_doc_tree_error = Произошла ошибка при построении дерева документов проекта
param_error = Ошибка параметра
doc_name_empty = Имя документа не может быть пустым
parent_id_not_existed = Родительский идентификатор не существует
doc_not_belong_project = Документ не принадлежит указанному проекту
attachment_not_exist = Вложение не существует
read_file_error = Ошибка чтения документа
confirm_override_doc = Документ был изменен. Вы уверены, что хотите его отменить?
doc_auto_published = Документ был автоматически успешно опубликован
export_func_disable = Функция экспорта отключена
cur_project_export_func_disable = Функция экспорта отключена для текущего проекта
cur_project_not_support_md = Редактор Markdown не поддерживается для текущего проекта
export_failed = Экспорт не удался, проверьте системный журнал
file_converting = Документ конвертируется в фоновом режиме, пожалуйста, загрузите его позже
unsupport_file_type = Неподдерживаемый тип файла
no_exportable_file = Проект не имеет экспортируемого файла
gen_qrcode_failed = Сгенерировать QR-код не удалось
search_result_error = Ошибка поиска
get_doc_his_failed = Не удалось получить историю документа
project_space_not_exist = Проектное пространство не существует
search_placeholder = введите ключевое слово пожалуйста
no_search_result = Результаты поиска отсутствуют!
user_exist_in_proj = Пользователь уже существует в проекте
cannot_change_own_priv = Невозможно изменить собственные разрешения
cannot_delete_self = Невозможно удалить себя
cannot_handover_myself = Cannot be transferred to yourself
confirm_delete_blog = Подтвердить удаление блога?
delete_blog_tips = Удаленный блог не может быть восстановлен
input_proj_id_pls = введите идентификатор проекта, пожалуйста
input_doc_id_pls = введите идентификатор документа, пожалуйста
blog_digest_tips = Дайджест блога не может превышать 500 символов
blog_title_empty = название блога не может быть пустым
blog_not_exist = Блог не существует или был удален
blog_pwd_incorrect = Неверный пароль доступа
set_pwd_pls = пожалуйста, установите пароль для шифрования блога
unknown_blog_type = неизвестный тип блога
blog_title_tips = Название блога не может превышать 200 символов
ref_doc_prj_not_existed = Проект справочного документа не существует
query_ref_doc_error = запрос справочного документа не удался
ref_doc_not_exist_or_no_permit = справочный документ не существует или имеет недостаточные разрешения
blog_id_existed = идентификатор блога уже существует
query_failed = запрос не удался
blog_has_modified = Статья была изменена
cur_user_cannot_change_pwd = Текущий пользователь не поддерживает смену пароля
origin_pwd_empty = Исходный пароль не может быть пустым
new_pwd_empty = Новый пароль не может быть пустым
confirm_pwd_empty = Подтверждение пароля не может быть пустым
pwd_length = Пароль должен содержать от 6 до 18 символов
pwd_length_tips = Пароль должен содержать от 6 до 50 символов
wrong_origin_pwd = Неверный пароль источника
wrong_confirm_pwd = Подтверждение пароля неверное
same_pwd = Новый пароль должен отличаться от исходного
pwd_encrypt_failed = Шифрование пароля не удалось
team_name_empty = Название команды не может быть пустым
proj_empty = Проект не может быть пустым
site_name_empty = Имя сайта не может быть пустым
proj_space_name_empty = Имя пространства проекта не может быть пустым
proj_space_id_empty = Идентификатор пространства проекта не может быть пустым
proj_space_id_tips = Идентификатор пространства проекта может состоять только из букв и цифр и содержать от 2 до 100 символов
project_order_desc = Только число, сортировать от большего к меньшему
project_label_desc = Максимально допустимое количество тегов — 10. Разделяйте несколько тегов знаком «;»
cannot_change_own_status = Невозможно изменить свой статус
cannot_change_super_status = Невозможно изменить статус суперадминистратора
cannot_change_super_priv = Невозможно изменить права суперадминистратора
editors_not_compatible = Эти два редактора несовместимы
[blog]
author = Автор
project_list = Список проектов
add_project = Добавить проект
import_project = Импорт проекта
delete_project = Удалить проект
project_summary = Резюме проекта
read = Читать
edit = Редактировать
delete = Удалить
copy = Копировать
view = Вид
publish = Публиковать
edit_doc = Редактировать документ
default_cover = Обложка по умолчанию
create_time = Время создания
update_time = Время обновления
creator = Создатель
doc_amount = Количество документов
doc_unit =
project_role = Роль проекта
last_edit = последний раз редактировалось
project_title = Название проекта
project_id = Идентификатор проекта
project_desc = Описание проекта
public = общественный
private = Частный
public_project = Общественный проект
private_project = Частный проект
summary = Краткое содержание
member = Член
team = Команда
comment_amount = Количество комментариев
comment_unit =
member_manage = управление членами
add_member = Добавить участника
administrator = Администратор
editor = Редактор
observer = Наблюдатель
team_manage = управление командой
add_team = Добавить команду
team_name = Название команды
member_amount = Количество членов
join_time = Присоединился Время
project_setting = Настройка проекта
handover_project = Проект передачи
make_public = В общественность
make_private = В частную жизнь
history_record_amount = Количество исторических записей
corp_id = название корпорации
text_editor = монтажер
project_label = Метка проекта
project_order = Сортировка проекта
access_pass = Пароль доступа
access_token = Токен доступа
auto_publish = Автоматическая публикация
enable_export = Включить экспорт
enable_share = Включить Поделиться
set_first_as_home = Установить первый документ в качестве домашней страницы по умолчанию
auto_save = Автоматическое сохранение
cover = крышка
click_change_cover = Нажмите, чтобы изменить обложку проекта
change_cover = сменить обложку
preview = Предварительный просмотр
choose = Выбирать
upload = Загрузить
recipient_account = Получатель
blog_list = Список блогов
add_blog = Добавить блог
encryption = шифрование
encrypt = шифровать
edit_blog = Редактировать блог
delete_blog = Удалить блог
setting_blog = Настройка блога
no_blog = Нет блога
blog_setting = Настройка блога
title = Название блога
type = Тип блога
normal_blog = Обычный блог
link_blog = Связанный блог
ref_doc = Справочный документ
blog_status = Статус блога
blog_pwd = Пароль блога
blog_digest = Блог Дайджест
posted_on = Опубликовано
modified_on = Изменено
prev = предыдущий
next = следующий
no = нет
edit_title = Редактировать блог
private_blog_tips = Это частный блог, введите пароль для доступа
print_text = Включить печать
[doc]
word_to_html = Word в HTML
html_to_markdown = HTML в Markdown
modify_doc = Изменить документ
comparison = Сравнение
save_merge = Сохранить Объединить
prev_diff = Предыдущая разница
next_diff = Следующее отличие
merge_to_left = Объединить слева
merge_to_right = Объединить справа
exchange_left_right = Обмен левого и правого
print = Печать
download = Скачать
share = Делиться
share_project = Поделиться проектом
share_url = URL-адрес проекта
contents = Каталог
search = Поиск
expand = Расширять
fold = Складывать
close = Закрывать
doc_publish_by = Документ опубликован
doc_publish = выпускать
edit_doc = Редактировать документ
backward = назад
save = Сохранять
save_as_tpl = Сохранить как шаблон
undo = Отменить
redo = Переделать
bold = жирный
italic = курсив
strikethrough = зачеркивание
h1 = H1
h2 = H2
h3 = H3
h4 = H4
h5 = H5
h6 = H6
unorder_list = Неупорядоченный список
order_list = Упорядоченный список
hline = Горизонтальная линия
link = Связь
ref_link = Ссылка на ссылку
add_pic = добавить картинку
code = код
code_block = блок кода
table = Добавить таблицу
quote = цитировать
gfm_task = Задача GFM
attachment = вложение
json_to_table = конвертировать json в таблицу
template = шаблон
draw = нарисовать
close_preview = отключить предварительный просмотр
modify_history = История модификаций
sidebar = боковая панель
help = помощь
publish = публиковать
document = документ
create_doc = создать документ
attachments = вложения
doc_name = Название документа
doc_name_tips = Щелкните правой кнопкой мыши по имени документа в каталоге, чтобы удалить и изменить имя документа, а также добавить подчиненные документы.
doc_id = Идентификатор документа
doc_id_tips = Идентификатор документа может содержать только строчные буквы, цифры, символы «-» и «_» и может начинаться только со строчной буквы.
expand_desc = (Расширять узлы при чтении)
fold_desc = (Сворачивайте узлы при чтении)
empty_contents = Пустой каталог
empty_contents_desc = Нажмите, чтобы развернуть подчиненные узлы
upload_attachment = Загрузить вложение
doc_history = История документа
choose_template_type = Выберите тип шаблона, пожалуйста
normal_tpl = Нормальный
api_tpl = API
data_dict = Словарь данных
custom_tpl = Обычай
tpl_default_type = Тип по умолчанию
tpl_plain_text = Обычный текст
for_api_doc = Используется для API-документа
code_highlight = поддержка подсветки кода
for_data_dict = Используется для словаря данных
form_support = Поддержка формы
any_type_doc = Поддержка любого типа документа
as_global_tpl = Можно установить как глобальный шаблон
tpl_name = Имя шаблона
tpl_type = Тип шаблона
creator = Создатель
create_time = Время создания
operation = Операция
global_tpl = Глобальный
global_tpl_desc = (Любой проект доступен)
project_tpl = Проект
project_tpl_desc = (Доступен только текущий проект)
insert = вставлять
uploading = Загрузка
his_ver = Историческая версия
update_time = Время обновления
updater = Обновление
version = Версия
delete = Удалить
recover = Восстанавливаться
merge = Слияние
comparison_title = Сравнение документов [слева — исторический документ, справа — текущий документ, пожалуйста, объедините документы справа]
font_size = размер шрифта
underscore = подчеркивание
right_intent = правильное намерение
left_intent = левое намерение
subscript = нижний индекс
superscript = верхний индекс
clean_format = очистить формат
add_video = добавить видео
formula = формула
font_color = цвет шрифта
bg_color = цвет фона
input_pwd = введите пароль пожалуйста
read_pwd = прочитать пароль
commit = совершить
ft_author = Автор:
ft_last_editor = Последний редактор:
ft_create_time = Время создания:
ft_update_time = Время обновления:
view_count = Количество просмотров
changetheme = Переключить темы
[project]
prj_space_list = Список проектных пространств
prj_space_list_of = Список проектного пространства %s
search_title = Показать элементы пространства проекта "%s"
author = Автор
no_project = Нет проекта
prj_amount = Количество проектов
creator = Создатель
create_time = Время создания
no_project_space = Нет места для проекта
[search]
title = Поиск
search_title = Показать результат поиска для "%s"
doc = документ
prj = проект
blog = блог
from_proj = из проекта
from_blog = из блога
author = автор
update_time = время обновления
no_result = Нет результатов поиска
[page]
first = первый
last = последний
prev = предыдущий
next = следующий
[uc]
user_center = Центр пользователя
base_info = основная информация
change_pwd = Изменить пароль
username = Имя пользователя
nickname = Псевдоним
realname = Настоящее имя
email = Email
mobile = Номер телефона
description = Описание
description_tips = Описание не может превышать 500 символов.
avatar = Аватар
change_avatar = Изменить аватар
password = Пароль
origin_pwd = Исходный пароль
new_pwd = Новый пароль
confirm_pwd = Подтвердите пароль
role = Роль
type = Тип
status = Статус
super_admin = Супер администратор
admin = Администратор
user = Пользователь
read_usr = Пользователи только для чтения
normal = Нормальный
disable = Отключено
enable = Включено
create_user = Добавить пользователя
edit_user = Редактировать пользователя
pwd_tips = Пожалуйста, оставьте поле пустым, если вы не меняете пароль. Изменить пароль могут только локальные пользователи.
[mgr]
language = Язык по умолчанию
zh_cn = 简体中文
en_us = English
dashboard_menu = Приборная панель
user_menu = Пользователи
team_menu = Команды
project_menu = Проекты
project_space_menu = Проектные пространства
comment_menu = Комментарии
config_menu = Конфигурирует
attachment_menu = Вложения
label_menu = Этикетки
dashboard_mgr = Приборная панель
user_mgr = Управление пользователями
team_mgr = Управление командой
project_mgr = Управление проектом
project_space_mgr = Управление пространством проекта
comment_mgr = Управление комментариями
config_mgr = Конфигурирует управление
config_file = Настроить файл
attachment_mgr = Управление вложениями
label_mgr = Управление этикетками
label_name = Название этикетки
used_quantity = Использованное количество
proj_amount = Количество проектов
blog_amount = Количество блогов
member_amount = Количество членов
comment_amount = Количество комментариев
attachment_amount = Количество вложений
member_mgr = Управление участниками
add_member = Добавить участника
create_team = Создать команду
team_name = Название команды
proj = Проект
member = Член
edit_team = Редактировать информацию о команде
team_member_mgr = Управление членами команды
team_proj = Командный проект
add_proj = Добавить проект
proj_name = Название проекта
proj_author = Автор проекта
join_time = Присоединяйтесь Время
join_proj = Присоединиться
file_name = Имя файла
is_exist = Существует
exist = Существовать
deleted = Удалено
proj_blog_name = Название проекта/блога
doc_name = Название документа
file_path = Путь файла
download_url = URL для загрузки файла
file_size = Размер файла
upload_time = Время, когда файл был загружен
download = Скачать
download_title = Загрузить на локальный путь
attachment_name = Имя вложения
site_name = Название сайта
domain_icp = Регистрационный номер доменного имени ICP
site_desc = Описание веб-сайта
site_desc_tips = Описание не может превышать 500 символов
enable_anonymous_access = Включить анонимный доступ
enable = Включить
disable = Отключить
enable_register = Включить регистрацию
enable_captcha = Включить капчу
enable_doc_his = Включить историю документов
proj_space_name = Название пространства проекта
proj_space_id = Идентификатор пространства проекта
create_proj_space = Создать пространство проекта
edit_proj_space = Редактировать пространство проекта
proj_list = Список проектов
edit_proj = Редактировать проект
create_time = Время создания
creator = Создатель
doc_amount = Количество документов
last_edit = Последний редактор
delete_project = Удалить проект
================================================
FILE: conf/lang/zh-cn.ini
================================================
[common]
title = 文档在线管理系统
home = 首页
blog = 文章
project_space = 项目空间
person_center = 个人中心
my_project = 我的项目
my_blog = 我的文章
manage = 管理后台
login = 登录
logout = 退出登录
official_website = 官方网站
feedback = 意见反馈
source_code = 项目源码
manual = 使用手册
username = 用户名
account = 账号
email = 邮箱
password = 密码
role = 角色
captcha = 验证码
keep_login = 保持登录
forgot_password = 忘记密码?
register = 立即注册
third_party_login = 第三方登录
dingtalk_login = 钉钉登录
wecom_login = 企业微信登录
account_recovery = 找回密码
new_password = 新密码
confirm_password = 确认密码
new_account = 用户注册
setting = 设置
save = 保存
edit = 编辑
delete = 删除
cancel = 取消
create = 创建
confirm_delete = 确定删除
upload_lang = zh
js_lang = zh-CN
remove = 移除
operate = 操作
confirm = 确定
creator = 创始人
administrator = 管理员
editor = 编辑者
observer = 观察者
back = 返回
detail = 详情
admin_right = 拥有阅读、写作和管理权限
editor_right = 拥有阅读和写作权限
observer_right = 拥有阅读权限
yes = 是
no = 否
read = 阅读
generate = 生成
clean = 清理
[init]
default_proj_name = MinDoc演示项目
default_proj_desc = 这是一个MinDoc演示项目,该项目是由系统初始化时自动创建。
default_proj_space = 默认项目空间
blank_doc = 空白文档
[message]
tips = 友情提示
page_not_existed = 页面不存在
no_permission = 权限不足
keyword_placeholder = 请输入关键词...
wrong_account_password = 账号或密码错误
wrong_password = 密码错误
click_to_change = 点击换一张
logging_in = 正在登录...
need_relogin = 请重新登录。
return_account_login = 返回账号密码登录
no_account_yet = 还没有账号?
has_account = 已有账号?
account_empty = 账号不能为空
email_empty = 邮箱不能为空
password_empty = 密码不能为空
captcha_empty = 验证码不能为空
system_error = 系统错误
processing = 正在处理...
email_sent = 邮件发送成功,请登录邮箱查看。
confirm_password_empty = 确认密码不能为空
incorrect_confirm_password = 确认密码输入不正确
illegal_request = 非法请求
account_or_password_empty = 账号或密码不能为空
captcha_wrong = 验证码不正确
password_length_invalid = 密码不能为空且必须在6-50个字符之间
mail_expired = 邮件已失效
captcha_expired = 验证码已过期,请重新操作。
user_not_existed = 用户不存在
readusr_only_observer = 只读用户只能设置为观察者
email_not_exist = 邮箱不存在
failed_save_password = 保存密码失败
mail_service_not_enable = 未启用邮件服务
account_disable = 账号已被禁用
failed_send_mail = 发送邮件失败
sent_too_many_times = 发送次数太多,请稍候再试
account_not_support_retrieval = 当前用户不支持找回密码
username_invalid_format = 账号只能由英文字母数字组成,且在3-50个字符
email_invalid_format = 邮箱格式不正确
account_existed = 账号已存在
failed_register = 注册失败,请联系管理员
failed_obtain_user_info = 获取身份信息失败
dingtalk_auto_login_not_enable = 未开启钉钉自动登录功能
failed_auto_login = 自动登录失败
no_project = 暂无项目
item_not_exist = 项目不存在或已删除
item_not_exist_or_no_permit = 项目不存在或权限不足
doc_not_exist = 文档不存在或已删除
doc_not_exist_or_no_permit = 文档不存在或权限不足
unknown_exception = 未知异常
no_data = 暂无数据
project_must_belong_space = 每个项目必须归属一个项目空间,超级管理员可在后台管理和维护
project_title_placeholder = 项目标题(不超过100字)
project_title_tips = 项目标题不能超过100字符
project_id_placeholder = 项目唯一标识(不超过50字)
project_id_tips = 文档标识只能包含小写字母、数字,以及“-”、“.”和“_”符号.
project_desc_placeholder = 描述信息不超过500个字符
project_public_desc = (任何人都可以访问)
project_private_desc = (只有参与者或使用令牌才能访问)
project_cover_desc = 项目图片可在项目设置中修改
confirm_delete_project = 确定删除项目吗?
warning_delete_project = 删除项目后将无法找回。
project_space_empty = 请选择项目空间
project_title_empty = 项目标题不能为空
project_id_empty = 项目标识不能为空
project_id_existed = 文档标识已被使用
project_id_error = 项目标识有误
project_id_length = 项目标识必须小于50字符
import_file_empty = 请选择需要上传的文件
file_type_placeholder = 请选择Zip或Docx文件
publish_to_queue = 发布任务已推送到任务队列,稍后将在后台执行。
team_name_empty = 团队名称不能为空
operate_failed = 操作失败
project_id_desc = 项目标识用来标记项目的唯一性,不可修改。
history_record_amount_desc = 当开启文档历史时,该值会限制每个文档保存的历史数量
corp_id_desc = 导出文档PDF文档时显示的页脚
project_desc_desc = 描述信息不超过500个字符,支持Markdown语法
project_desc_tips = 项目描述不能大于500字
access_pass_desc = 没有访问权限访问项目时需要提供的密码
auto_publish_desc = 开启后,每次保存会自动发布到最新版本
enable_export_desc = 开启导出前请先配置导出程序,并在配置文件中同时开启导出功能
enable_share_desc = 分享只对公开项目生效,私有项目不支持分享
auto_save_desc = 开启后每隔30秒会自动保存
confirm_into_private = 确定将项目转为私有吗?
into_private_notice = 转为私有后需要通过阅读令牌才能访问该项目。
confirm_into_public = 确定将项目转为公有吗?
into_public_notice = 转为公有后所有人都可以访问该项目。
project_name_empty = 项目名称不能为空
success = 成功
failed = 失败
receive_account_empty = 接受者账号不能为空
receive_account_not_exist = 接受用户不存在
receive_account_disabled = 接受用户已被禁用
cannot_preview = 不能预览
upload_failed = 上传失败
upload_file_size_limit = 文件必须小于2MB
upload_file_empty = 上传文件为空
uploda_file_type_error = 文件类型有误
choose_pic_file = 请选择图片
no_doc_in_cur_proj = 当前项目没有文档
build_doc_tree_error = 生成项目文档树时出错
param_error = 参数错误
doc_name_empty = 文档名称不能为空
parent_id_not_existed = 父分类不存在
doc_not_belong_project = 文档不属于指定的项目
attachment_not_exist = 附件不存在或已删除
read_file_error = 读取文档错误
confirm_override_doc = 文档已被修改确定要覆盖吗?
doc_auto_published = 文档自动发布成功
export_func_disable = 系统没有开启导出功能
cur_project_export_func_disable = 当前项目没有开启导出功能
cur_project_not_support_md = 当前项目不支持Markdown编辑器
export_failed = 导出失败,请查看系统日志
file_converting = 文档正在后台转换,请稍后再下载
unsupport_file_type = 不支持的文件格式
no_exportable_file = 项目没有导出文件
gen_qrcode_failed = 生成二维码失败
search_result_error = 搜索结果错误
get_doc_his_failed = 获取历史失败
project_space_not_exist = 项目空间不存在
search_placeholder = 请输入搜索关键字
no_search_result = 暂无相关搜索结果!
user_exist_in_proj = 用户已存在该项目中
cannot_change_own_priv = 不能变更自己的权限
cannot_delete_self = 不能删除自己
cannot_handover_myself = 不能转让给自己
confirm_delete_blog = 确定删除文章吗?
delete_blog_tips = 删除文章后将无法找回。
input_proj_id_pls = 请输入项目标识
input_doc_id_pls = 请输入文档标识
blog_digest_tips = 文章摘要不超过500个字符
blog_title_empty = 文章标题不能为空
blog_not_exist = 文章不存在或已删除
blog_pwd_incorrect = 文章密码不正确
set_pwd_pls = 加密文章请设置密码
unknown_blog_type = 未知的文章类型
blog_title_tips = 文章标题不能大于200个字符
ref_doc_prj_not_existed = 关联文档的项目不存在
query_ref_doc_error = 查询关联项目文档时出错
ref_doc_not_exist_or_no_permit = 关联文档不存在或权限不足
blog_id_existed = 文章标识已存在
query_failed = 查询失败
blog_has_modified = 文章已被修改
cur_user_cannot_change_pwd = 当前用户不支持修改密码
origin_pwd_empty = 原密码不能为空
new_pwd_empty = 新密码不能为空
confirm_pwd_empty = 确认密码不能为空
pwd_length = 密码必须在6-18字之间
pwd_length_tips = 密码必须在6-50个字符之间
wrong_origin_pwd = 原始密码不正确
wrong_confirm_pwd = 确认密码不正确
same_pwd = 新密码不能和原始密码相同
pwd_encrypt_failed = 密码加密失败
team_name_empty = 团队名称不能为空
proj_empty = 项目不能为空
site_name_empty = 网站标题不能为空
proj_space_name_empty = 项目空间名称不能为空
proj_space_id_empty = 项目空间标识不能为空
proj_space_id_tips = 项目空间标识只能由字母和数字组成且在2-100字符之间
project_order_desc = 只能是数字,序号越大排序越靠前
project_label_desc = 最多允许添加10个标签,多个标签请用“;”分割
cannot_change_own_status = 不能变更自己的状态
cannot_change_super_status = 不能变更超级管理员的状态
cannot_change_super_priv = 不能变更超级管理员的权限
editors_not_compatible = 两种编辑器不兼容
[blog]
author = 作者
project_list = 项目列表
add_project = 添加项目
import_project = 导入项目
delete_project = 删除项目
project_summary = 项目概要
read = 阅读
edit = 编辑
delete = 删除
copy = 复制
view = 查看文档
publish = 发布
edit_doc = 编辑文档
default_cover = 默认封面
create_time = 创建时间
update_time = 修改时间
creator = 创建者
doc_amount = 文档数量
doc_unit = 篇
project_role = 项目角色
last_edit = 最后编辑
project_title = 项目标题
project_id = 项目标识
project_desc = 项目描述
public = 公开
private = 私有
public_project = 公开项目
private_project = 私有项目
summary = 概要
member = 成员
team = 团队
comment_amount = 评论数量
comment_unit = 条
member_manage = 成员管理
add_member = 添加成员
administrator = 管理员
editor = 编辑者
observer = 观察者
team_manage = 团队管理
add_team = 添加团队
team_name = 团队名称
member_amount = 成员数量
join_time = 加入时间
project_setting = 项目设置
handover_project = 转让项目
make_public = 转为公有
make_private = 转为私有
history_record_amount = 历史记录数量
corp_id = 公司名称
text_editor = 编辑器
project_label = 项目标签
project_order = 项目排序
access_pass = 访问密码
access_token = 访问令牌
auto_publish = 自动发布
enable_export = 开启导出
enable_share = 开启分享
set_first_as_home = 设置第一篇文档为默认首页
auto_save = 自动保存
cover = 封面
click_change_cover = 点击图片可修改项目封面
change_cover = 修改封面
preview = 预览
choose = 选择
upload = 上传
recipient_account = 接收者账号
blog_list = 文章列表
add_blog = 添加文章
encryption = 加密
encrypt = 密
edit_blog = 文章编辑
delete_blog = 删除文章
setting_blog = 文章设置
no_blog = 暂无文章
blog_setting = 文章设置
title = 文章标题
type = 文章类型
normal_blog = 普通文章
link_blog = 链接文章
ref_doc = 关联文档
blog_status = 文章状态
blog_pwd = 文章密码
blog_digest = 文章摘要
posted_on = 发布于
modified_on = 修改于
prev = 上一篇
next = 下一篇
no = 无
edit_title = 编辑文章
private_blog_tips = 加密文章,请输入密码访问
print_text = 开启打印
[doc]
word_to_html = Word转笔记
html_to_markdown = HTML转Markdown
modify_doc = 修改文档
comparison = 文档比较
save_merge = 保存合并
prev_diff = 上一处差异
next_diff = 下一处差异
merge_to_left = 合并到左侧
merge_to_right = 合并到右侧
exchange_left_right = 左右切换
print = 打印
download = 下载
share = 分享
share_project = 项目分享
share_url = 项目地址
contents = 目录
search = 搜索
expand = 展开
fold = 收起
close = 关闭
doc_publish_by = 本文档使用
doc_publish = 发布
edit_doc = 编辑文档
backward = 返回
save = 保存
save_as_tpl = 保存为模板
undo = 撤销
redo = 重做
bold = 粗体
italic = 斜体
strikethrough = 删除线
h1 = 标题一
h2 = 标题二
h3 = 标题三
h4 = 标题四
h5 = 标题五
h6 = 标题六
unorder_list = 无序列表
order_list = 有序列表
hline = Horizontal line
link = 链接
ref_link = 引用链接
add_pic = 添加图片
code = 行内代码
code_block = 代码块
table = 添加表格
quote = 引用
gfm_task = GFM 任务列表
attachment = 附件
json_to_table = Json转换为表格
template = 模板
draw = 画图
close_preview = 关闭实时预览
modify_history = 修改历史
sidebar = 边栏
help = 使用帮助
publish = 发布
document = 文档
create_doc = 创建文档
attachments = 个附件
doc_name = 文档名称
doc_name_tips = 在目录的文档名上右键可以删除和修改文档名称以及添加下级文档
doc_id = 文档标识
doc_id_tips = 文档标识只能包含小写字母、数字,以及“-”和“_”符号,并且只能小写字母开头
expand_desc = (在阅读时会自动展开节点)
fold_desc = (在阅读时会关闭节点)
empty_contents = 空目录
empty_contents_desc = (单击时会展开下级节点)
upload_attachment = 上传附件
doc_history = 文档历史记录
choose_template_type = 请选择模板类型
normal_tpl = 普通文档
api_tpl = API文档
data_dict = 数据字典
custom_tpl = 自定义模板
tpl_default_type = 默认类型
tpl_plain_text = 简单的文本文档
for_api_doc = 用于API文档速写
code_highlight = 支持代码高亮
for_data_dict = 用于数据字典显示
form_support = 表格支持
any_type_doc = 支持任意类型文档
as_global_tpl = 可以设置为全局模板
tpl_name = 模板名称
tpl_type = 模板类型
creator = 创建人
create_time = 创建时间
operation = 操作
global_tpl = 全局
global_tpl_desc = (任何项目都可用)
project_tpl = 项目
project_tpl_desc = (只有当前项目可用)
insert = 插入
uploading = 正在上传
his_ver = 历史版本
update_time = 修改时间
updater = 修改人
version = 版本
delete = 删除
recover = 恢复
merge = 合并
comparison_title = 文档比较【左侧为历史文档,右侧为当前文档,请将文档合并到右侧】
font_size = 字号
underscore = 下划线
right_intent = 右缩进
left_intent = 左缩进
subscript = 下标
superscript = 上标
clean_format = 清空格式
add_video = 添加视频
formula = 公式
font_color = 字体颜色
bg_color = 背景颜色
input_pwd = 请输入密码
read_pwd = 浏览密码
commit = 提交
ft_author = 作者:
ft_last_editor = 最后编辑:
ft_create_time = 创建时间:
ft_update_time = 更新时间:
view_count = 阅读次数
changetheme = 切换主题
prev = 上一篇
next = 下一篇
[project]
prj_space_list = 项目空间列表
prj_space_list_of = 项目空间%s的项目列表
search_title = 显示项目空间为"%s"的项目
author = 作者
no_project = 暂无项目
prj_amount = 项目数量
creator = 创建人
create_time = 创建时间
no_project_space = 没有项目空间
[search]
title = 搜索
search_title = 显示"%s"的搜索结果
doc = 文档
prj = 项目
blog = 文章
from_proj = 来自项目
from_blog = 来自文章
author = 作者
update_time = 更新时间
no_result = 暂无相关搜索结果
[page]
first = 首页
last = 末页
prev = 上一页
next = 下一页
[uc]
user_center = 用户中心
base_info = 基本信息
change_pwd = 修改密码
username = 用户名
nickname = 昵称
realname = 真实姓名
email = 邮箱
mobile = 手机号
description = 描述
description_tips = 描述不能超过500字
avatar = 头像
change_avatar = 修改头像
password = 密码
origin_pwd = 原始密码
new_pwd = 新密码
confirm_pwd = 确认密码
role = 角色
type = 类型
status = 状态
super_admin = 超级管理员
admin = 管理员
user = 普通用户
read_usr = 只读用户
normal = 正常
disable = 禁用
enable = 启用
create_user = 创建用户
edit_user = 编辑用户
pwd_tips = 不修改密码请留空,只支持本地用户修改密码
[mgr]
language = 默认语言
zh_cn = 简体中文
en_us = English
dashboard_menu = 仪表盘
user_menu = 用户管理
team_menu = 团队管理
project_menu = 项目管理
project_space_menu = 项目空间管理
comment_menu = 评论管理
config_menu = 配置管理
attachment_menu = 附件管理
label_menu = 标签管理
dashboard_mgr = 仪表盘
user_mgr = 用户管理
team_mgr = 团队管理
project_mgr = 项目管理
project_space_mgr = 项目空间管理
comment_mgr = 评论管理
config_mgr = 配置管理
config_file = 配置文件
attachment_mgr = 附件管理
label_mgr = 标签管理
label_name = 标签名称
used_quantity = 使用数量
proj_amount = 项目数量
blog_amount = 文章数量
member_amount = 成员数量
comment_amount = 评论数量
attachment_amount = 附件数量
member_mgr = 成员管理
add_member = 添加成员
create_team = 创建团队
team_name = 团队名称
proj = 项目
member = 成员
edit_team = 编辑团队
team_member_mgr = 团队用户管理
team_proj = 团队项目
add_proj = 添加项目
proj_name = 项目名称
proj_author = 项目作者
join_time = 加入时间
join_proj = 加入项目
file_name = 文件名称
is_exist = 是否存在
exist = 存在
deleted = 已删除
proj_blog_name = 项目/文章名称
doc_name = 文档名称
file_path = 文件路径
download_url = 下载路径
file_size = 文件大小
upload_time = 上传时间
download = 下载
download_title = 下载到本地
attachment_name = 附件名称
site_name = 网站标题
domain_icp = 域名备案
site_desc = 网站描述
site_desc_tips = 描述信息不超过500个字符
enable_anonymous_access = 启用匿名访问
enable = 开启
disable = 关闭
enable_register = 启用注册
enable_captcha = 启用验证码
enable_doc_his = 启用文档历史
proj_space_name = 项目空间名称
proj_space_id = 项目空间标识
create_proj_space = 创建项目空间
edit_proj_space = 编辑项目空间
proj_list = 项目列表
edit_proj = 编辑项目
create_time = 创建时间
creator = 创建者
doc_amount = 文档数量
last_edit = 最后编辑
delete_project = 删除项目
================================================
FILE: conf/mail.go
================================================
package conf
import (
"strings"
"github.com/beego/beego/v2/server/web"
)
type SmtpConf struct {
EnableMail bool
MailNumber int
SmtpUserName string
SmtpHost string
SmtpPassword string
SmtpPort int
FormUserName string
MailExpired int
Secure string
}
func GetMailConfig() *SmtpConf {
user_name, _ := web.AppConfig.String("smtp_user_name")
password, _ := web.AppConfig.String("smtp_password")
smtp_host, _ := web.AppConfig.String("smtp_host")
smtp_port := web.AppConfig.DefaultInt("smtp_port", 25)
form_user_name, _ := web.AppConfig.String("form_user_name")
enable_mail, _ := web.AppConfig.String("enable_mail")
mail_number := web.AppConfig.DefaultInt("mail_number", 5)
secure := web.AppConfig.DefaultString("secure", "NONE")
if secure != "NONE" && secure != "LOGIN" && secure != "SSL" {
secure = "NONE"
}
c := &SmtpConf{
EnableMail: strings.EqualFold(enable_mail, "true"),
MailNumber: mail_number,
SmtpUserName: user_name,
SmtpHost: smtp_host,
SmtpPassword: password,
FormUserName: form_user_name,
SmtpPort: smtp_port,
Secure: secure,
}
return c
}
================================================
FILE: conf/workweixin.go
================================================
package conf
import (
"github.com/beego/beego/v2/server/web"
)
type WorkWeixinConf struct {
CorpId string // 企业ID
AgentId string // 应用ID
Secret string // 应用密钥
// ContactSecret string // 通讯录密钥
}
func GetWorkWeixinConfig() *WorkWeixinConf {
corpid, _ := web.AppConfig.String("workweixin_corpid")
agentid, _ := web.AppConfig.String("workweixin_agentid")
secret, _ := web.AppConfig.String("workweixin_secret")
// contact_secret, _ := web.AppConfig.String("workweixin_contact_secret")
c := &WorkWeixinConf{
CorpId: corpid,
AgentId: agentid,
Secret: secret,
// ContactSecret: contact_secret,
}
return c
}
================================================
FILE: controllers/AccountController.go
================================================
package controllers
import (
"context"
"encoding/json"
"errors"
"github.com/mindoc-org/mindoc/cache"
"github.com/mindoc-org/mindoc/utils/auth2"
"github.com/mindoc-org/mindoc/utils/auth2/dingtalk"
"github.com/mindoc-org/mindoc/utils/auth2/wecom"
"html/template"
"math/rand"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/beego/beego/v2/client/orm"
"github.com/beego/beego/v2/core/logs"
"github.com/beego/beego/v2/server/web"
"github.com/beego/i18n"
"github.com/lifei6671/gocaptcha"
"github.com/mindoc-org/mindoc/conf"
"github.com/mindoc-org/mindoc/mail"
"github.com/mindoc-org/mindoc/models"
"github.com/mindoc-org/mindoc/utils"
)
const (
SessionUserInfoKey = "session-user-info-key"
AccessTokenCacheKey = "access-token-cache-key"
)
var src = rand.New(rand.NewSource(time.Now().UnixNano()))
// AccountController 用户登录与注册
type AccountController struct {
BaseController
}
func (c *AccountController) referer() string {
u, _ := url.PathUnescape(c.GetString("url"))
if u == "" {
u = conf.URLFor("HomeController.Index")
}
return u
}
func (c *AccountController) IsInWorkWeixin() bool {
ua := c.Ctx.Input.UserAgent()
var wechatRule = regexp.MustCompile(`\bMicroMessenger\/\d+(\.\d+)*\b`)
var wxworkRule = regexp.MustCompile(`\bwxwork\/\d+(\.\d+)*\b`)
return wechatRule.MatchString(ua) && wxworkRule.MatchString(ua)
}
func (c *AccountController) Prepare() {
c.BaseController.Prepare()
c.EnableXSRF = web.AppConfig.DefaultBool("enablexsrf", true)
c.Data["xsrfdata"] = template.HTML(c.XSRFFormHTML())
c.Data["CanLoginWorkWeixin"] = len(web.AppConfig.DefaultString("workweixin_corpid", "")) > 0
c.Data["CanLoginDingTalk"] = len(web.AppConfig.DefaultString("dingtalk_app_key", "")) > 0
if !c.EnableXSRF {
return
}
if c.Ctx.Input.IsPost() {
token := c.Ctx.Input.Query("_xsrf")
if token == "" {
token = c.Ctx.Request.Header.Get("X-Xsrftoken")
}
if token == "" {
token = c.Ctx.Request.Header.Get("X-Csrftoken")
}
if token == "" {
if c.IsAjax() {
c.JsonResult(403, i18n.Tr(c.Lang, "message.illegal_request"))
} else {
c.ShowErrorPage(403, i18n.Tr(c.Lang, "message.illegal_request"))
}
}
xsrfToken := c.XSRFToken()
if xsrfToken != token {
if c.IsAjax() {
c.JsonResult(403, i18n.Tr(c.Lang, "message.illegal_request"))
} else {
c.ShowErrorPage(403, i18n.Tr(c.Lang, "message.illegal_request"))
}
}
}
}
// Login 用户登录
func (c *AccountController) Login() {
c.TplName = "account/login.tpl"
if member, ok := c.GetSession(conf.LoginSessionName).(models.Member); ok && member.MemberId > 0 {
u := c.GetString("url")
if u == "" {
u = c.Ctx.Request.Header.Get("Referer")
}
if u == "" {
u = conf.URLFor("HomeController.Index")
}
c.Redirect(u, 302)
}
var remember CookieRemember
// 如果 Cookie 中存在登录信息
if cookie, ok := c.GetSecureCookie(conf.GetAppKey(), "login"); ok {
if err := utils.Decode(cookie, &remember); err == nil {
if member, err := models.NewMember().Find(remember.MemberId); err == nil {
c.SetMember(*member)
c.LoggedIn(false)
c.StopRun()
}
}
}
if c.Ctx.Input.IsPost() {
account := c.GetString("account")
password := c.GetString("password")
captcha := c.GetString("code")
isRemember := c.GetString("is_remember")
// 如果开启了验证码
if v, ok := c.Option["ENABLED_CAPTCHA"]; ok && strings.EqualFold(v, "true") {
v, ok := c.GetSession(conf.CaptchaSessionName).(string)
if !ok || !strings.EqualFold(v, captcha) {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.captcha_wrong"))
}
}
if account == "" || password == "" {
c.JsonResult(6002, i18n.Tr(c.Lang, "message.account_or_password_empty"))
}
member, err := models.NewMember().Login(account, password)
if err == nil {
member.LastLoginTime = time.Now()
_ = member.Update("last_login_time")
c.SetMember(*member)
if strings.EqualFold(isRemember, "yes") {
remember.MemberId = member.MemberId
remember.Account = member.Account
remember.Time = time.Now()
v, err := utils.Encode(remember)
if err == nil {
c.SetSecureCookie(conf.GetAppKey(), "login", v, time.Now().Add(time.Hour*24*30).Unix())
}
}
c.JsonResult(0, "ok", c.referer())
} else {
logs.Error("用户登录 ->", err)
c.JsonResult(500, i18n.Tr(c.Lang, "message.wrong_account_password"), nil)
}
return
}
referer := c.referer()
u := c.GetString("url")
if u == "" {
u = referer
if u == "" {
u = conf.BaseUrl
}
} else {
var schemaRule = regexp.MustCompile(`^https?\:\/\/`)
if !schemaRule.MatchString(u) {
u = conf.BaseUrl + u
}
}
c.Data["url"] = referer
auth2Redirect := "AccountController.Auth2Redirect"
if can, _ := c.Data["CanLoginWorkWeixin"].(bool); can {
c.Data["workweixin_login_url"] = conf.URLFor(auth2Redirect, ":app", wecom.AppName, "url", url.PathEscape(u))
}
if can, _ := c.Data["CanLoginDingTalk"].(bool); can {
c.Data["dingtalk_login_url"] = conf.URLFor(auth2Redirect, ":app", dingtalk.AppName, "url", url.PathEscape(u))
}
return
}
/*
Auth2.0 第三方对接思路:
1. Auth2Redirect: 点击相应第三方接口,路由重定向至第三方提供的Auth2.0地址
2. Auth2Callback: 第三方回调处理,接收回调的授权码,并获取用户信息
已绑定: 则读取用户信息,直接登录
未绑定: 则弹窗提示(需要敏感信息)
a) Auth2BindAccount: 绑定已有账户(用户名+密码)
b) Auth2AutoAccount: 自动创建账户,以第三方用户ID作为用户名,密码123456。
用该方式创建的账户,无法使用账号密码登录,需要修改一次密码后才可以进行账号密码登录。
*/
func (c *AccountController) getAuth2Client() (auth2.Client, error) {
app := c.Ctx.Input.Param(":app")
var client auth2.Client
tokenKey := AccessTokenCacheKey + "-" + app
switch app {
case wecom.AppName:
if can, _ := c.Data["CanLoginWorkWeixin"].(bool); !can {
return nil, errors.New("auth2.client.wecom.disabled")
}
corpId, _ := web.AppConfig.String("workweixin_corpid")
agentId, _ := web.AppConfig.String("workweixin_agentid")
secret, _ := web.AppConfig.String("workweixin_secret")
client = wecom.NewClient(corpId, agentId, secret)
case dingtalk.AppName:
if can, _ := c.Data["CanLoginDingTalk"].(bool); !can {
return nil, errors.New("auth2.client.dingtalk.disabled")
}
appKey, _ := web.AppConfig.String("dingtalk_app_key")
appSecret, _ := web.AppConfig.String("dingtalk_app_secret")
client = dingtalk.NewClient(appSecret, appKey)
default:
return nil, errors.New("auth2.client.notsupported")
}
var tokenCache auth2.AccessTokenCache
err := cache.Get(tokenKey, &tokenCache)
if err != nil {
logs.Info("AccessToken从缓存读取失败")
token, err := client.GetAccessToken(context.Background())
if err != nil {
return client, nil
}
tokenCache = auth2.NewAccessToken(token)
cache.Put(tokenKey, tokenCache, tokenCache.GetExpireIn())
}
// 处理过期Token
if tokenCache.IsExpired() {
token, err := client.GetAccessToken(context.Background())
if err != nil {
return client, nil
}
tokenCache = auth2.NewAccessToken(token)
cache.Put(tokenKey, tokenCache, tokenCache.GetExpireIn())
}
client.SetAccessToken(tokenCache)
return client, nil
}
func (c *AccountController) parseAuth2CallbackParam() (code, state string) {
switch c.Ctx.Input.Param(":app") {
case wecom.AppName:
code = c.GetString("code")
state = c.GetString("state")
case dingtalk.AppName:
code = c.GetString("authCode")
state = c.GetString("state")
}
logs.Debug("code: ", code)
logs.Debug("state: ", state)
return
}
func (c *AccountController) getAuth2Account() (models.Auth2Account, error) {
switch c.Ctx.Input.Param(":app") {
case wecom.AppName:
return models.NewWorkWeixinAccount(), nil
case dingtalk.AppName:
return models.NewDingTalkAccount(), nil
}
return nil, errors.New("auth2.account.notsupported")
}
// Auth2Redirect 第三方auth2.0登录: 钉钉、企业微信
func (c *AccountController) Auth2Redirect() {
client, err := c.getAuth2Client()
if err != nil {
c.DelSession(conf.LoginSessionName)
c.SetMember(models.Member{})
c.SetSecureCookie(conf.GetAppKey(), "login", "", -3600)
c.StopRun()
return
}
app := c.Ctx.Input.Param(":app")
var isAppBrowser bool
switch app {
case wecom.AppName:
isAppBrowser = c.IsInWorkWeixin()
}
var callback string
u := c.GetString("url")
if u == "" {
u = c.referer()
callback = conf.URLFor("AccountController.Auth2Callback", ":app", app)
}
if u != "" {
var schemaRule = regexp.MustCompile(`^https?\:\/\/`)
if !schemaRule.MatchString(u) {
u = strings.TrimRight(conf.BaseUrl, "/") + strings.TrimLeft(u, "/")
}
callback = conf.URLFor("AccountController.Auth2Callback", ":app", app, "url", url.PathEscape(u))
}
logs.Debug("callback: ", callback) // debug
c.Redirect(client.BuildURL(callback, isAppBrowser), http.StatusFound)
}
// Auth2Callback 第三方auth2.0回调
func (c *AccountController) Auth2Callback() {
client, err := c.getAuth2Client()
if err != nil {
c.DelSession(conf.LoginSessionName)
c.SetMember(models.Member{})
c.SetSecureCookie(conf.GetAppKey(), "login", "", -3600)
c.StopRun()
logs.Error(err)
return
}
if member, ok := c.GetSession(conf.LoginSessionName).(models.Member); ok && member.MemberId > 0 {
u := c.GetString("url")
if u == "" {
u = c.Ctx.Request.Header.Get("Referer")
}
if u == "" {
u = conf.URLFor("HomeController.Index")
}
member, err := models.NewMember().Find(member.MemberId)
if err != nil {
c.DelSession(conf.LoginSessionName)
c.SetMember(models.Member{})
c.SetSecureCookie(conf.GetAppKey(), "login", "", -3600)
} else {
c.SetMember(*member)
}
c.Redirect(u, 302)
}
var remember CookieRemember
// 如果 Cookie 中存在登录信息
if cookie, ok := c.GetSecureCookie(conf.GetAppKey(), "login"); ok {
if err := utils.Decode(cookie, &remember); err == nil {
if member, err := models.NewMember().Find(remember.MemberId); err == nil {
c.SetMember(*member)
c.LoggedIn(false)
c.StopRun()
}
}
}
c.TplName = "account/auth2_callback.tpl"
bindExisted := "false"
errMsg := ""
userInfoJson := "{}"
defer func() {
c.Data["bind_existed"] = template.JS(bindExisted)
logs.Debug("bind_existed: ", bindExisted)
c.Data["error_msg"] = template.JS(errMsg)
c.Data["user_info_json"] = template.JS(userInfoJson)
c.Data["app"] = template.JS(c.Ctx.Input.Param(":app"))
}()
// 请求参数获取
code, state := c.parseAuth2CallbackParam()
if err := client.ValidateCallback(state); err != nil {
c.DelSession(conf.LoginSessionName)
c.SetMember(models.Member{})
c.SetSecureCookie(conf.GetAppKey(), "login", "", -3600)
errMsg = err.Error()
logs.Error(err)
return
}
userInfo, err := client.GetUserInfo(context.Background(), code)
if err != nil {
c.DelSession(conf.LoginSessionName)
c.SetMember(models.Member{})
c.SetSecureCookie(conf.GetAppKey(), "login", "", -3600)
errMsg = err.Error()
logs.Error(err)
return
}
account, err := c.getAuth2Account()
if err != nil {
logs.Error("获取Auth2用户失败 ->", err)
c.JsonResult(500, "不支持的第三方用户", nil)
return
}
member, err := account.ExistedMember(userInfo.UserId)
if err != nil {
if err == orm.ErrNoRows {
if userInfo.Mobile == "" {
errMsg = "请到应用浏览器中登录,并授权获取敏感信息。"
} else {
jsonInfo, _ := json.Marshal(userInfo)
userInfoJson = string(jsonInfo)
errMsg = ""
c.SetSession(SessionUserInfoKey, userInfo)
}
} else {
logs.Error("Error: ", err)
errMsg = "登录错误: " + err.Error()
}
return
}
bindExisted = "true"
errMsg = ""
member.LastLoginTime = time.Now()
_ = member.Update("last_login_time")
c.SetMember(*member)
remember.MemberId = member.MemberId
remember.Account = member.Account
remember.Time = time.Now()
v, err := utils.Encode(remember)
if err == nil {
c.SetSecureCookie(conf.GetAppKey(), "login", v, time.Now().Add(time.Hour*24*30*5).Unix())
}
u := c.GetString("url")
if u == "" {
u = conf.URLFor("HomeController.Index")
}
c.Redirect(u, 302)
}
// Auth2BindAccount 第三方auth2.0绑定已有账号
func (c *AccountController) Auth2BindAccount() {
userInfo, ok := c.GetSession(SessionUserInfoKey).(auth2.UserInfo)
if !ok || len(userInfo.UserId) <= 0 {
c.DelSession(SessionUserInfoKey)
c.JsonResult(400, "请求错误, 请从首页重新登录")
return
}
account := c.GetString("account")
password := c.GetString("password")
if account == "" || password == "" {
c.JsonResult(400, "账号或密码不能为空")
return
}
member, err := models.NewMember().Login(account, password)
if err != nil {
logs.Error("用户登录 ->", err)
c.JsonResult(500, "账号或密码错误", nil)
return
}
bindAccount, err := c.getAuth2Account()
if err != nil {
logs.Error("获取Auth2用户失败 ->", err)
c.JsonResult(500, "不支持的第三方用户", nil)
return
}
member.CreateAt = 0
ormer := orm.NewOrm()
o, err := ormer.Begin()
if err != nil {
logs.Error("开启事务时出错 -> ", err)
c.JsonResult(500, "开启事务时出错: ", err.Error())
return
}
if err := bindAccount.AddBind(ormer, userInfo, member); err != nil {
logs.Error(err)
o.Rollback()
c.JsonResult(500, "绑定失败,数据库错误: "+err.Error())
return
}
// 绑定成功之后修改用户信息
member.LastLoginTime = time.Now()
//member.RealName = user_info.Name
//member.Avatar = user_info.Avatar
if len(member.Avatar) < 1 {
member.Avatar = conf.GetDefaultAvatar()
}
//member.Email = user_info.Email
//member.Phone = user_info.Mobile
if _, err := ormer.Update(member, "last_login_time", "real_name", "avatar", "email", "phone"); err != nil {
o.Rollback()
logs.Error("保存用户信息失败=>", err)
c.JsonResult(500, "绑定失败,现有账户信息更新失败: "+err.Error())
return
}
if err := o.Commit(); err != nil {
logs.Error("开启事务时出错 -> ", err)
c.JsonResult(500, "开启事务时出错: ", err.Error())
return
}
c.DelSession(SessionUserInfoKey)
c.SetMember(*member)
var remember CookieRemember
remember.MemberId = member.MemberId
remember.Account = member.Account
remember.Time = time.Now()
v, err := utils.Encode(remember)
if err != nil {
c.JsonResult(500, "绑定成功, 但自动登录失败, 请返回首页重新登录", nil)
return
}
c.SetSecureCookie(conf.GetAppKey(), "login", v, time.Now().Add(time.Hour*24*30*5).Unix())
c.JsonResult(0, "绑定成功", nil)
}
// Auth2AutoAccount auth2.0自动创建账号
func (c *AccountController) Auth2AutoAccount() {
app := c.Ctx.Input.Param(":app")
logs.Debug("app: ", app)
userInfo, ok := c.GetSession(SessionUserInfoKey).(auth2.UserInfo)
if !ok || len(userInfo.UserId) <= 0 {
c.DelSession(SessionUserInfoKey)
c.JsonResult(400, "请求错误, 请从首页重新登录")
return
}
c.DelSession(SessionUserInfoKey)
member := models.NewMember()
if _, err := member.FindByAccount(userInfo.UserId); err == nil && member.MemberId > 0 {
c.JsonResult(400, "账号已存在")
return
}
ormer := orm.NewOrm()
o, err := ormer.Begin()
if err != nil {
logs.Error("开启事务时出错 -> ", err)
c.JsonResult(500, "开启事务时出错: ", err.Error())
return
}
member.Account = userInfo.UserId
member.RealName = userInfo.Name
member.Password = "123456" // 强制设置默认密码,需修改一次密码后,才可以进行账号密码登录
hash, err := utils.PasswordHash(member.Password)
if err != nil {
logs.Error("加密用户密码失败 =>", err)
c.JsonResult(500, "加密用户密码失败"+err.Error())
return
}
logs.Debug("member.Password: ", member.Password)
logs.Debug("hash: ", hash)
member.Password = hash
member.Role = conf.MemberGeneralRole
member.Avatar = userInfo.Avatar
if len(member.Avatar) < 1 {
member.Avatar = conf.GetDefaultAvatar()
}
member.CreateAt = 0
member.Email = userInfo.Mail
member.Phone = userInfo.Mobile
member.Status = 0
if _, err = ormer.Insert(member); err != nil {
o.Rollback()
c.JsonResult(500, "注册失败,数据库错误: "+err.Error())
return
}
account, err := c.getAuth2Account()
if err != nil {
logs.Error("获取Auth2用户失败 ->", err)
c.JsonResult(500, "不支持的第三方用户", nil)
return
}
member.CreateAt = 0
if err := account.AddBind(ormer, userInfo, member); err != nil {
logs.Error(err)
o.Rollback()
c.JsonResult(500, "注册失败,数据库错误: "+err.Error())
return
}
if err := o.Commit(); err != nil {
logs.Error("提交事务时出错 -> ", err)
c.JsonResult(500, "提交事务时出错: ", err.Error())
return
}
member.LastLoginTime = time.Now()
_ = member.Update("last_login_time")
c.SetMember(*member)
var remember CookieRemember
remember.MemberId = member.MemberId
remember.Account = member.Account
remember.Time = time.Now()
v, err := utils.Encode(remember)
if err != nil {
c.JsonResult(500, "绑定成功, 但自动登录失败, 请返回首页重新登录", nil)
return
}
c.SetSecureCookie(conf.GetAppKey(), "login", v, time.Now().Add(time.Hour*24*30*5).Unix())
c.JsonResult(0, "绑定成功", nil)
}
// 钉钉登录
//func (c *AccountController) DingTalkLogin() {
// code := c.GetString("dingtalk_code")
// if code == "" {
// c.JsonResult(500, i18n.Tr(c.Lang, "message.failed_obtain_user_info"), nil)
// }
//
// appKey, _ := web.AppConfig.String("dingtalk_app_key")
// appSecret, _ := web.AppConfig.String("dingtalk_app_secret")
// tmpReader, _ := web.AppConfig.String("dingtalk_tmp_reader")
//
// if appKey == "" || appSecret == "" || tmpReader == "" {
// c.JsonResult(500, i18n.Tr(c.Lang, "message.dingtalk_auto_login_not_enable"), nil)
// c.StopRun()
// }
//
// dingtalkAgent := dingtalk.NewDingTalkAgent(appSecret, appKey)
// err := dingtalkAgent.GetAccesstoken()
// if err != nil {
// logs.Warn("获取钉钉临时Token失败 ->", err)
// c.JsonResult(500, i18n.Tr(c.Lang, "message.failed_auto_login"), nil)
// c.StopRun()
// }
//
// userid, err := dingtalkAgent.GetUserIDByCode(code)
// if err != nil {
// logs.Warn("获取钉钉用户ID失败 ->", err)
// c.JsonResult(500, i18n.Tr(c.Lang, "message.failed_auto_login"), nil)
// c.StopRun()
// }
//
// username, avatar, err := dingtalkAgent.GetUserNameAndAvatarByUserID(userid)
// if err != nil {
// logs.Warn("获取钉钉用户信息失败 ->", err)
// c.JsonResult(500, i18n.Tr(c.Lang, "message.failed_auto_login"), nil)
// c.StopRun()
// }
//
// member, err := models.NewMember().TmpLogin(tmpReader)
// if err == nil {
// member.LastLoginTime = time.Now()
// _ = member.Update("last_login_time")
// member.Account = username
// if avatar != "" {
// member.Avatar = avatar
// }
//
// c.SetMember(*member)
// }
// c.JsonResult(0, "ok", username)
//}
// WorkWeixinLogin 用户企业微信登录
//func (c *AccountController) WorkWeixinLogin() {
// logs.Info("UserAgent: ", c.Ctx.Input.UserAgent()) // debug
//
// if member, ok := c.GetSession(conf.LoginSessionName).(models.Member); ok && member.MemberId > 0 {
// u := c.GetString("url")
// if u == "" {
// u = c.Ctx.Request.Header.Get("Referer")
// if u == "" {
// u = conf.URLFor("HomeController.Index")
// }
// }
// // session自动登录时刷新session内容
// member, err := models.NewMember().Find(member.MemberId)
// if err != nil {
// c.DelSession(conf.LoginSessionName)
// c.SetMember(models.Member{})
// c.SetSecureCookie(conf.GetAppKey(), "login", "", -3600)
// } else {
// c.SetMember(*member)
// }
// c.Redirect(u, 302)
// }
// var remember CookieRemember
// // 如果 Cookie 中存在登录信息
// if cookie, ok := c.GetSecureCookie(conf.GetAppKey(), "login"); ok {
// if err := utils.Decode(cookie, &remember); err == nil {
// if member, err := models.NewMember().Find(remember.MemberId); err == nil {
// c.SetMember(*member)
// c.LoggedIn(false)
// c.StopRun()
// }
// }
// }
//
// if c.Ctx.Input.IsPost() {
// // account := c.GetString("account")
// // password := c.GetString("password")
// // captcha := c.GetString("code")
// // isRemember := c.GetString("is_remember")
// c.JsonResult(400, "request method not allowed", nil)
// } else {
// var callback_u string
// u := c.GetString("url")
// if u == "" {
// u = c.referer()
// }
// if u != "" {
// var schemaRule = regexp.MustCompile(`^https?\:\/\/`)
// if !schemaRule.MatchString(u) {
// u = strings.TrimRight(conf.BaseUrl, "/") + strings.TrimLeft(u, "/")
// }
// }
// if u == "" {
// callback_u = conf.URLFor("AccountController.WorkWeixinLoginCallback")
// } else {
// callback_u = conf.URLFor("AccountController.WorkWeixinLoginCallback", "url", url.PathEscape(u))
// }
// logs.Info("callback_u: ", callback_u) // debug
//
// state := "mindoc"
// workweixinConf := conf.GetWorkWeixinConfig()
// appid := workweixinConf.CorpId
// agentid := workweixinConf.AgentId
// var redirect_uri string
//
// isInWorkWeixin := c.IsInWorkWeixin()
// c.Data["IsInWorkWeixin"] = isInWorkWeixin
// if isInWorkWeixin {
// // 企业微信内-网页授权登录
// urlFmt := "%s?appid=%s&agentid=%s&redirect_uri=%s&response_type=code&scope=snsapi_privateinfo&state=%s#wechat_redirect"
// redirect_uri = fmt.Sprintf(urlFmt, WorkWeixin_AuthorizeUrlBase, appid, agentid, url.PathEscape(callback_u), state)
// } else {
// // 浏览器内-扫码授权登录
// urlFmt := "%s?login_type=CorpApp&appid=%s&agentid=%s&redirect_uri=%s&state=%s"
// redirect_uri = fmt.Sprintf(urlFmt, WorkWeixin_QRConnectUrlBase, appid, agentid, url.PathEscape(callback_u), state)
// }
// logs.Info("redirect_uri: ", redirect_uri) // debug
// c.Redirect(redirect_uri, 302)
// }
//}
/*
思路:
1. 浏览器打开
用户名+密码 登录 与企业微信没有交集
手机企业微信登录->扫码页面->扫码后获取用户信息, 判断是否绑定了企业微信
已绑定,则读取用户信息,直接登录
未绑定,则弹窗提示[未绑定企业微信,请先在企业微信中打开,完成绑定]
2. 企业微信打开->自动登录->判断是否绑定了企业微信
已绑定,则读取用户信息,直接登录
未绑定,则弹窗提示
是否已有账户(用户名+密码方式)
有: 弹窗输入[用户名+密码+验证码]校验
无: 直接以企业UserId作为用户名(小写),创建随机密码
*/
// WorkWeixinLoginCallback 用户企业微信登录-回调
//func (c *AccountController) WorkWeixinLoginCallback() {
// c.TplName = "account/auth2_callback.tpl"
//
// if member, ok := c.GetSession(conf.LoginSessionName).(models.Member); ok && member.MemberId > 0 {
// u := c.GetString("url")
// if u == "" {
// u = c.Ctx.Request.Header.Get("Referer")
// }
// if u == "" {
// u = conf.URLFor("HomeController.Index")
// }
// member, err := models.NewMember().Find(member.MemberId)
// if err != nil {
// c.DelSession(conf.LoginSessionName)
// c.SetMember(models.Member{})
// c.SetSecureCookie(conf.GetAppKey(), "login", "", -3600)
// } else {
// c.SetMember(*member)
// }
// c.Redirect(u, 302)
// }
//
// var remember CookieRemember
// // 如果 Cookie 中存在登录信息
// if cookie, ok := c.GetSecureCookie(conf.GetAppKey(), "login"); ok {
// if err := utils.Decode(cookie, &remember); err == nil {
// if member, err := models.NewMember().Find(remember.MemberId); err == nil {
// c.SetMember(*member)
// c.LoggedIn(false)
// c.StopRun()
// }
// }
// }
//
// // 请求参数获取
// req_code := c.GetString("code")
// logs.Warning("req_code: ", req_code)
// req_state := c.GetString("state")
// logs.Warning("req_state: ", req_state)
// var user_info_json string
// var error_msg string
// var bind_existed string
// if len(req_code) > 0 && req_state == "mindoc" {
// // 获取当前应用的access_token
// access_token, ok := workweixin.GetAccessToken()
// if ok {
// logs.Warning("access_token: ", access_token)
// // 获取当前请求的userid
// user_id, ticket, ok := workweixin.RequestUserId(access_token, req_code)
// if ok {
// logs.Warning("user_id: ", user_id)
// // 查询系统现有数据,是否绑定了当前请求用户的企业微信
// member, err := models.NewWorkWeixinAccount().ExistedMember(user_id)
// if err == nil {
// member.LastLoginTime = time.Now()
// _ = member.Update("last_login_time")
//
// c.SetMember(*member)
//
// var remember CookieRemember
// remember.MemberId = member.MemberId
// remember.Account = member.Account
// remember.Time = time.Now()
// v, err := utils.Encode(remember)
// if err == nil {
// c.SetSecureCookie(conf.GetAppKey(), "login", v, time.Now().Add(time.Hour*24*30*5).Unix())
// }
// bind_existed = "true"
// error_msg = ""
// u := c.GetString("url")
// if u == "" {
// u = conf.URLFor("HomeController.Index")
// }
// c.Redirect(u, 302)
// } else if err == orm.ErrNoRows {
// bind_existed = "false"
// if ticket == "" {
// error_msg = "请到企业微信中登录,并授权获取敏感信息。"
// } else {
// user_info, err := workweixin.RequestUserPrivateInfo(access_token, user_id, ticket)
// if err != nil {
// error_msg = "获取敏感信息错误: " + err.Error()
// } else {
// json_info, _ := json.Marshal(user_info)
// user_info_json = string(json_info)
// error_msg = ""
// c.SetSession(SessionUserInfoKey, user_info)
// }
// }
// } else {
// logs.Error("Error: ", err)
// error_msg = "登录错误: " + err.Error()
// }
// } else {
// error_msg = "获取用户Id失败: " + user_id
// }
// } else {
// error_msg = "应用凭据获取失败: " + access_token
// }
// } else {
// error_msg = "参数错误"
// }
// if user_info_json == "" {
// user_info_json = "{}"
// }
// if bind_existed == "" {
// bind_existed = "null"
// }
// // refer & doc:
// // - https://golang.org/pkg/html/template/#HTML
// // - https://stackoverflow.com/questions/24411880/go-html-templates-can-i-stop-the-templates-package-inserting-quotes-around-stri
// // - https://stackoverflow.com/questions/38035176/insert-javascript-snippet-inside-template-with-beego-golang
// c.Data["bind_existed"] = template.JS(bind_existed)
// logs.Debug("bind_existed: ", bind_existed)
// c.Data["error_msg"] = template.JS(error_msg)
// c.Data["user_info_json"] = template.JS(user_info_json)
// /*
// // 调试: 显示源码
// result, err := c.RenderString()
// if err != nil {
// logs.Error(err)
// } else {
// logs.Warning(result)
// }
// */
//}
// WorkWeixinLoginBind 用户企业微信登录-绑定
//func (c *AccountController) WorkWeixinLoginBind() {
// if user_info, ok := c.GetSession(SessionUserInfoKey).(workweixin.WorkWeixinUserPrivateInfo); ok && len(user_info.UserId) > 0 {
// req_account := c.GetString("account")
// req_password := c.GetString("password")
// if req_account == "" || req_password == "" {
// c.JsonResult(400, "账号或密码不能为空")
// } else {
// member, err := models.NewMember().Login(req_account, req_password)
// if err == nil {
// account := models.NewWorkWeixinAccount()
// account.MemberId = member.MemberId
// account.WorkWeixin_UserId = user_info.UserId
// member.CreateAt = 0
// ormer := orm.NewOrm()
// o, err := ormer.Begin()
// if err != nil {
// logs.Error("开启事务时出错 -> ", err)
// c.JsonResult(500, "开启事务时出错: ", err.Error())
// }
// if err := account.AddBind(ormer); err != nil {
// o.Rollback()
// c.JsonResult(500, "绑定失败,数据库错误: "+err.Error())
// } else {
// // 绑定成功之后修改用户信息
// member.LastLoginTime = time.Now()
// //member.RealName = user_info.Name
// //member.Avatar = user_info.Avatar
// if len(member.Avatar) < 1 {
// member.Avatar = conf.GetDefaultAvatar()
// }
// //member.Email = user_info.Email
// //member.Phone = user_info.Mobile
// if _, err := ormer.Update(member, "last_login_time", "real_name", "avatar", "email", "phone"); err != nil {
// o.Rollback()
// logs.Error("保存用户信息失败=>", err)
// c.JsonResult(500, "绑定失败,现有账户信息更新失败: "+err.Error())
// } else {
// if err := o.Commit(); err != nil {
// logs.Error("开启事务时出错 -> ", err)
// c.JsonResult(500, "开启事务时出错: ", err.Error())
// } else {
// c.DelSession(SessionUserInfoKey)
// c.SetMember(*member)
//
// var remember CookieRemember
// remember.MemberId = member.MemberId
// remember.Account = member.Account
// remember.Time = time.Now()
// v, err := utils.Encode(remember)
// if err == nil {
// c.SetSecureCookie(conf.GetAppKey(), "login", v, time.Now().Add(time.Hour*24*30*5).Unix())
// c.JsonResult(0, "绑定成功", nil)
// } else {
// c.JsonResult(500, "绑定成功, 但自动登录失败, 请返回首页重新登录", nil)
// }
// }
// }
//
// }
//
// } else {
// logs.Error("用户登录 ->", err)
// c.JsonResult(500, "账号或密码错误", nil)
// }
// c.JsonResult(500, "TODO: 绑定以后账号功能开发中")
// }
// } else {
// if ok {
// c.DelSession(SessionUserInfoKey)
// }
// c.JsonResult(400, "请求错误, 请从首页重新登录")
// }
//
//}
// WorkWeixinLoginIgnore 用户企业微信登录-忽略
//func (c *AccountController) WorkWeixinLoginIgnore() {
// if user_info, ok := c.GetSession(SessionUserInfoKey).(workweixin.WorkWeixinUserPrivateInfo); ok && len(user_info.UserId) > 0 {
// c.DelSession(SessionUserInfoKey)
// member := models.NewMember()
//
// if _, err := member.FindByAccount(user_info.UserId); err == nil && member.MemberId > 0 {
// c.JsonResult(400, "账号已存在")
// }
//
// ormer := orm.NewOrm()
// o, err := ormer.Begin()
// if err != nil {
// logs.Error("开启事务时出错 -> ", err)
// c.JsonResult(500, "开启事务时出错: ", err.Error())
// }
//
// member.Account = user_info.UserId
// member.RealName = user_info.Name
// var rnd = rand.New(src)
// // fmt.Sprintf("%x", rnd.Uint64())
// // strconv.FormatUint(rnd.Uint64(), 16)
// member.Password = user_info.UserId + strconv.FormatUint(rnd.Uint64(), 16)
// member.Password = "123456" // 强制设置默认密码,需修改一次密码后,才可以进行账号密码登录
// hash, err := utils.PasswordHash(member.Password)
// if err != nil {
// logs.Error("加密用户密码失败 =>", err)
// c.JsonResult(500, "加密用户密码失败"+err.Error())
// } else {
// logs.Error("member.Password: ", member.Password)
// logs.Error("hash: ", hash)
// member.Password = hash
// }
// member.Role = conf.MemberGeneralRole
// member.Avatar = user_info.Avatar
// if len(member.Avatar) < 1 {
// member.Avatar = conf.GetDefaultAvatar()
// }
// member.CreateAt = 0
// member.Email = user_info.BizMail
// member.Phone = user_info.Mobile
// member.Status = 0
// if _, err = ormer.Insert(member); err != nil {
// o.Rollback()
// c.JsonResult(500, "注册失败,数据库错误: "+err.Error())
// } else {
// account := models.NewWorkWeixinAccount()
// account.MemberId = member.MemberId
// account.WorkWeixin_UserId = user_info.UserId
// member.CreateAt = 0
// if err := account.AddBind(ormer); err != nil {
// o.Rollback()
// c.JsonResult(500, "注册失败,数据库错误: "+err.Error())
// } else {
// if err := o.Commit(); err != nil {
// logs.Error("提交事务时出错 -> ", err)
// c.JsonResult(500, "提交事务时出错: ", err.Error())
// } else {
// member.LastLoginTime = time.Now()
// _ = member.Update("last_login_time")
//
// c.SetMember(*member)
//
// var remember CookieRemember
// remember.MemberId = member.MemberId
// remember.Account = member.Account
// remember.Time = time.Now()
// v, err := utils.Encode(remember)
// if err == nil {
// c.SetSecureCookie(conf.GetAppKey(), "login", v, time.Now().Add(time.Hour*24*30*5).Unix())
// c.JsonResult(0, "绑定成功", nil)
// } else {
// c.JsonResult(500, "绑定成功, 但自动登录失败, 请返回首页重新登录", nil)
// }
// }
// }
// }
// } else {
// if ok {
// c.DelSession(SessionUserInfoKey)
// }
// c.JsonResult(400, "请求错误, 请从首页重新登录")
// }
//}
// QR二维码登录
//func (c *AccountController) QRLogin() {
// appName := c.Ctx.Input.Param(":app")
//
// switch appName {
// // 钉钉扫码登录
// case "dingtalk":
// code := c.GetString("code")
// state := c.GetString("state")
// if state != "1" || code == "" {
// c.Redirect(conf.URLFor("AccountController.Login"), 302)
// c.StopRun()
// }
// appKey, _ := web.AppConfig.String("dingtalk_qr_key")
// appSecret, _ := web.AppConfig.String("dingtalk_qr_secret")
//
// qrDingtalk := dingtalk.NewDingtalkQRLogin(appSecret, appKey)
// unionID, err := qrDingtalk.GetUnionIDByCode(code)
// if err != nil {
// logs.Warn("获取钉钉临时UnionID失败 ->", err)
// c.Redirect(conf.URLFor("AccountController.Login"), 302)
// c.StopRun()
// }
//
// appKey, _ = web.AppConfig.String("dingtalk_app_key")
// appSecret, _ = web.AppConfig.String("dingtalk_app_secret")
// tmpReader, _ := web.AppConfig.String("dingtalk_tmp_reader")
//
// dingtalkAgent := dingtalk.NewDingTalkAgent(appSecret, appKey)
// err = dingtalkAgent.GetAccesstoken()
// if err != nil {
// logs.Warn("获取钉钉临时Token失败 ->", err)
// c.Redirect(conf.URLFor("AccountController.Login"), 302)
// c.StopRun()
// }
//
// userid, err := dingtalkAgent.GetUserIDByUnionID(unionID)
// if err != nil {
// logs.Warn("获取钉钉用户ID失败 ->", err)
// c.Redirect(conf.URLFor("AccountController.Login"), 302)
// c.StopRun()
// }
//
// username, avatar, err := dingtalkAgent.GetUserNameAndAvatarByUserID(userid)
// if err != nil {
// logs.Warn("获取钉钉用户信息失败 ->", err)
// c.Redirect(conf.URLFor("AccountController.Login"), 302)
// c.StopRun()
// }
//
// member, err := models.NewMember().TmpLogin(tmpReader)
// if err == nil {
// member.LastLoginTime = time.Now()
// _ = member.Update("last_login_time")
// member.Account = username
// if avatar != "" {
// member.Avatar = avatar
// }
//
// c.SetMember(*member)
// c.LoggedIn(false)
// c.StopRun()
// }
// c.Redirect(conf.URLFor("AccountController.Login"), 302)
//
// // 企业微信扫码登录
// case "workweixin":
// //
//
// default:
// c.Redirect(conf.URLFor("AccountController.Login"), 302)
// c.StopRun()
// }
//}
// 登录成功后的操作,如重定向到原始请求页面
func (c *AccountController) LoggedIn(isPost bool) interface{} {
turl := c.referer()
if !isPost {
c.Redirect(turl, 302)
return nil
} else {
var data struct {
TURL string `json:"url"`
}
data.TURL = turl
return data
}
}
// 用户注册
func (c *AccountController) Register() {
c.TplName = "account/register.tpl"
//如果用户登录了,则跳转到网站首页
if member, ok := c.GetSession(conf.LoginSessionName).(models.Member); ok && member.MemberId > 0 {
c.Redirect(conf.URLFor("HomeController.Index"), 302)
}
// 如果没有开启用户注册
if v, ok := c.Option["ENABLED_REGISTER"]; ok && !strings.EqualFold(v, "true") {
c.Abort("404")
}
if c.Ctx.Input.IsPost() {
account := c.GetString("account")
password1 := c.GetString("password1")
password2 := c.GetString("password2")
email := c.GetString("email")
captcha := c.GetString("code")
if ok, err := regexp.MatchString(conf.RegexpAccount, account); account == "" || !ok || err != nil {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.username_invalid_format"))
}
if l := strings.Count(password1, ""); password1 == "" || l > 50 || l < 6 {
c.JsonResult(6002, i18n.Tr(c.Lang, "message.password_length_invalid"))
}
if password1 != password2 {
c.JsonResult(6003, i18n.Tr(c.Lang, "message.incorrect_confirm_password"))
}
if ok, err := regexp.MatchString(conf.RegexpEmail, email); !ok || err != nil || email == "" {
c.JsonResult(6004, i18n.Tr(c.Lang, "message.email_invalid_format"))
}
// 如果开启了验证码
if v, ok := c.Option["ENABLED_CAPTCHA"]; ok && strings.EqualFold(v, "true") {
v, ok := c.GetSession(conf.CaptchaSessionName).(string)
if !ok || !strings.EqualFold(v, captcha) {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.captcha_wrong"))
}
}
member := models.NewMember()
if _, err := member.FindByAccount(account); err == nil && member.MemberId > 0 {
c.JsonResult(6005, i18n.Tr(c.Lang, "message.account_existed"))
}
member.Account = account
member.Password = password1
member.Role = conf.MemberGeneralRole
member.Avatar = conf.GetDefaultAvatar()
member.CreateAt = 0
member.Email = email
member.Status = 0
if err := member.Add(); err != nil {
c.JsonResult(6006, i18n.Tr(c.Lang, "message.failed_register"))
}
c.JsonResult(0, "ok", member)
}
}
// 找回密码
func (c *AccountController) FindPassword() {
c.TplName = "account/find_password_setp1.tpl"
mailConf := conf.GetMailConfig()
if c.Ctx.Input.IsPost() {
email := c.GetString("email")
captcha := c.GetString("code")
if email == "" {
c.JsonResult(6005, i18n.Tr(c.Lang, "message.email_empty"))
}
if !mailConf.EnableMail {
c.JsonResult(6004, i18n.Tr(c.Lang, "message.mail_service_not_enable"))
}
// 如果开启了验证码
if v, ok := c.Option["ENABLED_CAPTCHA"]; ok && strings.EqualFold(v, "true") {
v, ok := c.GetSession(conf.CaptchaSessionName).(string)
if !ok || !strings.EqualFold(v, captcha) {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.captcha_wrong"))
}
}
member, err := models.NewMember().FindByFieldFirst("email", email)
if err != nil {
c.JsonResult(6006, i18n.Tr(c.Lang, "message.email_not_exist"))
}
if member == nil || member.Status != 0 {
c.JsonResult(6007, i18n.Tr(c.Lang, "message.account_disable"))
}
if member == nil || member.AuthMethod == conf.AuthMethodLDAP {
c.JsonResult(6011, i18n.Tr(c.Lang, "message.account_not_support_retrieval"))
}
count, err := models.NewMemberToken().FindSendCount(email, time.Now().Add(-1*time.Hour), time.Now())
if err != nil {
logs.Error(err)
c.JsonResult(6008, i18n.Tr(c.Lang, "message.failed_send_mail"))
}
if count > mailConf.MailNumber {
c.JsonResult(6008, i18n.Tr(c.Lang, "message.sent_too_many_times"))
}
memberToken := models.NewMemberToken()
memberToken.Token = string(utils.Krand(32, utils.KC_RAND_KIND_ALL))
memberToken.Email = email
memberToken.MemberId = member.MemberId
memberToken.IsValid = false
if _, err := memberToken.InsertOrUpdate(); err != nil {
c.JsonResult(6009, i18n.Tr(c.Lang, "message.failed_send_mail"))
}
data := map[string]interface{}{
"SITE_NAME": c.Option["SITE_NAME"],
"url": conf.URLFor("AccountController.FindPassword", "token", memberToken.Token, "mail", email),
"BaseUrl": c.BaseUrl(),
}
body, err := c.ExecuteViewPathTemplate("account/mail_template.tpl", data)
if err != nil {
logs.Error(err)
c.JsonResult(6003, i18n.Tr(c.Lang, "message.failed_send_mail"))
}
go func(mailConf *conf.SmtpConf, email string, body string) {
mailConfig := &mail.SMTPConfig{
Username: mailConf.SmtpUserName,
Password: mailConf.SmtpPassword,
Host: mailConf.SmtpHost,
Port: mailConf.SmtpPort,
Secure: mailConf.Secure,
Identity: "",
}
logs.Info(mailConfig)
c := mail.NewSMTPClient(mailConfig)
m := mail.NewMail()
m.AddFrom(mailConf.FormUserName)
m.AddFromName(mailConf.FormUserName)
m.AddSubject("找回密码")
m.AddHTML(body)
m.AddTo(email)
if e := c.Send(m); e != nil {
logs.Error("发送邮件失败:" + e.Error())
} else {
logs.Info("邮件发送成功:" + email)
}
//auth := smtp.PlainAuth(
// "",
// mail_conf.SmtpUserName,
// mail_conf.SmtpPassword,
// mail_conf.SmtpHost,
//)
//
//mime := "MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\n"
//subject := "Subject: 找回密码!\n"
//
//err = smtp.SendMail(
// mail_conf.SmtpHost+":"+strconv.Itoa(mail_conf.SmtpPort),
// auth,
// mail_conf.FormUserName,
// []string{email},
// []byte(subject+mime+"\n"+body),
//)
//if err != nil {
// logs.Error("邮件发送失败 => ", email, err)
//}
}(mailConf, email, body)
c.JsonResult(0, "ok", conf.URLFor("AccountController.Login"))
}
token := c.GetString("token")
email := c.GetString("mail")
if token != "" && email != "" {
memberToken, err := models.NewMemberToken().FindByFieldFirst("token", token)
if err != nil {
logs.Error(err)
c.Data["ErrorMessage"] = i18n.Tr(c.Lang, "message.mail_expired")
c.TplName = "errors/error.tpl"
return
}
subTime := time.Until(memberToken.SendTime)
if !strings.EqualFold(memberToken.Email, email) || subTime.Minutes() > float64(mailConf.MailExpired) || !memberToken.ValidTime.IsZero() {
c.Data["ErrorMessage"] = i18n.Tr(c.Lang, "message.captcha_expired")
c.TplName = "errors/error.tpl"
return
}
c.Data["Email"] = memberToken.Email
c.Data["Token"] = memberToken.Token
c.TplName = "account/find_password_setp2.tpl"
}
}
// 校验邮件并修改密码
func (c *AccountController) ValidEmail() {
password1 := c.GetString("password1")
password2 := c.GetString("password2")
captcha := c.GetString("code")
token := c.GetString("token")
email := c.GetString("mail")
if password1 == "" {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.password_empty"))
}
if l := strings.Count(password1, ""); l < 6 || l > 50 {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.password_length_invalid"))
}
if password2 == "" {
c.JsonResult(6002, i18n.Tr(c.Lang, "message.confirm_password_empty"))
}
if password1 != password2 {
c.JsonResult(6003, i18n.Tr(c.Lang, "message.incorrect_confirm_password"))
}
if captcha == "" {
c.JsonResult(6004, i18n.Tr(c.Lang, "message.captcha_empty"))
}
v, ok := c.GetSession(conf.CaptchaSessionName).(string)
if !ok || !strings.EqualFold(v, captcha) {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.captcha_wrong"))
}
mailConf := conf.GetMailConfig()
memberToken, err := models.NewMemberToken().FindByFieldFirst("token", token)
if err != nil {
logs.Error(err)
c.JsonResult(6007, i18n.Tr(c.Lang, "message.mail_expired"))
}
subTime := time.Until(memberToken.SendTime)
if !strings.EqualFold(memberToken.Email, email) || subTime.Minutes() > float64(mailConf.MailExpired) || !memberToken.ValidTime.IsZero() {
c.JsonResult(6008, i18n.Tr(c.Lang, "message.captcha_expired"))
}
member, err := models.NewMember().Find(memberToken.MemberId)
if err != nil {
logs.Error(err)
c.JsonResult(6005, i18n.Tr(c.Lang, "message.user_not_existed"))
}
hash, err := utils.PasswordHash(password1)
if err != nil {
logs.Error(err)
c.JsonResult(6006, i18n.Tr(c.Lang, "message.failed_save_password"))
}
member.Password = hash
err = member.Update("password")
memberToken.ValidTime = time.Now()
memberToken.IsValid = true
memberToken.InsertOrUpdate()
if err != nil {
logs.Error(err)
c.JsonResult(6006, i18n.Tr(c.Lang, "message.failed_save_password"))
}
c.JsonResult(0, "ok", conf.URLFor("AccountController.Login"))
}
// Logout 退出登录
func (c *AccountController) Logout() {
c.SetMember(models.Member{})
c.SetSecureCookie(conf.GetAppKey(), "login", "", -3600)
u := c.Ctx.Request.Header.Get("Referer")
c.Redirect(conf.URLFor("AccountController.Login", "url", u), 302)
}
// 验证码
func (c *AccountController) Captcha() {
captchaImage := gocaptcha.NewCaptchaImage(140, 40, gocaptcha.RandLightColor())
captchaImage.DrawNoise(gocaptcha.CaptchaComplexLower)
// captchaImage.DrawTextNoise(gocaptcha.CaptchaComplexHigh)
txt := gocaptcha.RandText(4)
c.SetSession(conf.CaptchaSessionName, txt)
captchaImage.DrawText(txt)
// captchaImage.Drawline(3);
captchaImage.DrawBorder(gocaptcha.ColorToRGB(0x17A7A7A))
// captchaImage.DrawHollowLine()
captchaImage.SaveImage(c.Ctx.ResponseWriter, gocaptcha.ImageFormatJpeg)
c.StopRun()
}
================================================
FILE: controllers/BaseController.go
================================================
package controllers
import (
"bytes"
"encoding/json"
"io"
"strings"
"time"
"html/template"
"io/ioutil"
"path/filepath"
"github.com/beego/beego/v2/core/logs"
"github.com/beego/beego/v2/server/web"
"github.com/beego/i18n"
"github.com/mindoc-org/mindoc/conf"
"github.com/mindoc-org/mindoc/models"
"github.com/mindoc-org/mindoc/utils"
)
type BaseController struct {
web.Controller
Member *models.Member
Option map[string]string
EnableAnonymous bool
EnableDocumentHistory bool
Lang string
}
type CookieRemember struct {
MemberId int
Account string
Time time.Time
}
// Prepare 预处理.
func (c *BaseController) Prepare() {
c.Data["SiteName"] = "MinDoc"
c.Data["Member"] = models.NewMember()
controller, action := c.GetControllerAndAction()
c.Data["ActionName"] = action
c.Data["ControllerName"] = controller
c.EnableAnonymous = false
c.EnableDocumentHistory = false
if member, ok := c.GetSession(conf.LoginSessionName).(models.Member); ok && member.MemberId > 0 {
c.Member = &member
c.Data["Member"] = c.Member
} else {
var remember CookieRemember
// //如果Cookie中存在登录信息,从cookie中获取用户信息
if cookie, ok := c.GetSecureCookie(conf.GetAppKey(), "login"); ok {
if err := utils.Decode(cookie, &remember); err == nil {
if member, err := models.NewMember().Find(remember.MemberId); err == nil {
c.Member = member
c.Data["Member"] = member
c.SetMember(*member)
}
}
}
}
conf.BaseUrl = c.BaseUrl()
c.Data["BaseUrl"] = c.BaseUrl()
if options, err := models.NewOption().All(); err == nil {
c.Option = make(map[string]string, len(options))
for _, item := range options {
c.Data[item.OptionName] = item.OptionValue
c.Option[item.OptionName] = item.OptionValue
}
c.EnableAnonymous = strings.EqualFold(c.Option["ENABLE_ANONYMOUS"], "true")
c.EnableDocumentHistory = strings.EqualFold(c.Option["ENABLE_DOCUMENT_HISTORY"], "true")
}
c.Data["HighlightStyle"] = web.AppConfig.DefaultString("highlight_style", "github")
if b, err := ioutil.ReadFile(filepath.Join(web.BConfig.WebConfig.ViewsPath, "widgets", "scripts.tpl")); err == nil {
c.Data["Scripts"] = template.HTML(string(b))
}
c.SetLang()
}
// 判断用户是否登录.
func (c *BaseController) isUserLoggedIn() bool {
return c.Member != nil && c.Member.MemberId > 0
}
// SetMember 获取或设置当前登录用户信息,如果 MemberId 小于 0 则标识删除 Session
func (c *BaseController) SetMember(member models.Member) {
if member.MemberId <= 0 {
c.DelSession(conf.LoginSessionName)
c.DelSession("uid")
c.DestroySession()
} else {
c.SetSession(conf.LoginSessionName, member)
c.SetSession("uid", member.MemberId)
}
}
// JsonResult 响应 json 结果
func (c *BaseController) JsonResult(errCode int, errMsg string, data ...interface{}) {
jsonData := make(map[string]interface{}, 3)
jsonData["errcode"] = errCode
jsonData["message"] = errMsg
if len(data) > 0 && data[0] != nil {
jsonData["data"] = data[0]
}
returnJSON, err := json.Marshal(jsonData)
if err != nil {
logs.Error(err)
}
c.Ctx.ResponseWriter.Header().Set("Content-Type", "application/json; charset=utf-8")
c.Ctx.ResponseWriter.Header().Set("Cache-Control", "no-cache, no-store")
_, err = io.WriteString(c.Ctx.ResponseWriter, string(returnJSON))
if err != nil {
logs.Error(err)
}
c.StopRun()
}
// 如果错误不为空,则响应错误信息到浏览器.
func (c *BaseController) CheckJsonError(code int, err error) {
if err == nil {
return
}
jsonData := make(map[string]interface{}, 3)
jsonData["errcode"] = code
jsonData["message"] = err.Error()
returnJSON, err := json.Marshal(jsonData)
if err != nil {
logs.Error(err)
}
c.Ctx.ResponseWriter.Header().Set("Content-Type", "application/json; charset=utf-8")
c.Ctx.ResponseWriter.Header().Set("Cache-Control", "no-cache, no-store")
_, err = io.WriteString(c.Ctx.ResponseWriter, string(returnJSON))
if err != nil {
logs.Error(err)
}
c.StopRun()
}
// ExecuteViewPathTemplate 执行指定的模板并返回执行结果.
func (c *BaseController) ExecuteViewPathTemplate(tplName string, data interface{}) (string, error) {
var buf bytes.Buffer
viewPath := c.ViewPath
if c.ViewPath == "" {
viewPath = web.BConfig.WebConfig.ViewsPath
}
if err := web.ExecuteViewPathTemplate(&buf, tplName, viewPath, data); err != nil {
return "", err
}
return buf.String(), nil
}
func (c *BaseController) BaseUrl() string {
baseUrl := web.AppConfig.DefaultString("baseurl", "")
if baseUrl != "" {
if strings.HasSuffix(baseUrl, "/") {
baseUrl = strings.TrimSuffix(baseUrl, "/")
}
} else {
baseUrl = c.Ctx.Input.Scheme() + "://" + c.Ctx.Request.Host
}
return baseUrl
}
// 显示错误信息页面.
func (c *BaseController) ShowErrorPage(errCode int, errMsg string) {
c.TplName = "errors/error.tpl"
c.Data["ErrorMessage"] = errMsg
c.Data["ErrorCode"] = errCode
var buf bytes.Buffer
exeData := map[string]interface{}{"ErrorMessage": errMsg, "ErrorCode": errCode, "BaseUrl": conf.BaseUrl, "Lang": c.Lang}
if err := web.ExecuteViewPathTemplate(&buf, "errors/error.tpl", web.BConfig.WebConfig.ViewsPath, exeData); err != nil {
c.Abort("500")
}
if errCode >= 200 && errCode <= 510 {
c.CustomAbort(errCode, buf.String())
} else {
c.CustomAbort(500, buf.String())
}
}
func (c *BaseController) CheckErrorResult(code int, err error) {
if err != nil {
c.ShowErrorPage(code, err.Error())
}
}
func (c *BaseController) SetLang() {
hasCookie := false
lang := c.GetString("lang")
if len(lang) == 0 {
lang = c.Ctx.GetCookie("lang")
hasCookie = true
}
if len(lang) == 0 ||
!i18n.IsExist(lang) {
if c.Data["language"] != nil {
lang = c.Data["language"].(string)
} else {
lang, _ = web.AppConfig.String("default_lang")
}
}
if !hasCookie {
c.Ctx.SetCookie("lang", lang, 1<<31-1, "/")
}
c.Data["Lang"] = lang
c.Lang = lang
}
================================================
FILE: controllers/BlogController.go
================================================
package controllers
import (
"context"
"encoding/json"
"fmt"
"html/template"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/beego/beego/v2/client/orm"
"github.com/beego/beego/v2/core/logs"
"github.com/beego/beego/v2/server/web"
"github.com/beego/i18n"
"github.com/mindoc-org/mindoc/conf"
"github.com/mindoc-org/mindoc/models"
"github.com/mindoc-org/mindoc/utils"
"github.com/mindoc-org/mindoc/utils/pagination"
)
type BlogController struct {
BaseController
}
func (c *BlogController) Prepare() {
c.BaseController.Prepare()
if !c.EnableAnonymous && c.Member == nil {
c.Redirect(conf.URLFor("AccountController.Login")+"?url="+url.PathEscape(conf.BaseUrl+c.Ctx.Request.URL.RequestURI()), 302)
}
}
// 文章阅读
func (c *BlogController) Index() {
c.Prepare()
c.TplName = "blog/index.tpl"
blogId, _ := strconv.Atoi(c.Ctx.Input.Param(":id"))
if blogId <= 0 {
c.ShowErrorPage(404, i18n.Tr(c.Lang, "message.page_not_existed"))
}
blogReadSession := fmt.Sprintf("blog:read:%d", blogId)
blog, err := models.NewBlog().FindFromCache(blogId)
if err != nil {
c.ShowErrorPage(404, i18n.Tr(c.Lang, "message.blog_not_existed"))
}
if c.Ctx.Input.IsPost() {
password := c.GetString("password")
if blog.BlogStatus == "password" && password != blog.Password {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.blog_pwd_incorrect"))
} else if blog.BlogStatus == "password" && password == blog.Password {
// Store the session value for the next GET request.
_ = c.CruSession.Set(context.TODO(), blogReadSession, blogId)
c.JsonResult(0, "OK")
} else {
c.JsonResult(0, "OK")
}
} else if blog.BlogStatus == "password" && c.CruSession.Get(context.TODO(), blogReadSession) == nil && // Read session doesn't exist
(c.Member == nil || (blog.MemberId != c.Member.MemberId && !c.Member.IsAdministrator())) { // User isn't author or administrator
//如果不存在已输入密码的标记
c.TplName = "blog/index_password.tpl"
}
if blog.BlogType != 1 {
//加载文章附件
_ = blog.LinkAttach()
}
c.Data["Model"] = blog
c.Data["Content"] = template.HTML(blog.BlogRelease)
if blog.BlogExcerpt == "" {
c.Data["Description"] = utils.AutoSummary(blog.BlogRelease, 120)
} else {
c.Data["Description"] = blog.BlogExcerpt
}
if nextBlog, err := models.NewBlog().QueryNext(blogId); err == nil {
c.Data["Next"] = nextBlog
}
if preBlog, err := models.NewBlog().QueryPrevious(blogId); err == nil {
c.Data["Previous"] = preBlog
}
}
// 文章列表
func (c *BlogController) List() {
c.Prepare()
c.TplName = "blog/list.tpl"
pageIndex, _ := c.GetInt("page", 1)
var blogList []*models.Blog
var totalCount int
var err error
blogList, totalCount, err = models.NewBlog().FindToPager(pageIndex, conf.PageSize, 0, "")
if err != nil && err != orm.ErrNoRows {
c.ShowErrorPage(500, err.Error())
}
if totalCount > 0 {
pager := pagination.NewPagination(c.Ctx.Request, totalCount, conf.PageSize, c.BaseUrl())
c.Data["PageHtml"] = pager.HtmlPages()
for _, blog := range blogList {
//如果没有添加文章摘要,则自动提取
if blog.BlogExcerpt == "" {
blog.BlogExcerpt = utils.AutoSummary(blog.BlogRelease, 120)
}
blog.Link()
}
} else {
c.Data["PageHtml"] = ""
}
c.Data["Lists"] = blogList
}
// 管理后台文章列表
func (c *BlogController) ManageList() {
c.Prepare()
c.TplName = "blog/manage_list.tpl"
pageIndex, _ := c.GetInt("page", 1)
blogList, totalCount, err := models.NewBlog().FindToPager(pageIndex, conf.PageSize, c.Member.MemberId, "all")
if err != nil {
c.ShowErrorPage(500, err.Error())
}
if totalCount > 0 {
pager := pagination.NewPagination(c.Ctx.Request, totalCount, conf.PageSize, c.BaseUrl())
c.Data["PageHtml"] = pager.HtmlPages()
} else {
c.Data["PageHtml"] = ""
}
c.Data["ModelList"] = blogList
}
// 文章设置
func (c *BlogController) ManageSetting() {
c.Prepare()
c.TplName = "blog/manage_setting.tpl"
//如果是post请求
if c.Ctx.Input.IsPost() {
blogId, _ := c.GetInt("id", 0)
blogTitle := c.GetString("title")
blogIdentify := c.GetString("identify")
orderIndex, _ := c.GetInt("order_index", 0)
blogType, _ := c.GetInt("blog_type", 0)
blogExcerpt := c.GetString("excerpt", "")
blogStatus := c.GetString("status", "publish")
blogPassword := c.GetString("password", "")
documentIdentify := strings.TrimSpace(c.GetString("documentIdentify"))
bookIdentify := strings.TrimSpace(c.GetString("bookIdentify"))
documentId := 0
if c.Member.Role == conf.MemberReaderRole {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.no_permission"))
}
if blogTitle == "" {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.blog_title_empty"))
}
if strings.Count(blogExcerpt, "") > 500 {
c.JsonResult(6008, i18n.Tr(c.Lang, "message.blog_digest_tips"))
}
if blogStatus != "private" && blogStatus != "public" && blogStatus != "password" && blogStatus != "draft" {
blogStatus = "public"
}
if blogStatus == "password" && blogPassword == "" {
c.JsonResult(6010, i18n.Tr(c.Lang, "message.set_pwd_pls"))
}
if blogType != 0 && blogType != 1 {
c.JsonResult(6005, i18n.Tr(c.Lang, "message.unknown_blog_type"))
}
if strings.Count(blogTitle, "") > 200 {
c.JsonResult(6002, i18n.Tr(c.Lang, "message.blog_title_tips"))
}
//如果是关联文章,需要同步关联的文档
if blogType == 1 {
book, err := models.NewBook().FindByIdentify(bookIdentify)
if err != nil {
c.JsonResult(6011, i18n.Tr(c.Lang, "message.ref_doc_not_exist_or_no_permit"))
}
doc, err := models.NewDocument().FindByIdentityFirst(documentIdentify, book.BookId)
if err != nil {
c.JsonResult(6003, i18n.Tr(c.Lang, "message.query_failed"))
}
documentId = doc.DocumentId
// 如果不是超级管理员,则校验权限
if !c.Member.IsAdministrator() {
bookResult, err := models.NewBookResult().FindByIdentify(book.Identify, c.Member.MemberId)
if err != nil || bookResult.RoleId == conf.BookObserver {
c.JsonResult(6002, i18n.Tr(c.Lang, "message.ref_doc_not_exist_or_no_permit"))
}
}
}
var blog *models.Blog
var err error
//如果文章ID存在,则从数据库中查询文章
if blogId > 0 {
if c.Member.IsAdministrator() {
blog, err = models.NewBlog().Find(blogId)
} else {
blog, err = models.NewBlog().FindByIdAndMemberId(blogId, c.Member.MemberId)
}
if err != nil {
c.JsonResult(6003, i18n.Tr(c.Lang, "message.blog_not_exist"))
}
//如果设置了文章标识
if blogIdentify != "" {
//如果查询到的文章标识存在并且不是当前文章的id
if b, err := models.NewBlog().FindByIdentify(blogIdentify); err == nil && b.BlogId != blogId {
c.JsonResult(6004, i18n.Tr(c.Lang, "message.blog_id_existed"))
}
}
blog.Modified = time.Now()
blog.ModifyAt = c.Member.MemberId
} else {
//如果设置了文章标识
if blogIdentify != "" {
if models.NewBlog().IsExist(blogIdentify) {
c.JsonResult(6004, i18n.Tr(c.Lang, "message.blog_id_existed"))
}
}
blog = models.NewBlog()
blog.MemberId = c.Member.MemberId
blog.Created = time.Now()
}
if blogIdentify == "" {
blog.BlogIdentify = fmt.Sprintf("%s-%d", "post", time.Now().UnixNano())
} else {
blog.BlogIdentify = blogIdentify
}
blog.BlogTitle = blogTitle
blog.OrderIndex = orderIndex
blog.BlogType = blogType
if blogType == 1 {
blog.DocumentId = documentId
}
blog.BlogExcerpt = blogExcerpt
blog.BlogStatus = blogStatus
blog.Password = blogPassword
if err := blog.Save(); err != nil {
logs.Error("保存文章失败 -> ", err)
c.JsonResult(6011, i18n.Tr(c.Lang, "message.failed"))
} else {
c.JsonResult(0, "ok", blog)
}
}
if c.Ctx.Input.Referer() == "" {
c.Data["Referer"] = "javascript:history.back();"
} else {
c.Data["Referer"] = c.Ctx.Input.Referer()
}
blogId, err := strconv.Atoi(c.Ctx.Input.Param(":id"))
c.Data["DocumentIdentify"] = ""
if err == nil {
blog, err := models.NewBlog().FindByIdAndMemberId(blogId, c.Member.MemberId)
if err != nil {
c.ShowErrorPage(500, err.Error())
}
c.Data["Model"] = blog
} else {
c.Data["Model"] = models.NewBlog()
}
}
// 文章创建或编辑
func (c *BlogController) ManageEdit() {
c.Prepare()
c.TplName = "blog/manage_edit.tpl"
if c.Member.Role == conf.MemberReaderRole {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.no_permission"))
}
if c.Ctx.Input.IsPost() {
blogId, _ := c.GetInt("blogId", 0)
if blogId <= 0 {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.param_error"))
}
blogContent := c.GetString("content", "")
blogHtml := c.GetString("htmlContent", "")
version, _ := c.GetInt64("version", 0)
cover := c.GetString("cover")
var blog *models.Blog
var err error
if c.Member.IsAdministrator() {
blog, err = models.NewBlog().Find(blogId)
} else {
blog, err = models.NewBlog().FindByIdAndMemberId(blogId, c.Member.MemberId)
}
if err != nil {
logs.Error("查询文章失败 ->", err)
c.JsonResult(6002, i18n.Tr(c.Lang, "message.query_failed"))
}
if version > 0 && blog.Version != version && cover != "yes" {
c.JsonResult(6005, i18n.Tr(c.Lang, "message.blog_has_modified"))
}
//如果是关联文章,需要同步关联的文档
if blog.BlogType == 1 {
doc, err := models.NewDocument().Find(blog.DocumentId)
if err != nil {
logs.Error("查询关联项目文档时出错 ->", err)
c.JsonResult(6003, i18n.Tr(c.Lang, "message.query_failed"))
}
book, err := models.NewBook().Find(doc.BookId)
if err != nil {
c.JsonResult(6002, i18n.Tr(c.Lang, "message.item_not_exist_or_no_permit"))
}
// 如果不是超级管理员,则校验权限
if !c.Member.IsAdministrator() {
bookResult, err := models.NewBookResult().FindByIdentify(book.Identify, c.Member.MemberId)
if err != nil || bookResult.RoleId == conf.BookObserver {
logs.Error("FindByIdentify => ", err)
c.JsonResult(6002, i18n.Tr(c.Lang, "message.ref_doc_not_exist_or_no_permit"))
}
}
doc.Markdown = blogContent
doc.Release = blogHtml
doc.Content = blogHtml
doc.ModifyTime = time.Now()
doc.ModifyAt = c.Member.MemberId
if err := doc.InsertOrUpdate("markdown", "release", "content", "modify_time", "modify_at"); err != nil {
logs.Error("保存关联文档时出错 ->", err)
c.JsonResult(6004, i18n.Tr(c.Lang, "message.failed"))
}
}
blog.BlogContent = blogContent
blog.BlogRelease = blogHtml
blog.ModifyAt = c.Member.MemberId
blog.Modified = time.Now()
if err := blog.Save("blog_content", "blog_release", "modify_at", "modify_time", "version"); err != nil {
logs.Error("保存文章失败 -> ", err)
c.JsonResult(6011, i18n.Tr(c.Lang, "message.failed"))
} else {
c.JsonResult(0, "ok", blog)
}
}
blogId, _ := strconv.Atoi(c.Ctx.Input.Param(":id"))
if blogId <= 0 {
c.ShowErrorPage(500, i18n.Tr(c.Lang, "message.param_error"))
}
var blog *models.Blog
var err error
if c.Member.IsAdministrator() {
blog, err = models.NewBlog().Find(blogId)
} else {
blog, err = models.NewBlog().FindByIdAndMemberId(blogId, c.Member.MemberId)
}
if err != nil {
c.ShowErrorPage(404, i18n.Tr(c.Lang, "message.blog_not_exist"))
}
blog.LinkAttach()
if len(blog.AttachList) > 0 {
returnJSON, err := json.Marshal(blog.AttachList)
if err != nil {
logs.Error("序列化文章附件时出错 ->", err)
} else {
c.Data["AttachList"] = template.JS(string(returnJSON))
}
} else {
c.Data["AttachList"] = template.JS("[]")
}
if conf.GetUploadFileSize() > 0 {
c.Data["UploadFileSize"] = conf.GetUploadFileSize()
} else {
c.Data["UploadFileSize"] = "undefined"
}
c.Data["Model"] = blog
}
// 删除文章
func (c *BlogController) ManageDelete() {
c.Prepare()
blogId, _ := c.GetInt("blog_id", 0)
if blogId <= 0 {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.param_error"))
}
var blog *models.Blog
var err error
if c.Member.IsAdministrator() {
blog, err = models.NewBlog().Find(blogId)
} else {
blog, err = models.NewBlog().FindByIdAndMemberId(blogId, c.Member.MemberId)
}
if err != nil {
c.JsonResult(6002, i18n.Tr(c.Lang, "message.blog_not_exist"))
}
if err := blog.Delete(blogId); err != nil {
c.JsonResult(6003, i18n.Tr(c.Lang, "message.failed"))
} else {
c.JsonResult(0, i18n.Tr(c.Lang, "message.success"))
}
}
// 上传附件或图片
func (c *BlogController) Upload() {
c.Prepare()
blogId, _ := c.GetInt("blogId")
if blogId <= 0 {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.param_error"))
}
blog, err := models.NewBlog().Find(blogId)
if err != nil {
c.JsonResult(6010, i18n.Tr(c.Lang, "message.blog_not_exist"))
}
if !c.Member.IsAdministrator() && blog.MemberId != c.Member.MemberId {
c.JsonResult(6011, i18n.Tr(c.Lang, "message.no_permission"))
}
name := "editormd-file-file"
file, moreFile, err := c.GetFile(name)
if err == http.ErrMissingFile {
name = "editormd-image-file"
file, moreFile, err = c.GetFile(name)
if err == http.ErrMissingFile {
c.JsonResult(6003, i18n.Tr(c.Lang, "message.upload_file_empty"))
}
}
if err != nil {
c.JsonResult(6002, err.Error())
}
defer file.Close()
type Size interface {
Size() int64
}
if conf.GetUploadFileSize() > 0 && moreFile.Size > conf.GetUploadFileSize() {
c.JsonResult(6009, i18n.Tr(c.Lang, "message.upload_file_size_limit"))
}
ext := filepath.Ext(moreFile.Filename)
if ext == "" {
c.JsonResult(6003, i18n.Tr(c.Lang, "message.upload_file_type_error"))
}
//如果文件类型设置为 * 标识不限制文件类型
if web.AppConfig.DefaultString("upload_file_ext", "") != "*" {
if !conf.IsAllowUploadFileExt(ext) {
c.JsonResult(6004, i18n.Tr(c.Lang, "message.upload_file_type_error"))
}
}
// 如果是超级管理员,则不判断权限
if c.Member.IsAdministrator() {
_, err := models.NewBlog().Find(blogId)
if err != nil {
c.JsonResult(6006, i18n.Tr(c.Lang, "message.doc_not_exist_or_no_permit"))
}
} else {
_, err := models.NewBlog().FindByIdAndMemberId(blogId, c.Member.MemberId)
if err != nil {
logs.Error("查询文章时出错 -> ", err)
if err == orm.ErrNoRows {
c.JsonResult(6006, i18n.Tr(c.Lang, "message.no_permission"))
}
c.JsonResult(6001, err.Error())
}
}
fileName := "attach_" + strconv.FormatInt(time.Now().UnixNano(), 16)
filePath := filepath.Join(conf.WorkingDirectory, "uploads", "blog", time.Now().Format("200601"), fileName+ext)
path := filepath.Dir(filePath)
os.MkdirAll(path, os.ModePerm)
err = c.SaveToFile(name, filePath)
if err != nil {
logs.Error("SaveToFile => ", err)
c.JsonResult(6005, i18n.Tr(c.Lang, "message.failed"))
}
var httpPath string
result := make(map[string]interface{})
//如果是图片,则当做内置图片处理,否则当做附件处理
if strings.EqualFold(ext, ".jpg") || strings.EqualFold(ext, ".jpeg") || strings.EqualFold(ext, ".png") || strings.EqualFold(ext, ".gif") {
httpPath = "/" + strings.Replace(strings.TrimPrefix(filePath, conf.WorkingDirectory), "\\", "/", -1)
if strings.HasPrefix(httpPath, "//") {
httpPath = conf.URLForWithCdnImage(string(httpPath[1:]))
}
} else {
attachment := models.NewAttachment()
attachment.BookId = 0
attachment.FileName = moreFile.Filename
attachment.CreateAt = c.Member.MemberId
attachment.FileExt = ext
attachment.FilePath = strings.TrimPrefix(filePath, conf.WorkingDirectory)
attachment.DocumentId = blogId
//如果是关联文章,则将附件设置为关联文档的文档上
if blog.BlogType == 1 {
attachment.BookId = blog.BookId
attachment.DocumentId = blog.DocumentId
}
if fileInfo, err := os.Stat(filePath); err == nil {
attachment.FileSize = float64(fileInfo.Size())
}
attachment.HttpPath = httpPath
if err := attachment.Insert(); err != nil {
os.Remove(filePath)
logs.Error("保存文件附件失败 -> ", err)
c.JsonResult(6006, i18n.Tr(c.Lang, "message.failed"))
}
if attachment.HttpPath == "" {
attachment.HttpPath = conf.URLForNotHost("BlogController.Download", ":id", blogId, ":attach_id", attachment.AttachmentId)
if err := attachment.Update(); err != nil {
logs.Error("保存文件失败 -> ", attachment.FilePath, err)
c.JsonResult(6005, i18n.Tr(c.Lang, "message.failed"))
}
}
result["attach"] = attachment
}
result["errcode"] = 0
result["success"] = 1
result["message"] = "ok"
result["url"] = httpPath
result["alt"] = fileName
c.Ctx.Output.JSON(result, true, false)
c.StopRun()
}
// 删除附件
func (c *BlogController) RemoveAttachment() {
c.Prepare()
attachId, _ := c.GetInt("attach_id")
blogId, _ := strconv.Atoi(c.Ctx.Input.Param(":id"))
if attachId <= 0 {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.param_error"))
}
blog, err := models.NewBlog().Find(blogId)
if err != nil {
if err == orm.ErrNoRows {
c.ShowErrorPage(500, i18n.Tr(c.Lang, "message.doc_not_exist"))
} else {
c.ShowErrorPage(500, i18n.Tr(c.Lang, "message.query_failed"))
}
}
attach, err := models.NewAttachment().Find(attachId)
if err != nil {
logs.Error(err)
c.JsonResult(6002, i18n.Tr(c.Lang, "message.attachment_not_exist"))
}
if !c.Member.IsAdministrator() {
_, err := models.NewBlog().FindByIdAndMemberId(attach.DocumentId, c.Member.MemberId)
if err != nil {
logs.Error(err)
c.JsonResult(6003, i18n.Tr(c.Lang, "message.doc_not_exist"))
}
}
if blog.BlogType == 1 && attach.BookId != blog.BookId && attach.DocumentId != blog.DocumentId {
c.ShowErrorPage(404, i18n.Tr(c.Lang, "message.attachment_not_exist"))
} else if attach.BookId != 0 || attach.DocumentId != blogId {
c.ShowErrorPage(404, i18n.Tr(c.Lang, "message.attachment_not_exist"))
}
if err := attach.Delete(); err != nil {
logs.Error(err)
c.JsonResult(6005, i18n.Tr(c.Lang, "message.failed"))
}
os.Remove(filepath.Join(conf.WorkingDirectory, attach.FilePath))
c.JsonResult(0, "ok", attach)
}
// 下载附件
func (c *BlogController) Download() {
c.Prepare()
blogId, _ := strconv.Atoi(c.Ctx.Input.Param(":id"))
attachId, _ := strconv.Atoi(c.Ctx.Input.Param(":attach_id"))
password := c.GetString("password")
blog, err := models.NewBlog().Find(blogId)
if err != nil {
if err == orm.ErrNoRows {
c.ShowErrorPage(500, i18n.Tr(c.Lang, "message.doc_not_exist"))
} else {
c.ShowErrorPage(500, i18n.Tr(c.Lang, "message.query_failed"))
}
}
blogReadSession := fmt.Sprintf("blog:read:%d", blogId)
//如果没有启动匿名访问,或者设置了访问密码
if (c.Member == nil && !c.EnableAnonymous) || (blog.BlogStatus == "password" && password != blog.Password && c.CruSession.Get(context.TODO(), blogReadSession) == nil) {
c.ShowErrorPage(403, i18n.Tr(c.Lang, "message.no_permission"))
}
// 查找附件
attachment, err := models.NewAttachment().Find(attachId)
if err != nil {
if err == orm.ErrNoRows {
c.ShowErrorPage(404, i18n.Tr(c.Lang, "message.attachment_not_exist"))
} else {
logs.Error("查询附件时出现异常 -> ", err)
c.ShowErrorPage(500, i18n.Tr(c.Lang, "message.query_failed"))
}
}
//如果是链接的文章,需要校验文档ID是否一致,如果不是,需要保证附件的项目ID为0且文档的ID等于博文ID
if blog.BlogType == 1 && attachment.DocumentId != blog.DocumentId {
c.ShowErrorPage(404, i18n.Tr(c.Lang, "message.attachment_not_exist"))
} else if blog.BlogType != 1 && (attachment.BookId != 0 || attachment.DocumentId != blogId) {
c.ShowErrorPage(404, i18n.Tr(c.Lang, "message.attachment_not_exist"))
}
c.Ctx.Output.Download(filepath.Join(conf.WorkingDirectory, attachment.FilePath), attachment.FileName)
c.StopRun()
}
================================================
FILE: controllers/BookController.go
================================================
package controllers
import (
"context"
"encoding/json"
"errors"
"fmt"
"html/template"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/beego/i18n"
"github.com/mindoc-org/mindoc/utils/sqltil"
"net/http"
"github.com/beego/beego/v2/client/orm"
"github.com/beego/beego/v2/core/logs"
"github.com/mindoc-org/mindoc/conf"
"github.com/mindoc-org/mindoc/graphics"
"github.com/mindoc-org/mindoc/models"
"github.com/mindoc-org/mindoc/utils"
"github.com/mindoc-org/mindoc/utils/pagination"
"github.com/russross/blackfriday/v2"
)
type BookController struct {
BaseController
}
func (c *BookController) Index() {
c.Prepare()
c.TplName = "book/index.tpl"
pageIndex, _ := c.GetInt("page", 1)
books, totalCount, err := models.NewBook().FindToPager(pageIndex, conf.PageSize, c.Member.MemberId, c.Lang)
if err != nil {
logs.Error("BookController.Index => ", err)
c.Abort("500")
}
for i, book := range books {
books[i].Description = utils.StripTags(string(blackfriday.Run([]byte(book.Description))))
books[i].ModifyTime = book.ModifyTime.Local()
books[i].CreateTime = book.CreateTime.Local()
}
if totalCount > 0 {
pager := pagination.NewPagination(c.Ctx.Request, totalCount, conf.PageSize, c.BaseUrl())
c.Data["PageHtml"] = pager.HtmlPages()
} else {
c.Data["PageHtml"] = ""
}
b, err := json.Marshal(books)
if err != nil || len(books) <= 0 {
c.Data["Result"] = template.JS("[]")
} else {
c.Data["Result"] = template.JS(string(b))
}
if itemsets, err := models.NewItemsets().First(1); err == nil {
c.Data["Item"] = itemsets
}
}
// Dashboard 项目概要 .
func (c *BookController) Dashboard() {
c.Prepare()
c.TplName = "book/dashboard.tpl"
key := c.Ctx.Input.Param(":key")
if key == "" {
c.Abort("404")
}
book, err := models.NewBookResult().SetLang(c.Lang).FindByIdentify(key, c.Member.MemberId)
if err != nil {
if err == models.ErrPermissionDenied {
c.Abort("403")
}
c.Abort("500")
return
}
c.Data["Description"] = template.HTML(blackfriday.Run([]byte(book.Description)))
c.Data["Model"] = *book
}
// Setting 项目设置 .
func (c *BookController) Setting() {
c.Prepare()
c.TplName = "book/setting.tpl"
key := c.Ctx.Input.Param(":key")
if key == "" {
c.Abort("404")
}
book, err := models.NewBookResult().FindByIdentify(key, c.Member.MemberId)
if err != nil {
if err == orm.ErrNoRows {
c.Abort("404")
}
if err == models.ErrPermissionDenied {
c.Abort("403")
}
c.Abort("500")
return
}
//如果不是创始人也不是管理员则不能操作
if book.RoleId != conf.BookFounder && book.RoleId != conf.BookAdmin {
c.Abort("403")
}
if book.PrivateToken != "" {
book.PrivateToken = conf.URLFor("DocumentController.Index", ":key", book.Identify, "token", book.PrivateToken)
}
c.Data["Model"] = book
}
// 保存项目信息
func (c *BookController) SaveBook() {
bookResult, err := c.IsPermission()
if err != nil {
c.JsonResult(6001, err.Error())
}
book, err := models.NewBook().Find(bookResult.BookId)
if err != nil {
logs.Error("SaveBook => ", err)
c.JsonResult(6002, err.Error())
}
bookName := strings.TrimSpace(c.GetString("book_name"))
description := strings.TrimSpace(c.GetString("description", ""))
commentStatus := c.GetString("comment_status")
//tag := strings.TrimSpace(c.GetString("label"))
editor := strings.TrimSpace(c.GetString("editor"))
autoRelease := strings.TrimSpace(c.GetString("auto_release")) == "on"
publisher := strings.TrimSpace(c.GetString("publisher"))
historyCount, _ := c.GetInt("history_count", 0)
isDownload := strings.TrimSpace(c.GetString("is_download")) == "on"
enableShare := strings.TrimSpace(c.GetString("enable_share")) == "on"
isUseFirstDocument := strings.TrimSpace(c.GetString("is_use_first_document")) == "on"
autoSave := strings.TrimSpace(c.GetString("auto_save")) == "on"
itemId, _ := c.GetInt("itemId")
pringState := strings.TrimSpace(c.GetString("print_state")) == "on"
if strings.Count(description, "") > 500 {
c.JsonResult(6004, i18n.Tr(c.Lang, "message.project_desc_tips"))
}
if commentStatus != "open" && commentStatus != "closed" && commentStatus != "group_only" && commentStatus != "registered_only" {
commentStatus = "closed"
}
if !models.NewItemsets().Exist(itemId) {
c.JsonResult(6006, i18n.Tr(c.Lang, "message.project_space_not_exist"))
}
// if editor != EditorMarkdown && editor != EditorCherryMarkdown && editor != EditorHtml && editor != EditorNewHtml {
if editor != EditorMarkdown && editor != EditorCherryMarkdown && editor != EditorHtml && editor != EditorNewHtml && editor != EditorFroala {
editor = EditorMarkdown
}
book.BookName = bookName
book.Description = description
book.CommentStatus = commentStatus
book.Publisher = publisher
//book.Label = tag
if book.Editor == EditorMarkdown && editor == EditorCherryMarkdown || book.Editor == EditorCherryMarkdown && editor == EditorMarkdown {
c.JsonResult(6006, i18n.Tr(c.Lang, "message.editors_not_compatible"))
}
book.Editor = editor
if editor == EditorCherryMarkdown {
book.Theme = "cherry"
}
book.HistoryCount = historyCount
book.IsDownload = 0
book.BookPassword = strings.TrimSpace(c.GetString("bPassword"))
book.ItemId = itemId
if autoRelease {
book.AutoRelease = 1
} else {
book.AutoRelease = 0
}
if isDownload {
book.IsDownload = 0
} else {
book.IsDownload = 1
}
if enableShare {
book.IsEnableShare = 0
} else {
book.IsEnableShare = 1
}
if isUseFirstDocument {
book.IsUseFirstDocument = 1
} else {
book.IsUseFirstDocument = 0
}
if autoSave {
book.AutoSave = 1
} else {
book.AutoSave = 0
}
if pringState {
book.PrintSate = 1
} else {
book.PrintSate = 0
}
if err := book.Update(); err != nil {
c.JsonResult(6006, i18n.Tr(c.Lang, "message.failed"))
}
bookResult.BookName = bookName
bookResult.Description = description
bookResult.CommentStatus = commentStatus
logs.Info("用户 [", c.Member.Account, "] 修改了项目 ->", book)
c.JsonResult(0, "ok", bookResult)
}
// 设置项目私有状态.
func (c *BookController) PrivatelyOwned() {
status := c.GetString("status")
if status != "open" && status != "close" {
c.JsonResult(6003, i18n.Tr(c.Lang, "message.param_error"))
}
state := 0
if status == "open" {
state = 0
} else {
state = 1
}
bookResult, err := c.IsPermission()
if err != nil {
c.JsonResult(6001, err.Error())
return
}
//只有创始人才能变更私有状态
if bookResult.RoleId != conf.BookFounder {
c.JsonResult(6002, i18n.Tr(c.Lang, "message.no_permission"))
}
book, err := models.NewBook().Find(bookResult.BookId)
if err != nil {
c.JsonResult(6005, i18n.Tr(c.Lang, "message.item_not_exist"))
return
}
book.PrivatelyOwned = state
err = book.Update()
if err != nil {
logs.Error("PrivatelyOwned => ", err)
c.JsonResult(6004, i18n.Tr(c.Lang, "message.failed"))
}
logs.Info("用户 【", c.Member.Account, "]修改了项目权限 ->", state)
c.JsonResult(0, "ok")
}
// Transfer 转让项目.
func (c *BookController) Transfer() {
c.Prepare()
account := c.GetString("account")
if account == "" {
c.JsonResult(6004, i18n.Tr(c.Lang, "message.receive_account_empty"))
}
member, err := models.NewMember().FindByAccount(account)
if err != nil {
logs.Error("FindByAccount => ", err)
c.JsonResult(6005, i18n.Tr(c.Lang, "message.receive_account_not_exist"))
}
if member.Status != 0 {
c.JsonResult(6006, i18n.Tr(c.Lang, "message.receive_account_disabled"))
}
if member.MemberId == c.Member.MemberId {
c.JsonResult(6007, i18n.Tr(c.Lang, "message.cannot_handover_myself"))
}
bookResult, err := c.IsPermission()
if err != nil {
c.JsonResult(6001, err.Error())
return
}
err = models.NewRelationship().Transfer(bookResult.BookId, c.Member.MemberId, member.MemberId)
if err != nil {
logs.Error("转让项目失败 -> ", err)
c.JsonResult(6008, err.Error())
}
c.JsonResult(0, "ok")
}
// 上传项目封面.
func (c *BookController) UploadCover() {
bookResult, err := c.IsPermission()
if err != nil {
c.JsonResult(6001, err.Error())
return
}
book, err := models.NewBook().Find(bookResult.BookId)
if err != nil {
logs.Error("SaveBook => ", err)
c.JsonResult(6002, err.Error())
}
file, moreFile, err := c.GetFile("image-file")
if err != nil {
logs.Error("获取上传文件失败 ->", err.Error())
c.JsonResult(500, "读取文件异常")
return
}
defer file.Close()
ext := filepath.Ext(moreFile.Filename)
if !strings.EqualFold(ext, ".png") && !strings.EqualFold(ext, ".jpg") && !strings.EqualFold(ext, ".gif") && !strings.EqualFold(ext, ".jpeg") {
c.JsonResult(500, "不支持的图片格式")
}
x1, _ := strconv.ParseFloat(c.GetString("x"), 10)
y1, _ := strconv.ParseFloat(c.GetString("y"), 10)
w1, _ := strconv.ParseFloat(c.GetString("width"), 10)
h1, _ := strconv.ParseFloat(c.GetString("height"), 10)
x := int(x1)
y := int(y1)
width := int(w1)
height := int(h1)
fileName := "cover_" + strconv.FormatInt(time.Now().UnixNano(), 16)
//附件路径按照项目组织
// filePath := filepath.Join("uploads", book.Identify, "images", fileName+ext)
filePath := filepath.Join(conf.WorkingDirectory, "uploads", book.Identify, "images", fileName+ext)
path := filepath.Dir(filePath)
os.MkdirAll(path, os.ModePerm)
err = c.SaveToFile("image-file", filePath)
if err != nil {
logs.Error("", err)
c.JsonResult(500, "图片保存失败")
}
defer func(filePath string) {
os.Remove(filePath)
}(filePath)
//剪切图片
subImg, err := graphics.ImageCopyFromFile(filePath, x, y, width, height)
if err != nil {
logs.Error("graphics.ImageCopyFromFile => ", err)
c.JsonResult(500, "图片剪切")
}
filePath = filepath.Join(conf.WorkingDirectory, "uploads", time.Now().Format("200601"), fileName+"_small"+ext)
//生成缩略图并保存到磁盘
err = graphics.ImageResizeSaveFile(subImg, 350, 460, filePath)
if err != nil {
logs.Error("ImageResizeSaveFile => ", err.Error())
c.JsonResult(500, "保存图片失败")
}
url := "/" + strings.Replace(strings.TrimPrefix(filePath, conf.WorkingDirectory), "\\", "/", -1)
if strings.HasPrefix(url, "//") {
url = string(url[1:])
}
oldCover := book.Cover
book.Cover = conf.URLForWithCdnImage(url)
if err := book.Update(); err != nil {
c.JsonResult(6001, "保存图片失败")
}
//如果原封面不是默认封面则删除
if oldCover != conf.GetDefaultCover() {
os.Remove("." + oldCover)
}
logs.Info("用户[", c.Member.Account, "]上传了项目封面 ->", book.BookName, book.BookId, book.Cover)
c.JsonResult(0, "ok", url)
}
// Users 用户列表.
func (c *BookController) Users() {
c.Prepare()
c.TplName = "book/users.tpl"
key := c.Ctx.Input.Param(":key")
pageIndex, _ := c.GetInt("page", 1)
if key == "" {
c.ShowErrorPage(404, i18n.Tr(c.Lang, "message.item_not_exist"))
}
book, err := models.NewBookResult().FindByIdentify(key, c.Member.MemberId)
if err != nil {
if err == models.ErrPermissionDenied {
c.Abort("403")
}
c.Abort("500")
return
}
//如果不是创始人也不是管理员则不能操作
if book.RoleId != conf.BookFounder && book.RoleId != conf.BookAdmin {
c.Abort("403")
}
c.Data["Model"] = *book
members, totalCount, err := models.NewMemberRelationshipResult().FindForUsersByBookId(c.Lang, book.BookId, pageIndex, conf.PageSize)
if totalCount > 0 {
pager := pagination.NewPagination(c.Ctx.Request, totalCount, conf.PageSize, c.BaseUrl())
c.Data["PageHtml"] = pager.HtmlPages()
} else {
c.Data["PageHtml"] = ""
}
b, err := json.Marshal(members)
if err != nil {
c.Data["Result"] = template.JS("[]")
} else {
c.Data["Result"] = template.JS(string(b))
}
}
// Create 创建项目.
func (c *BookController) Create() {
if c.Ctx.Input.IsPost() {
bookName := strings.TrimSpace(c.GetString("book_name", ""))
identify := strings.TrimSpace(c.GetString("identify", ""))
description := strings.TrimSpace(c.GetString("description", ""))
privatelyOwned, _ := strconv.Atoi(c.GetString("privately_owned"))
commentStatus := c.GetString("comment_status")
editor := c.GetString("editor")
itemId, _ := c.GetInt("itemId")
if c.Member.Role == conf.MemberReaderRole {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.no_permission"))
}
if bookName == "" {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.project_name_empty"))
}
if identify == "" {
c.JsonResult(6002, i18n.Tr(c.Lang, "message.project_id_empty"))
}
if ok, err := regexp.MatchString(`^[a-z]+[a-zA-Z0-9_\-]*$`, identify); !ok || err != nil {
c.JsonResult(6003, i18n.Tr(c.Lang, "message.project_id_tips"))
}
if strings.Count(identify, "") > 50 {
c.JsonResult(6004, i18n.Tr(c.Lang, "message.project_id_length"))
}
if strings.Count(description, "") > 500 {
c.JsonResult(6004, i18n.Tr(c.Lang, "message.project_desc_tips"))
}
if privatelyOwned != 0 && privatelyOwned != 1 {
privatelyOwned = 1
}
if !models.NewItemsets().Exist(itemId) {
c.JsonResult(6005, i18n.Tr(c.Lang, "message.project_space_not_exist"))
}
if commentStatus != "open" && commentStatus != "closed" && commentStatus != "group_only" && commentStatus != "registered_only" {
commentStatus = "closed"
}
book := models.NewBook()
book.Cover = conf.GetDefaultCover()
//如果客户端上传了项目封面则直接保存
if file, moreFile, err := c.GetFile("image-file"); err == nil {
defer file.Close()
ext := filepath.Ext(moreFile.Filename)
//如果上传的是图片
if strings.EqualFold(ext, ".png") || strings.EqualFold(ext, ".jpg") || strings.EqualFold(ext, ".gif") || strings.EqualFold(ext, ".jpeg") {
fileName := "cover_" + strconv.FormatInt(time.Now().UnixNano(), 16)
filePath := filepath.Join("uploads", time.Now().Format("200601"), fileName+ext)
path := filepath.Dir(filePath)
os.MkdirAll(path, os.ModePerm)
if err := c.SaveToFile("image-file", filePath); err == nil {
url := "/" + strings.Replace(strings.TrimPrefix(filePath, conf.WorkingDirectory), "\\", "/", -1)
if strings.HasPrefix(url, "//") {
url = string(url[1:])
}
book.Cover = url
}
}
}
if books, _ := book.FindByField("identify", identify, "book_id"); len(books) > 0 {
c.JsonResult(6006, i18n.Tr(c.Lang, "message.project_id_existed"))
}
book.BookName = bookName
book.Description = description
book.CommentCount = 0
book.PrivatelyOwned = privatelyOwned
book.CommentStatus = commentStatus
book.Identify = identify
book.DocCount = 0
book.MemberId = c.Member.MemberId
book.Version = time.Now().Unix()
book.IsEnableShare = 0
book.IsUseFirstDocument = 1
book.IsDownload = 1
book.AutoRelease = 0
book.ItemId = itemId
book.Editor = editor
book.Theme = "default"
if err := book.Insert(c.Lang); err != nil {
logs.Error("Insert => ", err)
c.JsonResult(6005, i18n.Tr(c.Lang, "message.failed"))
}
bookResult, err := models.NewBookResult().FindByIdentify(book.Identify, c.Member.MemberId)
if err != nil {
logs.Error(err)
}
logs.Info("用户[", c.Member.Account, "]创建了项目 ->", book)
c.JsonResult(0, "ok", bookResult)
}
c.JsonResult(6001, "error")
}
// 复制项目
func (c *BookController) Copy() {
if c.Ctx.Input.IsPost() {
//检查是否有复制项目的权限
if _, err := c.IsPermission(); err != nil {
c.JsonResult(500, err.Error())
}
if c.Member.Role == conf.MemberReaderRole {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.no_permission"))
}
identify := strings.TrimSpace(c.GetString("identify", ""))
if identify == "" {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.param_error"))
}
book := models.NewBook()
err := book.Copy(identify)
if err != nil {
c.JsonResult(6002, "复制项目出错")
} else {
bookResult, err := models.NewBookResult().FindByIdentify(book.Identify, c.Member.MemberId)
if err != nil {
logs.Error("查询失败")
}
c.JsonResult(0, "ok", bookResult)
}
}
}
// 导入zip压缩包或docx
func (c *BookController) Import() {
if c.Member.Role == conf.MemberReaderRole {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.no_permission"))
}
file, moreFile, err := c.GetFile("import-file")
if err == http.ErrMissingFile {
c.JsonResult(6003, "没有发现需要上传的文件")
}
defer file.Close()
bookName := strings.TrimSpace(c.GetString("book_name"))
identify := strings.TrimSpace(c.GetString("identify"))
description := strings.TrimSpace(c.GetString("description", ""))
privatelyOwned, _ := strconv.Atoi(c.GetString("privately_owned"))
itemId, _ := c.GetInt("itemId")
if bookName == "" {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.project_name_empty"))
}
if len([]rune(bookName)) > 500 {
c.JsonResult(6002, "项目名称不能大于500字")
}
if identify == "" {
c.JsonResult(6002, i18n.Tr(c.Lang, "message.project_id_empty"))
}
if ok, err := regexp.MatchString(`^[a-z]+[a-zA-Z0-9_\-]*$`, identify); !ok || err != nil {
c.JsonResult(6003, i18n.Tr(c.Lang, "message.project_id_tips"))
}
if !models.NewItemsets().Exist(itemId) {
c.JsonResult(6007, i18n.Tr(c.Lang, "message.project_space_not_exist"))
}
if strings.Count(identify, "") > 50 {
c.JsonResult(6004, i18n.Tr(c.Lang, "message.project_id_length"))
}
ext := filepath.Ext(moreFile.Filename)
if !strings.EqualFold(ext, ".zip") && !strings.EqualFold(ext, ".docx") {
c.JsonResult(6004, "不支持的文件类型")
}
if books, _ := models.NewBook().FindByField("identify", identify, "book_id"); len(books) > 0 {
c.JsonResult(6006, i18n.Tr(c.Lang, "message.project_id_existed"))
}
tempPath := filepath.Join(os.TempDir(), c.CruSession.SessionID(context.TODO()))
os.MkdirAll(tempPath, 0766)
tempPath = filepath.Join(tempPath, moreFile.Filename)
err = c.SaveToFile("import-file", tempPath)
if err != nil {
c.JsonResult(6004, i18n.Tr(c.Lang, "message.upload_failed"))
}
book := models.NewBook()
book.MemberId = c.Member.MemberId
book.Cover = conf.GetDefaultCover()
book.BookName = bookName
book.Description = description
book.CommentCount = 0
book.PrivatelyOwned = privatelyOwned
book.CommentStatus = "closed"
book.Identify = identify
book.DocCount = 0
book.MemberId = c.Member.MemberId
book.Version = time.Now().Unix()
book.ItemId = itemId
book.Editor = "markdown"
book.Theme = "default"
if strings.EqualFold(ext, ".zip") {
go book.ImportBook(tempPath, c.Lang)
} else if strings.EqualFold(ext, ".docx") {
go book.ImportWordBook(tempPath, c.Lang)
}
logs.Info("用户[", c.Member.Account, "]导入了项目 ->", book)
c.JsonResult(0, "项目正在后台转换中,请稍后查看")
}
// CreateToken 创建访问来令牌.
//func (c *BookController) CreateToken() {
//
// action := c.GetString("action")
//
// bookResult, err := c.IsPermission()
//
// if err != nil {
// if err == models.ErrPermissionDenied {
// c.JsonResult(403, i18n.Tr(c.Lang, "message.no_permission"))
// }
// if err == orm.ErrNoRows {
// c.JsonResult(404, i18n.Tr(c.Lang, "message.item_not_exist"))
// }
// logs.Error("生成阅读令牌失败 =>", err)
// c.JsonResult(6002, err.Error())
// }
// book := models.NewBook()
//
// if _, err := book.Find(bookResult.BookId); err != nil {
// c.JsonResult(6001, i18n.Tr(c.Lang, "message.item_not_exist"))
// }
// if action == "create" {
// if bookResult.PrivatelyOwned == 0 {
// c.JsonResult(6001, "公开项目不能创建阅读令牌")
// }
//
// book.PrivateToken = string(utils.Krand(conf.GetTokenSize(), utils.KC_RAND_KIND_ALL))
// if err := book.Update(); err != nil {
// logs.Error("生成阅读令牌失败 => ", err)
// c.JsonResult(6003, "生成阅读令牌失败")
// }
// logs.Info("用户[", c.Member.Account, "]创建项目令牌 ->", book.PrivateToken)
// c.JsonResult(0, "ok", conf.URLFor("DocumentController.Index", ":key", book.Identify, "token", book.PrivateToken))
// } else {
// book.PrivateToken = ""
// if err := book.Update(); err != nil {
// logs.Error("CreateToken => ", err)
// c.JsonResult(6004, "删除令牌失败")
// }
// logs.Info("用户[", c.Member.Account, "]创建项目令牌 ->", book.PrivateToken)
// c.JsonResult(0, "ok", "")
// }
//}
// Delete 删除项目.
func (c *BookController) Delete() {
c.Prepare()
bookResult, err := c.IsPermission()
if err != nil {
c.JsonResult(6001, err.Error())
return
}
if bookResult.RoleId != conf.BookFounder {
c.JsonResult(6002, "只有创始人才能删除项目")
}
err = models.NewBook().ThoroughDeleteBook(bookResult.BookId)
if err == orm.ErrNoRows {
c.JsonResult(6002, i18n.Tr(c.Lang, "message.item_not_exist"))
}
if err != nil {
logs.Error("删除项目 => ", err)
c.JsonResult(6003, "删除失败")
}
logs.Info("用户[", c.Member.Account, "]删除了项目 ->", bookResult)
c.JsonResult(0, "ok")
}
// 发布项目.
func (c *BookController) Release() {
c.Prepare()
identify := c.GetString("identify")
bookId := 0
if c.Member.IsAdministrator() {
book, err := models.NewBook().FindByFieldFirst("identify", identify)
if err != nil {
logs.Error("发布文档失败 ->", err)
c.JsonResult(6003, "文档不存在")
return
}
bookId = book.BookId
} else {
book, err := models.NewBookResult().FindByIdentify(identify, c.Member.MemberId)
if err != nil {
if err == models.ErrPermissionDenied {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.no_permission"))
}
if err == orm.ErrNoRows {
c.JsonResult(6002, i18n.Tr(c.Lang, "message.item_not_exist"))
}
logs.Error(err)
c.JsonResult(6003, i18n.Tr(c.Lang, "message.unknown_exception"))
}
if book.RoleId != conf.BookAdmin && book.RoleId != conf.BookFounder && book.RoleId != conf.BookEditor {
c.JsonResult(6003, i18n.Tr(c.Lang, "message.no_permission"))
}
bookId = book.BookId
}
go models.NewBook().ReleaseContent(bookId, c.Lang)
c.JsonResult(0, i18n.Tr(c.Lang, "message.publish_to_queue"))
}
// 更新项目排序
func (c *BookController) UpdateBookOrder() {
if !c.Member.IsAdministrator() {
c.JsonResult(403, "权限不足")
return
}
type Params struct {
Ids string `form:"ids"`
}
var params Params
if err := c.ParseForm(¶ms); err != nil {
c.JsonResult(6003, "参数错误")
return
}
idArray := strings.Split(params.Ids, ",")
orderCount := len(idArray)
for _, id := range idArray {
bookId, _ := strconv.Atoi(id)
orderCount--
book, err := models.NewBook().Find(bookId)
if err != nil {
continue
}
book.BookId = bookId
book.OrderIndex = orderCount
err = book.Update()
if err != nil {
continue
}
}
c.JsonResult(0, "ok")
}
// 文档排序.
func (c *BookController) SaveSort() {
c.Prepare()
identify := c.Ctx.Input.Param(":key")
if identify == "" {
c.Abort("404")
}
bookId := 0
if c.Member.IsAdministrator() {
book, err := models.NewBook().FindByFieldFirst("identify", identify)
if err != nil || book == nil {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.item_not_exist"))
return
}
bookId = book.BookId
} else {
bookResult, err := models.NewBookResult().FindByIdentify(identify, c.Member.MemberId)
if err != nil {
logs.Error("DocumentController.Edit => ", err)
c.Abort("403")
}
if bookResult.RoleId == conf.BookObserver {
c.JsonResult(6002, i18n.Tr(c.Lang, "message.item_not_exist_or_no_permit"))
}
bookId = bookResult.BookId
}
content := c.Ctx.Input.RequestBody
var docs []map[string]interface{}
err := json.Unmarshal(content, &docs)
if err != nil {
logs.Error(err)
c.JsonResult(6003, "数据错误")
}
for _, item := range docs {
if docId, ok := item["id"].(float64); ok {
doc, err := models.NewDocument().Find(int(docId))
if err != nil {
logs.Error(err)
continue
}
if doc.BookId != bookId {
logs.Info("%s", i18n.Tr(c.Lang, "message.no_permission"))
continue
}
sort, ok := item["sort"].(float64)
if !ok {
logs.Info("排序数字转换失败 => ", item)
continue
}
parentId, ok := item["parent"].(float64)
if !ok {
logs.Info("父分类转换失败 => ", item)
continue
}
if parentId > 0 {
if parent, err := models.NewDocument().Find(int(parentId)); err != nil || parent.BookId != bookId {
continue
}
}
doc.OrderSort = int(sort)
doc.ParentId = int(parentId)
if err := doc.InsertOrUpdate(); err != nil {
fmt.Printf("%s", err.Error())
logs.Error(err)
}
} else {
fmt.Printf("文档ID转换失败 => %+v", item)
}
}
c.JsonResult(0, "ok")
}
func (c *BookController) Team() {
c.Prepare()
c.TplName = "book/team.tpl"
key := c.Ctx.Input.Param(":key")
pageIndex, _ := c.GetInt("page", 1)
if key == "" {
c.ShowErrorPage(404, i18n.Tr(c.Lang, "message.item_not_exist"))
}
book, err := models.NewBookResult().FindByIdentify(key, c.Member.MemberId)
if err != nil || book == nil {
if err == models.ErrPermissionDenied {
c.ShowErrorPage(403, i18n.Tr(c.Lang, "message.no_permission"))
}
c.ShowErrorPage(500, i18n.Tr(c.Lang, "message.system_error"))
return
}
//如果不是创始人也不是管理员则不能操作
if book.RoleId != conf.BookFounder && book.RoleId != conf.BookAdmin {
c.Abort("403")
}
c.Data["Model"] = book
members, totalCount, err := models.NewTeamRelationship().FindByBookToPager(book.BookId, pageIndex, conf.PageSize)
if totalCount > 0 {
pager := pagination.NewPagination(c.Ctx.Request, totalCount, conf.PageSize, c.BaseUrl())
c.Data["PageHtml"] = pager.HtmlPages()
} else {
c.Data["PageHtml"] = ""
}
b, err := json.Marshal(members)
if err != nil {
c.Data["Result"] = template.JS("[]")
} else {
c.Data["Result"] = template.JS(string(b))
}
}
func (c *BookController) TeamAdd() {
c.Prepare()
teamId, _ := c.GetInt("teamId")
book, err := c.IsPermission()
if err != nil {
c.JsonResult(500, err.Error())
return
}
//如果不是创始人也不是管理员则不能操作
if book.RoleId != conf.BookFounder && book.RoleId != conf.BookAdmin {
c.Abort("403")
}
_, err = models.NewTeam().First(teamId, "team_id")
if err != nil {
if err == orm.ErrNoRows {
c.JsonResult(500, "团队不存在")
}
c.JsonResult(5002, err.Error())
}
if _, err := models.NewTeamRelationship().FindByBookId(book.BookId, teamId); err == nil {
c.JsonResult(5003, "团队已加入当前项目")
}
teamRel := models.NewTeamRelationship()
teamRel.BookId = book.BookId
teamRel.TeamId = teamId
err = teamRel.Save()
if err != nil {
c.JsonResult(5004, "加入项目失败")
return
}
teamRel.Include()
c.JsonResult(0, "OK", teamRel)
}
// 删除项目的团队.
func (c *BookController) TeamDelete() {
c.Prepare()
teamId, _ := c.GetInt("teamId")
if teamId <= 0 {
c.JsonResult(5001, i18n.Tr(c.Lang, "message.param_error"))
}
book, err := c.IsPermission()
if err != nil {
c.JsonResult(5002, err.Error())
return
}
//如果不是创始人也不是管理员则不能操作
if book.RoleId != conf.BookFounder && book.RoleId != conf.BookAdmin {
c.Abort("403")
}
err = models.NewTeamRelationship().DeleteByBookId(book.BookId, teamId)
if err != nil {
if err == orm.ErrNoRows {
c.JsonResult(5003, "团队未加入项目")
}
c.JsonResult(5004, err.Error())
}
c.JsonResult(0, "OK")
}
// 团队搜索.
func (c *BookController) TeamSearch() {
c.Prepare()
keyword := strings.TrimSpace(c.GetString("q"))
book, err := c.IsPermission()
if err != nil {
c.JsonResult(500, err.Error())
}
keyword = sqltil.EscapeLike(keyword)
searchResult, err := models.NewTeamRelationship().FindNotJoinBookByBookIdentify(book.BookId, keyword, 10)
if err != nil {
c.JsonResult(500, err.Error(), searchResult)
}
c.JsonResult(0, "OK", searchResult)
}
// 项目空间搜索.
func (c *BookController) ItemsetsSearch() {
c.Prepare()
keyword := strings.TrimSpace(c.GetString("q"))
keyword = sqltil.EscapeLike(keyword)
searchResult, err := models.NewItemsets().FindItemsetsByName(keyword, 10)
if err != nil {
c.JsonResult(500, err.Error(), searchResult)
}
c.JsonResult(0, "OK", searchResult)
}
func (c *BookController) IsPermission() (*models.BookResult, error) {
identify := c.GetString("identify")
if identify == "" {
return nil, errors.New(i18n.Tr(c.Lang, "message.param_error"))
}
book, err := models.NewBookResult().FindByIdentify(identify, c.Member.MemberId)
if err != nil {
if err == models.ErrPermissionDenied {
return book, errors.New(i18n.Tr(c.Lang, "message.no_permission"))
}
if err == orm.ErrNoRows {
return book, errors.New(i18n.Tr(c.Lang, "message.item_not_exist"))
}
return book, err
}
if book.RoleId != conf.BookAdmin && book.RoleId != conf.BookFounder {
return book, errors.New(i18n.Tr(c.Lang, "message.no_permission"))
}
return book, nil
}
================================================
FILE: controllers/BookMemberController.go
================================================
package controllers
import (
"errors"
"github.com/beego/beego/v2/client/orm"
"github.com/beego/beego/v2/core/logs"
"github.com/beego/i18n"
"github.com/mindoc-org/mindoc/conf"
"github.com/mindoc-org/mindoc/models"
)
type BookMemberController struct {
BaseController
}
// AddMember 参加参与用户.
func (c *BookMemberController) AddMember() {
identify := c.GetString("identify")
account, _ := c.GetInt("account")
roleId, _ := c.GetInt("role_id", 3)
logs.Info(account)
if identify == "" || account <= 0 {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.param_error"))
}
book, err := c.IsPermission()
if err != nil {
c.JsonResult(6001, err.Error())
}
member := models.NewMember()
if _, err := member.Find(account); err != nil {
c.JsonResult(404, i18n.Tr(c.Lang, "message.user_not_existed"))
}
if member.Status == 1 {
c.JsonResult(6003, i18n.Tr(c.Lang, "message.user_disable"))
}
if _, err := models.NewRelationship().FindForRoleId(book.BookId, member.MemberId); err == nil {
c.JsonResult(6003, i18n.Tr(c.Lang, "message.user_exist_in_proj"))
}
//如果是只读用户,只能设置为观察者
if member.Role == conf.MemberReaderRole && roleId != int(conf.BookObserver) {
c.JsonResult(6003, i18n.Tr(c.Lang, "message.readusr_only_observer"))
}
relationship := models.NewRelationship()
relationship.BookId = book.BookId
relationship.MemberId = member.MemberId
relationship.RoleId = conf.BookRole(roleId)
if err := relationship.Insert(); err == nil {
memberRelationshipResult := models.NewMemberRelationshipResult().FromMember(member)
memberRelationshipResult.RoleId = conf.BookRole(roleId)
memberRelationshipResult.RelationshipId = relationship.RelationshipId
memberRelationshipResult.BookId = book.BookId
memberRelationshipResult.ResolveRoleName(c.Lang)
c.JsonResult(0, "ok", memberRelationshipResult)
}
c.JsonResult(500, err.Error())
}
// 变更指定用户在指定项目中的权限
func (c *BookMemberController) ChangeRole() {
identify := c.GetString("identify")
memberId, _ := c.GetInt("member_id", 0)
role, _ := c.GetInt("role_id", 0)
if identify == "" || memberId <= 0 {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.param_error"))
}
if memberId == c.Member.MemberId {
c.JsonResult(6006, i18n.Tr(c.Lang, "message.cannot_change_own_priv"))
}
book, err := models.NewBookResult().FindByIdentify(identify, c.Member.MemberId)
if err != nil {
if err == models.ErrPermissionDenied {
c.JsonResult(403, i18n.Tr(c.Lang, "message.no_permission"))
}
if err == orm.ErrNoRows {
c.JsonResult(404, i18n.Tr(c.Lang, "message.item_not_exist"))
}
c.JsonResult(6002, err.Error())
}
if book.RoleId != 0 && book.RoleId != 1 {
c.JsonResult(403, i18n.Tr(c.Lang, "message.no_permission"))
}
member := models.NewMember()
if _, err := member.Find(memberId); err != nil {
c.JsonResult(6003, i18n.Tr(c.Lang, "message.user_not_existed"))
}
if member.Status == 1 {
c.JsonResult(6004, i18n.Tr(c.Lang, "message.user_disable"))
}
//如果是只读用户,只能设置为观察者
if member.Role == conf.MemberReaderRole && role != int(conf.BookObserver) {
c.JsonResult(6003, i18n.Tr(c.Lang, "message.readusr_only_observer"))
}
relationship, err := models.NewRelationship().UpdateRoleId(book.BookId, memberId, conf.BookRole(role))
if err != nil {
logs.Error("变更用户在项目中的权限 => ", err)
c.JsonResult(6005, err.Error())
}
memberRelationshipResult := models.NewMemberRelationshipResult().FromMember(member)
memberRelationshipResult.RoleId = relationship.RoleId
memberRelationshipResult.RelationshipId = relationship.RelationshipId
memberRelationshipResult.BookId = book.BookId
memberRelationshipResult.ResolveRoleName(c.Lang)
c.JsonResult(0, "ok", memberRelationshipResult)
}
// 删除参与者.
func (c *BookMemberController) RemoveMember() {
identify := c.GetString("identify")
member_id, _ := c.GetInt("member_id", 0)
if identify == "" || member_id <= 0 {
c.JsonResult(6001, i18n.Tr(c.Lang, "message.param_error"))
}
if member_id == c.Member.MemberId {
c.JsonResult(6006, i18n.Tr(c.Lang, "message.cannot_delete_self"))
}
book, err := models.NewBookResult().FindByIdentify(identify, c.Member.MemberId)
if err != nil {
if err == models.ErrPermissionDenied {
c.JsonResult(403, i18n.Tr(c.Lang, "message.no_permission"))
}
if err == orm.ErrNoRows {
c.JsonResult(404, i18n.Tr(c.Lang, "message.item_not_exist"))
}
c.JsonResult(6002, err.Error())
}
//如果不是创始人也不是管理员则不能操作
if book.RoleId != conf.BookFounder && book.RoleId != conf.BookAdmin {
c.JsonResult(403, i18n.Tr(c.Lang, "message.no_permission"))
}
err = models.NewRelationship().DeleteByBookIdAndMemberId(book.BookId, member_id)
if err != nil {
c.JsonResult(6007, err.Error())
}
c.JsonResult(0, "ok")
}
func (c *BookMemberController) IsPermission() (*models.BookResult, error) {
identify := c.GetString("identify")
book, err := models.NewBookResult().FindByIdentify(identify, c.Member.MemberId)
if err != nil {
if err == models.ErrPermissionDenied {
return book, errors.New(i18n.Tr(c.Lang, "message.no_permission"))
}
if err == orm.ErrNoRows {
return book, errors.New(i18n.Tr(c.Lang, "message.item_not_exist"))
}
return book, err
}
if book.RoleId != conf.BookAdmin && book.RoleId != conf.BookFounder {
return book, errors.New(i18n.Tr(c.Lang, "message.no_permission"))
}
return book, nil
}
================================================
FILE: controllers/CommentController.go
================================================
package controllers
import (
"strings"
"time"
"github.com/mindoc-org/mindoc/conf"
"github.com/mindoc-org/mindoc/models"
"github.com/mindoc-org/mindoc/utils/pagination"
)
type CommentController struct {
BaseController
}
func (c *CommentController) Lists() {
docid, _ := c.GetInt("docid", 0)
pageIndex, _ := c.GetInt("page", 1)
// 获取评论、分页
comments, count, pageIndex := models.NewComment().QueryCommentByDocumentId(docid, pageIndex, conf.PageSize, c.Member)
page := pagination.PageUtil(int(count), pageIndex, conf.PageSize, comments)
var data struct {
DocId int `json:"doc_id"`
Page pagination.Page `json:"page"`
}
data.DocId = docid
data.Page = page
c.JsonResult(0, "ok", data)
return
}
func (c *CommentController) Create() {
content := c.GetString("content")
id, _ := c.GetInt("doc_id")
_, err := models.NewDocument().Find(id)
if err != nil {
c.JsonResult(1, "文章不存在")
}
m := models.NewComment()
m.DocumentId = id
if c.Member == nil {
c.JsonResult(1, "请先登录,再评论")
}
if len(c.Member.RealName) != 0 {
m.Author = c.Member.RealName
} else {
m.Author = c.Member.Account
}
m.MemberId = c.Member.MemberId
m.IPAddress = c.Ctx.Request.RemoteAddr
m.IPAddress = strings.Split(m.IPAddress, ":")[0]
m.CommentDate = time.Now()
m.Content = content
m.Insert()
var data struct {
DocId int `json:"doc_id"`
}
data.DocId = id
c.JsonResult(0, "ok", data)
}
func (c *CommentController) Index() {
c.Prepare()
c.TplName = "comment/index.tpl"
}
func (c *CommentController) Delete() {
if c.Ctx.Input.IsPost() {
id, _ := c.GetInt("id", 0)
m, err := models.NewComment().Find(id)
if err != nil {
c.JsonResult(1, "评论不存在")
}
doc, err := models.NewDocument().Find(m.DocumentId)
if err != nil {
c.JsonResult(1, "文章不存在")
}
// 判断是否有权限删除
bookRole, _ := models.NewRelationship().FindForRoleId(doc.BookId, c.Member.MemberId)
if m.CanDelete(c.Member.MemberId, bookRole) {
err := m.Delete()
if err != nil {
c.JsonResult(1, "删除错误")
} else {
c.JsonResult(0, "ok")
}
} else {
c.JsonResult(1, "没有权限删除")
}
}
}
================================================
FILE: controllers/DocumentController.go
================================================
package controllers
import (
"context"
"encoding/json"
"fmt"
"html/template"
"image/png"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/beego/beego/v2/client/orm"
"github.com/beego/beego/v2/core/logs"
"github.com/beego/beego/v2/server/web"
"github.com/beego/i18n"
"github.com/boombuler/barcode"
"github.com/boombuler/barcode/qr"
"github.com/mindoc-org/mindoc/conf"
"github.com/mindoc-org/mindoc/models"
"github.com/mindoc-org/mindoc/utils"
"github.com/mindoc-org/mindoc/utils/cryptil"
"github.com/mindoc-org/mindoc/utils/filetil"
"github.com/mindoc-org/mindoc/utils/gopool"
"github.com/mindoc-org/mindoc/utils/pagination"
"github.com/russross/blackfriday/v2"
)
// DocumentController struct
type DocumentController struct {
BaseController
}
// Document prev&next
type DocumentTreeFlatten struct {
DocumentId int `json:"id"`
DocumentName string `json:"text"`
// ParentId interface{} `json:"parent"`
Identify string `json:"identify"`
// BookIdentify string `json:"-"`
// Version int64 `json:"version"`
}
// 文档首页
func (c *DocumentController) Index() {
c.Prepare()
identify := c.Ctx.Input.Param(":key")
token := c.GetString("token")
if identify == "" {
c.ShowErrorPage(404, i18n.Tr(c.Lang, "message.item_not_exist"))
}
// 如果没有开启匿名访问则跳转到登录
if !c.EnableAnonymous && !c.isUserLoggedIn() {
promptUserToLogIn(c)
return
}
bookResult := c.isReadable(identify, token)
c.TplName = "document/" + bookResult.Theme + "_read.tpl"
selected := 0
if bookResult.IsUseFirstDocument {
doc, err := bookResult.FindFirstDocumentByBookId(bookResult.BookId)
if err == nil {
selected = doc.DocumentId
c.Data["Title"] = doc.DocumentName
c.Data["Content"] = template.HTML(doc.Release)
c.Data["Description"] = utils.AutoSummary(doc.Release, 120)
c.Data["FoldSetting"] = "first"
if bookResult.Editor == EditorCherryMarkdown {
c.Data["MarkdownTheme"] = doc.MarkdownTheme
}
if bookResult.IsDisplayComment {
// 获取评论、分页
comments, count, _ := models.NewComment().QueryCommentByDocumentId(doc.DocumentId, 1, conf.PageSize, c.Member)
page := pagination.PageUtil(int(count), 1, conf.PageSize, comments)
c.Data["Page"] = page
}
}
} else {
c.Data["Title"] = i18n.Tr(c.Lang, "blog.summary")
c.Data["Content"] = template.HTML(blackfriday.Run([]byte(bookResult.Description)))
c.Data["FoldSetting"] = "closed"
}
tree, err := models.NewDocument().CreateDocumentTreeForHtml(bookResult.BookId, selected)
if err != nil {
if err == orm.ErrNoRows {
c.ShowErrorPage(404, i18n.Tr(c.Lang, "message.no_doc_in_cur_proj"))
} else {
logs.Error("生成项目文档树时出错 -> ", err)
c.ShowErrorPage(500, i18n.Tr(c.Lang, "message.build_doc_tree_error"))
}
}
c.Data["IS_DOCUMENT_INDEX"] = true
c.Data["Model"] = bookResult
c.Data["Result"] = template.HTML(tree)
}
// CheckPassword : Handles password verification for private documents,
// and front-end requests are made through Ajax.
func (c *DocumentController) CheckPassword() {
identify := c.Ctx.Input.Param(":key")
password := c.GetString("bPassword")
if identify == "" || password == "" {
c.JsonResult(http.StatusBadRequest, i18n.Tr(c.Lang, "message.param_error"))
}
// You have not logged in and need to log in again.
if !c.EnableAnonymous && !c.isUserLoggedIn() {
logs.Info("You have not logged in and need to log in again(SessionId: %s).",
c.CruSession.SessionID(context.TODO()))
c.JsonResult(6000, i18n.Tr(c.Lang, "message.need_relogin"))
return
}
book, err := models.NewBook().FindByFieldFirst("identify", identify)
if err != nil {
logs.Error(err)
c.JsonResult(500, i18n.Tr(c.Lang, "message.item_not_exist"))
}
if book.BookPassword != password {
c.JsonResult(5001, i18n.Tr(c.Lang, "message.wrong_password"))
} else {
c.SetSession(identify, password)
c.JsonResult(0, "OK")
}
}
// 阅读文档
func (c *DocumentController) Read() {
identify := c.Ctx.Input.Param(":key")
token := c.GetString("token")
id := c.GetString(":id")
if identify == "" || id == "" {
c.ShowErrorPage(404, i18n.Tr(c.Lang, "message.item_not_exist"))
}
// 如果没有开启匿名访问则跳转到登录
if !c.EnableAnonymous && !c.isUserLoggedIn() {
promptUserToLogIn(c)
return
}
bookResult := c.isReadable(identify, token)
c.TplName = fmt.Sprintf("document/%s_read.tpl", bookResult.Theme)
doc := models.NewDocument()
if docId, err := strconv.Atoi(id); err == nil {
doc, err = doc.FromCacheById(docId)
if err != nil || doc == nil {
logs.Error("从缓存中读取文档时失败 ->", err)
c.ShowErrorPage(404, i18n.Tr(c.Lang, "message.doc_not_exist"))
return
}
} else {
doc, err = doc.FromCacheByIdentify(id, bookResult.BookId)
if err != nil || doc == nil {
if err == orm.ErrNoRows {
c.ShowErrorPage(404, i18n.Tr(c.Lang, "message.doc_not_exist"))
} else {
logs.Error("从数据库查询文档时出错 ->", err)
c.ShowErrorPage(500, i18n.Tr(c.Lang, "message.unknown_exception"))
}
return
}
}
if doc.BookId != bookResult.BookId {
c.ShowErrorPage(404, i18n.Tr(c.Lang, "message.doc_not_exist"))
}
doc.Lang = c.Lang
doc.Processor()
attach, err := models.NewAttachment().FindListByDocumentId(doc.DocumentId)
if err == nil {
doc.AttachList = attach
}
// prev,next
treeJson, err := models.NewDocument().FindDocumentTree2(bookResult.BookId)
if err != nil {
logs.Error("生成项目文档树时出错 ->", err)
}
res := getTreeRecursive(treeJson, 0)
flat := make([]DocumentTreeFlatten, 0)
Flatten(res, &flat)
var index int
for i, v := range flat {
if v.Identify == id {
index = i
}
}
var PrevName, PrevPath, NextName, NextPath string
if index == 0 {
c.Data["PrevName"] = ""
PrevName = ""
} else {
c.Data["PrevPath"] = identify + "/" + flat[index-1].Identify
c.Data["PrevName"] = flat[index-1].DocumentName
PrevPath = identify + "/" + flat[index-1].Identify
PrevName = flat[index-1].DocumentName
}
if index == len(flat)-1 {
c.Data["NextName"] = ""
NextName = ""
} else {
c.Data["NextPath"] = identify + "/" + flat[index+1].Identify
c.Data["NextName"] = flat[index+1].DocumentName
NextPath = identify + "/" + flat[index+1].Identify
NextName = flat[index+1].DocumentName
}
doc.IncrViewCount(doc.DocumentId)
doc.ViewCount = doc.ViewCount + 1
doc.PutToCache()
if c.IsAjax() {
var data struct {
DocId int `json:"doc_id"`
DocIdentify string `json:"doc_identify"`
DocTitle string `json:"doc_title"`
Body string `json:"body"`
Title string `json:"title"`
Version int64 `json:"version"`
ViewCount int `json:"view_count"`
MarkdownTheme string `json:"markdown_theme"`
IsMarkdown bool `json:"is_markdown"`
}
data.DocId = doc.DocumentId
data.DocIdentify = doc.Identify
data.DocTitle = doc.DocumentName
data.Body = doc.Release + "
本文档使用 MinDoc 构建 - _PAGENUM_ -
", Header: "_SECTION_
", Identifier: "", Language: "zh-CN", Creator: m.CreateName, Publisher: m.Publisher, Contributor: m.Publisher, Title: m.BookName, Format: []string{"epub", "mobi", "pdf", "docx"}, FontSize: "14", PaperSize: "a4", MarginLeft: "72", MarginRight: "72", MarginTop: "72", MarginBottom: "72", Toc: tocList, More: []string{}, } if m.Publisher != "" { ebookConfig.Footer = "本文档由 " + m.Publisher + " 生成- _PAGENUM_ -
" } else if web.AppConfig.DefaultString("publisher_def", "") != "" { defPub := web.AppConfig.DefaultString("publisher_def", "") ebookConfig.Footer = "本文档由 " + defPub + " 生成- _PAGENUM_ -
" } if m.RealName != "" { ebookConfig.Creator = m.RealName } if tempOutputPath, err = filepath.Abs(tempOutputPath); err != nil { logs.Error("导出目录配置错误:" + err.Error()) return convertBookResult, err } for _, item := range docs { name := strconv.Itoa(item.DocumentId) fpath := filepath.Join(tempOutputPath, name+".html") f, err := os.OpenFile(fpath, os.O_CREATE|os.O_RDWR, 0755) if err != nil { return convertBookResult, err } var buf bytes.Buffer if err := web.ExecuteViewPathTemplate(&buf, "document/export.tpl", viewPath, map[string]interface{}{"Model": m, "Lists": item, "BaseUrl": conf.BaseUrl}); err != nil { return convertBookResult, err } html := buf.String() if err != nil { f.Close() return convertBookResult, err } bufio := bytes.NewReader(buf.Bytes()) doc, err := goquery.NewDocumentFromReader(bufio) doc.Find("img").Each(func(i int, contentSelection *goquery.Selection) { if src, ok := contentSelection.Attr("src"); ok { //var encodeString string dstSrcString := "Images/" + filepath.Base(src) //如果是本地路径则直接读取文件内容 if strings.HasPrefix(src, "/") { spath := filepath.Join(conf.WorkingDirectory, src) if filetil.CopyFile(spath, filepath.Join(tempOutputPath, dstSrcString)); err != nil { logs.Error("复制图片失败 -> ", err, src) return } } else { client := &http.Client{} if req, err := http.NewRequest("GET", src, nil); err == nil { req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36") req.Header.Set("Referer", src) //10秒连接超时时间 client.Timeout = time.Second * 100 if resp, err := client.Do(req); err == nil { defer resp.Body.Close() if body, err := ioutil.ReadAll(resp.Body); err == nil { //encodeString = base64.StdEncoding.EncodeToString(body) if err := ioutil.WriteFile(filepath.Join(tempOutputPath, dstSrcString), body, 0755); err != nil { logs.Error("下载图片失败 -> ", err, src) return } } else { logs.Error("下载图片失败 -> ", err, src) return } } else { logs.Error("下载图片失败 -> ", err, src) return } } } contentSelection.SetAttr("src", dstSrcString) } }) //移除文档底部的更新信息 if selection := doc.Find("div.wiki-bottom").First(); selection.Size() > 0 { selection.Remove() } html, err = doc.Html() if err != nil { f.Close() return convertBookResult, err } f.WriteString(html) f.Close() } if err := filetil.CopyFile(filepath.Join(conf.WorkingDirectory, "static", "css", "kancloud.css"), filepath.Join(tempOutputPath, "styles", "css", "kancloud.css")); err != nil { logs.Error("复制CSS样式出错 -> static/css/kancloud.css", err) } if err := filetil.CopyFile(filepath.Join(conf.WorkingDirectory, "static", "css", "export.css"), filepath.Join(tempOutputPath, "styles", "css", "export.css")); err != nil { logs.Error("复制CSS样式出错 -> static/css/export.css", err) } if err := filetil.CopyFile(filepath.Join(conf.WorkingDirectory, "static", "editor.md", "css", "editormd.preview.css"), filepath.Join(tempOutputPath, "styles", "editor.md", "css", "editormd.preview.css")); err != nil { logs.Error("复制CSS样式出错 -> static/editor.md/css/editormd.preview.css", err) } if err := filetil.CopyFile(filepath.Join(conf.WorkingDirectory, "static", "css", "markdown.preview.css"), filepath.Join(tempOutputPath, "styles", "css", "markdown.preview.css")); err != nil { logs.Error("复制CSS样式出错 -> static/css/markdown.preview.css", err) } if err := filetil.CopyFile(filepath.Join(conf.WorkingDirectory, "static", "editor.md", "lib", "highlight", "styles", "github.css"), filepath.Join(tempOutputPath, "styles", "css", "github.css")); err != nil { logs.Error("复制CSS样式出错 -> static/editor.md/lib/highlight/styles/github.css", err) } if err := filetil.CopyDir(filepath.Join(conf.WorkingDirectory, "static", "font-awesome"), filepath.Join(tempOutputPath, "styles", "font-awesome")); err != nil { logs.Error("复制CSS样式出错 -> static/font-awesome", err) } eBookConverter := &converter.Converter{ BasePath: tempOutputPath, OutputPath: filepath.Join(strings.TrimSuffix(tempOutputPath, "source"), "output"), Config: ebookConfig, Debug: true, ProcessNum: conf.GetExportProcessNum(), } os.MkdirAll(eBookConverter.OutputPath, 0766) if err := eBookConverter.Convert(); err != nil { logs.Error("转换文件错误:" + m.BookName + " -> " + err.Error()) return convertBookResult, err } logs.Info("文档转换完成:" + m.BookName) if err := filetil.CopyFile(filepath.Join(eBookConverter.OutputPath, "output", "book.mobi"), mobipath); err != nil { logs.Error("复制文档失败 -> ", filepath.Join(eBookConverter.OutputPath, "output", "book.mobi"), err) } if err := filetil.CopyFile(filepath.Join(eBookConverter.OutputPath, "output", "book.pdf"), pdfpath); err != nil { logs.Error("复制文档失败 -> ", filepath.Join(eBookConverter.OutputPath, "output", "book.pdf"), err) } if err := filetil.CopyFile(filepath.Join(eBookConverter.OutputPath, "output", "book.epub"), epubpath); err != nil { logs.Error("复制文档失败 -> ", filepath.Join(eBookConverter.OutputPath, "output", "book.epub"), err) } if err := filetil.CopyFile(filepath.Join(eBookConverter.OutputPath, "output", "book.docx"), docxpath); err != nil { logs.Error("复制文档失败 -> ", filepath.Join(eBookConverter.OutputPath, "output", "book.docx"), err) } convertBookResult.MobiPath = mobipath convertBookResult.PDFPath = pdfpath convertBookResult.EpubPath = epubpath convertBookResult.WordPath = docxpath return convertBookResult, nil } // 导出Markdown原始文件 func (m *BookResult) ExportMarkdown(sessionId string) (string, error) { outputPath := filepath.Join(conf.WorkingDirectory, "uploads", "books", strconv.Itoa(m.BookId), "book.zip") os.MkdirAll(filepath.Dir(outputPath), 0644) tempOutputPath := filepath.Join(os.TempDir(), sessionId, "markdown") defer os.RemoveAll(tempOutputPath) bookUrl := conf.URLFor("DocumentController.Index", ":key", m.Identify) + "/" err := exportMarkdown(tempOutputPath, 0, m.BookId, tempOutputPath, bookUrl) if err != nil { return "", err } if err := ziptil.Compress(outputPath, tempOutputPath); err != nil { logs.Error("导出Markdown失败->", err) return "", err } return outputPath, nil } // 递归导出Markdown文档 func exportMarkdown(p string, parentId int, bookId int, baseDir string, bookUrl string) error { o := orm.NewOrm() var docs []*Document _, err := o.QueryTable(NewDocument().TableNameWithPrefix()).Filter("book_id", bookId).Filter("parent_id", parentId).All(&docs) if err != nil { logs.Error("导出Markdown失败->", err) return err } for _, doc := range docs { //获取当前文档的子文档数量,如果数量不为0,则将当前文档命名为READMD.md并设置成目录。 subDocCount, err := o.QueryTable(NewDocument().TableNameWithPrefix()).Filter("parent_id", doc.DocumentId).Count() if err != nil { logs.Error("导出Markdown失败->", err) return err } var docPath string if subDocCount > 0 { if doc.Identify != "" { docPath = filepath.Join(p, doc.Identify, "README.md") } else { docPath = filepath.Join(p, strconv.Itoa(doc.DocumentId), "README.md") } } else { if doc.Identify != "" { if strings.HasSuffix(doc.Identify, ".md") || strings.HasSuffix(doc.Identify, ".markdown") { docPath = filepath.Join(p, doc.Identify) } else { docPath = filepath.Join(p, doc.Identify+".md") } } else { docPath = filepath.Join(p, strings.TrimSpace(doc.DocumentName)+".md") } } dirPath := filepath.Dir(docPath) os.MkdirAll(dirPath, 0766) markdown := doc.Markdown //如果当前文档不为空 if strings.TrimSpace(doc.Markdown) != "" { re := regexp.MustCompile(`!\[(.*?)\]\((.*?)\)`) //处理文档中图片 markdown = re.ReplaceAllStringFunc(doc.Markdown, func(image string) string { images := re.FindAllSubmatch([]byte(image), -1) if len(images) <= 0 || len(images[0]) < 3 { return image } originalImageUrl := string(images[0][2]) imageUrl := strings.Replace(string(originalImageUrl), "\\", "/", -1) //如果是本地路径,则需要将图片复制到项目目录 if strings.HasPrefix(imageUrl, "http://") || strings.HasPrefix(imageUrl, "https://") { imageExt := cryptil.Md5Crypt(imageUrl) + filepath.Ext(imageUrl) dstFile := filepath.Join(baseDir, "uploads", time.Now().Format("200601"), imageExt) if err := requests.DownloadAndSaveFile(imageUrl, dstFile); err == nil { imageUrl = strings.TrimPrefix(strings.Replace(dstFile, "\\", "/", -1), strings.Replace(baseDir, "\\", "/", -1)) if !strings.HasPrefix(imageUrl, "/") && !strings.HasPrefix(imageUrl, "\\") { imageUrl = "/" + imageUrl } } } else if strings.HasPrefix(imageUrl, "/") { filetil.CopyFile(filepath.Join(conf.WorkingDirectory, imageUrl), filepath.Join(baseDir, imageUrl)) } imageUrl = strings.Replace(strings.TrimSuffix(image, originalImageUrl+")")+imageUrl+")", "\\", "/", -1) return imageUrl }) linkRe := regexp.MustCompile(`\[(.*?)\]\((.*?)\)`) markdown = linkRe.ReplaceAllStringFunc(markdown, func(link string) string { links := linkRe.FindAllStringSubmatch(link, -1) if len(links) > 0 && len(links[0]) >= 3 { originalLink := links[0][2] //如果当前链接位于当前项目内 if strings.HasPrefix(originalLink, bookUrl) { docIdentify := strings.TrimSpace(strings.TrimPrefix(originalLink, bookUrl)) tempDoc := NewDocument() if id, err := strconv.Atoi(docIdentify); err == nil && id > 0 { err := o.QueryTable(NewDocument().TableNameWithPrefix()).Filter("document_id", id).One(tempDoc, "identify", "parent_id", "document_id") if err != nil { logs.Error(err) return link } } else { err := o.QueryTable(NewDocument().TableNameWithPrefix()).Filter("identify", docIdentify).One(tempDoc, "identify", "parent_id", "document_id") if err != nil { logs.Error(err) return link } } tempLink := recursiveJoinDocumentIdentify(tempDoc.ParentId, "") + strings.TrimPrefix(originalLink, bookUrl) if !strings.HasSuffix(tempLink, ".md") && !strings.HasSuffix(doc.Identify, ".markdown") { tempLink = tempLink + ".md" } relative := strings.TrimPrefix(strings.Replace(p, "\\", "/", -1), strings.Replace(baseDir, "\\", "/", -1)) repeat := 0 if relative != "" { relative = strings.TrimSuffix(strings.TrimPrefix(relative, "/"), "/") repeat = strings.Count(relative, "/") + 1 } logs.Info(repeat, "|", relative, "|", p, "|", baseDir) tempLink = strings.Repeat("../", repeat) + tempLink link = strings.TrimSuffix(link, originalLink+")") + tempLink + ")" } } return link }) } else { markdown = "# " + doc.DocumentName + "\n" } if err := ioutil.WriteFile(docPath, []byte(markdown), 0644); err != nil { logs.Error("导出Markdown失败->", err) return err } if subDocCount > 0 { if err = exportMarkdown(dirPath, doc.DocumentId, bookId, baseDir, bookUrl); err != nil { return err } } } return nil } func recursiveJoinDocumentIdentify(parentDocId int, identify string) string { o := orm.NewOrm() doc := NewDocument() err := o.QueryTable(NewDocument().TableNameWithPrefix()).Filter("document_id", parentDocId).One(doc, "identify", "parent_id", "document_id") if err != nil { logs.Error(err) return identify } if doc.Identify == "" { identify = strconv.Itoa(doc.DocumentId) + "/" + identify } else { identify = doc.Identify + "/" + identify } if doc.ParentId > 0 { identify = recursiveJoinDocumentIdentify(doc.ParentId, identify) } return identify } // 查询项目的第一篇文档 func (m *BookResult) FindFirstDocumentByBookId(bookId int) (*Document, error) { o := orm.NewOrm() doc := NewDocument() err := o.QueryTable(doc.TableNameWithPrefix()).Filter("book_id", bookId).Filter("parent_id", 0).OrderBy("order_sort").One(doc) return doc, err } ================================================ FILE: models/CommentModel.go ================================================ package models import ( "errors" "time" "github.com/beego/beego/v2/client/orm" "github.com/mindoc-org/mindoc/conf" ) // Comment struct type Comment struct { CommentId int `orm:"pk;auto;unique;column(comment_id)" json:"comment_id"` Floor int `orm:"column(floor);type(unsigned);default(0)" json:"floor"` BookId int `orm:"column(book_id);type(int)" json:"book_id"` // DocumentId 评论所属的文档. DocumentId int `orm:"column(document_id);type(int)" json:"document_id"` // Author 评论作者. Author string `orm:"column(author);size(100)" json:"author"` //MemberId 评论用户ID. MemberId int `orm:"column(member_id);type(int)" json:"member_id"` // IPAddress 评论者的IP地址 IPAddress string `orm:"column(ip_address);size(100)" json:"ip_address"` // 评论日期. CommentDate time.Time `orm:"type(datetime);column(comment_date);auto_now_add" json:"comment_date"` //Content 评论内容. Content string `orm:"column(content);size(2000)" json:"content"` // Approved 评论状态:0 待审核/1 已审核/2 垃圾评论/ 3 已删除 Approved int `orm:"column(approved);type(int)" json:"approved"` // UserAgent 评论者浏览器内容 UserAgent string `orm:"column(user_agent);size(500)" json:"user_agent"` // Parent 评论所属父级 ParentId int `orm:"column(parent_id);type(int);default(0)" json:"parent_id"` AgreeCount int `orm:"column(agree_count);type(int);default(0)" json:"agree_count"` AgainstCount int `orm:"column(against_count);type(int);default(0)" json:"against_count"` Index int `orm:"-" json:"index"` ShowDel int `orm:"-" json:"show_del"` Avatar string `orm:"-" json:"avatar"` } // TableName 获取对应数据库表名. func (m *Comment) TableName() string { return "comments" } // TableEngine 获取数据使用的引擎. func (m *Comment) TableEngine() string { return "INNODB" } func (m *Comment) TableNameWithPrefix() string { return conf.GetDatabasePrefix() + m.TableName() } func NewComment() *Comment { return &Comment{} } // 是否有权限删除 func (m *Comment) CanDelete(user_memberid int, user_bookrole conf.BookRole) bool { return user_memberid == m.MemberId || user_bookrole == conf.BookFounder || user_bookrole == conf.BookAdmin } // 根据文档id查询文档评论 func (m *Comment) QueryCommentByDocumentId(doc_id, page, pagesize int, member *Member) (comments []*Comment, count int64, ret_page int) { doc, err := NewDocument().Find(doc_id) if err != nil { return } o := orm.NewOrm() count, _ = o.QueryTable(m.TableNameWithPrefix()).Filter("document_id", doc_id).Count() if -1 == page { // 请求最后一页 var total int = int(count) if total%pagesize == 0 { page = total / pagesize } else { page = total/pagesize + 1 } } offset := (page - 1) * pagesize ret_page = page o.QueryTable(m.TableNameWithPrefix()).Filter("document_id", doc_id).OrderBy("comment_date").Offset(offset).Limit(pagesize).All(&comments) // 需要判断未登录的情况 var bookRole conf.BookRole if member != nil { bookRole, _ = NewRelationship().FindForRoleId(doc.BookId, member.MemberId) } for i := 0; i < len(comments); i++ { comments[i].Index = (i + 1) + (page-1)*pagesize if member != nil && comments[i].CanDelete(member.MemberId, bookRole) { comments[i].ShowDel = 1 comments[i].Avatar = member.Avatar } } return } func (m *Comment) Update(cols ...string) error { o := orm.NewOrm() _, err := o.Update(m, cols...) return err } // Insert 添加一条评论. func (m *Comment) Insert() error { if m.DocumentId <= 0 { return errors.New("评论文档不存在") } if m.Content == "" { return ErrCommentContentNotEmpty } o := orm.NewOrm() if m.CommentId > 0 { comment := NewComment() //如果父评论不存在 if err := o.Read(comment); err != nil { return err } } document := NewDocument() //如果评论的文档不存在 if _, err := document.Find(m.DocumentId); err != nil { return err } book, err := NewBook().Find(document.BookId) //如果评论的项目不存在 if err != nil { return err } //如果已关闭评论 if book.CommentStatus == "closed" { return ErrCommentClosed } if book.CommentStatus == "registered_only" && m.MemberId <= 0 { return ErrPermissionDenied } //如果仅参与者评论 if book.CommentStatus == "group_only" { if m.MemberId <= 0 { return ErrPermissionDenied } rel := NewRelationship() if _, err := rel.FindForRoleId(book.BookId, m.MemberId); err != nil { return ErrPermissionDenied } } if m.MemberId > 0 { member := NewMember() //如果用户不存在 if _, err := member.Find(m.MemberId); err != nil { return ErrMemberNoExist } //如果用户被禁用 if member.Status == 1 { return ErrMemberDisabled } } else if m.Author == "" { m.Author = "[匿名用户]" } m.BookId = book.BookId _, err = o.Insert(m) return err } // 删除一条评论 func (m *Comment) Delete() error { o := orm.NewOrm() _, err := o.Delete(m) return err } func (m *Comment) Find(id int, cols ...string) (*Comment, error) { o := orm.NewOrm() if err := o.QueryTable(m.TableNameWithPrefix()).Filter("comment_id", id).One(m, cols...); err != nil { return m, err } return m, nil } ================================================ FILE: models/ContentReverseIndex.go ================================================ package models import ( "crypto/md5" "encoding/hex" "errors" "fmt" "math" "github.com/beego/beego/v2/client/orm" "github.com/beego/beego/v2/core/logs" "github.com/mindoc-org/mindoc/conf" "github.com/mindoc-org/mindoc/utils" "github.com/mindoc-org/mindoc/utils/segmenter" ) func init() { //go InitializeMissingIndexes() } // ContentReverseIndex 倒排索引结构 type ContentReverseIndex struct { Id string `orm:"pk;column(id);size(64);description(唯一标识ID)" json:"id"` // Word 分词词汇,最长64个字 Word string `orm:"column(word);size(64);index;description(分词词汇)" json:"word"` // ContentType 内容类型:1-Document 2-Blog ContentType int `orm:"column(content_type);type(int);index:idx_content_type_id,priority:1;description(内容类型:1-Document 2-Blog)" json:"content_type"` // ContentId 内容ID,对应DocumentId或BlogId ContentId int `orm:"column(content_id);type(int);index:idx_content_type_id,priority:2;description(内容ID)" json:"content_id"` // WordCount 词频数 WordCount int `orm:"column(word_count);type(int);default(0);description(词频数)" json:"word_count"` } // TableName 获取对应数据库表名 func (c *ContentReverseIndex) TableName() string { return "t_content_reverse_index" } // TableEngine 获取数据使用的引擎 func (c *ContentReverseIndex) TableEngine() string { return "INNODB" } func (c *ContentReverseIndex) TableNameWithPrefix() string { return conf.GetDatabasePrefix() + c.TableName() } func NewContentReverseIndex() *ContentReverseIndex { return &ContentReverseIndex{} } // Insert 插入倒排索引记录 func (c *ContentReverseIndex) Insert() error { if c.Id == "" { return errors.New("id不能为空") } if c.Word == "" { return errors.New("分词词汇不能为空") } if c.ContentType != 1 && c.ContentType != 2 { return errors.New("内容类型必须是1(Document)或2(Blog)") } if c.ContentId <= 0 { return errors.New("内容ID必须大于0") } o := orm.NewOrm() _, err := o.Insert(c) return err } // DeleteByContentTypeAndContentId 根据内容类型和内容ID删除所有倒排索引记录 func (c *ContentReverseIndex) DeleteByContentTypeAndContentId(contentType, contentId int) error { if contentType != 1 && contentType != 2 { return errors.New("内容类型必须是1(Document)或2(Blog)") } if contentId <= 0 { return errors.New("内容ID必须大于0") } o := orm.NewOrm() _, err := o.QueryTable(c.TableNameWithPrefix()).Filter("content_type", contentType).Filter("content_id", contentId).Delete() return err } // BatchInsert 批量插入倒排索引记录 func (c *ContentReverseIndex) BatchInsert(indices []*ContentReverseIndex) error { if len(indices) == 0 { return nil } o := orm.NewOrm() _, err := o.InsertMulti(len(indices), indices) return err } // ContentReverseIndexResult 倒排索引查询结果结构 type ContentReverseIndexResult struct { ContentId int `json:"content_id"` ContentType int `json:"content_type"` Score float64 `json:"score"` // TF-IDF分数 WordCounts []int `json:"word_counts"` // 各个词的词频 } // FindByWordsWithPagination 根据多个分词词汇分页批量查询结果,按IDF值排序 // words: 分词词汇列表 // pageIndex: 页码,从1开始 // pageSize: 每页数量 func (c *ContentReverseIndex) FindByWordsWithPagination(words []string, pageIndex, pageSize int) ([]*ContentReverseIndexResult, int, error) { if len(words) == 0 { return nil, 0, errors.New("分词词汇列表不能为空") } if pageIndex <= 0 { pageIndex = 1 } if pageSize <= 0 { pageSize = 10 } o := orm.NewOrm() tableName := c.TableNameWithPrefix() // 计算总文档数 totalDocsSql := "SELECT COUNT(DISTINCT CONCAT(content_type, '-', content_id)) FROM " + tableName var totalDocs int err := o.Raw(totalDocsSql).QueryRow(&totalDocs) if err != nil { return nil, 0, err } // 构建IN条件 wordPlaceholders := "" wordArgs := make([]any, 0) for i, word := range words { if i > 0 { wordPlaceholders += "," } wordPlaceholders += "?" wordArgs = append(wordArgs, word) } sql := "SELECT word, content_type, content_id, word_count FROM " + tableName + " WHERE word IN (" + wordPlaceholders + ") ORDER BY content_type, content_id" type indexRecord struct { Word string ContentType int ContentId int WordCount int } var records []indexRecord _, err = o.Raw(sql, wordArgs...).QueryRows(&records) if err != nil { return nil, 0, err } // 计算各文档的总词数 sql = "SELECT content_type, content_id, count(word_count) total_word_count FROM " + tableName + " GROUP BY content_type, content_id" type docWordCountRecord struct { ContentType int ContentId int TotalWordCount int } var docWordCountRecords []docWordCountRecord _, err = o.Raw(sql).QueryRows(&docWordCountRecords) if err != nil { return nil, 0, err } docTotalWordCountMap := make(map[string]int) for _, record := range docWordCountRecords { key := fmt.Sprintf("%d-%d", record.ContentType, record.ContentId) docTotalWordCountMap[key] = record.TotalWordCount } // 聚合每个(content_type, content_id)的词频和计算TF-IDF contentMap := make(map[string]*ContentReverseIndexResult) for _, record := range records { key := fmt.Sprintf("%d-%d", record.ContentType, record.ContentId) if result, exists := contentMap[key]; exists { result.WordCounts = append(result.WordCounts, record.WordCount) } else { contentMap[key] = &ContentReverseIndexResult{ ContentId: record.ContentId, ContentType: record.ContentType, WordCounts: []int{record.WordCount}, } } } docMapWithWords := make(map[string]int) // 用于计算包含搜索词的文档数 // 计算每个文档包含多少个查询词 docWordCount := make(map[string]int) for _, record := range records { key := fmt.Sprintf("%d-%d", record.ContentType, record.ContentId) docWordCount[key] += record.WordCount docMapWithWords[key] += 1 } // 计算IDF并生成结果 results := make([]*ContentReverseIndexResult, 0, len(contentMap)) for key := range contentMap { result := contentMap[key] // 计算TF:词频之和 tf := float64(docWordCount[key]) / float64(docTotalWordCountMap[key]+1) // 计算DF:包含该词的文档数(简化处理,使用该文档包含的查询词数量) df := len(docMapWithWords) // 计算IDF idf := 0.0 if df > 0 && totalDocs > 0 { idf = math.Log(float64(totalDocs+1) / float64(df)) } // 用于根据文档总词数调整TF-IDF的权重,避免总词数过小的文档权重过高 alpha := math.Log(1.0+float64(docTotalWordCountMap[key])*0.01) * 100 // TF-IDF分数 result.Score = float64(tf) * idf * float64(alpha) results = append(results, result) } // 按Score降序排序 sortResultsByScore(results) totalCount := len(results) // 分页 offset := (pageIndex - 1) * pageSize start := offset end := offset + pageSize if start > totalCount { start = totalCount } if end > totalCount { end = totalCount } if start >= end { return nil, totalCount, nil } return results[start:end], totalCount, nil } func sortResultsByScore(results []*ContentReverseIndexResult) { for i := 0; i < len(results)-1; i++ { for j := i + 1; j < len(results); j++ { if results[i].Score < results[j].Score { results[i], results[j] = results[j], results[i] } } } } func generateIndexId(contentType, contentId int, word string) string { source := fmt.Sprintf("%d-%d-%s", contentType, contentId, word) hasher := md5.New() hasher.Write([]byte(source)) hash := hasher.Sum(nil) return hex.EncodeToString(hash)[:32] } func BuildIndexForDocument(documentId int, content string) error { if documentId <= 0 { return errors.New("文档ID必须大于0") } index := NewContentReverseIndex() err := index.DeleteByContentTypeAndContentId(1, documentId) if err != nil { logs.Error("删除文档倒排索引失败 ->", documentId, err) return err } words := segmenter.Segment(content) if len(words) == 0 { return nil } wordCountMap := make(map[string]int) for _, word := range words { if len(word) > 64 { word = word[:64] } wordCountMap[word]++ } indices := make([]*ContentReverseIndex, 0, len(wordCountMap)) for word, count := range wordCountMap { id := generateIndexId(1, documentId, word) indexItem := &ContentReverseIndex{ Id: id, Word: word, ContentType: 1, ContentId: documentId, WordCount: count, } indices = append(indices, indexItem) } if len(indices) > 0 { err = index.BatchInsert(indices) if err != nil { return fmt.Errorf("批量插入文档倒排索引失败 -> %d %v", documentId, err) } } return nil } func BuildIndexForBlog(blogId int, content string) error { if blogId <= 0 { return errors.New("BlogID必须大于0") } index := NewContentReverseIndex() err := index.DeleteByContentTypeAndContentId(2, blogId) if err != nil { logs.Error("删除Blog倒排索引失败 ->", blogId, err) return err } words := segmenter.Segment(content) if len(words) == 0 { return nil } wordCountMap := make(map[string]int) for _, word := range words { if len(word) > 64 { word = word[:64] } wordCountMap[word]++ } indices := make([]*ContentReverseIndex, 0, len(wordCountMap)) for word, count := range wordCountMap { id := generateIndexId(2, blogId, word) indexItem := &ContentReverseIndex{ Id: id, Word: word, ContentType: 2, ContentId: blogId, WordCount: count, } indices = append(indices, indexItem) } if len(indices) > 0 { err = index.BatchInsert(indices) if err != nil { logs.Error("批量插入Blog倒排索引失败 ->", blogId, err) return err } } return nil } func CheckDocumentIndexed(documentId int) bool { if documentId <= 0 { return false } o := orm.NewOrm() index := NewContentReverseIndex() return o.QueryTable(index.TableNameWithPrefix()).Filter("content_type", 1).Filter("content_id", documentId).Exist() } func CheckBlogIndexed(blogId int) bool { if blogId <= 0 { return false } o := orm.NewOrm() index := NewContentReverseIndex() return o.QueryTable(index.TableNameWithPrefix()).Filter("content_type", 2).Filter("content_id", blogId).Exist() } func GetUnindexedDocuments(limit int) ([]*Document, error) { o := orm.NewOrm() var documents []*Document docTable := NewDocument().TableNameWithPrefix() indexTable := NewContentReverseIndex().TableNameWithPrefix() sql := "SELECT d.* FROM " + docTable + " d " + "LEFT JOIN " + indexTable + " i ON i.content_type = 1 AND i.content_id = d.document_id " + "WHERE i.id IS NULL " + "ORDER BY d.document_id DESC" if limit > 0 { sql += " LIMIT ?" _, err := o.Raw(sql, limit).QueryRows(&documents) return documents, err } _, err := o.Raw(sql).QueryRows(&documents) return documents, err } func GetUnindexedBlogs(limit int) ([]*Blog, error) { o := orm.NewOrm() var blogs []*Blog blogTable := NewBlog().TableNameWithPrefix() indexTable := NewContentReverseIndex().TableNameWithPrefix() sql := "SELECT b.* FROM " + blogTable + " b " + "LEFT JOIN " + indexTable + " i ON i.content_type = 2 AND i.content_id = b.blog_id " + "WHERE i.id IS NULL " + "ORDER BY b.blog_id DESC" if limit > 0 { sql += " LIMIT ?" _, err := o.Raw(sql, limit).QueryRows(&blogs) return blogs, err } _, err := o.Raw(sql).QueryRows(&blogs) return blogs, err } // InitializeMissingIndexes 初始化缺失的倒排索引 func InitializeMissingIndexes() { go func() { logs.Info("开始检查并初始化缺失的倒排索引...") InitializeMissingDocumentIndexes() InitializeMissingBlogIndexes() logs.Info("倒排索引初始化检查完成") }() } func InitializeMissingDocumentIndexes() { batchSize := 100 for { documents, err := GetUnindexedDocuments(batchSize) if err != nil { logs.Error("获取未索引文档失败 ->", err) break } if len(documents) == 0 { break } for _, doc := range documents { indexed := CheckDocumentIndexed(doc.DocumentId) if !indexed { content := doc.Release if content == "" { content = doc.Markdown } for i := 0; i < 10; i++ { // 标题内容"十分"重要 content = doc.DocumentName + "\n" + content } content = utils.StripTags(content) err := BuildIndexForDocument(doc.DocumentId, content) if err != nil { logs.Error("构建文档倒排索引失败 ->", doc.DocumentId, err) } else { logs.Info("文档倒排索引构建成功 ->", doc.DocumentId) } } } } } func InitializeMissingBlogIndexes() { batchSize := 100 for { blogs, err := GetUnindexedBlogs(batchSize) if err != nil { logs.Error("获取未索引Blog失败 ->", err) break } if len(blogs) == 0 { break } for _, blog := range blogs { indexed := CheckBlogIndexed(blog.BlogId) if !indexed { content := blog.BlogRelease if content == "" { content = blog.BlogContent } for i := 0; i < 10; i++ { // 标题内容"十分"重要 content = blog.BlogTitle + "\n" + content } content = utils.StripTags(content) err := BuildIndexForBlog(blog.BlogId, content) if err != nil { logs.Error("构建Blog倒排索引失败 ->", blog.BlogId, err) } else { logs.Info("Blog倒排索引构建成功 ->", blog.BlogId) } } } } } ================================================ FILE: models/ConvertBookResult.go ================================================ package models // 转换结果 type ConvertBookResult struct { PDFPath string EpubPath string MobiPath string WordPath string } ================================================ FILE: models/Dashboard.go ================================================ package models import "github.com/beego/beego/v2/client/orm" type Dashboard struct { BookNumber int64 `json:"book_number"` DocumentNumber int64 `json:"document_number"` MemberNumber int64 `json:"member_number"` CommentNumber int64 `json:"comment_number"` AttachmentNumber int64 `json:"attachment_number"` } func NewDashboard() *Dashboard { return &Dashboard{} } func (m *Dashboard) Query() *Dashboard { o := orm.NewOrm() book_number, _ := o.QueryTable(NewBook().TableNameWithPrefix()).Count() m.BookNumber = book_number document_count, _ := o.QueryTable(NewDocument().TableNameWithPrefix()).Count() m.DocumentNumber = document_count member_number, _ := o.QueryTable(NewMember().TableNameWithPrefix()).Count() m.MemberNumber = member_number //comment_number,_ := o.QueryTable(NewComment().TableNameWithPrefix()).Count() m.CommentNumber = 0 attachment_number, _ := o.QueryTable(NewAttachment().TableNameWithPrefix()).Count() m.AttachmentNumber = attachment_number return m } ================================================ FILE: models/DocumentHistory.go ================================================ package models import ( "time" "github.com/beego/beego/v2/client/orm" "github.com/beego/beego/v2/core/logs" "github.com/mindoc-org/mindoc/conf" ) type DocumentHistory struct { HistoryId int `orm:"column(history_id);pk;auto;unique" json:"history_id"` Action string `orm:"column(action);size(255);description(modify)" json:"action"` ActionName string `orm:"column(action_name);size(255);description(修改文档)" json:"action_name"` DocumentId int `orm:"column(document_id);type(int);index;description(关联文档id)" json:"doc_id"` DocumentName string `orm:"column(document_name);size(500);description(关联文档id)" json:"doc_name"` ParentId int `orm:"column(parent_id);type(int);index;default(0);description(父级文档id)" json:"parent_id"` Markdown string `orm:"column(markdown);type(text);null;description(文档内容)" json:"markdown"` Content string `orm:"column(content);type(text);null;description(文档内容)" json:"content"` MemberId int `orm:"column(member_id);type(int);description(作者id)" json:"member_id"` ModifyTime time.Time `orm:"column(modify_time);type(datetime);auto_now;description(修改时间)" json:"modify_time"` ModifyAt int `orm:"column(modify_at);type(int);description(修改人id)" json:"-"` Version int64 `orm:"type(bigint);column(version);description(版本)" json:"version"` IsOpen int `orm:"column(is_open);type(int);default(0);description(是否展开子目录 0:阅读时关闭节点 1:阅读时展开节点 2:空目录 单击时会展开下级节点)" json:"is_open"` } type DocumentHistorySimpleResult struct { HistoryId int `json:"history_id"` ActionName string `json:"action_name"` MemberId int `json:"member_id"` Account string `json:"account"` ModifyAt int `json:"modify_at"` ModifyName string `json:"modify_name"` ModifyTime time.Time `json:"modify_time"` Version int64 `json:"version"` } // TableName 获取对应数据库表名. func (m *DocumentHistory) TableName() string { return "document_history" } // TableEngine 获取数据使用的引擎. func (m *DocumentHistory) TableEngine() string { return "INNODB" } func (m *DocumentHistory) TableNameWithPrefix() string { return conf.GetDatabasePrefix() + m.TableName() } func NewDocumentHistory() *DocumentHistory { return &DocumentHistory{} } func (m *DocumentHistory) Find(id int) (*DocumentHistory, error) { o := orm.NewOrm() err := o.QueryTable(m.TableNameWithPrefix()).Filter("history_id", id).One(m) return m, err } //清空指定文档的历史. func (m *DocumentHistory) Clear(docId int) error { o := orm.NewOrm() _, err := o.Raw("DELETE md_document_history WHERE document_id = ?", docId).Exec() return err } //删除历史. func (m *DocumentHistory) Delete(historyId, docId int) error { o := orm.NewOrm() _, err := o.QueryTable(m.TableNameWithPrefix()).Filter("history_id", historyId).Filter("document_id", docId).Delete() return err } //恢复指定历史的文档. func (m *DocumentHistory) Restore(historyId, docId, uid int) error { o := orm.NewOrm() err := o.QueryTable(m.TableNameWithPrefix()).Filter("history_id", historyId).Filter("document_id", docId).One(m) if err != nil { return err } doc, err := NewDocument().Find(m.DocumentId) if err != nil { return err } history := NewDocumentHistory() history.DocumentId = docId history.Content = doc.Content history.Markdown = doc.Markdown history.DocumentName = doc.DocumentName history.ModifyAt = uid history.MemberId = doc.MemberId history.ParentId = doc.ParentId history.Version = time.Now().Unix() history.Action = "restore" history.ActionName = "恢复文档" history.IsOpen = doc.IsOpen history.InsertOrUpdate() doc.DocumentName = m.DocumentName doc.Content = m.Content doc.Markdown = m.Markdown doc.Release = m.Content doc.Version = time.Now().Unix() doc.IsOpen = m.IsOpen _, err = o.Update(doc) return err } func (m *DocumentHistory) InsertOrUpdate() (history *DocumentHistory, err error) { o := orm.NewOrm() history = m if m.HistoryId > 0 { _, err = o.Update(m) } else { _, err = o.Insert(m) if err == nil { if doc, e := NewDocument().Find(m.DocumentId); e == nil { if book, e := NewBook().Find(doc.BookId); e == nil && book.HistoryCount > 0 { //如果已存在的历史记录大于指定的记录,则清除旧记录 if c, e := o.QueryTable(m.TableNameWithPrefix()).Filter("document_id", doc.DocumentId).Count(); e == nil && c > int64(book.HistoryCount) { count := c - int64(book.HistoryCount) logs.Info("需要删除的历史文档数量:", count) var lists []DocumentHistory if _, e := o.QueryTable(m.TableNameWithPrefix()).Filter("document_id", doc.DocumentId).OrderBy("history_id").Limit(count).All(&lists, "history_id"); e == nil { for _, d := range lists { o.Delete(&d) } } } else { logs.Info(book.HistoryCount) } } } } } return } //分页查询指定文档的历史. func (m *DocumentHistory) FindToPager(docId, pageIndex, pageSize int) (docs []*DocumentHistorySimpleResult, totalCount int, err error) { o := orm.NewOrm() offset := (pageIndex - 1) * pageSize totalCount = 0 sql := `SELECT history.*,m1.account,m2.account as modify_name FROM md_document_history AS history LEFT JOIN md_members AS m1 ON history.member_id = m1.member_id LEFT JOIN md_members AS m2 ON history.modify_at = m2.member_id WHERE history.document_id = ? ORDER BY history.history_id DESC limit ? offset ?;` _, err = o.Raw(sql, docId, pageSize, offset).QueryRows(&docs) if err != nil { return } var count int64 count, err = o.QueryTable(m.TableNameWithPrefix()).Filter("document_id", docId).Count() if err != nil { return } totalCount = int(count) return } ================================================ FILE: models/DocumentModel.go ================================================ package models import ( "time" "github.com/beego/i18n" "fmt" "strconv" "bytes" "os" "path/filepath" "strings" "github.com/PuerkitoBio/goquery" "github.com/beego/beego/v2/client/orm" "github.com/beego/beego/v2/core/logs" "github.com/beego/beego/v2/server/web" "github.com/mindoc-org/mindoc/cache" "github.com/mindoc-org/mindoc/conf" "github.com/mindoc-org/mindoc/utils" ) // Document struct. type Document struct { DocumentId int `orm:"pk;auto;unique;column(document_id)" json:"doc_id"` DocumentName string `orm:"column(document_name);size(500);description(文档名称)" json:"doc_name"` Identify string `orm:"column(identify);size(100);index;null;default(null);description(唯一标识)" json:"identify"` // Identify 文档唯一标识 BookId int `orm:"column(book_id);type(int);index;description(关联bools表主键)" json:"book_id"` ParentId int `orm:"column(parent_id);type(int);index;default(0);description(父级文档)" json:"parent_id"` OrderSort int `orm:"column(order_sort);default(0);type(int);index;description(排序从小到大排序)" json:"order_sort"` Markdown string `orm:"column(markdown);type(text);null;description(markdown内容)" json:"markdown"` // Markdown markdown格式文档. MarkdownTheme string `orm:"column(markdown_theme);size(50);default(theme__light);description(markdown主题)" json:"markdown_theme"` Release string `orm:"column(release);type(text);null;description(文章内容)" json:"release"` // Release 发布后的Html格式内容. Content string `orm:"column(content);type(text);null;description(文章内容)" json:"content"` // Content 未发布的 Html 格式内容. CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add;description(创建时间)" json:"create_time"` MemberId int `orm:"column(member_id);type(int);description(关系用户id)" json:"member_id"` ModifyTime time.Time `orm:"column(modify_time);type(datetime);auto_now;description(修改时间)" json:"modify_time"` ModifyAt int `orm:"column(modify_at);type(int);description(修改人id)" json:"-"` Version int64 `orm:"column(version);type(bigint);description(版本,关联历史文档里的version)" json:"version"` IsOpen int `orm:"column(is_open);type(int);default(0);description(是否展开子目录 0:阅读时关闭节点 1:阅读时展开节点 2:空目录 单击时会展开下级节点)" json:"is_open"` //是否展开子目录:0 否/1 是 /2 空间节点,单击时展开下一级 ViewCount int `orm:"column(view_count);type(int);description(浏览量)" json:"view_count"` AttachList []*Attachment `orm:"-" json:"attach"` //i18n Lang string `orm:"-"` } // 多字段唯一键 func (item *Document) TableUnique() [][]string { return [][]string{{"book_id", "identify"}} } // TableName 获取对应数据库表名. func (item *Document) TableName() string { return "documents" } // TableEngine 获取数据使用的引擎. func (item *Document) TableEngine() string { return "INNODB" } func (item *Document) TableNameWithPrefix() string { return conf.GetDatabasePrefix() + item.TableName() } func NewDocument() *Document { return &Document{ Version: time.Now().Unix(), } } // 根据文档ID查询指定文档. func (item *Document) Find(id int) (*Document, error) { if id <= 0 { return item, ErrInvalidParameter } o := orm.NewOrm() err := o.QueryTable(item.TableNameWithPrefix()).Filter("document_id", id).One(item) if err == orm.ErrNoRows { return item, ErrDataNotExist } return item, nil } // 插入和更新文档. func (item *Document) InsertOrUpdate(cols ...string) error { o := orm.NewOrm() item.DocumentName = utils.StripTags(item.DocumentName) var err error if item.DocumentId > 0 { _, err = o.Update(item, cols...) } else { if item.Identify == "" { book := NewBook() identify := "docs" if err := o.QueryTable(book.TableNameWithPrefix()).Filter("book_id", item.BookId).One(book, "identify"); err == nil { identify = book.Identify } item.Identify = fmt.Sprintf("%s-%s", identify, strconv.FormatInt(time.Now().UnixNano(), 32)) } if item.OrderSort == 0 { sort, _ := o.QueryTable(item.TableNameWithPrefix()).Filter("book_id", item.BookId).Filter("parent_id", item.ParentId).Count() item.OrderSort = int(sort) + 1 } _, err = o.Insert(item) NewBook().ResetDocumentNumber(item.BookId) } if err != nil { return err } return nil } // 根据文档识别编号和项目id获取一篇文档 func (item *Document) FindByIdentityFirst(identify string, bookId int) (*Document, error) { o := orm.NewOrm() err := o.QueryTable(item.TableNameWithPrefix()).Filter("book_id", bookId).Filter("identify", identify).One(item) return item, err } // 递归删除一个文档. func (item *Document) RecursiveDocument(docId int) error { o := orm.NewOrm() if doc, err := item.Find(docId); err == nil { // 删除文档的倒排索引 index := NewContentReverseIndex() _ = index.DeleteByContentTypeAndContentId(1, docId) o.Delete(doc) NewDocumentHistory().Clear(doc.DocumentId) } var maps []orm.Params _, err := o.Raw("SELECT document_id FROM " + item.TableNameWithPrefix() + " WHERE parent_id=" + strconv.Itoa(docId)).Values(&maps) if err != nil { logs.Error("RecursiveDocument => ", err) return err } for _, param := range maps { if docId, ok := param["document_id"].(string); ok { id, _ := strconv.Atoi(docId) // 删除子文档的倒排索引 index := NewContentReverseIndex() _ = index.DeleteByContentTypeAndContentId(1, id) o.QueryTable(item.TableNameWithPrefix()).Filter("document_id", id).Delete() item.RecursiveDocument(id) } } return nil } // 将文档写入缓存 func (item *Document) PutToCache() { go func(m Document) { if m.Identify == "" { if err := cache.Put("Document.Id."+strconv.Itoa(m.DocumentId), m, time.Second*3600); err != nil { logs.Info("文档缓存失败:", m.DocumentId) } } else { if err := cache.Put(fmt.Sprintf("Document.BookId.%d.Identify.%s", m.BookId, m.Identify), m, time.Second*3600); err != nil { logs.Info("文档缓存失败:", m.DocumentId) } } }(*item) } // 清除缓存 func (item *Document) RemoveCache() { go func(m Document) { cache.Put("Document.Id."+strconv.Itoa(m.DocumentId), m, time.Second*3600) if m.Identify != "" { cache.Put(fmt.Sprintf("Document.BookId.%d.Identify.%s", m.BookId, m.Identify), m, time.Second*3600) } }(*item) } // 从缓存获取 func (item *Document) FromCacheById(id int) (*Document, error) { if err := cache.Get("Document.Id."+strconv.Itoa(id), &item); err == nil && item.DocumentId > 0 { logs.Info("从缓存中获取文档信息成功 ->", item.DocumentId) return item, nil } if item.DocumentId > 0 { item.PutToCache() } item, err := item.Find(id) if err == nil { item.PutToCache() } return item, err } // 根据文档标识从缓存中查询文档 func (item *Document) FromCacheByIdentify(identify string, bookId int) (*Document, error) { key := fmt.Sprintf("Document.BookId.%d.Identify.%s", bookId, identify) if err := cache.Get(key, item); err == nil && item.DocumentId > 0 { logs.Info("从缓存中获取文档信息成功 ->", key) return item, nil } defer func() { if item.DocumentId > 0 { item.PutToCache() } }() return item.FindByIdentityFirst(identify, bookId) } // 根据项目ID查询文档列表. func (item *Document) FindListByBookId(bookId int) (docs []*Document, err error) { o := orm.NewOrm() _, err = o.QueryTable(item.TableNameWithPrefix()).Filter("book_id", bookId).OrderBy("order_sort").All(&docs) return } // 判断文章是否存在 func (item *Document) IsExist(documentId int) bool { o := orm.NewOrm() return o.QueryTable(item.TableNameWithPrefix()).Filter("document_id", documentId).Exist() } // 发布单篇文档 func (item *Document) ReleaseContent() error { item.Release = strings.TrimSpace(item.Content) err := item.Processor().InsertOrUpdate("release") if err != nil { logs.Error(fmt.Sprintf("发布失败 -> %+v", item), err) return err } //当文档发布后,需要清除已缓存的转换文档和文档缓存 item.RemoveCache() if err := os.RemoveAll(filepath.Join(conf.WorkingDirectory, "uploads", "books", strconv.Itoa(item.BookId))); err != nil { logs.Error("删除已缓存的文档目录失败 -> ", filepath.Join(conf.WorkingDirectory, "uploads", "books", strconv.Itoa(item.BookId))) return err } // 刷新倒排索引 go func(docId int, docName, release, markdown string) { content := docName + "\n" + release if content == "" { content = markdown } content = utils.StripTags(content) if err := BuildIndexForDocument(docId, content); err != nil { logs.Error("error: 构建文档倒排索引失败 ->", docId, err) } }(item.DocumentId, item.DocumentName, item.Release, item.Markdown) return nil } // Processor 调用位置两处: // 1. 项目发布和文档发布: 处理文档的外链,附件,底部编辑信息等; // 2. 文档阅读:可以修复存在问题的文档,使其能正常显示附件下载和文档作者信息等。 func (item *Document) Processor() *Document { if item.Release != "" { item.Release = utils.SafetyProcessor(item.Release) } else { // Release内容为空,直接赋值文档标签,保证附件下载正常 item.Release = "" } // Next: 生成文档的一些附加信息 if docQuery, err := goquery.NewDocumentFromReader(bytes.NewBufferString(item.Release)); err == nil { //处理附件 if selector := docQuery.Find("div.attach-list").First(); selector.Size() <= 0 { //处理附件 attachList, err := NewAttachment().FindListByDocumentId(item.DocumentId) if err == nil && len(attachList) > 0 { content := bytes.NewBufferString("