Repository: smallpath/blog Branch: master Commit: 8f1a4b7557a5 Files: 169 Total size: 245.6 KB Directory structure: gitextract_n6nb127_/ ├── Dockerfile ├── LICENSE ├── README.md ├── admin/ │ ├── .babelrc │ ├── .eslintignore │ ├── .eslintrc.js │ ├── .gitignore │ ├── README.md │ ├── build/ │ │ ├── build.js │ │ ├── check-versions.js │ │ ├── dev-client.js │ │ ├── dev-server.js │ │ ├── utils.js │ │ ├── webpack.base.conf.js │ │ ├── webpack.dev.conf.js │ │ └── webpack.prod.conf.js │ ├── config/ │ │ ├── dev.env.js │ │ ├── index.js │ │ ├── prod.env.js │ │ └── test.env.js │ ├── index.html │ ├── package.json │ ├── src/ │ │ ├── App.vue │ │ ├── components/ │ │ │ ├── Main.vue │ │ │ ├── containers/ │ │ │ │ ├── Create.vue │ │ │ │ ├── List.vue │ │ │ │ ├── Markdown.vue │ │ │ │ └── Post.vue │ │ │ ├── pages/ │ │ │ │ ├── Dashboard.vue │ │ │ │ ├── Login.vue │ │ │ │ ├── Logout.vue │ │ │ │ ├── Sidebar.vue │ │ │ │ └── Top.vue │ │ │ ├── utils/ │ │ │ │ └── marked.js │ │ │ └── views/ │ │ │ ├── CreateEditView.js │ │ │ ├── CreateListView.js │ │ │ └── CreateMarkdownView.js │ │ ├── main.js │ │ ├── route/ │ │ │ └── index.js │ │ ├── store/ │ │ │ ├── api.js │ │ │ └── index.js │ │ └── utils/ │ │ └── error.js │ ├── static/ │ │ └── .gitkeep │ └── test/ │ ├── e2e/ │ │ ├── custom-assertions/ │ │ │ └── elementCount.js │ │ ├── nightwatch.conf.js │ │ ├── runner.js │ │ └── specs/ │ │ └── test.js │ └── unit/ │ ├── .eslintrc │ ├── index.js │ ├── karma.conf.js │ └── specs/ │ └── Hello.spec.js ├── docs/ │ ├── .nojekyll │ ├── README.md │ └── index.html ├── front/ │ ├── .babelrc │ ├── .eslintignore │ ├── .eslintrc.js │ ├── .gitignore │ ├── README.md │ ├── build/ │ │ ├── build-client.js │ │ ├── setup-dev-server.js │ │ ├── utils.js │ │ ├── vue-loader.config.js │ │ ├── webpack.base.config.js │ │ ├── webpack.client.config.js │ │ └── webpack.server.config.js │ ├── config/ │ │ ├── dev.env.js │ │ ├── index.js │ │ ├── prod.env.js │ │ └── test.env.js │ ├── middleware/ │ │ ├── favicon.js │ │ └── serverGoogleAnalytic.js │ ├── package.json │ ├── production.js │ ├── server/ │ │ ├── config.js │ │ ├── model.js │ │ ├── mongo.tpl │ │ ├── robots.js │ │ ├── rss.js │ │ ├── server-axios.js │ │ └── sitemap.js │ ├── server.js │ ├── src/ │ │ ├── assets/ │ │ │ ├── css/ │ │ │ │ ├── article.css │ │ │ │ ├── base.css │ │ │ │ ├── footer.css │ │ │ │ ├── header.css │ │ │ │ ├── highlight.css │ │ │ │ ├── icon.css │ │ │ │ ├── pagination.css │ │ │ │ ├── responsive.css │ │ │ │ └── sidebar.css │ │ │ └── js/ │ │ │ └── base.js │ │ ├── client-entry.js │ │ ├── components/ │ │ │ ├── App.vue │ │ │ ├── Archive.vue │ │ │ ├── BlogPager.vue │ │ │ ├── BlogSummary.vue │ │ │ ├── Disqus.vue │ │ │ ├── Footer.vue │ │ │ ├── Header.vue │ │ │ ├── Loading.vue │ │ │ ├── Pagination.vue │ │ │ ├── Post.vue │ │ │ ├── Sidebar.vue │ │ │ ├── Tag.vue │ │ │ └── TagPager.vue │ │ ├── index.template.html │ │ ├── main.js │ │ ├── mixin/ │ │ │ ├── disqus.js │ │ │ └── image.js │ │ ├── route/ │ │ │ ├── create-route-client.js │ │ │ ├── create-route-server.js │ │ │ └── index.js │ │ ├── server-entry.js │ │ ├── store/ │ │ │ ├── api.js │ │ │ ├── client-axios.js │ │ │ ├── create-api-client.js │ │ │ ├── create-api-server.js │ │ │ ├── index.js │ │ │ └── vuex.js │ │ ├── utils/ │ │ │ ├── 404.js │ │ │ └── clientGoogleAnalyse.js │ │ └── views/ │ │ └── CreatePostView.js │ └── test/ │ ├── e2e/ │ │ ├── custom-assertions/ │ │ │ └── elementCount.js │ │ ├── nightwatch.conf.js │ │ ├── runner.js │ │ └── specs/ │ │ └── test.js │ └── unit/ │ ├── .eslintrc │ ├── index.js │ ├── karma.conf.js │ └── specs/ │ └── Hello.spec.js ├── pm2.json └── server/ ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── README.md ├── app.js ├── blogpack.js ├── build/ │ ├── blogpack.base.config.js │ ├── blogpack.dev.config.js │ └── blogpack.prod.config.js ├── conf/ │ ├── config.tpl │ └── option.js ├── entry.js ├── model/ │ ├── mongo.js │ └── redis.js ├── mongoRest/ │ ├── actions.js │ ├── index.js │ └── routes.js ├── package.json ├── plugins/ │ ├── beforeRestful/ │ │ └── checkAuth.js │ ├── beforeServerStart/ │ │ ├── initOption.js │ │ ├── initUser.js │ │ └── installTheme.js │ ├── beforeUseRoutes/ │ │ ├── bodyParser.js │ │ ├── logTime.js │ │ ├── ratelimit.js │ │ └── restc.js │ └── mountingRoute/ │ ├── login.js │ ├── logout.js │ └── qiniu.js ├── service/ │ └── token.js ├── test/ │ ├── data/ │ │ ├── index.js │ │ └── post.js │ ├── index.js │ ├── integration/ │ │ └── postRestful.js │ └── unit/ │ └── postQuery.js ├── theme/ │ └── firekylin.js └── utils/ └── log.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: Dockerfile ================================================ FROM node:7.7 MAINTAINER Jerry Bendy , qfdk # copy all files to target COPY . /app # install global packages RUN npm install -g yarn pm2 --registry=https://registry.npm.taobao.org # install dependences RUN cd /app/server && cp conf/config.tpl conf/config.js && yarn RUN cd /app/front && cp server/mongo.tpl server/mongo.js && yarn && npm run build RUN cd /app/admin && yarn && npm run build # clean cache RUN npm cache clean WORKDIR /app EXPOSE 3000 EXPOSE 8080 CMD pm2-docker start pm2.json ================================================ FILE: LICENSE ================================================ 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 ================================================ # Blog A blog system. Based on Vue2, Koa2, MongoDB and Redis 前后端分离 + 服务端渲染的博客系统, 前端 SPA + 后端 RESTful 服务器 # Demo 前端:[https://smallpath.me](https://smallpath.me) 后台管理截图:[https://smallpath.me/post/blog-back-v2](https://smallpath.me/post/blog-back-v2) Table of Contents ================= * [TODO](#todo) * [构建与部署](#构建与部署) * [前置](#前置) * [server](#server) * [front](#front) * [admin](#admin) * [后端RESTful API](#后端restful-api) * [说明](#说明) * [HTTP动词](#http动词) * [权限验证](#权限验证) * [获得Token](#获得token) * [撤销Token](#撤销token) * [Token说明](#token说明) * [查询](#查询) * [新建](#新建) * [替换](#替换) * [更新](#更新) * [删除](#删除) # TODO > [TODO](https://github.com/smallpath/blog/issues/14) # 构建与部署 ## 前置 - Node v6 - pm2 - MongoDB - Redis ## server
博客的提供RESTful API的后端 复制conf文件夹中的默认配置`config.tpl`, 并命名为`config.js` 有如下属性可以自行配置: - `tokenSecret` - 改为任意字符串 - `defaultAdminPassword` - 默认密码, 必须修改, 否则服务器将拒绝启动 如果mongoDB或redis不在本机对应端口,可以修改对应的属性 - `mongoHost` - `mongoDatabase` - `mongoPort` - `redisHost` - `redisPort` 如果需要启用后台管理单页的七牛图片上传功能,请再修改如下属性: - `qiniuAccessKey` - 七牛账号的公钥 - `qiniuSecretKey` - 七牛账号的私钥 - `qiniuBucketHost` - 七牛Bucket对应的外链域名 - `qiniuBucketName` - 七牛Bucket的名称 - `qiniuPipeline` - 七牛多媒体处理队列的名称 ``` npm install pm2 start entry.js ``` RESTful服务器在本机3000端口开启
## front
博客的前台单页, 支持服务端渲染 复制server文件夹中的默认配置`mongo.tpl`, 并命名为`mongo.js` 如果mongoDB不在本机对应端口,请自行配置`mongo.js`中的属性: - `mongoHost` - `mongoDatabase` - `mongoPort` ``` npm install npm run build pm2 start production.js ``` 请将`logo.png`与`favicon.ico`放至`static`目录中 再用nginx代理本机8080端口即可, 可以使用如下的模板 ``` server{ listen 80; #如果是https, 则替换80为443 server_name *.smallpath.me smallpath.me; #替换域名 root /alidata/www/Blog/front/dist; #替换路径为构建出来的dist路径 set $node_port 3000; set $ssr_port 8080; location ^~ / { proxy_http_version 1.1; proxy_set_header Connection "upgrade"; proxy_pass http://127.0.0.1:$ssr_port; proxy_redirect off; } location ^~ /proxyPrefix/ { rewrite ^/proxyPrefix/(.*) /$1 break; proxy_http_version 1.1; proxy_set_header Connection "upgrade"; proxy_pass http://127.0.0.1:$node_port; proxy_redirect off; } location ^~ /dist/ { rewrite ^/dist/(.*) /$1 break; etag on; expires max; } location ^~ /static/ { etag on; expires max; } } ``` 开发端口为本机8080
## admin
博客的后台管理单页 ``` npm install npm run build ``` 用nginx代理构建出来的`dist`文件夹即可, 可以使用如下的模板 ``` server{ listen 80; #如果是https, 则替换80为443 server_name admin.smallpath.me; #替换域名 root /alidata/www/Blog/admin/dist; #替换路径为构建出来的dist路径 set $node_port 3000; index index.js index.html index.htm; location / { try_files $uri $uri/ @rewrites; } location @rewrites { rewrite ^(.*)$ / last; } location ^~ /proxyPrefix/ { rewrite ^/proxyPrefix/(.*) /$1 break; proxy_http_version 1.1; proxy_set_header Connection "upgrade"; proxy_pass http://127.0.0.1:$node_port; proxy_redirect off; } location ^~ /static/ { etag on; expires max; } } ``` 开发端口为本机8082
# 后端 RESTful API ## 说明 后端服务器默认开启在 3000 端口, 如不愿意暴露 IP, 可以自行设置 nginx 代理, 或者直接使用前端两个单页的代理前缀`/proxyPrefix` 例如, demo的API根目录如下: > https://smallpath.me/proxyPrefix/api/:modelName/:id 其中, `:modelName`为模型名, 总计如下6个模型 ``` post theme tag category option user ``` `:id`为指定的文档ID, 用以对指定文档进行 CRUD ## HTTP 动词 支持如下五种: ``` bash GET //查询 POST //新建 PUT //替换 PATCH //更新部分属性 DELETE //删除指定ID的文档 ``` 有如下两个规定: - 对所有请求 - header中必须将 `Content-Type` 设置为 `application/json` , 需要 `body` 的则 `body` 必须是合法 JSON格式 - 对所有回应 - header中的`Content-Type`均为`application/json`, 且返回的数据也是 JSON格式 ## 权限验证 服务器直接允许对`user`模型外的所有模型的GET请求 `user`表的所有请求, 以及其他表的非 GET 请求, 都必须将 header 中的`authorization`设置为服务器下发的 Token, 服务器验证通过后才会继续执行 CRUD 操作 ### 获得 Token > POST https://smallpath.me/proxyPrefix/admin/login `body`格式如下: ``` { "name": "admin", "password": "testpassword" } ``` 成功, 则返回带有`token`字段的 JSON 数据 ``` { "status": "success", "token": "tokenExample" } ``` 失败, 则返回如下格式的 JSON 数据: ``` { "status": "fail", "description": "Get token failed. Check name and password" } ``` 获取到`token`后, 在上述需要 token 验证的请求中, 请将 header 中的`authorization`设置为服务器下发的 Token, 否则请求将被服务器拒绝 ### 撤销 Token > POST https://smallpath.me/proxyPrefix/admin/logout 将`header`中的`authorization`设置为服务器下发的 token, 即可撤销此 token ### Token说明 Token 默认有效期为获得后的一小时, 超出时间后请重新请求 Token 如需自定义有效期, 请修改服务端配置文件中的`tokenExpiresIn`字段, 其单位为秒 ## 查询 服务器直接允许对`user`模型外的所有模型的 GET 请求, 不需要验证 Token 为了直接通过 URI 来进行 mongoDB 查询, 后台提供六种关键字的查询: ``` conditions, select, count, sort, skip, limit ``` ### 条件(conditions)查询 类型为JSON, 被解析为对象后, 直接将其作为`mongoose.find`的查询条件 #### 查询所有文档 > GET https://smallpath.me/proxyPrefix/api/post #### 查询title字段为'关于'的文档 > GET https://smallpath.me/proxyPrefix/api/post?conditions={"title":"关于"} #### 查询指定id的文档的上一篇文档 > GET https://smallpath.me/proxyPrefix/api/post/?conditions={"_id":{"$lt":"580b3ff504f59b4cc27845f0"}}&sort=1&limit=1 #### select查询 类型为JSON, 用以拾取每条数据所需要的属性名, 以过滤输出来加快响应速度 #### 查询title字段为'关于'的文档的建立时间和更新时间 > GET https://smallpath.me/proxyPrefix/api/post?conditions={"title":"关于"}&select={"createdAt":1,"updatedAt":1} ### count查询 获得查询结果的数量 #### 查询文档的数量 > GET https://smallpath.me/proxyPrefix/api/post?conditions={"type":0}&count=1 ### sort查询 #### 查询所有文档并按id倒序 > GET https://smallpath.me/proxyPrefix/api/post?sort={"_id":-1} #### 查询所有文档并按更新时间倒序 > GET https://smallpath.me/proxyPrefix/api/post?sort={"updatedAt":-1} ### skip 查询和 limit 查询 #### 查询第2页的文档(每页10条)并按时间倒叙 > GET https://smallpath.me/proxyPrefix/api/post?limit=10&skip=10&sort=1 ## 新建 需要验证Token > POST https://smallpath.me/proxyPrefix/api/:modelName Body中为用来新建文档的JSON数据 每个模型的具体字段, 可以查看该模型的[Schema定义](https://github.com/smallpath/blog/blob/master/server/model/mongo.js#L24)来获得 ## 替换 需要验证Token > PUT https://smallpath.me/proxyPrefix/api/:modelName/:id `:id`为查询到的文档的`_id`属性, Body中为用来替换该文档的JSON数据 ## 更新 需要验证Token > PATCH https://smallpath.me/proxyPrefix/api/:modelName/:id `:id`为查询到的文档的`_id`属性, Body中为用来更新该文档的JSON数据 更新操作请使用`PATCH`而不是`PUT` ## 删除 需要验证 Token > DELETE https://smallpath.me/proxyPrefix/api/:modelName/:id 删除指定 ID 的文档 ================================================ FILE: admin/.babelrc ================================================ { "presets": ["es2015", "stage-2"], "plugins": ["transform-runtime"], "comments": false } ================================================ FILE: admin/.eslintignore ================================================ build/*.js config/*.js ================================================ FILE: admin/.eslintrc.js ================================================ module.exports = { root: true, parser: 'babel-eslint', parserOptions: { sourceType: 'module' }, // https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style extends: 'standard', // required to lint *.vue files plugins: [ 'html' ], // add your custom rules here 'rules': { // allow paren-less arrow functions 'arrow-parens': 0, // allow async-await 'generator-star-spacing': 0, 'space-before-function-paren': ['error', 'never'], // allow debugger during development 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 } } ================================================ FILE: admin/.gitignore ================================================ .DS_Store node_modules/ dist/ npm-debug.log test/unit/coverage test/e2e/reports selenium-debug.log .editorconfig ================================================ FILE: admin/README.md ================================================ # admin > 博客的后台管理单页 ## 开发测试环境 ``` bash npm install npm run dev ``` 开发端口为本机8082 ## 生产环境 ``` bash npm install npm run build ``` 用nginx代理构建出来的`dist`文件夹即可, 可以使用如下的模板 ``` server{ listen 80; #如果是https, 则替换80为443 server_name admin.smallpath.me; #替换域名 root /alidata/www/Blog/admin/dist; #替换路径为构建出来的dist路径 set $node_port 3000; index index.js index.html index.htm; location / { try_files $uri $uri/ @rewrites; } location @rewrites { rewrite ^(.*)$ / last; } location ^~ /proxyPrefix/ { rewrite ^/proxyPrefix/(.*) /$1 break; proxy_http_version 1.1; proxy_set_header Connection "upgrade"; proxy_pass http://127.0.0.1:$node_port; proxy_redirect off; } location ^~ /static/ { etag on; expires max; } } ``` ================================================ FILE: admin/build/build.js ================================================ // https://github.com/shelljs/shelljs require('./check-versions')() require('shelljs/global') env.NODE_ENV = 'production' var path = require('path') var config = require('../config') var ora = require('ora') var webpack = require('webpack') var webpackConfig = require('./webpack.prod.conf') console.log( ' Tip:\n' + ' Built files are meant to be served over an HTTP server.\n' + ' Opening index.html over file:// won\'t work.\n' ) var spinner = ora('building for production...') spinner.start() var assetsPath = path.join(config.build.assetsRoot, config.build.assetsSubDirectory) rm('-rf', assetsPath) mkdir('-p', assetsPath) cp('-R', 'static/*', assetsPath) webpack(webpackConfig, function (err, stats) { spinner.stop() if (err) throw err process.stdout.write(stats.toString({ colors: true, modules: false, children: false, chunks: false, chunkModules: false }) + '\n') }) ================================================ FILE: admin/build/check-versions.js ================================================ var semver = require('semver') var chalk = require('chalk') var packageConfig = require('../package.json') var exec = function (cmd) { return require('child_process') .execSync(cmd).toString().trim() } var versionRequirements = [ { name: 'node', currentVersion: semver.clean(process.version), versionRequirement: packageConfig.engines.node }, { name: 'npm', currentVersion: exec('npm --version'), versionRequirement: packageConfig.engines.npm } ] module.exports = function () { var warnings = [] for (var i = 0; i < versionRequirements.length; i++) { var mod = versionRequirements[i] if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) { warnings.push(mod.name + ': ' + chalk.red(mod.currentVersion) + ' should be ' + chalk.green(mod.versionRequirement) ) } } if (warnings.length) { console.log('') console.log(chalk.yellow('To use this template, you must update following to modules:')) console.log() for (var i = 0; i < warnings.length; i++) { var warning = warnings[i] console.log(' ' + warning) } console.log() process.exit(1) } } ================================================ FILE: admin/build/dev-client.js ================================================ /* eslint-disable */ require('eventsource-polyfill') var hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true') hotClient.subscribe(function (event) { if (event.action === 'reload') { window.location.reload() } }) ================================================ FILE: admin/build/dev-server.js ================================================ require('./check-versions')() var config = require('../config') if (!process.env.NODE_ENV) process.env.NODE_ENV = JSON.parse(config.dev.env.NODE_ENV) var path = require('path') var express = require('express') var webpack = require('webpack') var opn = require('opn') var proxyMiddleware = require('http-proxy-middleware') var webpackConfig = process.env.NODE_ENV === 'testing' ? require('./webpack.prod.conf') : require('./webpack.dev.conf') // default port where dev server listens for incoming traffic var port = process.env.PORT || config.dev.port // Define HTTP proxies to your custom API backend // https://github.com/chimurai/http-proxy-middleware var proxyTable = config.dev.proxyTable var app = express() var compiler = webpack(webpackConfig) var devMiddleware = require('webpack-dev-middleware')(compiler, { publicPath: webpackConfig.output.publicPath, stats: { colors: true, chunks: false } }) var hotMiddleware = require('webpack-hot-middleware')(compiler) // force page reload when html-webpack-plugin template changes compiler.plugin('compilation', function (compilation) { compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) { hotMiddleware.publish({ action: 'reload' }) cb() }) }) // proxy api requests Object.keys(proxyTable).forEach(function (context) { var options = proxyTable[context] if (typeof options === 'string') { options = { target: options } } app.use(proxyMiddleware(context, options)) }) // handle fallback for HTML5 history API app.use(require('connect-history-api-fallback')()) // serve webpack bundle output app.use(devMiddleware) // enable hot-reload and state-preserving // compilation error display app.use(hotMiddleware) // serve pure static assets var staticPath = path.posix.join(config.dev.assetsPublicPath, config.dev.assetsSubDirectory) app.use(staticPath, express.static('./static')) module.exports = app.listen(port, function (err) { if (err) { console.log(err) return } var uri = 'http://localhost:' + port console.log('Listening at ' + uri + '\n') // when env is testing, don't need open it if (process.env.NODE_ENV !== 'testing') { opn(uri) } }) ================================================ FILE: admin/build/utils.js ================================================ var path = require('path') var config = require('../config') var ExtractTextPlugin = require('extract-text-webpack-plugin') exports.assetsPath = function (_path) { var assetsSubDirectory = process.env.NODE_ENV === 'production' ? config.build.assetsSubDirectory : config.dev.assetsSubDirectory return path.posix.join(assetsSubDirectory, _path) } exports.cssLoaders = function (options) { options = options || {} // generate loader string to be used with extract text plugin function generateLoaders (loaders) { var sourceLoader = loaders.map(function (loader) { var extraParamChar if (/\?/.test(loader)) { loader = loader.replace(/\?/, '-loader?') extraParamChar = '&' } else { loader = loader + '-loader' extraParamChar = '?' } return loader + (options.sourceMap ? extraParamChar + 'sourceMap' : '') }).join('!') // Extract CSS when that option is specified // (which is the case during production build) if (options.extract) { return ExtractTextPlugin.extract('vue-style-loader', sourceLoader) } else { return ['vue-style-loader', sourceLoader].join('!') } } // http://vuejs.github.io/vue-loader/en/configurations/extract-css.html return { css: generateLoaders(['css']), postcss: generateLoaders(['css']), less: generateLoaders(['css', 'less']), sass: generateLoaders(['css', 'sass?indentedSyntax']), scss: generateLoaders(['css', 'sass']), stylus: generateLoaders(['css', 'stylus']), styl: generateLoaders(['css', 'stylus']) } } // Generate loaders for standalone style files (outside of .vue) exports.styleLoaders = function (options) { var output = [] var loaders = exports.cssLoaders(options) for (var extension in loaders) { var loader = loaders[extension] output.push({ test: new RegExp('\\.' + extension + '$'), loader: loader }) } return output } ================================================ FILE: admin/build/webpack.base.conf.js ================================================ var path = require('path') var config = require('../config') var utils = require('./utils') var projectRoot = path.resolve(__dirname, '../') var env = process.env.NODE_ENV // check env & config/index.js to decide weither to enable CSS Sourcemaps for the // various preprocessor loaders added to vue-loader at the end of this file var cssSourceMapDev = (env === 'development' && config.dev.cssSourceMap) var cssSourceMapProd = (env === 'production' && config.build.productionSourceMap) var useCssSourceMap = cssSourceMapDev || cssSourceMapProd module.exports = { entry: { app: './src/main.js' }, output: { path: config.build.assetsRoot, publicPath: process.env.NODE_ENV === 'production' ? config.build.assetsPublicPath : config.dev.assetsPublicPath, filename: '[name].js' }, resolve: { extensions: ['', '.js', '.vue'], fallback: [path.join(__dirname, '../node_modules')], alias: { 'vue$': 'vue/dist/vue.common', 'src': path.resolve(__dirname, '../src'), 'assets': path.resolve(__dirname, '../src/assets'), 'components': path.resolve(__dirname, '../src/components') } }, resolveLoader: { fallback: [path.join(__dirname, '../node_modules')] }, module: { // preLoaders: [ // { // test: /\.vue$/, // loader: 'eslint', // include: projectRoot, // exclude: /node_modules/ // }, // { // test: /\.js$/, // loader: 'eslint', // include: projectRoot, // exclude: /node_modules/ // } // ], loaders: [ { test: /\.vue$/, loader: 'vue' }, { test: /\.js$/, loader: 'babel', include: projectRoot, exclude: /node_modules/ }, { test: /\.json$/, loader: 'json' }, { test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, loader: 'url', query: { limit: 10000, name: utils.assetsPath('img/[name].[hash:7].[ext]') } }, { test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, loader: 'url', query: { limit: 10000, name: utils.assetsPath('fonts/[name].[hash:7].[ext]') } } ] }, eslint: { formatter: require('eslint-friendly-formatter') }, vue: { loaders: utils.cssLoaders({ sourceMap: useCssSourceMap }), postcss: [ require('autoprefixer')({ browsers: ['last 2 versions'] }) ] } } ================================================ FILE: admin/build/webpack.dev.conf.js ================================================ var config = require('../config') var webpack = require('webpack') var merge = require('webpack-merge') var utils = require('./utils') var baseWebpackConfig = require('./webpack.base.conf') var HtmlWebpackPlugin = require('html-webpack-plugin') // add hot-reload related code to entry chunks Object.keys(baseWebpackConfig.entry).forEach(function (name) { baseWebpackConfig.entry[name] = ['./build/dev-client'].concat(baseWebpackConfig.entry[name]) }) module.exports = merge(baseWebpackConfig, { module: { loaders: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap }) }, // eval-source-map is faster for development devtool: '#eval-source-map', plugins: [ new webpack.DefinePlugin({ 'process.env': config.dev.env }), // https://github.com/glenjamin/webpack-hot-middleware#installation--usage new webpack.optimize.OccurenceOrderPlugin(), new webpack.HotModuleReplacementPlugin(), new webpack.NoErrorsPlugin(), // https://github.com/ampedandwired/html-webpack-plugin new HtmlWebpackPlugin({ filename: 'index.html', template: 'index.html', inject: true }) ] }) ================================================ FILE: admin/build/webpack.prod.conf.js ================================================ var path = require('path') var config = require('../config') var utils = require('./utils') var webpack = require('webpack') var merge = require('webpack-merge') var baseWebpackConfig = require('./webpack.base.conf') var ExtractTextPlugin = require('extract-text-webpack-plugin') var HtmlWebpackPlugin = require('html-webpack-plugin') var env = process.env.NODE_ENV === 'testing' ? require('../config/test.env') : config.build.env var webpackConfig = merge(baseWebpackConfig, { module: { loaders: utils.styleLoaders({ sourceMap: config.build.productionSourceMap, extract: true }) }, devtool: config.build.productionSourceMap ? '#source-map' : false, output: { path: config.build.assetsRoot, filename: utils.assetsPath('js/[name].[chunkhash].js'), chunkFilename: utils.assetsPath('js/[id].[chunkhash].js') }, vue: { loaders: utils.cssLoaders({ sourceMap: config.build.productionSourceMap, extract: true }) }, plugins: [ // http://vuejs.github.io/vue-loader/en/workflow/production.html new webpack.DefinePlugin({ 'process.env': env }), new webpack.optimize.UglifyJsPlugin({ compress: { warnings: false } }), new webpack.optimize.OccurrenceOrderPlugin(), // extract css into its own file new ExtractTextPlugin(utils.assetsPath('css/[name].[contenthash].css')), // generate dist index.html with correct asset hash for caching. // you can customize output by editing /index.html // see https://github.com/ampedandwired/html-webpack-plugin new HtmlWebpackPlugin({ filename: process.env.NODE_ENV === 'testing' ? 'index.html' : config.build.index, template: 'index.html', inject: true, minify: { removeComments: true, collapseWhitespace: true, removeAttributeQuotes: true // more options: // https://github.com/kangax/html-minifier#options-quick-reference }, // necessary to consistently work with multiple chunks via CommonsChunkPlugin chunksSortMode: 'dependency' }), // split vendor js into its own file new webpack.optimize.CommonsChunkPlugin({ name: 'vendor', minChunks: function (module, count) { // any required modules inside node_modules are extracted to vendor return ( module.resource && /\.js$/.test(module.resource) && module.resource.indexOf( path.join(__dirname, '../node_modules') ) === 0 ) } }), // extract webpack runtime and module manifest to its own file in order to // prevent vendor hash from being updated whenever app bundle is updated new webpack.optimize.CommonsChunkPlugin({ name: 'manifest', chunks: ['vendor'] }) ] }) if (config.build.productionGzip) { var CompressionWebpackPlugin = require('compression-webpack-plugin') webpackConfig.plugins.push( new CompressionWebpackPlugin({ asset: '[path].gz[query]', algorithm: 'gzip', test: new RegExp( '\\.(' + config.build.productionGzipExtensions.join('|') + ')$' ), threshold: 10240, minRatio: 0.8 }) ) } module.exports = webpackConfig ================================================ FILE: admin/config/dev.env.js ================================================ var merge = require('webpack-merge') var prodEnv = require('./prod.env') module.exports = merge(prodEnv, { NODE_ENV: '"development"' }) ================================================ FILE: admin/config/index.js ================================================ // see http://vuejs-templates.github.io/webpack for documentation. var path = require('path') module.exports = { build: { env: require('./prod.env'), index: path.resolve(__dirname, '../dist/index.html'), assetsRoot: path.resolve(__dirname, '../dist'), assetsSubDirectory: 'static', assetsPublicPath: '/', productionSourceMap: false, // Gzip off by default as many popular static hosts such as // Surge or Netlify already gzip all static assets for you. // Before setting to `true`, make sure to: // npm install --save-dev compression-webpack-plugin productionGzip: false, productionGzipExtensions: ['js', 'css'] }, dev: { env: require('./dev.env'), port: 8082, assetsSubDirectory: 'static', assetsPublicPath: '/', proxyTable: { '/proxyPrefix': { target: 'http://localhost:3000/', changeOrigin: true, pathRewrite: { '^/proxyPrefix': '' } } }, // CSS Sourcemaps off by default because relative paths are "buggy" // with this option, according to the CSS-Loader README // (https://github.com/webpack/css-loader#sourcemaps) // In our experience, they generally work as expected, // just be aware of this issue when enabling this option. cssSourceMap: false } } ================================================ FILE: admin/config/prod.env.js ================================================ module.exports = { NODE_ENV: '"production"' } ================================================ FILE: admin/config/test.env.js ================================================ var merge = require('webpack-merge') var devEnv = require('./dev.env') module.exports = merge(devEnv, { NODE_ENV: '"testing"' }) ================================================ FILE: admin/index.html ================================================ back
================================================ FILE: admin/package.json ================================================ { "name": "admin", "version": "1.0.0", "description": "admin spa for blog", "author": "Smallpath ", "private": true, "scripts": { "dev": "node build/dev-server.js", "build": "node build/build.js", "unit": "karma start test/unit/karma.conf.js --single-run", "e2e": "node test/e2e/runner.js", "test": "npm run unit && npm run e2e", "lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs" }, "dependencies": { "axios": "^0.15.2", "element-ui": "^1.0.6", "highlight.js": "^9.8.0", "marked": "^0.3.6", "moment": "^2.16.0", "vue": "^2.1.4", "vue-router": "^2.1.0", "vuex": "^2.0.0", "vuex-router-sync": "^3.0.0" }, "devDependencies": { "autoprefixer": "^6.4.0", "babel-core": "^6.0.0", "babel-eslint": "^7.0.0", "babel-loader": "^6.0.0", "babel-plugin-transform-runtime": "^6.0.0", "babel-preset-es2015": "^6.0.0", "babel-preset-stage-2": "^6.0.0", "babel-register": "^6.0.0", "chai": "^3.5.0", "chalk": "^1.1.3", "connect-history-api-fallback": "^1.1.0", "cross-spawn": "^4.0.2", "css-loader": "^0.25.0", "eslint": "^3.7.1", "eslint-config-airbnb-base": "^8.0.0", "eslint-config-standard": "^6.2.1", "eslint-friendly-formatter": "^2.0.5", "eslint-import-resolver-webpack": "^0.6.0", "eslint-loader": "^1.5.0", "eslint-plugin-html": "^1.3.0", "eslint-plugin-import": "^1.16.0", "eslint-plugin-promise": "^3.3.2", "eslint-plugin-standard": "^2.0.1", "eventsource-polyfill": "^0.9.6", "express": "^4.13.3", "extract-text-webpack-plugin": "^1.0.1", "file-loader": "^0.9.0", "function-bind": "^1.0.2", "html-webpack-plugin": "^2.8.1", "http-proxy-middleware": "^0.17.2", "inject-loader": "^2.0.1", "isparta-loader": "^2.0.0", "json-loader": "^0.5.4", "lolex": "^1.4.0", "mocha": "^3.1.0", "nightwatch": "^0.9.8", "node-sass": "^4.5.0", "opn": "^4.0.2", "ora": "^0.3.0", "sass-loader": "^6.0.2", "semver": "^5.3.0", "shelljs": "^0.7.4", "sinon": "^1.17.3", "sinon-chai": "^2.8.0", "url-loader": "^0.5.7", "vue-loader": "^9.4.0", "vue-style-loader": "^1.0.0", "webpack": "^1.13.2", "webpack-dev-middleware": "^1.8.3", "webpack-hot-middleware": "^2.12.2", "webpack-merge": "^0.14.1" }, "engines": { "node": ">= 4.0.0", "npm": ">= 3.0.0" } } ================================================ FILE: admin/src/App.vue ================================================ ================================================ FILE: admin/src/components/Main.vue ================================================ ================================================ FILE: admin/src/components/containers/Create.vue ================================================ ================================================ FILE: admin/src/components/containers/List.vue ================================================ ================================================ FILE: admin/src/components/containers/Markdown.vue ================================================ ================================================ FILE: admin/src/components/containers/Post.vue ================================================ ================================================ FILE: admin/src/components/pages/Dashboard.vue ================================================ ================================================ FILE: admin/src/components/pages/Login.vue ================================================ ================================================ FILE: admin/src/components/pages/Logout.vue ================================================ ================================================ FILE: admin/src/components/pages/Sidebar.vue ================================================ ================================================ FILE: admin/src/components/pages/Top.vue ================================================ ================================================ FILE: admin/src/components/utils/marked.js ================================================ import Marked from 'marked' import hljs from 'highlight.js' const renderer = new Marked.Renderer() export const toc = [] renderer.heading = function(text, level) { var slug = text.toLowerCase().replace(/\s+/g, '-') toc.push({ level: level, slug: slug, title: text }) return `${text}` } Marked.setOptions({ highlight: function(code, lang) { if (hljs.getLanguage(lang)) { return hljs.highlight(lang, code).value } else { return hljs.highlightAuto(code).value } }, renderer }) export const marked = text => { var tok = Marked.lexer(text) text = Marked.parser(tok).replace(/
/ig, '
')
  return text
}


================================================
FILE: admin/src/components/views/CreateEditView.js
================================================
import Item from '../containers/Create.vue'

export default function(options) {
  return {
    name: `${options.name}-create-view`,
    preFetch(store) {
      return store.dispatch('FETCH_LIST', options)
    },
    render(h) {
      return h(Item, { props: { options: options } })
    }
  }
}


================================================
FILE: admin/src/components/views/CreateListView.js
================================================
import Item from '../containers/List.vue'

export default function(options) {
  return {
    name: `${options.name}-list-view`,
    preFetch(store) {
      return store.dispatch('FETCH_LIST', options)
    },
    render(h) {
      return h(Item, { props: { options: options } })
    }
  }
}


================================================
FILE: admin/src/components/views/CreateMarkdownView.js
================================================
import Item from '../containers/Post.vue'

export default function(options) {
  return {
    name: `${options.name}-markdown-view`,
    preFetch(store) {
      return store.dispatch('FETCH_LIST', options)
    },
    render(h) {
      return h(Item, { props: { options: options } })
    }
  }
}


================================================
FILE: admin/src/main.js
================================================
/* eslint-disable */
import Vue from 'vue'

import router from './route/index'
import { sync } from 'vuex-router-sync'
import store from './store/index'

sync(store, router)

import 'element-ui/lib/theme-default/index.css'
import Element from 'element-ui'
Vue.use(Element)

import App from './App'

const app = new Vue({
  router,
  store,
  ...App
})

app.$mount('#app')

export { app, router, store }


================================================
FILE: admin/src/route/index.js
================================================
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)

import createListView from '../components/views/CreateListView'
import createEditView from '../components/views/CreateEditView'
import createMarkdownView from '../components/views/CreateMarkdownView'

import Main from '../components/Main'
import Dashboard from '../components/pages/Dashboard'
import Login from '../components/pages/Login'
import Logout from '../components/pages/Logout'

export default new VueRouter({
  mode: 'history',
  scrollBehavior: function(to, from, savedPosition) {
    return savedPosition || { x: 0, y: 0 }
  },
  routes: [
    {
      path: '/admin/login',
      name: 'login',
      components: {
        default: Login
      }
    },
    {
      path: '/admin/logout',
      name: 'logout',
      components: {
        default: Logout
      }
    },
    {
      path: '/dashboard',
      component: Main,
      children: [
        {
          path: '/',
          name: 'dashboard',
          component: Dashboard
        }
      ]
    },
    {
      path: '/post',
      name: 'post',
      component: Main,
      children: [
        {
          path: 'list',
          name: 'postList',
          component: createListView({
            name: 'post',
            model: 'post',
            isButtonFixed: true,
            items: [
              {
                prop: 'title',
                label: '标题',
                width: 300
              },
              {
                prop: 'pathName',
                label: '路径',
                width: 300
              }
            ],
            query: {
              conditions: {
                type: 'post'
              },
              select: {
                title: 1,
                pathName: 1,
                tags: 1,
                category: 1
              },
              sort: {
                createdAt: -1
              }
            }
          })
        },
        {
          path: 'create/:id?',
          name: 'postCreate',
          component: createMarkdownView({
            name: 'post',
            model: 'post',
            items: [
              {
                prop: 'title',
                label: '标题',
                type: 'input',
                default: '',
                width: 250
              },
              {
                prop: 'pathName',
                label: '路径',
                type: 'input',
                default: '',
                width: 250,
                description: '作为文章的唯一标志在前端路径中显示,例如test-article'
              },
              {
                prop: 'markdownContent',
                label: '内容',
                type: 'markdown',
                default: '',
                width: 170,
                subProp: 'markdownToc'
              },
              {
                type: 'split'
              },
              {
                prop: 'createdAt',
                label: '创建日期',
                type: 'date-picker',
                default: '',
                width: 170
              },
              {
                prop: 'updatedAt',
                label: '修改日期',
                type: 'date-picker',
                default: '',
                width: 170
              },
              {
                prop: 'tags',
                label: '标签',
                type: 'select',
                default: [],
                width: 170
              },
              {
                prop: 'category',
                label: '分类',
                type: 'radio',
                default: '',
                width: 170
              },
              {
                prop: 'isPublic',
                label: '是否公开',
                type: 'switch',
                default: true,
                width: 170
              },
              {
                prop: 'allowComment',
                label: '允许评论',
                type: 'switch',
                default: true,
                width: 170
              }
            ],
            query: {
              conditions: {
                type: 'post'
              },
              select: {
                title: 1,
                pathName: 1,
                tags: 1,
                category: 1,
                isPublic: 1,
                allowComment: 1,
                updatedAt: 1,
                createdAt: 1,
                markdownContent: 1,
                type: 1,
                markdownToc: 1
              }
            }
          })
        }
      ]
    },
    {
      path: '/page',
      name: 'page',
      component: Main,
      children: [
        {
          path: 'list',
          name: 'pageList',
          component: createListView({
            name: 'page',
            model: 'post',
            items: [
              {
                prop: 'title',
                label: '标题',
                width: 250
              },
              {
                prop: 'pathName',
                label: '路径',
                width: 170
              },
              {
                prop: 'createdAt',
                label: '创建日期',
                width: 170
              },
              {
                prop: 'updatedAt',
                label: '修改日期',
                width: 170
              }
            ],
            query: {
              conditions: {
                type: 'page'
              },
              select: {
                title: 1,
                pathName: 1,
                createdAt: 1,
                updatedAt: 1
              },
              sort: {
                createdAt: -1
              }
            }
          })
        },
        {
          path: 'create/:id?',
          name: 'pageCreate',
          component: createMarkdownView({
            name: 'page',
            model: 'post',
            items: [
              {
                prop: 'title',
                label: '标题',
                type: 'input',
                default: '',
                width: 250
              },
              {
                prop: 'pathName',
                label: '路径',
                type: 'input',
                default: '',
                width: 250,
                description: '作为文章的唯一标志在前端路径中显示,例如test-article'
              },
              {
                prop: 'markdownContent',
                label: '内容',
                type: 'markdown',
                default: '',
                width: 170,
                subProp: 'markdownToc'
              },
              {
                type: 'split'
              },
              {
                prop: 'createdAt',
                label: '创建日期',
                type: 'date-picker',
                default: '',
                width: 170
              },
              {
                prop: 'updatedAt',
                label: '修改日期',
                type: 'date-picker',
                default: '',
                width: 170
              },
              {
                prop: 'isPublic',
                label: '是否公开',
                type: 'switch',
                default: true,
                width: 170
              },
              {
                prop: 'allowComment',
                label: '允许评论',
                type: 'switch',
                default: true,
                width: 170
              }
            ],
            query: {
              conditions: {
                type: 'page'
              },
              select: {
                title: 1,
                pathName: 1,
                isPublic: 1,
                allowComment: 1,
                updatedAt: 1,
                createdAt: 1,
                markdownContent: 1,
                type: 1,
                markdownToc: 1
              }
            }
          })
        }
      ]
    },
    {
      path: '/theme',
      name: 'theme',
      component: Main,
      children: [
        {
          path: 'list',
          name: 'themeList',
          component: createListView({
            name: 'theme',
            model: 'theme',
            items: [
              {
                prop: 'name',
                label: '主题',
                width: 250
              },
              {
                prop: 'author',
                label: '作者',
                width: 170
              }
            ],
            query: {}
          })
        },
        {
          path: 'create/:id?',
          name: 'themeEdit',
          component: createEditView({
            name: 'theme',
            model: 'theme',
            items: [
              {
                prop: 'name',
                label: '主题名称',
                width: 250
              },
              {
                prop: 'author',
                label: '作者',
                width: 170
              },
              {
                prop: 'option',
                label: '配置',
                width: 170,
                type: 'textarea',
                sourceType: 'Object',
                description: '配置内容应为一个JSON对象,不符合JSON格式时提交将被忽略'
              }
            ],
            query: {}
          })
        }
      ]
    },
    {
      path: '/cate',
      name: 'cate',
      component: Main,
      children: [
        {
          path: 'list',
          name: 'cateList',
          component: createListView({
            name: 'cate',
            model: 'category',
            items: [
              {
                prop: 'name',
                label: '分类',
                width: 250
              }
            ],
            query: {}
          })
        },
        {
          path: 'create/:id?',
          name: 'cateCreate',
          component: createEditView({
            name: 'cate',
            model: 'category',
            items: [
              {
                prop: 'name',
                label: '分类名称',
                width: 250
              }
            ],
            query: {}
          })
        }
      ]
    },
    {
      path: '/tag',
      name: 'tag',
      component: Main,
      children: [
        {
          path: 'list',
          name: 'tagList',
          component: createListView({
            name: 'tag',
            model: 'tag',
            items: [
              {
                prop: 'name',
                label: '标签',
                width: 250
              }
            ],
            query: {}
          })
        },
        {
          path: 'create/:id?',
          name: 'tagCreate',
          component: createEditView({
            name: 'tag',
            model: 'tag',
            items: [
              {
                prop: 'name',
                label: '标签名称',
                width: 250
              }
            ],
            query: {}
          })
        }
      ]
    },
    {
      path: '/user',
      name: 'user',
      component: Main,
      children: [
        {
          path: 'edit',
          name: 'userEdit',
          component: createEditView({
            name: 'user',
            model: 'user',
            isPlain: true,
            items: [
              {
                prop: 'name',
                label: '账号',
                default: '',
                width: 250
              },
              {
                prop: 'password',
                label: '密码',
                default: '',
                width: 170
              },
              {
                prop: 'displayName',
                label: '昵称',
                default: '',
                width: 170,
                description: '在后台管理的顶部导航栏中显示'
              },
              {
                prop: 'email',
                label: '邮箱',
                default: '',
                width: 170,
                description: '在文章被回复时博客需要通知的目标邮箱,空则不通知'
              }
            ],
            query: {}
          })
        }
      ]
    },
    {
      path: '/option',
      name: 'option',
      component: Main,
      children: [
        {
          path: 'general',
          name: 'optionGeneral',
          component: createEditView({
            name: 'general',
            model: 'option',
            isPlain: true,
            items: [
              {
                prop: 'title',
                label: '网站名称',
                default: '',
                width: 250,
                description: '网站的名称,作为前后台的标题'
              },
              {
                prop: 'logoUrl',
                label: 'logo地址',
                default: '',
                width: 170,
                description: '前台单页的正方形图标,80x80'
              },
              {
                prop: 'description',
                label: '站点描述',
                default: '',
                width: 170,
                description: '作为前台的侧边栏描述'
              },
              {
                prop: 'siteUrl',
                label: '网站地址',
                default: '',
                width: 170,
                description: '博客前台的域名,建议加上http/https前缀'
              },
              {
                prop: 'faviconUrl',
                label: 'favicon地址',
                default: '',
                width: 170,
                description: '博客前台的favicon地址,请填写相对前台域名的根路径'
              },
              {
                prop: 'keywords',
                label: '关键词',
                default: '',
                width: 170,
                description: '作为前台单页的meta中的keywords,以供搜索引擎收录'
              },
              {
                prop: 'githubUrl',
                label: 'github地址',
                default: '',
                width: 170,
                description: 'github地址,填写用户昵称即可'
              },
              {
                prop: 'weiboUrl',
                label: '微博地址',
                default: '',
                width: 170,
                description: '微博地址,请填写全部链接,包括http/https前缀'
              },
              {
                prop: 'miitbeian',
                label: '网站备案号',
                default: '',
                width: 170,
                description: '网站的备案号,在前台单页的底部显示'
              }
            ],
            query: {}
          })
        },
        {
          path: 'comment',
          name: 'optionComment',
          component: createEditView({
            name: 'comment',
            model: 'option',
            isPlain: true,
            items: [
              {
                prop: 'commentType',
                label: '评论类型',
                default: '',
                width: 250,
                description: '目前仅支持disqus,计划支持duoshuo'
              },
              {
                prop: 'commentName',
                label: '评论名称',
                default: '',
                width: 250,
                description: 'disqus或duoshuo中分配给博客的id'
              }
            ],
            query: {}
          })
        },
        {
          path: 'analytic',
          name: 'optionAnalytic',
          component: createEditView({
            name: 'analytic',
            model: 'option',
            isPlain: true,
            items: [
              {
                prop: 'analyzeCode',
                label: '统计代码',
                default: '',
                width: 250,
                description: '目前仅支持谷歌统计,填入谷歌统计分配给博客的ID即可'
              }
            ],
            query: {}
          })
        }
      ]
    },
    {
      path: '/',
      redirect: '/admin/login'
    }
  ]
})


================================================
FILE: admin/src/store/api.js
================================================
import request from 'axios'

const root = `/proxyPrefix/api`

const store = {}

export default store

store.request = request

store.login = (conditions) => {
  return request.post(`/proxyPrefix/admin/login`, conditions)
}

store.logout = (conditions) => {
  return request.post(`/proxyPrefix/admin/logout`, conditions)
}

store.getImageHeight = url => {
  return request.get(`${url}?imageInfo`).then(response => response.data)
}

store.getImageToken = (body) => {
  return request.post(`/proxyPrefix/admin/qiniu`, body).then(response => response.data)
}

store.fetchList = (model, query) => {
  let target = `${root}/${model}`
  return request.get(target, { params: query }).then((response) => {
    return response.data
  })
}

store.fetchByID = (model, id, query) => {
  let target = `${root}/${model}/${id}`
  return request.get(target, { params: query }).then((response) => {
    return response.data
  })
}

store.post = (model, form) => {
  let target = `${root}/${model}`
  return request.post(target, form).then((response) => {
    return response.data
  })
}

store.patchByID = (model, id, form) => {
  let target = `${root}/${model}/${id}`
  return request.patch(target, form).then((response) => {
    return response.data
  })
}

store.deleteByID = (model, id) => {
  let target = `${root}/${model}/${id}`
  return request.delete(target).then((response) => {
    return response.data
  })
}


================================================
FILE: admin/src/store/index.js
================================================
import Vue from 'vue'
import Vuex from 'vuex'
import api from './api'

Vue.use(Vuex)

const store = new Vuex.Store({
  state: {
    siteInfo: {},
    list: [],
    curr: {},
    user: {}
  },

  actions: {

    GET_IMAGE_HEIGHT: ({ commit, state }, { url }) => {
      return api.getImageHeight(url).then(data => data.height || 100)
    },

    FETCH: ({ commit, state }, { model, query }) => {
      return api.fetchList(model, query)
    },

    FETCH_BY_ID: ({ commit, state }, { model, id, query }) => {
      return api.fetchByID(model, id, query)
    },

    FETCH_LIST: ({ commit, state }, { model, query }) => {
      return api.fetchList(model, query).then(obj => {
        commit('SET_LIST', { obj })
      })
    },

    FETCH_CREATE: ({ commit, state }, { model, id, query }) => {
      if (id === -1) {
        return api.fetchList(model, query).then(curr => {
          let obj = curr.reduce((prev, value) => {
            if (model === 'option') {
              prev[value.key] = value.value
            } else if (model === 'user') {
              Object.keys(value).forEach(item => {
                prev[item] = value[item]
              })
            }
            return prev
          }, {})
          commit('SET_CURR', { obj })
        })
      } else {
        return api.fetchByID(model, id, query).then(obj => {
          commit('SET_CURR', { obj })
        })
      }
    },

    POST: ({ commit, state }, { model, form }) => {
      if (model !== 'post' && model !== 'option' && model !== 'user') {
        return api.post(model, form)
      } else if (model === 'user' || model === 'post') {
        let { _id: id } = form
        if (typeof id !== 'undefined') {
          return api.patchByID(model, id, form).then((result) => {
            if (model === 'user') {
              commit('SET_USER', { user: result })
            }
            return result
          })
        } else {
          return api.post(model, form)
        }
      } else if (model === 'option') {
        let promiseArr = Object.keys(form).reduce((prev, curr) => {
          if (form[curr] !== state.curr[curr]) {
            const { _id: id } = state.siteInfo[curr]
            let promise = api.patchByID(model, id, {
              value: form[curr]
            })
            prev.push(promise)
          }
          return prev
        }, [])
        return Promise.all(promiseArr)
      }
    },

    GET_IMAGE_TOKEN: ({ commit, state }, body) => {
      return api.getImageToken(body)
    },

    PATCH: ({ commit, state }, { model, id, form }) => {
      return api.patchByID(model, id, form)
    },

    DELETE: ({ commit, state }, { model, id }) => {
      return api.deleteByID(model, id)
    },

    FETCH_USER: ({ commit, state }, { model, query, username }) => {
      return api.fetchList(model, query).then(result => {
        for (let i = 0, len = result.length; i < len; i++) {
          let user = result[i]
          if (user.name === username) {
            commit('SET_USER', { user })
            break
          }
        }
      })
    },

    FETCH_OPTIONS: ({ commit, state }) => {
      return api.fetchList('option', {}).then(optionArr => {
        let obj = optionArr.reduce((prev, curr) => {
          prev[curr.key] = curr
          return prev
        }, {})
        commit('SET_OPTIONS', { obj })
      })
    }

  },

  mutations: {
    SET_LIST: (state, { obj }) => {
      Vue.set(state, 'list', obj)
    },

    SET_OPTIONS: (state, { obj }) => {
      Vue.set(state, 'siteInfo', obj)
    },

    SET_CURR: (state, { obj }) => {
      Vue.set(state, 'curr', obj)
    },

    SET_USER: (state, { user }) => {
      Vue.set(state, 'user', user)
    }
  },

  getters: {

  }
})

export default store


================================================
FILE: admin/src/utils/error.js
================================================
export const getChineseDesc = (desc) => {
  switch (desc) {
    case 'Token not found':
      return '请求失败,请确认您已登陆'
    case 'Get token failed. Check the password':
      return '登陆失败,请检查您的密码'
    case 'Get token failed. Check the name':
      return '登陆失败,请检查您的账号'
    case 'Token verify failed':
    case 'Token invalid':
      return 'Token验证失败,请重新登录'
    default:
      return desc
  }
}


================================================
FILE: admin/static/.gitkeep
================================================


================================================
FILE: admin/test/e2e/custom-assertions/elementCount.js
================================================
// A custom Nightwatch assertion.
// the name of the method is the filename.
// can be used in tests like this:
//
//   browser.assert.elementCount(selector, count)
//
// for how to write custom assertions see
// http://nightwatchjs.org/guide#writing-custom-assertions
exports.assertion = function (selector, count) {
  this.message = 'Testing if element <' + selector + '> has count: ' + count;
  this.expected = count;
  this.pass = function (val) {
    return val === this.expected;
  }
  this.value = function (res) {
    return res.value;
  }
  this.command = function (cb) {
    var self = this;
    return this.api.execute(function (selector) {
      return document.querySelectorAll(selector).length;
    }, [selector], function (res) {
      cb.call(self, res);
    });
  }
}


================================================
FILE: admin/test/e2e/nightwatch.conf.js
================================================
require('babel-register')
var config = require('../../config')

// http://nightwatchjs.org/guide#settings-file
module.exports = {
  "src_folders": ["test/e2e/specs"],
  "output_folder": "test/e2e/reports",
  "custom_assertions_path": ["test/e2e/custom-assertions"],

  "selenium": {
    "start_process": true,
    "server_path": "node_modules/selenium-server/lib/runner/selenium-server-standalone-2.53.1.jar",
    "host": "127.0.0.1",
    "port": 4444,
    "cli_args": {
      "webdriver.chrome.driver": require('chromedriver').path
    }
  },

  "test_settings": {
    "default": {
      "selenium_port": 4444,
      "selenium_host": "localhost",
      "silent": true,
      "globals": {
        "devServerURL": "http://localhost:" + (process.env.PORT || config.dev.port)
      }
    },

    "chrome": {
      "desiredCapabilities": {
        "browserName": "chrome",
        "javascriptEnabled": true,
        "acceptSslCerts": true
      }
    },

    "firefox": {
      "desiredCapabilities": {
        "browserName": "firefox",
        "javascriptEnabled": true,
        "acceptSslCerts": true
      }
    }
  }
}


================================================
FILE: admin/test/e2e/runner.js
================================================
// 1. start the dev server using production config
process.env.NODE_ENV = 'testing';
var server = require('../../build/dev-server.js');

// 2. run the nightwatch test suite against it
// to run in additional browsers:
//    1. add an entry in test/e2e/nightwatch.conf.json under "test_settings"
//    2. add it to the --env flag below
// or override the environment flag, for example: `npm run e2e -- --env chrome,firefox`
// For more information on Nightwatch's config file, see
// http://nightwatchjs.org/guide#settings-file
var opts = process.argv.slice(2);
if (opts.indexOf('--config') === -1) {
  opts = opts.concat(['--config', 'test/e2e/nightwatch.conf.js']);
}
if (opts.indexOf('--env') === -1) {
  opts = opts.concat(['--env', 'chrome']);
}

var spawn = require('cross-spawn');
var runner = spawn('./node_modules/.bin/nightwatch', opts, { stdio: 'inherit' });

runner.on('exit', function (code) {
  server.close();
  process.exit(code);
});

runner.on('error', function (err) {
  server.close();
  throw err;
});


================================================
FILE: admin/test/e2e/specs/test.js
================================================
// For authoring Nightwatch tests, see
// http://nightwatchjs.org/guide#usage

module.exports = {
  'default e2e tests': function test(browser) {
    // automatically uses dev Server port from /config.index.js
    // default: http://localhost:8080
    // see nightwatch.conf.js
    const devServer = browser.globals.devServerURL

    browser
      .url(devServer)
      .waitForElementVisible('#app', 5000)
      .assert.elementPresent('.hello')
      .assert.containsText('h1', 'Welcome to Your Vue.js App')
      .assert.elementCount('img', 1)
      .end()
  }
}


================================================
FILE: admin/test/unit/.eslintrc
================================================
{
  "env": {
    "mocha": true
  },
  "globals": {
    "expect": true,
    "sinon": true
  }
}


================================================
FILE: admin/test/unit/index.js
================================================
// Polyfill fn.bind() for PhantomJS
/* eslint-disable no-extend-native */
Function.prototype.bind = require('function-bind');

// require all test files (files that ends with .spec.js)
const testsContext = require.context('./specs', true, /\.spec$/);
testsContext.keys().forEach(testsContext);

// require all src files except main.js for coverage.
// you can also change this to match only the subset of files that
// you want coverage for.
const srcContext = require.context('../../src', true, /^\.\/(?!main(\.js)?$)/);
srcContext.keys().forEach(srcContext);


================================================
FILE: admin/test/unit/karma.conf.js
================================================
// This is a karma config file. For more details see
//   http://karma-runner.github.io/0.13/config/configuration-file.html
// we are also using it with karma-webpack
//   https://github.com/webpack/karma-webpack

var path = require('path');
var merge = require('webpack-merge');
var baseConfig = require('../../build/webpack.base.conf');
var utils = require('../../build/utils');
var webpack = require('webpack');
var projectRoot = path.resolve(__dirname, '../../');

var webpackConfig = merge(baseConfig, {
  // use inline sourcemap for karma-sourcemap-loader
  module: {
    loaders: utils.styleLoaders()
  },
  devtool: '#inline-source-map',
  vue: {
    loaders: {
      js: 'isparta'
    }
  },
  plugins: [
    new webpack.DefinePlugin({
      'process.env': require('../../config/test.env')
    })
  ]
});

// no need for app entry during tests
delete webpackConfig.entry;

// make sure isparta loader is applied before eslint
webpackConfig.module.preLoaders = webpackConfig.module.preLoaders || [];
webpackConfig.module.preLoaders.unshift({
  test: /\.js$/,
  loader: 'isparta',
  include: path.resolve(projectRoot, 'src'),
});

// only apply babel for test files when using isparta
webpackConfig.module.loaders.some(function (loader, i) {
  if (loader.loader === 'babel') {
    loader.include = path.resolve(projectRoot, 'test/unit');
    return true;
  }
});

module.exports = function (config) {
  config.set({
    // to run in additional browsers:
    // 1. install corresponding karma launcher
    //    http://karma-runner.github.io/0.13/config/browsers.html
    // 2. add it to the `browsers` array below.
    browsers: ['PhantomJS'],
    frameworks: ['mocha', 'sinon-chai'],
    reporters: ['spec', 'coverage'],
    files: ['./index.js'],
    preprocessors: {
      './index.js': ['webpack', 'sourcemap']
    },
    webpack: webpackConfig,
    webpackMiddleware: {
      noInfo: true,
    },
    coverageReporter: {
      dir: './coverage',
      reporters: [
        { type: 'lcov', subdir: '.' },
        { type: 'text-summary' },
      ]
    },
  });
};


================================================
FILE: admin/test/unit/specs/Hello.spec.js
================================================
import Vue from 'vue'
import Hello from 'src/components/Hello'

describe('Hello.vue', () => {
  it('should render correct contents', () => {
    const vm = new Vue({
      el: document.createElement('div'),
      render: (h) => h(Hello)
    })
    expect(vm.$el.querySelector('.hello h1').textContent)
      .to.equal('Welcome to Your Vue.js App')
  })
})


================================================
FILE: docs/.nojekyll
================================================


================================================
FILE: docs/README.md
================================================
# Blog
A blog system. Based on Vue2, Koa2, MongoDB and Redis

前后端分离 + 服务端渲染的博客系统, 前端 SPA + 后端 RESTful 服务器

# Demo
前端:[https://smallpath.me](https://smallpath.me)  
后台管理截图:[https://smallpath.me/post/blog-back-v2](https://smallpath.me/post/blog-back-v2)

Table of Contents
=================

* [TODO](#todo)
* [构建与部署](#构建与部署)
  * [前置](#前置)
  * [server](#server)
  * [front](#front)
  * [admin](#admin)
* [后端RESTful API](#后端restful-api)
  * [说明](#说明)
  * [HTTP动词](#http动词)
  * [权限验证](#权限验证)
      * [获得Token](#获得token)
      * [撤销Token](#撤销token)
      * [Token说明](#token说明)
  * [查询](#查询)
  * [新建](#新建)
  * [替换](#替换)
  * [更新](#更新)
  * [删除](#删除)

# TODO

> [TODO](https://github.com/smallpath/blog/issues/14)

# 构建与部署

## 前置

- Node v6
- pm2
- MongoDB
- Redis

## server

博客的提供RESTful API的后端 复制conf文件夹中的默认配置`config.tpl`, 并命名为`config.js` 有如下属性可以自行配置: - `tokenSecret` - 改为任意字符串 - `defaultAdminPassword` - 默认密码, 必须修改, 否则服务器将拒绝启动 如果mongoDB或redis不在本机对应端口,可以修改对应的属性 - `mongoHost` - `mongoDatabase` - `mongoPort` - `redisHost` - `redisPort` 如果需要启用后台管理单页的七牛图片上传功能,请再修改如下属性: - `qiniuAccessKey` - 七牛账号的公钥 - `qiniuSecretKey` - 七牛账号的私钥 - `qiniuBucketHost` - 七牛Bucket对应的外链域名 - `qiniuBucketName` - 七牛Bucket的名称 - `qiniuPipeline` - 七牛多媒体处理队列的名称 ``` npm install pm2 start entry.js ``` RESTful服务器在本机3000端口开启
## front
博客的前台单页, 支持服务端渲染 复制server文件夹中的默认配置`mongo.tpl`, 并命名为`mongo.js` 如果mongoDB不在本机对应端口,请自行配置`mongo.js`中的属性: - `mongoHost` - `mongoDatabase` - `mongoPort` ``` npm install npm run build pm2 start production.js ``` 请将`logo.png`与`favicon.ico`放至`static`目录中 再用nginx代理本机8080端口即可, 可以使用如下的模板 ``` server{ listen 80; #如果是https, 则替换80为443 server_name *.smallpath.me smallpath.me; #替换域名 root /alidata/www/Blog/front/dist; #替换路径为构建出来的dist路径 set $node_port 3000; set $ssr_port 8080; location ^~ / { proxy_http_version 1.1; proxy_set_header Connection "upgrade"; proxy_pass http://127.0.0.1:$ssr_port; proxy_redirect off; } location ^~ /proxyPrefix/ { rewrite ^/proxyPrefix/(.*) /$1 break; proxy_http_version 1.1; proxy_set_header Connection "upgrade"; proxy_pass http://127.0.0.1:$node_port; proxy_redirect off; } location ^~ /dist/ { rewrite ^/dist/(.*) /$1 break; etag on; expires max; } location ^~ /static/ { etag on; expires max; } } ``` 开发端口为本机8080
## admin
博客的后台管理单页 ``` npm install npm run build ``` 用nginx代理构建出来的`dist`文件夹即可, 可以使用如下的模板 ``` server{ listen 80; #如果是https, 则替换80为443 server_name admin.smallpath.me; #替换域名 root /alidata/www/Blog/admin/dist; #替换路径为构建出来的dist路径 set $node_port 3000; index index.js index.html index.htm; location / { try_files $uri $uri/ @rewrites; } location @rewrites { rewrite ^(.*)$ / last; } location ^~ /proxyPrefix/ { rewrite ^/proxyPrefix/(.*) /$1 break; proxy_http_version 1.1; proxy_set_header Connection "upgrade"; proxy_pass http://127.0.0.1:$node_port; proxy_redirect off; } location ^~ /static/ { etag on; expires max; } } ``` 开发端口为本机8082
# 后端 RESTful API ## 说明 后端服务器默认开启在 3000 端口, 如不愿意暴露 IP, 可以自行设置 nginx 代理, 或者直接使用前端两个单页的代理前缀`/proxyPrefix` 例如, demo的API根目录如下: > https://smallpath.me/proxyPrefix/api/:modelName/:id 其中, `:modelName`为模型名, 总计如下6个模型 ``` post theme tag category option user ``` `:id`为指定的文档ID, 用以对指定文档进行 CRUD ## HTTP 动词 支持如下五种: ``` bash GET //查询 POST //新建 PUT //替换 PATCH //更新部分属性 DELETE //删除指定ID的文档 ``` 有如下两个规定: - 对所有请求 - header中必须将 `Content-Type` 设置为 `application/json` , 需要 `body` 的则 `body` 必须是合法 JSON格式 - 对所有回应 - header中的`Content-Type`均为`application/json`, 且返回的数据也是 JSON格式 ## 权限验证 服务器直接允许对`user`模型外的所有模型的GET请求 `user`表的所有请求, 以及其他表的非 GET 请求, 都必须将 header 中的`authorization`设置为服务器下发的 Token, 服务器验证通过后才会继续执行 CRUD 操作 ### 获得 Token > POST https://smallpath.me/proxyPrefix/admin/login `body`格式如下: ``` { "name": "admin", "password": "testpassword" } ``` 成功, 则返回带有`token`字段的 JSON 数据 ``` { "status": "success", "token": "tokenExample" } ``` 失败, 则返回如下格式的 JSON 数据: ``` { "status": "fail", "description": "Get token failed. Check name and password" } ``` 获取到`token`后, 在上述需要 token 验证的请求中, 请将 header 中的`authorization`设置为服务器下发的 Token, 否则请求将被服务器拒绝 ### 撤销 Token > POST https://smallpath.me/proxyPrefix/admin/logout 将`header`中的`authorization`设置为服务器下发的 token, 即可撤销此 token ### Token说明 Token 默认有效期为获得后的一小时, 超出时间后请重新请求 Token 如需自定义有效期, 请修改服务端配置文件中的`tokenExpiresIn`字段, 其单位为秒 ## 查询 服务器直接允许对`user`模型外的所有模型的 GET 请求, 不需要验证 Token 为了直接通过 URI 来进行 mongoDB 查询, 后台提供六种关键字的查询: ``` conditions, select, count, sort, skip, limit ``` ### 条件(conditions)查询 类型为JSON, 被解析为对象后, 直接将其作为`mongoose.find`的查询条件 #### 查询所有文档 > GET https://smallpath.me/proxyPrefix/api/post #### 查询title字段为'关于'的文档 > GET https://smallpath.me/proxyPrefix/api/post?conditions={"title":"关于"} #### 查询指定id的文档的上一篇文档 > GET https://smallpath.me/proxyPrefix/api/post/?conditions={"_id":{"$lt":"580b3ff504f59b4cc27845f0"}}&sort=1&limit=1 #### select查询 类型为JSON, 用以拾取每条数据所需要的属性名, 以过滤输出来加快响应速度 #### 查询title字段为'关于'的文档的建立时间和更新时间 > GET https://smallpath.me/proxyPrefix/api/post?conditions={"title":"关于"}&select={"createdAt":1,"updatedAt":1} ### count查询 获得查询结果的数量 #### 查询文档的数量 > GET https://smallpath.me/proxyPrefix/api/post?conditions={"type":0}&count=1 ### sort查询 #### 查询所有文档并按id倒序 > GET https://smallpath.me/proxyPrefix/api/post?sort={"_id":-1} #### 查询所有文档并按更新时间倒序 > GET https://smallpath.me/proxyPrefix/api/post?sort={"updatedAt":-1} ### skip 查询和 limit 查询 #### 查询第2页的文档(每页10条)并按时间倒叙 > GET https://smallpath.me/proxyPrefix/api/post?limit=10&skip=10&sort=1 ## 新建 需要验证Token > POST https://smallpath.me/proxyPrefix/api/:modelName Body中为用来新建文档的JSON数据 每个模型的具体字段, 可以查看该模型的[Schema定义](https://github.com/smallpath/blog/blob/master/server/model/mongo.js#L24)来获得 ## 替换 需要验证Token > PUT https://smallpath.me/proxyPrefix/api/:modelName/:id `:id`为查询到的文档的`_id`属性, Body中为用来替换该文档的JSON数据 ## 更新 需要验证Token > PATCH https://smallpath.me/proxyPrefix/api/:modelName/:id `:id`为查询到的文档的`_id`属性, Body中为用来更新该文档的JSON数据 更新操作请使用`PATCH`而不是`PUT` ## 删除 需要验证 Token > DELETE https://smallpath.me/proxyPrefix/api/:modelName/:id 删除指定 ID 的文档 ================================================ FILE: docs/index.html ================================================ Document
================================================ FILE: front/.babelrc ================================================ { "presets": [ ["latest", { "es2015": { "loose": true, "modules": false } }] ], "plugins": [ "transform-object-rest-spread", "syntax-dynamic-import" ], "comments": true } ================================================ FILE: front/.eslintignore ================================================ build/*.js config/*.js ================================================ FILE: front/.eslintrc.js ================================================ module.exports = { root: true, parser: 'babel-eslint', parserOptions: { sourceType: 'module' }, // https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style extends: 'standard', // required to lint *.vue files plugins: [ 'html' ], // add your custom rules here 'rules': { // allow paren-less arrow functions 'arrow-parens': 0, // allow async-await 'generator-star-spacing': 0, 'space-before-function-paren': ['error', 'never'], // allow debugger during development 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 } } ================================================ FILE: front/.gitignore ================================================ .DS_Store node_modules/ dist/ npm-debug.log selenium-debug.log test/unit/coverage test/e2e/reports static/ server/mongo.js .editorconfig ================================================ FILE: front/README.md ================================================ ## front > 博客的前台单页, 支持服务端渲染 复制server文件夹中的默认配置`mongo.tpl`, 并命名为`mongo.js` ``` bash npm install # 测试环境 npm run dev # 默认打包文件在 dist 文件夹内 npm run build ``` 命令顺利执行会得到类似以下回显: ``` [2017-04-07 22:09:55.971] [DEBUG] ssr axios - MongoDB is ready [HPM] Proxy created: /proxyPrefix -> http://localhost:3000/ [HPM] Proxy rewrite rule created: "^/proxyPrefix" ~> "" [2017-04-07 22:09:56.640] [DEBUG] ssr server - server started at localhost:8080 ``` 不出意外的话访问 http://localhost:8080/ 就可以访问博客页面了 ## TIPS - 如果mongoDB不在本机对应端口,请自行配置`mongo.js`中的属性: - 请将`logo.png`与`favicon.ico`放至`static`目录中 - 生产环境 nginx 代理本机8080端口模板 ``` server{ listen 80; #如果是https, 则替换80为443 server_name *.smallpath.me smallpath.me; #替换域名 root /alidata/www/Blog/front/dist; #替换路径为构建出来的dist路径 set $node_port 3000; set $ssr_port 8080; location ^~ / { proxy_http_version 1.1; proxy_set_header Connection "upgrade"; proxy_pass http://127.0.0.1:$ssr_port; proxy_redirect off; } location ^~ /proxyPrefix/ { rewrite ^/proxyPrefix/(.*) /$1 break; proxy_http_version 1.1; proxy_set_header Connection "upgrade"; proxy_pass http://127.0.0.1:$node_port; proxy_redirect off; } location ^~ /dist/ { rewrite ^/dist/(.*) /$1 break; etag on; expires max; } location ^~ /static/ { etag on; expires max; } } ``` ================================================ FILE: front/build/build-client.js ================================================ // https://github.com/shelljs/shelljs require('shelljs/global') env.NODE_ENV = 'production' var path = require('path') var config = require('../config') var ora = require('ora') var webpack = require('webpack') var webpackConfig = require('./webpack.client.config') var spinner = ora('building for production...') spinner.start() var assetsPath = path.join(config.build.assetsRoot, config.build.assetsSubDirectory) var dist = path.join(__dirname, '../dist') rm('-rf', dist) mkdir('-p', dist) rm('-rf', assetsPath) mkdir('-p', assetsPath) cp('-R', 'static/*', assetsPath) webpack(webpackConfig, function (err, stats) { spinner.stop() if (err) throw err process.stdout.write(stats.toString({ colors: true, modules: false, children: false, chunks: false, chunkModules: false }) + '\n') }) ================================================ FILE: front/build/setup-dev-server.js ================================================ const path = require('path') const webpack = require('webpack') const MFS = require('memory-fs') const proxyTable = require('../config/index').dev.proxyTable; const clientConfig = require('./webpack.client.config') const serverConfig = require('./webpack.server.config') const proxyMiddleware = require('http-proxy-middleware') module.exports = function setupDevServer (app, opts) { // modify client config to work with hot middleware clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app] clientConfig.output.filename = '[name].js' clientConfig.plugins.push( new webpack.HotModuleReplacementPlugin(), new webpack.NoEmitOnErrorsPlugin() ) // dev middleware const clientCompiler = webpack(clientConfig) const devMiddleware = require('webpack-dev-middleware')(clientCompiler, { publicPath: clientConfig.output.publicPath, stats: { colors: true, chunks: false } }) app.use(devMiddleware) clientCompiler.plugin('done', () => { const fs = devMiddleware.fileSystem const filePath = path.join(clientConfig.output.path, 'index.html') if (fs.existsSync(filePath)) { const index = fs.readFileSync(filePath, 'utf-8') opts.indexUpdated(index) } }) // hot middleware app.use(require('webpack-hot-middleware')(clientCompiler)) // proxy api requests Object.keys(proxyTable).forEach(function (context) { var options = proxyTable[context] if (typeof options === 'string') { options = { target: options } } app.use(proxyMiddleware(context, options)) }) // watch and update server renderer const serverCompiler = webpack(serverConfig) const mfs = new MFS() const outputPath = path.join(serverConfig.output.path, serverConfig.output.filename) serverCompiler.outputFileSystem = mfs serverCompiler.watch({}, (err, stats) => { if (err) throw err stats = stats.toJson() stats.errors.forEach(err => console.error(err)) stats.warnings.forEach(err => console.warn(err)) opts.bundleUpdated(mfs.readFileSync(outputPath, 'utf-8')) }) } ================================================ FILE: front/build/utils.js ================================================ var path = require('path') var config = require('../config') var ExtractTextPlugin = require('extract-text-webpack-plugin') exports.assetsPath = function (_path) { return path.posix.join(config.build.assetsSubDirectory, _path) } exports.cssLoaders = function (options) { options = options || {} // generate loader string to be used with extract text plugin function generateLoaders (loaders) { var sourceLoader = loaders.map(function (loader) { var extraParamChar if (/\?/.test(loader)) { loader = loader.replace(/\?/, '-loader?') extraParamChar = '&' } else { loader = loader + '-loader' extraParamChar = '?' } return loader + (options.sourceMap ? extraParamChar + 'sourceMap' : '') }).join('!') if (options.extract) { return ExtractTextPlugin.extract({ fallback: 'vue-style-loader', use: sourceLoader }); } else { return ['vue-style-loader', sourceLoader].join('!') } } // http://vuejs.github.io/vue-loader/configurations/extract-css.html return { css: generateLoaders(['css']), postcss: generateLoaders(['css']), less: generateLoaders(['css', 'less']), sass: generateLoaders(['css', 'sass?indentedSyntax']), scss: generateLoaders(['css', 'sass']), stylus: generateLoaders(['css', 'stylus']), styl: generateLoaders(['css', 'stylus']) } } // Generate loaders for standalone style files (outside of .vue) exports.styleLoaders = function (options) { var output = [] var loaders = exports.cssLoaders(options) for (var extension in loaders) { var loader = loaders[extension] output.push({ test: new RegExp('\\.' + extension + '$'), loader: loader }) } return output } ================================================ FILE: front/build/vue-loader.config.js ================================================ module.exports = { preserveWhitespace: false, postcss: [ require('autoprefixer')({ browsers: ['last 3 versions'] }) ] } ================================================ FILE: front/build/webpack.base.config.js ================================================ var config = require('../config') var vueConfig = require('./vue-loader.config') module.exports = { entry: { app: './src/client-entry.js' }, output: { path: config.build.assetsRoot, publicPath: process.env.NODE_ENV === 'production' ? config.build.assetsPublicPath : config.dev.assetsPublicPath, filename: '[name].[chunkhash].js', chunkFilename: '[name].[chunkhash].js' }, resolve: { extensions: ['.js', '.vue'] }, module: { noParse: /es6-promise\.js$/, // avoid webpack shimming process rules: [ { test: /\.vue$/, loader: 'vue-loader', options: vueConfig }, { test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/ }, { test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, loader: 'url-loader', options: { limit: 10000, name: 'img/[name].[hash].[ext]' } }, { test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, loader: 'url-loader', options: { limit: 10000, name: 'fonts/[name].[hash].[ext]' } } ] }, performance: { hints: process.env.NODE_ENV === 'production' ? 'warning' : false } } ================================================ FILE: front/build/webpack.client.config.js ================================================ const webpack = require('webpack') const base = require('./webpack.base.config') const vueConfig = require('./vue-loader.config') const utils = require('./utils') const path = require('path') const HTMLPlugin = require('html-webpack-plugin') const ExtractTextPlugin = require('extract-text-webpack-plugin') const SWPrecachePlugin = require('sw-precache-webpack-plugin') const config = Object.assign({}, base, { resolve: { alias: Object.assign({}, base.resolve.alias, { 'create-api': './create-api-client.js', 'create-route': './create-route-client.js' }), extensions: base.resolve.extensions }, plugins: (base.plugins || []).concat([ // strip comments in Vue code new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 'process.env.VUE_ENV': '"client"', 'process.BROWSER': true }), // generate output HTML new HTMLPlugin({ template: 'src/index.template.html' }) ]) }) if (process.env.NODE_ENV === 'production') { // Use ExtractTextPlugin to extract CSS into a single file // so it's applied on initial render. vueConfig.loaders = utils.cssLoaders({ extract: true }) config.plugins.push( new ExtractTextPlugin('styles.css'), // this is needed in webpack 2 for minifying CSS new webpack.LoaderOptionsPlugin({ minimize: true }), // minify JS new webpack.optimize.UglifyJsPlugin({ compress: { warnings: false }, output: { comments: false } }), new SWPrecachePlugin({ cacheId: 'blog', filename: 'service-worker.js', minify: true, mergeStaticsConfig: true, staticFileGlobs: [ path.join(__dirname, '../dist/static/*.*') ], stripPrefixMulti: { [path.join(__dirname, '../dist/static')]: '/static' }, // add hash to path to force updated dontCacheBustUrlsMatching: false, staticFileGlobsIgnorePatterns: [ /index\.html$/, /\.map$/, /\.css$/, /\.svg$/, /\.eot$/ ], // runtime caching for offline pwa runtimeCaching: [ { // never cache service worker urlPattern: /service-worker.js/, handler: 'networkOnly' }, { // note that this pattern will cache ajax request urlPattern: /(.+\/[^\.]*$)/, handler: 'networkFirst', options: { cache: { maxEntries: 30, name: 'blog-runtime-cache' } } }, { urlPattern: /\.(png|jpg|webp|gif)/, handler: 'cacheFirst', options: { cache: { maxEntries: 20, name: 'blog-picture-cache' } } } ] }) ) } module.exports = config ================================================ FILE: front/build/webpack.server.config.js ================================================ const webpack = require('webpack') const base = require('./webpack.base.config') const VueSSRPlugin = require('vue-ssr-webpack-plugin') const plugins = [ new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 'process.env.VUE_ENV': '"server"', 'process.BROWSER': false }) ] if (process.env.NODE_ENV === 'production') { plugins.push(new VueSSRPlugin()) } module.exports = Object.assign({}, base, { target: 'node', devtool: '#source-map', entry: './src/server-entry.js', output: Object.assign({}, base.output, { filename: 'server-bundle.js', libraryTarget: 'commonjs2' }), resolve: { alias: Object.assign({}, base.resolve.alias, { 'create-api': './create-api-server.js', 'create-route': './create-route-server.js' }), extensions: base.resolve.extensions }, externals: Object.keys(require('../package.json').dependencies), plugins: plugins }) ================================================ FILE: front/config/dev.env.js ================================================ var merge = require('webpack-merge') var prodEnv = require('./prod.env') module.exports = merge(prodEnv, { NODE_ENV: '"development"' }) ================================================ FILE: front/config/index.js ================================================ // see http://vuejs-templates.github.io/webpack for documentation. var path = require('path') module.exports = { build: { env: require('./prod.env'), index: path.resolve(__dirname, '../dist/index.html'), assetsRoot: path.resolve(__dirname, '../dist'), assetsSubDirectory: 'static', assetsPublicPath: '/dist/', productionSourceMap: true, // Gzip off by default as many popular static hosts such as // Surge or Netlify already gzip all static assets for you. // Before setting to `true`, make sure to: // npm install --save-dev compression-webpack-plugin productionGzip: false, productionGzipExtensions: ['js', 'css'] }, dev: { env: require('./dev.env'), port: 8080, assetsSubDirectory: 'static', assetsPublicPath: '/dist/', proxyTable: { '/proxyPrefix':{ target:'http://localhost:3000/', changeOrigin: true, pathRewrite:{ '^/proxyPrefix':'', } } }, // CSS Sourcemaps off by default because relative paths are "buggy" // with this option, according to the CSS-Loader README // (https://github.com/webpack/css-loader#sourcemaps) // In our experience, they generally work as expected, // just be aware of this issue when enabling this option. cssSourceMap: false, } } ================================================ FILE: front/config/prod.env.js ================================================ module.exports = { NODE_ENV: '"production"' } ================================================ FILE: front/config/test.env.js ================================================ var merge = require('webpack-merge') var devEnv = require('./dev.env') module.exports = merge(devEnv, { NODE_ENV: '"testing"' }) ================================================ FILE: front/middleware/favicon.js ================================================ const fs = require('fs') const serveFavicon = require('serve-favicon') const log = require('log4js').getLogger('favicon') const path = require('path') module.exports = function(faviconPath, options) { let _middleware return function(req, res, next) { if (_middleware) return _middleware(req, res, next) const target = path.join(__dirname, `../${faviconPath}`) fs.readFile(target, function(err, buf) { if (err) { log.error(err) return res.status(404).end() } _middleware = serveFavicon(buf, options) return _middleware(req, res, next) }) } } ================================================ FILE: front/middleware/serverGoogleAnalytic.js ================================================ const log = require('log4js').getLogger('google analytic') const config = require('../server/config') const request = require('axios') const EMPTY_GIF = new Buffer('R0lGODlhAQABAIABAAAAAP///yH5BAEAAAEALAAAAAABAAEAAAICTAEAOw==', 'base64') const uuid = require('uuid') const expires = 3600 * 1000 * 24 * 365 * 2 const ipReg = /\d+\.\d+\.\d+\.\d+/ const lowerReg = /\s+/g const shouldBanSpider = ua => { if (!ua) { return true } ua = ua.toLowerCase().replace(lowerReg, '') return config.ga.spider.some(item => ua.indexOf(item) > -1) } const getClientIp = (req) => { let matched = req.ip.match(ipReg) return matched ? matched[0] : req.ip } module.exports = (req, res, next, query) => { let realQuery let clientId if (!query) { clientId = req.cookies.id if (!clientId) { clientId = uuid.v4() res.cookie('id', clientId, { expires: new Date(Date.now() + expires) }) } res.setHeader('Content-Type', 'image/gif') res.setHeader('Content-Length', 43) res.end(EMPTY_GIF) realQuery = req.query } else { realQuery = query clientId = realQuery.cid } let passParams = config.ga.required.reduce((prev, curr) => { if (!realQuery[curr]) { prev = false } return prev }, true) if (passParams === false) return if (config.googleTrackID === '') return let userAgent = req.header('user-agent') if (shouldBanSpider(userAgent) === true) return let { z: timeStamp = Date.now() } = realQuery let form = Object.assign({}, realQuery, { v: config.ga.version, tid: config.googleTrackID, ds: 'web', z: timeStamp + clientId, cid: clientId, uip: getClientIp(req), ua: userAgent, t: 'pageview', an: config.title, dh: config.siteUrl }) request.get(config.ga.api, { params: form }).then(response => { if (response.status !== 200) { log.error(response, form) return } log.info('Google Analytic sended', form.cid, form.ua) }).catch(err => log.error(err, form)) } ================================================ FILE: front/package.json ================================================ { "name": "vue_client_side", "version": "1.0.0", "description": "A Vue.js project", "author": "Smallpath ", "private": true, "scripts": { "dev": "node server", "lint": "eslint --ext .js,.vue middleware server server.js src test/unit/specs test/e2e/specs", "start": "cross-env NODE_ENV=production node server", "build": "npm run build:client && npm run build:server", "build:client": "node build/build-client.js", "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js --progress --hide-modules" }, "dependencies": { "axios": "^0.15.3", "es6-promise": "^4.0.5", "express": "^4.14.0", "lru-cache": "^4.0.1", "node-schedule": "^1.2.0", "vue": "2.3.0", "vue-meta": "0.5.3", "vue-router": "^2.0.3", "vue-server-renderer": "2.3.0", "vuex": "^2.0.0", "vuex-router-sync": "^3.0.0" }, "devDependencies": { "autoprefixer": "^6.4.0", "babel-core": "^6.0.0", "babel-eslint": "^7.2.2", "babel-loader": "^6.0.0", "babel-plugin-dynamic-import-node": "^1.0.1", "babel-plugin-syntax-dynamic-import": "^6.18.0", "babel-plugin-transform-object-rest-spread": "^6.16.0", "babel-plugin-transform-runtime": "^6.0.0", "babel-preset-es2015": "^6.0.0", "babel-preset-latest": "^6.22.0", "babel-preset-stage-2": "^6.0.0", "bluebird": "^3.4.7", "chai": "^3.5.0", "connect-history-api-fallback": "^1.1.0", "cookie-parser": "^1.4.3", "cross-env": "^3.1.3", "cross-spawn": "^2.1.5", "css-loader": "^0.25.0", "eslint": "^2.10.2", "eslint-config-standard": "^5.1.0", "eslint-friendly-formatter": "^2.0.5", "eslint-loader": "^1.3.0", "eslint-plugin-html": "^1.3.0", "eslint-plugin-promise": "^1.0.8", "eslint-plugin-standard": "^1.3.2", "eventsource-polyfill": "^0.9.6", "express": "^4.13.3", "extract-text-webpack-plugin": "^2.0.0-beta.3", "file-loader": "^0.10.0", "function-bind": "^1.0.2", "html-webpack-plugin": "^2.8.1", "http-proxy-middleware": "^0.12.0", "inject-loader": "^2.0.1", "isparta-loader": "^2.0.0", "json-loader": "^0.5.4", "log4js": "^1.1.0", "lolex": "^1.4.0", "mongoose": "^4.8.5", "ora": "^0.3.0", "request": "^2.81.0", "rimraf": "^2.5.4", "serve-favicon": "^2.3.2", "shelljs": "^0.7.5", "sinon": "^1.17.3", "sinon-chai": "^2.8.0", "stylus": "^0.54.5", "stylus-loader": "^2.3.1", "sw-precache-webpack-plugin": "^0.9.0", "url-loader": "^0.5.7", "uuid": "^3.0.1", "vue-hot-reload-api": "^1.2.0", "vue-html-loader": "^1.0.0", "vue-loader": "^12.0.0", "vue-ssr-webpack-plugin": "^1.0.2", "vue-style-loader": "^3.0.0", "vue-template-compiler": "2.3.0", "webpack": "^2.4.0", "webpack-dev-middleware": "^1.6.1", "webpack-hot-middleware": "^2.12.2", "webpack-merge": "^0.8.3" }, "engines": { "node": ">= 4.0.0", "npm": ">= 3.0.0" } } ================================================ FILE: front/production.js ================================================ process.env.NODE_ENV = 'production' require('./server.js') ================================================ FILE: front/server/config.js ================================================ const isProd = process.env.NODE_ENV === 'production' const request = require('./server-axios') const { ssrPort, serverPort, enableServerSideGA } = require('./mongo') let siteUrl = 'http://localhost:8080' let title = 'Blog' let description = '' let googleTrackID = '' let favicon = isProd ? './dist' : '.' let ga = { version: 1, api: 'https://www.google-analytics.com/collect', required: ['dt', 'dr', 'dp', 'z'], spider: [ 'spider', 'bot', 'appid', // for google appengine 'go-http-client', 'loadtest', // for load test 'feed' ].map(item => item.toLowerCase().replace(/\s+/g, '')) } function flushOption() { return request.get('http://localhost:3000/api/option').then(res => { let options = res.data.reduce((prev, curr) => { prev[curr.key] = curr.value return prev }, {}) siteUrl = options['siteUrl'] title = options['title'] description = options['description'] googleTrackID = options['analyzeCode'] favicon += options['faviconUrl'] }) } exports.ssrPort = ssrPort exports.serverPort = serverPort exports.enableServerSideGA = enableServerSideGA Object.defineProperty(exports, 'title', { enumerable: true, get: function() { return title }, set: function(value) { title = value } }) Object.defineProperty(exports, 'siteUrl', { enumerable: true, get: function() { return siteUrl }, set: function(value) { siteUrl = value } }) Object.defineProperty(exports, 'description', { enumerable: true, get: function() { return description }, set: function(value) { description = value } }) Object.defineProperty(exports, 'googleTrackID', { enumerable: true, get: function() { return googleTrackID }, set: function(value) { googleTrackID = value } }) Object.defineProperty(exports, 'favicon', { enumerable: true, get: function() { return favicon }, set: function(value) { favicon = value } }) Object.defineProperty(exports, 'flushOption', { enumerable: true, get: function() { return flushOption } }) Object.defineProperty(exports, 'ga', { enumerable: true, get: function() { return ga } }) ================================================ FILE: front/server/model.js ================================================ const config = require('./mongo.js') const mongoose = require('mongoose') const log = require('log4js').getLogger('ssr axios') mongoose.Promise = global.Promise = require('bluebird') const mongoUrl = `${config.mongoHost}:${config.mongoPort}/${config.mongoDatabase}` mongoose.connect(mongoUrl) const db = mongoose.connection db.on('error', (err) => { log.error('connect error:', err) }) db.once('open', () => { log.debug('MongoDB is ready') }) const Schema = mongoose.Schema let post = new Schema({ type: { type: String, default: '' }, status: { type: Number, default: 0 }, title: String, pathName: { type: String, default: '' }, summary: { type: String }, markdownContent: { type: String }, content: { type: String }, markdownToc: { type: String, default: '' }, toc: { type: String, default: '' }, allowComment: { type: Boolean, default: true }, createdAt: { type: String, default: '' }, updatedAt: { type: String, default: '' }, isPublic: { type: Boolean, default: true }, commentNum: Number, options: Object, category: String, tags: Array }) let category = new Schema({ name: String, pathName: String }) let tag = new Schema({ name: String, pathName: String }) let option = new Schema({ key: String, value: Schema.Types.Mixed, desc: String }) let theme = new Schema({ name: String, author: String, option: Schema.Types.Mixed }) post = mongoose.model('post', post) category = mongoose.model('category', category) option = mongoose.model('option', option) theme = mongoose.model('theme', theme) tag = mongoose.model('tag', tag) module.exports = { post, category, option, tag, theme } ================================================ FILE: front/server/mongo.tpl ================================================ const env = process.env module.exports = { ssrPort: env.ssrPort || 8080, mongoHost: env.mongoHost || '127.0.0.1', mongoDatabase: env.mongoDatabase || 'blog', mongoPort: env.mongoPort || 27017, serverPort: env.serverPort || 3000, enableServerSideGA: env.enableServerSideGA || false, redisHost: env.redisHost || '127.0.0.1', redisPort: env.redisPort || 6379, redisPassword: env.redisPassword || '' } ================================================ FILE: front/server/robots.js ================================================ module.exports = (config) => `User-agent: * Allow: / Sitemap: ${config.siteUrl}/sitemap.xml User-agent: YisouSpider Disallow: / User-agent: EasouSpider Disallow: / User-agent: EtaoSpider Disallow: / User-agent: MJ12bot Disallow: /` ================================================ FILE: front/server/rss.js ================================================ let getUpdatedDate = date => ` ${date}\r\n` let tail = ` ` let api = 'http://localhost:3000/api/post' let params = { conditions: { type: 'post', isPublic: true }, select: { pathName: 1, updatedAt: 1, content: 1, title: 1 }, sort: { updatedAt: -1 }, limit: 10 } let getRssBodyFromBody = (result, config) => { let head = ` ${config.title} ${config.siteUrl} ${config.description} zh-cn\r\n` let body = result.data.reduce((prev, curr) => { let date = new Date(curr.updatedAt).toUTCString() let content = curr.content.replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') prev += ` \r\n` prev += ` ${curr.title}\r\n` prev += ` ${config.siteUrl}/post/${curr.pathName}\r\n` prev += ` ${content}\r\n` prev += ` ${date}\r\n` prev += ` ${config.siteUrl}/post/${curr.pathName}\r\n` prev += ` \r\n` return prev }, '') return head + getUpdatedDate(new Date().toUTCString()) + body + tail } module.exports = { api, params, getRssBodyFromBody } ================================================ FILE: front/server/server-axios.js ================================================ const models = require('./model') module.exports = process.__API__ = { get: function(target, options = {}) { const modelName = target.split('/').slice(-1) const query = options.params || {} const model = models[modelName] return new Promise((resolve, reject) => { queryModel(model, query).then(data => { resolve({ data }) }) // mongoose uses mpromise who doesn't have a reject method here }) } } function queryModel(model, query) { let conditions = {} let select = {} if (query.conditions) { conditions = query.conditions } let builder = model.find(conditions) if (query.select) { select = query.select builder = builder.select(select) } ['limit', 'skip', 'sort', 'count'].forEach(key => { if (query[key]) { let arg = query[key] if (key === 'limit' || key === 'skip') { arg = parseInt(arg) } if (key === 'sort' && typeof arg === 'string') { arg = JSON.parse(arg) } if (key !== 'count') builder[key](arg) else builder[key]() } }) return builder.exec() } ================================================ FILE: front/server/sitemap.js ================================================ let head = ` \r\n` let tail = '' let api = 'http://localhost:3000/api/post' let params = { conditions: { type: 'post', isPublic: true }, select: { pathName: 1, updatedAt: 1 }, sort: { updatedAt: -1 } } let getSitemapFromBody = (result, config) => { let res = result.data let body = res.reduce((prev, curr) => { prev += ` \r\n` prev += ` ${config.siteUrl}/post/${curr.pathName}\r\n` prev += ` ${curr.updatedAt.slice(0, 10)}\r\n` prev += ` 0.6\r\n` prev += ` \r\n` return prev }, '') return head + body + tail } module.exports = { api, params, getSitemapFromBody } ================================================ FILE: front/server.js ================================================ const isProd = process.env.NODE_ENV === 'production' const log = require('log4js').getLogger('ssr server') const fs = require('fs') const path = require('path') const resolve = file => path.resolve(__dirname, file) const express = require('express') const schedule = require('node-schedule') const createBundleRenderer = require('vue-server-renderer').createBundleRenderer const sendGoogleAnalytic = require('./middleware/serverGoogleAnalytic') const favicon = require('./middleware/favicon') const getRobotsFromConfig = require('./server/robots.js') const { api: sitemapApi, params: sitemapParams, getSitemapFromBody } = require('./server/sitemap.js') const { api: rssApi, params: rssParams, getRssBodyFromBody } = require('./server/rss.js') const inline = isProd ? fs.readFileSync(resolve('./dist/styles.css'), 'utf-8') : '' const config = require('./server/config') const request = require('./server/server-axios') const proxyRequest = require('request') const uuid = require('uuid') const titleReg = /<.*?>(.+?)<.*?>/ const expires = 3600 * 1000 * 24 * 365 * 2 const chunkObj = {} if (isProd) { const fileArr = fs.readdirSync(resolve('./dist')) for (let i = 0, len = fileArr.length; i < len; i++) { const fileName = fileArr[i] const arr = fileName.split('.') if (arr.length === 3 && arr[0] !== 'app') { const input = fs.readFileSync(resolve(`./dist/${fileName}`), 'utf-8') chunkObj[fileName] = input } } } let sitemap = '' let rss = '' let robots = '' config.flushOption().then(() => { robots = getRobotsFromConfig(config) const flushSitemap = () => request.get(sitemapApi, sitemapParams).then(result => { sitemap = getSitemapFromBody(result, config) }) const flushRss = () => request.get(rssApi, rssParams).then(result => { rss = getRssBodyFromBody(result, config) }) flushSitemap() flushRss() schedule.scheduleJob('30 3 * * * ', function() { flushRss() flushSitemap() }) let app = express() app.enable('trust proxy') let renderer let html // generated by html-webpack-plugin if (isProd) { renderer = createRenderer(require('./dist/vue-ssr-bundle.json')) html = flushHtml(fs.readFileSync(resolve('./dist/index.html'), 'utf-8')) } else { // in development: setup the dev server with watch and hot-reload, // and update renderer / index HTML on file change. require('./build/setup-dev-server')(app, { bundleUpdated: bundle => { renderer = createRenderer(bundle) }, indexUpdated: index => { html = flushHtml(index) } }) } function flushHtml(template) { const style = isProd ? `` : '' const i = template.indexOf('
') return { head: template.slice(0, i).replace('', style), tail: template.slice(i + '
'.length), origin: template } } function createRenderer(bundle) { return createBundleRenderer(bundle, { cache: require('lru-cache')({ max: 1000, maxAge: 1000 * 60 * 15 }), runInNewContext: false }) } app.use(require('cookie-parser')()) app.get('/favicon.ico', favicon(config.favicon)) const prefix = '/proxyPrefix/' app.use((req, res, next) => { const url = decodeURIComponent(req.url) log.debug(`${req.method} ${url}`) if (!isProd) return next() // proxy with node in production if (url.startsWith(prefix)) { const rewriteUrl = `http://localhost:${config.serverPort}/${url.replace(prefix, '')}` proxyRequest.get(rewriteUrl).on('error', (err) => { res.end(err) log.error(err) }).pipe(res) } else { return next() } }) const serve = (path, cache) => express.static(resolve(path), { maxAge: cache && isProd ? 60 * 60 * 24 * 30 : 0, fallthrough: false }) app.use('/service-worker.js', serve('./dist/service-worker.js')) app.use('/dist', serve('./dist')) app.use('/static', serve('./static')) app.get('/_.gif', (req, res, next) => sendGoogleAnalytic(req, res, next)) app.get('/robots.txt', (req, res, next) => res.end(robots)) app.get('/rss.xml', (req, res, next) => { res.header('Content-Type', 'application/xml') return res.end(rss) }) app.get('/sitemap.xml', (req, res, next) => { res.header('Content-Type', 'application/xml') return res.end(sitemap) }) app.get('*', (req, res, next) => { if (!renderer) { return res.end('waiting for compilation... refresh in a moment.') } const supportWebp = req.header('accept').includes('image/webp') let s = Date.now() const context = { url: req.url, supportWebp } const renderStream = renderer.renderToStream(context) res.header('Content-Type', 'text/html; charset=utf-8') renderStream.once('data', () => { const { title, link, meta } = context.meta.inject() const titleText = title.text() const metaData = `${titleText}${meta.text()}${link.text()}` const matched = titleText.match(titleReg) let clientId = req.cookies.id if (!clientId) { clientId = uuid.v4() res.cookie('id', clientId, { expires: new Date(Date.now() + expires) }) } const chunk = html.head.replace('', metaData) res.write(chunk) sendGoogleAnalytic(req, res, next, { dt: matched ? matched[1] : config.title, dr: req.url, dp: req.url, z: +Date.now(), cid: clientId }) }) renderStream.on('data', chunk => { res.write(chunk) }) renderStream.on('end', () => { if (context.initialState) { context.initialState.supportWebp = supportWebp context.initialState.route = Object.assign({}, context.initialState.route, { matched: {} }) res.write( `` ) } let tail = html.tail if (isProd && typeof context.chunkName === 'string') { for (let key in chunkObj) { if (key.split('.')[0] === context.chunkName) { const chunk = `` tail = tail.replace('', chunk) break } } } res.end(tail) log.debug(`whole request: ${Date.now() - s}ms`) }) renderStream.on('error', err => { res.end(html.origin) log.error(err) }) }) const port = config.ssrPort app.listen(port, () => { log.debug(`server started at localhost:${port}`) }) }).catch(err => log.error(err)) ================================================ FILE: front/src/assets/css/article.css ================================================ #toc { float: right; border: 1px solid #e2e2e2; font-size: 14px; margin: 0 0 15px 20px; max-width: 260px; min-width: 120px; padding: 6px; background-color: #fff; } #toc p { margin: 1px; } #toc ul { margin: 0 .5em 0 1.5em; } #toc strong { border-bottom: 1px solid #e2e2e2; display: block; } a.anchor { display: block; position: relative; visibility: hidden; } article img { height: auto; max-width: 910px; } article { border-bottom: 1px solid #ddd; border-top: 1px solid #fff; padding: 30px 0; position: relative; word-wrap: break-word } article .meta { color: #555; float: right; font-size: .9em; line-height: 2; position: relative; text-align: right; width: auto } article .meta a { color: #999 } article h1.title { font-size: 2em; font-weight: 300; line-height: 35px; margin-bottom: 25px } article h1.title, article h1.title a { color: #333 } article .meta .date, article .meta .comment, article .meta .tags { position: relative } article h1.title a:hover { color: #2479CC; transition: color .3s } article button, article input.runcode { -webkit-appearance: button; background: #12b0e6; border: none; box-shadow: inset 0 -5px 20px rgba(0, 0, 0, .1); color: #fff; cursor: pointer; font-size: 14px; line-height: 1; margin-top: 10px; padding: 0.625em .5em } article strong { font-weight: 700 } article em { font-style: italic } article blockquote { background-color: #f8f8f8; border-left: 5px solid #2479CC; margin-top: 10px; margin-left: 0; padding: 15px 20px } article code { background-color: #f2f2f2; border-radius: 5px; font-family: "Consolas", "Courier New", Courier, mono, serif; font-size: 80%; margin: 0 2px; padding: 4px 5px; vertical-align: middle } article pre { color: #5d6a6a; font-family: "Consolas", "Liberation Mono", Courier, monospace; font-size: 14px; line-height: 1.6; overflow: hidden; padding: 0.6em; position: relative; white-space: pre-wrap; word-break: break-all; word-wrap: break-word } article img { border: 1px solid #ccc; display: block; margin: 10px 0 5px; max-width: 100%; padding: 0 } article table { border: 0; border-collapse: collapse; border-spacing: 0 } article blockquote p { margin-bottom: 0 } article pre code { background-color: transparent; border-radius: 0 0 0 0; border: 0; display: block; font-size: 100%; margin: 0; padding: 0; position: relative } article table th, article table td { border: 0 } article table th { border-bottom: 2px solid #848484; padding: 6px 20px; text-align: left } article table td { border-bottom: 1px solid #d0d0d0; padding: 6px 20px } article .expire-tips { background-color: #ffffc0; border: 1px solid #e2e2e2; border-left: 5px solid #fff000; color: #333; font-size: 15px; padding: 5px 10px } article .aliyun-tips { background-color: #f0f8f4; border: 1px solid #e2e2e2; border-left: 5px solid #7cc4a0; font-size: 15px; padding: 5px 10px } article .post-info { font-size: 14px } article .entry-content { font-size: 16px; line-height: 1.8; word-wrap: break-word } article .entry-content p, article .entry-content blockquote, article .entry-content ul, article .entry-content ol, article .entry-content dl, article .entry-content table, article .entry-content iframe, article .entry-content h1, article .entry-content h2, article .entry-content h3, article .entry-content h4, article .entry-content h5, article .entry-content h6, article .entry-content p, article .entry-content pre { margin-top: 15px } article pre b.name { color: #eee; font-size: 60px; line-height: 1; pointer-events: none; position: absolute; right: 10px; top: 10px } article .entry-content .date { color: #999; font-size: 14px; font-style: italic } article input.runcode:hover, article input.runcode:focus, article input.runcode:active { background: #f6ad08 } article .entry-content ul ul, article .entry-content ul ol, article .entry-content ul dl, article .entry-content ol ul, article .entry-content ol ol, article .entry-content ol dl, article .entry-content dl ul, article .entry-content dl ol, article .entry-content dl dl, article .entry-content blockquote > p:first-of-type { margin-top: 0 } article .entry-content ul, article .entry-content ol, article .entry-content dl { margin-left: 25px } article.tags section a { border: 1px solid rgba(36, 121, 204, .8); border-radius: 4px; color: rgba(36, 121, 204, .8); display: inline-block; font-size: 14px; height: 40px; line-height: 40px; margin: 0 15px 10px 0; padding: 0 15px; text-decoration: none; -webkit-transition: color .2s cubic-bezier(.4, .01, .165, .99), border .2s cubic-bezier(.4, .01, .165, .99); transition: color .2s cubic-bezier(.4, .01, .165, .99), border .2s cubic-bezier(.4, .01, .165, .99) } article.tags section a:hover { border-color: #2479CC; color: #2479CC } ================================================ FILE: front/src/assets/css/base.css ================================================ body { color: #666; font-family: "Helvetica Neue", Arial, "Hiragino Sans GB", STHeiti, "Microsoft YaHei"; -webkit-font-smoothing: antialiased; margin: 0; -webkit-overflow-scrolling: touch; padding: 0; -webkit-tap-highlight-color: transparent; -webkit-text-size-adjust: none; -webkit-transition: -webkit-transform .2s cubic-bezier(.4, .01, .165, .99); transition: transform .2s cubic-bezier(.4, .01, .165, .99), -webkit-transform .2s cubic-bezier(.4, .01, .165, .99) } body, html { height: 100%; width: 100% } h1, h2, h3, h4, h5, h6 { font-weight: 400; margin: 0; padding: 0 } a, a:hover { color: #2479CC; text-decoration: none } * { -webkit-box-sizing: border-box; box-sizing: border-box } ul, ol { padding: 0 } body.side { position: fixed; -webkit-transform: translate3D(250px, 0, 0); -ms-transform: translate3D(250px, 0, 0); transform: translate3D(250px, 0, 0) } h1.intro { background-color: #f6f9fa; color: #999; padding: 20px 30px; text-align: center } #main { background-color: #fff; max-width: 1390px; -webkit-overflow-scrolling: touch; padding-left: 290px; padding-right: 40px } ================================================ FILE: front/src/assets/css/footer.css ================================================ #footer { border-top: 1px solid #fff; font-size: .9em; line-height: 1.8; padding: 15px; text-align: center } #footer .beian { color: #666 } ================================================ FILE: front/src/assets/css/header.css ================================================ #header { display: none; background-color: #323436; height: 50px; left: 0; line-height: 50px; overflow: hidden; position: fixed; top: 0; width: 100%; z-index: 9 } #header h1 { font-size: 16px; text-align: center } #header h1 a { color: #999 } #header .btn-bar { height: 50px; left: 0; position: absolute; top: 0; width: 50px } body.side #header .btn-bar i { opacity: 0 } body.side #header .btn-bar:before { top: 25px; -webkit-transform: rotate(-45deg); -ms-transform: rotate(-45deg); transform: rotate(-45deg); width: 24px } body.side #header .btn-bar:after { bottom: 24px; -webkit-transform: rotate(45deg); -ms-transform: rotate(45deg); transform: rotate(45deg); width: 24px } #header .btn-bar i, #header .btn-bar:after, #header .btn-bar:before { background-color: #fff; height: 1px; left: 14px; position: absolute; -webkit-transition: all .2s cubic-bezier(.4, .01, .165, .99) .3s; transition: all .2s cubic-bezier(.4, .01, .165, .99) .3s; width: 22px } #header .btn-bar i { opacity: 1; top: 25px } #header .btn-bar:before { content: ''; top: 17px } #header .btn-bar:after { bottom: 16px; content: '' } #header a.me, #header a.me img { border-radius: 30px; height: 30px; overflow: hidden; width: 30px } #header a.me { position: absolute; right: 10px; top: 10px } ================================================ FILE: front/src/assets/css/highlight.css ================================================ /* Atom One Dark by Daniel Gamage Original One Dark Syntax theme from https://github.com/atom/one-dark-syntax base: #282c34 mono-1: #abb2bf mono-2: #818896 mono-3: #5c6370 hue-1: #56b6c2 hue-2: #61aeee hue-3: #c678dd hue-4: #98c379 hue-5: #e06c75 hue-5-2: #be5046 hue-6: #d19a66 hue-6-2: #e6c07b */ .hljs { display: block; overflow-x: auto; padding: 0.5em; color: #abb2bf; background: #282c34; /* modified by smallpath */ border-left: 5px solid #61aeee; } .hljs-comment { color: #61aeee; } .hljs-quote { color: #5c6370; font-style: italic; } .hljs-doctag, .hljs-keyword, .hljs-formula { color: #c678dd; } .hljs-section, .hljs-name, .hljs-selector-tag, .hljs-deletion, .hljs-subst { color: #e06c75; } .hljs-literal { color: #56b6c2; } .hljs-string, .hljs-regexp, .hljs-addition, .hljs-attribute, .hljs-meta-string { color: #98c379; } .hljs-built_in, .hljs-class .hljs-title { color: #e6c07b; } .hljs-attr, .hljs-variable, .hljs-template-variable, .hljs-type, .hljs-selector-class, .hljs-selector-attr, .hljs-selector-pseudo, .hljs-number { color: #d19a66; } .hljs-symbol, .hljs-bullet, .hljs-link, .hljs-meta, .hljs-selector-id, .hljs-title { color: #61aeee; } .hljs-emphasis { font-style: italic; } .hljs-strong { font-weight: bold; } .hljs-link { text-decoration: underline; } ================================================ FILE: front/src/assets/css/icon.css ================================================ @font-face { font-family: iconfont; src: url(../font/iconfont.eot); src: url(../font/iconfont.eot?#iefix) format("embedded-opentype"), url(../font/iconfont.ttf) format("truetype"), url(../font/iconfont.svg#iconfont) format("svg"); } .iconfont { font-family: iconfont!important; font-size: 16px; font-style: normal; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; -webkit-text-stroke-width: .2px } .icon-weibo:before { content: "\e600" } .icon-archive:before { content: "\e601" } .icon-user:before { content: "\e602" } .icon-rss-v:before { content: "\e603" } .icon-tags:before { content: "\e604" } .icon-home:before { content: "\e605" } .icon-search:before { content: "\e606" } .icon-googleplus:before { content: "\e607" } .icon-weixin:before { content: "\e608" } .icon-mail:before { content: "\e609" } .icon-twitter-v:before { content: "\e60a" } .icon-linkedin:before { content: "\e60b" } .icon-stackoverflow:before { content: "\e60c" } .icon-github-v:before { content: "\e60d" } .icon-facebook:before { content: "\e60e" } .icon-right:before { content: "\e60f" } .icon-left:before { content: "\e610" } .icon-link:before { content: "\e611" } .icon-https:before { content: "\e612" } ================================================ FILE: front/src/assets/css/pagination.css ================================================ .pagination { border-top: 1px solid #fff; border-bottom: 1px solid #ddd; line-height: 20px; overflow: hidden; padding: 20px 0; position: relative; width: 100% } .pagination .prev { float: left } .pagination .next { float: right } .pagination .center { margin: auto; text-align: center; width: 80px } ================================================ FILE: front/src/assets/css/responsive.css ================================================ @media screen and (max-width:768px) { a.anchor { top: -50px; } #toc { margin: 0; max-width: 100%; float: none; } #header { display: block; -webkit-transform: translate3D(0, 0, 0); -ms-transform: translate3D(0, 0, 0); transform: translate3D(0, 0, 0); -webkit-transition: all .2s cubic-bezier(.4, .01, .165, .99); transition: all .2s cubic-bezier(.4, .01, .165, .99) } #sidebar { -webkit-transition: -webkit-transform .2s cubic-bezier(.4, .01, .165, .99); transition: transform .2s cubic-bezier(.4, .01, .165, .99), -webkit-transform .2s cubic-bezier(.4, .01, .165, .99) } #sidebar.behavior_1 { -webkit-transform: translate3D(-250px, 0, 0); -ms-transform: translate3D(-250px, 0, 0); transform: translate3D(-250px, 0, 0) } #sidebar.behavior_2 { -webkit-transform: translate3D(0, 0, 0); -ms-transform: translate3D(0, 0, 0); transform: translate3D(0, 0, 0) } #sidebar .profile { padding-top: 20px; padding-bottom: 20px } #sidebar .profile a, #sidebar .profile img { border-radius: 100px; height: 100px; width: 100px } #sidebar .profile span { display: none } } @media screen and (min-width:769px) and (max-width:1024px) { #sidebar { width: 75px } #sidebar .profile { padding-top: 20px } #sidebar .profile a, #sidebar .profile img { border-radius: 40px; height: 40px; width: 40px } #sidebar .profile span { display: none } #sidebar .buttons li a { padding: 0 } #sidebar .buttons li a i { display: block; font-size: 18px; margin: 0 auto } #sidebar .buttons li a span { display: none } #sidebar .buttons li a.inline { width: 100% } } @media screen and (min-width:768px) and (max-width:1024px) { #main { padding-left: 115px } } @media screen and (max-width:769px) { #main { min-height: 100%; padding-top: 50px; padding-left: 10px; padding-right: 10px; width: 100% } } @media screen and (max-width:769px) { article { background-color: #fff; padding: 10px } article h1 { font-size: 22px; margin: 0; padding: 5px 0 10px } article .meta { display: none } article .desc { color: #999; font-size: 14px } article p.more { font-size: 14px; margin: 5px 0 } } ================================================ FILE: front/src/assets/css/sidebar.css ================================================ #sidebar { background-color: #202020; height: 100%; left: 0; overflow: auto; -webkit-overflow-scrolling: touch; position: fixed; top: 0; width: 250px; z-index: 1 } #sidebar-mask { background-color: rgba(255, 255, 255, 0); bottom: 0; display: none; left: 0; overflow: hidden; position: absolute; right: 0; top: 0; z-index: 999 } #sidebar li, #sidebar ul { list-style: none; margin: 0; padding: 0 } #sidebar .profile { padding-top: 40px; padding-bottom: 10px } #sidebar .buttons { margin: 0 0 20px } #sidebar .profile a, #sidebar .profile img { border-radius: 70px; height: 140px; overflow: hidden; width: 140px } #sidebar .profile a { display: block; margin: 0 auto } #sidebar .profile span { color: #999; display: block; font-size: 18px; padding: 10px 0; text-align: center } #sidebar .buttons li { display: block; font-size: 16px; height: 45px; line-height: 45px; width: 100% } #sidebar .buttons li a { color: #999; display: block; padding-left: 25px; text-decoration: none; -webkit-transition: color .2s cubic-bezier(.4, .01, .165, .99); transition: color .2s cubic-bezier(.4, .01, .165, .99) } #sidebar .buttons li a i, #sidebar .buttons li a span { display: inline-block; vertical-align: middle } #sidebar .buttons li a i { font-size: 20px; height: 45px; line-height: 45px; margin-right: 20px; text-align: center; width: 25px } #sidebar .buttons li a:hover { color: rgba(153, 153, 153, .8) } #sidebar .buttons li a.inline { display: inline-block; width: 40px } ================================================ FILE: front/src/assets/js/base.js ================================================ /* eslint-disable */ export default function () { if (typeof window !== 'undefined') { (function (win, doc) { var getById = function (el) { return document.getElementById(el); }; //from qwrap var getDocRect = function (doc) { doc = doc || document; var win = doc.defaultView || doc.parentWindow, mode = doc.compatMode, root = doc.documentElement, h = win.innerHeight || 0, w = win.innerWidth || 0, scrollX = win.pageXOffset || 0, scrollY = win.pageYOffset || 0, scrollW = root.scrollWidth, scrollH = root.scrollHeight; if (mode != 'CSS1Compat') { // Quirks root = doc.body; scrollW = root.scrollWidth; scrollH = root.scrollHeight; } if (mode) { // IE, Gecko w = root.clientWidth; h = root.clientHeight; } scrollW = Math.max(scrollW, w); scrollH = Math.max(scrollH, h); scrollX = Math.max(scrollX, doc.documentElement.scrollLeft, doc.body.scrollLeft); scrollY = Math.max(scrollY, doc.documentElement.scrollTop, doc.body.scrollTop); return { width: w, height: h, scrollWidth: scrollW, scrollHeight: scrollH, scrollX: scrollX, scrollY: scrollY }; }; var getXY = function (node) { var doc = node.ownerDocument, docRect = getDocRect(doc), scrollLeft = docRect.scrollX, scrollTop = docRect.scrollY, box = node.getBoundingClientRect(), xy = [box.left, box.top], mode, off1, off2; if (scrollTop || scrollLeft) { xy[0] += scrollLeft; xy[1] += scrollTop; } return xy; }; var getRect = function (el) { var p = getXY(el); var x = p[0]; var y = p[1]; var w = el.offsetWidth; var h = el.offsetHeight; return { 'width': w, 'height': h, 'left': x, 'top': y, 'bottom': y + h, 'right': x + w }; }; var utils = { isMob: (function () { var ua = navigator.userAgent.toLowerCase(); var agents = ["Android", "iPhone", "SymbianOS", "Windows Phone", "iPad", "iPod"]; var result = false; for (var i = 0; i < agents.length; i++) { if (ua.indexOf(agents[i].toLowerCase()) > -1) { result = true; } } return result; })() } if (utils.isMob) { document.documentElement.className += ' mob'; } else { document.documentElement.className += ' pc'; } var Dom = { $sidebar: document.querySelector('#sidebar'), $main: document.querySelector('#main'), $sidebar_mask: document.querySelector('#sidebar-mask'), $body: document.body, $btn_side: document.querySelector('#header .btn-bar'), $article: document.querySelectorAll('.mob #page-index article') }; Dom.bindEvent = function () { var _this = this, body_class_name = 'side', eventFirst = 'click', eventSecond = 'click'; if (utils.isMob) { eventFirst = 'touchstart'; eventSecond = 'touchend'; } try { this.$btn_side.addEventListener(eventSecond, function () { if (_this.$body.className.indexOf(body_class_name) > -1) { _this.$body.className = _this.$body.className.replace(body_class_name, ''); _this.$sidebar_mask.style.display = 'none'; } else { _this.$body.className += (' ' + body_class_name); _this.$sidebar_mask.style.display = 'block'; } }, false); } catch (err) {} try { this.$sidebar_mask.addEventListener(eventFirst, function (e) { _this.$body.className = _this.$body.className.replace(body_class_name, ''); _this.$sidebar_mask.style.display = 'none'; e.preventDefault(); }, false); } catch (err) {} window.addEventListener('resize', function () { try { _this.$body.className = _this.$body.className.replace(body_class_name, ''); _this.$sidebar_mask.style.display = 'none'; } catch (err) {} }, false); } Dom.bindEvent(); })(window, document); } } ================================================ FILE: front/src/client-entry.js ================================================ import Vue from 'vue' import createApp from './main' const { app, appOption, router, store, isProd, preFetchComponent } = createApp() import clientGoogleAnalyse from './utils/clientGoogleAnalyse' import makeResponsive from './assets/js/base' const callback = isProd ? setTimeout : router.onReady.bind(router) if (isProd) { store.state.isLoadingAsyncComponent = true } // setTimeout to make the following chunks loaded to webpack modules, // therefore webpackJsonp won't create script to head to send a request callback(() => { if (isProd) store.state.isLoadingAsyncComponent = false let realApp = isProd ? new Vue(appOption) : app // SSR can not render hash since browsers even don't send it // therefore we must hydrate the hash for the client side vue-router, // which is important for hash anchor jump especially for Table Of Contents(toc) if (window.__INITIAL_STATE__) { makeResponsive() window.__INITIAL_STATE__.route.hash = window.location.hash store.replaceState(window.__INITIAL_STATE__) realApp.$mount('#app') } // service worker if (isProd && 'serviceWorker' in navigator) { navigator.serviceWorker.register('/service-worker.js') } const beforeEachHook = (to, from, next) => { // required by a new hash, just navigate to it if (to.path === from.path && to.hash !== from.hash) { return next() } let loadingPromise = store.dispatch('START_LOADING') let endLoadingCallback = (path) => { return loadingPromise.then(interval => { clearInterval(interval) store.dispatch('SET_PROGRESS', 100) next(path) }) } // there must be a matched component according // to routes definition let component = router.getMatchedComponents(to.fullPath)[0] // if it's an async component, resolve it and check the preFetch // which can avoid clock when routes change if (typeof component === 'function' && !component.options) { return new Promise((resolve, reject) => { const _resolve = realComponent => { resolve(realComponent) } // for general component let res = component(_resolve) // for factory component if (res && res.then) { res.then(_resolve) } }).then(component => letsGo(component, store, to, endLoadingCallback)) } // component is there, check the preFetch letsGo(component, store, to, endLoadingCallback) } router.beforeEach(beforeEachHook) function letsGo(component, store, to, endLoadingCallback) { if (component && component.preFetch) { // component need fetching some data before navigating to it return component.preFetch(store, to, endLoadingCallback).catch(err => { console.error(new Date().toLocaleString(), err) endLoadingCallback(false) }) } else { // component's a static page and just navigate to it endLoadingCallback() } } if (typeof window.__INITIAL_STATE__ === 'undefined') { realApp.$mount('#app') beforeEachHook(router.currentRoute, {}, () => {}) Promise.all( preFetchComponent.map(component => component.preFetch(store, store.state.route)) ).then(() => makeResponsive()) } // send user info if google analytics code is provided. if (window.__INITIAL_STATE__ && window.__INITIAL_STATE__.siteInfo) { let analyzeCode = window.__INITIAL_STATE__.siteInfo.analyzeCode if (analyzeCode && analyzeCode.value !== '') { router.afterEach((to, from) => { // should delay it to get the correct title generated by vue-meta from.name && to.path !== from.path && setTimeout(() => { clientGoogleAnalyse(to.path || '/') }) }) } } }) ================================================ FILE: front/src/components/App.vue ================================================ ================================================ FILE: front/src/components/Archive.vue ================================================ ================================================ FILE: front/src/components/BlogPager.vue ================================================ ================================================ FILE: front/src/components/BlogSummary.vue ================================================ ================================================ FILE: front/src/components/Disqus.vue ================================================ ================================================ FILE: front/src/components/Footer.vue ================================================ ================================================ FILE: front/src/components/Header.vue ================================================ ================================================ FILE: front/src/components/Loading.vue ================================================ ================================================ FILE: front/src/components/Pagination.vue ================================================ ================================================ FILE: front/src/components/Post.vue ================================================ ================================================ FILE: front/src/components/Sidebar.vue ================================================ ================================================ FILE: front/src/components/Tag.vue ================================================ ================================================ FILE: front/src/components/TagPager.vue ================================================ ================================================ FILE: front/src/index.template.html ================================================
================================================ FILE: front/src/main.js ================================================ import Vue from 'vue' import createRouter from './route' import createStore from './store/index' import { sync } from 'vuex-router-sync' import App from './components/App' import Sidebar from './components/Sidebar' const isProd = process.env.NODE_ENV === 'production' export default function createApp(context) { const router = createRouter() const store = createStore() sync(store, router) const appOption = { router, store, ...App } let app if (isProd === false) { app = new Vue(appOption) } let preFetchComponent = [ Sidebar ] return { app, appOption, router, store, preFetchComponent, isProd } } ================================================ FILE: front/src/mixin/disqus.js ================================================ const TYPES = ['post', 'page'] export default { watch: { '$route': 'resetDisqus' }, methods: { reset(dsq) { const self = this dsq.reset({ reload: true, config: function() { this.page.identifier = (self.$route.path || window.location.pathname) this.page.url = window.location.href } }) }, resetDisqus(val, oldVal) { if (TYPES.indexOf(val.name) === -1) return if (val.path === oldVal.path) return if (window.DISQUS) { this.reset(window.DISQUS) } } } } ================================================ FILE: front/src/mixin/image.js ================================================ import { mapGetters } from '../store/vuex' export default { computed: { ...mapGetters([ 'option', 'siteInfo', 'supportWebp' ]), logoUrl() { return this.getValidImageUrl(this.option ? this.option.logoUrl || '' : '') }, sidebarUrl() { return this.getValidImageUrl(this.option ? this.option.sidebarImageUrl || '' : '') } }, methods: { getValidImageUrl(url) { if (!this.supportWebp) return url.replace(/.webp$/, '.png').replace('/webp', '') return url } } } ================================================ FILE: front/src/route/create-route-client.js ================================================ const CreatePostView = type => resolve => { return import(/* webpackChunkName: "CreatePostView" */ '../views/CreatePostView').then(factory => { const component = factory(type) return resolve(component) }) } export const Post = CreatePostView('post') export const Page = CreatePostView('page') export const TagPager = () => import(/* webpackChunkName: "TagPager" */ '../components/TagPager') export const Tag = () => import(/* webpackChunkName: "Tag" */ '../components/Tag') export const BlogPager = () => import(/* webpackChunkName: "BlogPager" */ '../components/BlogPager') export const Archive = () => import(/* webpackChunkName: "Archive" */ '../components/Archive') ================================================ FILE: front/src/route/create-route-server.js ================================================ export const CreatePostView = require('../views/CreatePostView') export const TagPager = require('../components/TagPager') export const Tag = require('../components/Tag') export const BlogPager = require('../components/BlogPager') export const Archive = require('../components/Archive') export const Post = CreatePostView('post') export const Page = CreatePostView('page') Post.chunkName = Page.chunkName = 'CreatePostView' TagPager.chunkName = 'TagPager' Tag.chunkName = 'Tag' BlogPager.chunkName = 'BlogPager' Archive.chunkName = 'Archive' ================================================ FILE: front/src/route/index.js ================================================ import Vue from 'vue' import VueRouter from 'vue-router' import VueMeta from 'vue-meta' Vue.use(VueRouter) Vue.use(VueMeta) import Footer from '../components/Footer' import Pagination from '../components/Pagination' Vue.component('my-footer', Footer) Vue.component('pagination', Pagination) import { Post, Page, TagPager, Tag, BlogPager, Archive } from 'create-route' export default function createRouter() { return new VueRouter({ mode: 'history', scrollBehavior: function(to, from, savedPosition) { if (savedPosition) { return savedPosition } else { let position = {x: 0, y: 0} if (to.hash) { position = { selector: to.hash } } return position } }, routes: [ { path: '/', name: 'main', component: BlogPager }, { path: '/archive', name: 'archive', component: Archive }, { path: '/tag', name: 'tag', component: Tag }, { path: '/post/:pathName', name: 'post', component: Post }, { path: '/tag/:tagName', name: 'tagPager', component: TagPager }, { path: '/:page*', name: 'page', component: Page } ] }) } ================================================ FILE: front/src/server-entry.js ================================================ import Vue from 'vue' import createApp from './main' export default context => { const { app, appOption, router, store, isProd, preFetchComponent } = createApp() const realApp = isProd ? new Vue(appOption) : app router.push(context.url) let current = router.currentRoute context.path = current.path context.query = current.query context.params = current.params context.url = current.fullPath context.meta = realApp.$meta() store.state.supportWebp = context.supportWebp const s = !isProd && Date.now() return Promise.all(preFetchComponent.concat(router.getMatchedComponents()).map((component, index) => { const chunkName = component.chunkName if (typeof chunkName === 'string') { context.chunkName = chunkName } if (component.preFetch) { return component.preFetch(store, context).catch(() => {}) } })).then((arr) => { !isProd && console.log(`data pre-fetch: ${Date.now() - s}ms`) context.initialState = store.state return realApp }) } ================================================ FILE: front/src/store/api.js ================================================ import api from 'create-api' const prefix = `${api.host}/api` const store = {} export default store store.fetch = (model, query) => { const target = `${prefix}/${model}` return api.axios.get(target, { params: query }).then((response) => { const result = response.data return result }) } ================================================ FILE: front/src/store/client-axios.js ================================================ function get(url, cb) { let xhr try { xhr = new window.XMLHttpRequest() } catch (e) { try { xhr = new window.ActiveXObject('Msxml2.XMLHTTP') } catch (e) { return cb(new Error('XHR: not supported')) } } xhr.onreadystatechange = function() { if (xhr.readyState !== 4) return cb(xhr.status !== 200 ? new Error('XHR: server response status is ' + xhr.status) : false, xhr.responseText) } xhr.open('GET', url, true) xhr.setRequestHeader('Content-type', 'application/json') xhr.send() } export default { get: (target, { params: query }) => { const suffix = Object.keys(query).map(name => { return `${name}=${JSON.stringify(query[name])}` }).join('&') const url = `${target}?${suffix}` return new Promise((resolve, reject) => { get(url, (err, data) => { if (err) reject(err) try { const json = JSON.parse(data) resolve({ data: json }) } catch (err) { reject(err) } }) }) } } ================================================ FILE: front/src/store/create-api-client.js ================================================ import axios from './client-axios' export default { host: '/proxyPrefix', axios: axios } ================================================ FILE: front/src/store/create-api-server.js ================================================ const isProd = process.env.NODE_ENV === 'production' export default { host: isProd ? 'http://localhost:3000' : 'http://localhost:8080/proxyPrefix', axios: process.__API__ } ================================================ FILE: front/src/store/index.js ================================================ import Vue from 'vue' import Vuex from './vuex' import api from './api' Vue.use(Vuex) export default function createStore() { return new Vuex.Store({ state: { supportWebp: false, isLoadingAsyncComponent: false, itemsPerPage: 10, totalPage: -1, items: [], achieves: {}, blog: {}, prev: {}, next: {}, page: {}, tagPager: [], tags: [], theme: {}, progress: 0, siteInfo: { githubUrl: { value: '' }, title: { value: '' }, logoUrl: { value: '' }, description: { value: '' }, keywords: { value: '' }, faviconUrl: { value: '' }, miitbeian: { value: '' } } }, actions: { SET_PROGRESS: ({ commit, state }, progress) => { commit('SET_PROGRESS_VALUE', progress) }, START_LOADING: ({ commit, state, dispatch }) => { dispatch('SET_PROGRESS', 30) let interval = setInterval(() => { let progress = state.progress if (progress < 90) { let target = progress + 10 dispatch('SET_PROGRESS', target) } }, 400) return interval }, FETCH_BLOG: ({ commit, state, dispatch }, { model, query, callback }) => { return api.fetch(model, query).then(result => { let blog = result[0] if (!blog) { return Promise.reject('post not exist') } commit('SET_BLOG', { blog }) callback && callback() let first = api.fetch('post', { conditions: { _id: { $lt: blog._id }, type: 'post', isPublic: true }, select: { _id: 0, title: 1, pathName: 1, type: 1 }, sort: { createdAt: -1 }, limit: 1 }) let second = api.fetch('post', { conditions: { _id: { $gt: blog._id }, type: 'post', isPublic: true }, select: { _id: 0, title: 1, pathName: 1, type: 1 }, limit: 1 }) return Promise.all([first, second]).then(result => { let prevPost = result[0][0] if (prevPost && prevPost.type === 'post') { commit('SET_PREV', { post: prevPost }) } else { commit('SET_PREV', { post: {} }) } let nextPost = result[1][0] if (nextPost && nextPost.type === 'post') { commit('SET_NEXT', { post: nextPost }) } else { commit('SET_NEXT', { post: {} }) } }) }) }, FETCH_PAGE: ({ commit, state, dispatch }, { model, query, callback }) => { return api.fetch(model, query).then(result => { let blog = result[0] if (blog) { commit('SET_PAGE', { blog }) } callback && callback() }) }, FETCH_TAGS: ({ commit, state, dispatch }, { model, query, callback }) => { return api.fetch(model, query).then(result => { let tags = result.reduce((prev, curr) => { curr.tags.forEach(tag => { if (typeof prev[tag] === 'undefined') { prev[tag] = 1 } else { prev[tag] = prev[tag] + 1 } }) return prev }, {}) commit('SET_TAGS', { tags }) callback && callback() }) }, FETCH_ITEMS: ({ commit, state, dispatch }, { model, query, callback }) => { return api.fetch(model, query).then(items => { commit('SET_ITEMS', { items }) callback && callback() if (state.totalPage === -1) { return api.fetch(model, { conditions: { type: 'post', isPublic: true }, count: 1 }).then(totalPage => { commit('SET_PAGES', { totalPage: Math.ceil(totalPage / 10) }) }) } return Promise.resolve() }) }, FETCH_TAG_PAGER: ({ commit, state, dispatch }, { model, query, callback }) => { return api.fetch(model, query).then(items => { commit('SET_TAG_PAGER', { items }) callback && callback() }) }, FETCH_ACHIEVE: ({ commit, state, dispatch }, { model, query, callback }) => { return api.fetch(model, query).then(items => { let sortedItem = items.reduce((prev, curr) => { let time = curr.createdAt.slice(0, 7).replace('-', '年') + '月' if (typeof prev[time] === 'undefined') { prev[time] = [curr] } else { prev[time].push(curr) } return prev }, {}) commit('SET_ACHIEVE', { sortedItem }) callback && callback() }) }, FETCH_FIREKYLIN: ({ commit, state }) => { return api.fetch('theme', { conditions: { name: 'firekylin' }, select: { _id: 0 } }).then(obj => { if (obj[0]) { commit('SET_FIREKYLIN', { obj: obj[0] }) } }) }, FETCH_OPTIONS: ({ commit, state }) => { return api.fetch('option', { select: { _id: 0, key: 1, value: 1 } }).then(optionArr => { let obj = optionArr.reduce((prev, curr) => { prev[curr.key] = curr return prev }, {}) commit('SET_OPTIONS', { obj }) }) } }, mutations: { SET_BLOG: (state, { blog }) => { Vue.set(state, 'blog', blog) }, SET_PREV: (state, { post }) => { Vue.set(state, 'prev', post) }, SET_NEXT: (state, { post }) => { Vue.set(state, 'next', post) }, SET_PROGRESS_VALUE: (state, progress) => { Vue.set(state, 'progress', progress) }, SET_TAGS: (state, { tags }) => { Vue.set(state, 'tags', tags) }, SET_TAG_PAGER: (state, { items }) => { Vue.set(state, 'tagPager', items) }, SET_ITEMS: (state, { items }) => { Vue.set(state, 'items', items) }, SET_PAGES: (state, { totalPage }) => { Vue.set(state, 'totalPage', totalPage) }, SET_PAGE: (state, { blog }) => { Vue.set(state, 'page', blog) }, SET_ACHIEVE: (state, { sortedItem }) => { Vue.set(state, 'achieves', sortedItem) }, SET_FIREKYLIN: (state, { obj }) => { Vue.set(state, 'theme', obj) }, SET_OPTIONS: (state, { obj }) => { Vue.set(state, 'siteInfo', obj) } }, getters: { items(state) { return state.items }, siteInfo(state) { return state.siteInfo }, achieves(state) { return state.achieves }, menu(state) { return state.menu }, page(state) { return state.route.query.page || 1 }, totalPage(state) { return state.totalPage }, progress(state) { return state.progress }, option(state) { return state.theme.option }, prev(state) { return state.prev }, next(state) { return state.next }, tags(state) { return state.tags }, tagPager(state) { return state.tagPager }, isLoadingAsyncComponent(state) { return state.isLoadingAsyncComponent }, supportWebp(state) { return state.supportWebp } } }) } ================================================ FILE: front/src/store/vuex.js ================================================ const global = typeof window !== 'undefined' ? window : process class Store { constructor({ state, actions, mutations, getters }) { this.actions = actions this.mutations = mutations this.getters = {} this._watcherVM = new global.Vue() const store = this const computed = {} forEachValue(getters, (fn, key) => { computed[key] = () => { const state = store.state return fn(state) } Object.defineProperty(store.getters, key, { get: () => { return store._vm[key] }, enumerable: true }) }) store._vm = new global.Vue({ data: { $$state: state }, computed }) } get state() { return this._vm._data.$$state } set state(state) { this._vm._data.$$state = state } commit(mutation, options) { if (mutation === 'router/ROUTE_CHANGED') { return global.Vue.set(this.state, 'route', options) } return this.mutations[mutation].call(null, this.state, options) } dispatch(action, options) { const result = this.actions[action].call(null, { commit: this.commit.bind(this), state: this.state, dispatch: this.dispatch.bind(this) }, options) if (result && typeof result.then === 'function') { return result } else { return Promise.resolve(result) } } watch(getter, cb, options) { return this._watcherVM.$watch(() => getter(this.state, this.getters), cb, options) } replaceState(state) { this._vm._data.$$state = state } registerModule(path, rawModule) { this.state.route = rawModule } } function install(_Vue) { if (global.Vue || _Vue.isInstalled === true) return _Vue.isInstalled = true global.Vue = _Vue global.Vue.mixin({ beforeCreate: vuexInit }) } const mapGetters = (getters) => { const res = {} getters.forEach(item => { res[item] = function() { const result = this.$store.getters[item] return result } }) return res } function vuexInit() { const options = this.$options // store injection if (options.store) { this.$store = options.store } else if (options.parent && options.parent.$store) { this.$store = options.parent.$store } } export default { install, Store } export { mapGetters } function forEachValue(obj, fn) { Object.keys(obj).forEach(key => fn(obj[key], key)) } ================================================ FILE: front/src/utils/404.js ================================================ module.exports = { pathName: 404, createdAt: '2017-01-01 00:00:00', updatedAt: '2017-01-01 00:00:00', title: '404 Not Found', toc: '', content: '

你要找的页面不存在。

请检查URL是否有误,或者查看本博客所有文章

' } ================================================ FILE: front/src/utils/clientGoogleAnalyse.js ================================================ export default function(fullPath) { let screen = window.screen let params = { dt: document.title, dr: fullPath, ul: navigator.language || navigator.browserLanguage || '', sd: screen.colorDepth + '-bit', sr: screen.width + 'x' + screen.height, dpr: window.devicePixelRatio || window.webkitDevicePixelRatio || window.mozDevicePixelRatio || 1, dp: fullPath, z: +new Date() } let queryArr = [] for (let i in params) { queryArr.push(i + '=' + encodeURIComponent(params[i])) } let queryString = '?' + queryArr.join('&') window.ga_image = new window.Image() window.ga_image.src = '/_.gif' + queryString } ================================================ FILE: front/src/views/CreatePostView.js ================================================ const vuex = require('../store/vuex') const { mapGetters } = vuex const Post = require('../components/Post') const mock404 = require('../utils/404') module.exports = function(type) { const isPost = type === 'post' const action = isPost ? 'FETCH_BLOG' : 'FETCH_PAGE' const regExp = isPost ? /^\/post\//g : /^\//g const select = isPost ? { tags: 1, category: 1 } : {} const preFetch = function(store, { path: pathName, params, query }, callback) { pathName = decodeURIComponent(pathName.replace(regExp, '')) return store.dispatch(action, { model: 'post', query: { conditions: { pathName, isPublic: true, type }, select: Object.assign({ title: 1, createdAt: 1, content: 1, toc: 1, updatedAt: 1, pathName: 1, allowComment: 1 }, select) }, callback }) } return { metaInfo() { return { title: this.post.title } }, name: `${type}-view`, computed: { ...mapGetters([ 'prev', 'next', 'siteInfo', 'isLoadingAsyncComponent', 'supportWebp' ]), post() { const target = isPost ? this.$store.state.blog : this.$store.state.page return target.pathName ? target : mock404 } }, preFetch, beforeMount() { this.isLoadingAsyncComponent && this.$root._isMounted && preFetch(this.$store, this.$route) }, render(h) { return h(Post, { props: { type, post: this.post, prev: this.prev, next: this.next, siteInfo: this.siteInfo, supportWebp: this.supportWebp } }) } } } ================================================ FILE: front/test/e2e/custom-assertions/elementCount.js ================================================ // A custom Nightwatch assertion. // the name of the method is the filename. // can be used in tests like this: // // browser.assert.elementCount(selector, count) // // for how to write custom assertions see // http://nightwatchjs.org/guide#writing-custom-assertions exports.assertion = function (selector, count) { this.message = 'Testing if element <' + selector + '> has count: ' + count this.expected = count this.pass = function (val) { return val === this.expected } this.value = function (res) { return res.value } this.command = function (cb) { var self = this return this.api.execute(function (selector) { return document.querySelectorAll(selector).length }, [selector], function (res) { cb.call(self, res) }) } } ================================================ FILE: front/test/e2e/nightwatch.conf.js ================================================ // http://nightwatchjs.org/guide#settings-file module.exports = { "src_folders": ["test/e2e/specs"], "output_folder": "test/e2e/reports", "custom_assertions_path": ["test/e2e/custom-assertions"], "selenium": { "start_process": true, "server_path": "node_modules/selenium-server/lib/runner/selenium-server-standalone-2.53.0.jar", "host": "127.0.0.1", "port": 4444, "cli_args": { "webdriver.chrome.driver": require('chromedriver').path } }, "test_settings": { "default": { "selenium_port": 4444, "selenium_host": "localhost", "silent": true }, "chrome": { "desiredCapabilities": { "browserName": "chrome", "javascriptEnabled": true, "acceptSslCerts": true } }, "firefox": { "desiredCapabilities": { "browserName": "firefox", "javascriptEnabled": true, "acceptSslCerts": true } } } } ================================================ FILE: front/test/e2e/runner.js ================================================ // 1. start the dev server using production config process.env.NODE_ENV = 'testing' var server = require('../../build/dev-server.js') // 2. run the nightwatch test suite against it // to run in additional browsers: // 1. add an entry in test/e2e/nightwatch.conf.json under "test_settings" // 2. add it to the --env flag below // or override the environment flag, for example: `npm run e2e -- --env chrome,firefox` // For more information on Nightwatch's config file, see // http://nightwatchjs.org/guide#settings-file var opts = process.argv.slice(2) if (opts.indexOf('--config') === -1) { opts = opts.concat(['--config', 'test/e2e/nightwatch.conf.js']) } if (opts.indexOf('--env') === -1) { opts = opts.concat(['--env', 'chrome']) } var spawn = require('cross-spawn') var runner = spawn('./node_modules/.bin/nightwatch', opts, { stdio: 'inherit' }) runner.on('exit', function (code) { server.close() process.exit(code) }) runner.on('error', function (err) { server.close() throw err }) ================================================ FILE: front/test/e2e/specs/test.js ================================================ // For authoring Nightwatch tests, see // http://nightwatchjs.org/guide#usage module.exports = { 'default e2e tests': function(browser) { browser .url('http://localhost:8080') .waitForElementVisible('#app', 5000) .assert.elementPresent('.logo') .assert.containsText('h1', 'Hello World!') .assert.elementCount('p', 3) .end() } } ================================================ FILE: front/test/unit/.eslintrc ================================================ { "env": { "mocha": true }, "globals": { "expect": true, "sinon": true } } ================================================ FILE: front/test/unit/index.js ================================================ // Polyfill fn.bind() for PhantomJS /* eslint-disable no-extend-native */ Function.prototype.bind = require('function-bind') // require all test files (files that ends with .spec.js) var testsContext = require.context('./specs', true, /\.spec$/) testsContext.keys().forEach(testsContext) // require all src files except main.js for coverage. // you can also change this to match only the subset of files that // you want coverage for. var srcContext = require.context('../../src', true, /^\.\/(?!main(\.js)?$)/) srcContext.keys().forEach(srcContext) ================================================ FILE: front/test/unit/karma.conf.js ================================================ // This is a karma config file. For more details see // http://karma-runner.github.io/0.13/config/configuration-file.html // we are also using it with karma-webpack // https://github.com/webpack/karma-webpack var path = require('path') var merge = require('webpack-merge') var baseConfig = require('../../build/webpack.base.conf') var utils = require('../../build/utils') var webpack = require('webpack') var projectRoot = path.resolve(__dirname, '../../') var webpackConfig = merge(baseConfig, { // use inline sourcemap for karma-sourcemap-loader module: { loaders: utils.styleLoaders() }, devtool: '#inline-source-map', vue: { loaders: { js: 'isparta' } }, plugins: [ new webpack.DefinePlugin({ 'process.env': require('../../config/test.env') }) ] }) // no need for app entry during tests delete webpackConfig.entry // make sure isparta loader is applied before eslint webpackConfig.module.preLoaders = webpackConfig.module.preLoaders || [] webpackConfig.module.preLoaders.unshift({ test: /\.js$/, loader: 'isparta', include: path.resolve(projectRoot, 'src') }) // only apply babel for test files when using isparta webpackConfig.module.loaders.some(function (loader, i) { if (loader.loader === 'babel') { loader.include = path.resolve(projectRoot, 'test/unit') return true } }) module.exports = function (config) { config.set({ // to run in additional browsers: // 1. install corresponding karma launcher // http://karma-runner.github.io/0.13/config/browsers.html // 2. add it to the `browsers` array below. browsers: ['PhantomJS'], frameworks: ['mocha', 'sinon-chai'], reporters: ['spec', 'coverage'], files: ['./index.js'], preprocessors: { './index.js': ['webpack', 'sourcemap'] }, webpack: webpackConfig, webpackMiddleware: { noInfo: true }, coverageReporter: { dir: './coverage', reporters: [ { type: 'lcov', subdir: '.' }, { type: 'text-summary' } ] } }) } ================================================ FILE: front/test/unit/specs/Hello.spec.js ================================================ import Vue from 'vue' import Hello from 'src/components/Hello' describe('Hello.vue', () => { it('should render correct contents', () => { const vm = new Vue({ template: '
', components: { Hello } }).$mount() expect(vm.$el.querySelector('.hello h1').textContent).to.contain('Hello World!') }) }) ================================================ FILE: pm2.json ================================================ { "apps": [{ "name": "backend", "script": "server/entry.js", "cwd": "", "exec_mode": "cluster", "instances": 0, "max_memory_restart": "256M", "autorestart": true, "node_args": [], "args": [], "env": { } }, { "name": "frontend", "script": "front/production.js", "cwd": "", "exec_mode": "cluster", "instances": 0, "max_memory_restart": "256M", "autorestart": true, "node_args": [], "args": [], "env": { } }] } ================================================ FILE: server/.eslintignore ================================================ config/*.js ================================================ FILE: server/.eslintrc.js ================================================ module.exports = { root: true, parser: 'babel-eslint', parserOptions: { sourceType: 'module' }, // https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style extends: 'standard', // required to lint *.vue files plugins: [ 'html' ], // add your custom rules here 'rules': { // allow paren-less arrow functions 'arrow-parens': 0, // allow async-await 'generator-star-spacing': 0, 'space-before-function-paren': ['error', 'never'], 'no-return-assign': 0, 'no-useless-constructor': 0, // allow debugger during development 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 } } ================================================ FILE: server/.gitignore ================================================ node_modules/* play.bat npm-debug.log # OSX # .DS_Store conf/config.js .editorconfig ================================================ FILE: server/README.md ================================================ # server > 博客的提供RESTful API的后端 ## 设置配置文件 复制conf文件夹中的默认配置`config.tpl`, 并命名为`config.js` 有如下属性可以自行配置: - `tokenSecret` - 改为任意字符串 - `defaultAdminPassword` - 默认密码, 必须修改, 否则服务器将拒绝启动 如果mongoDB或redis不在本机对应端口,可以修改对应的属性 - `mongoHost` - `mongoDatabase` - `mongoPort` - `redisHost` - `redisPort` 如果需要启用后台管理单页的七牛图片上传功能,请再修改如下属性: - `qiniuAccessKey` - 七牛账号的公钥 - `qiniuSecretKey` - 七牛账号的私钥 - `qiniuBucketHost` - 七牛Bucket对应的外链域名 - `qiniuBucketName` - 七牛Bucket的名称 - `qiniuPipeline` - 七牛多媒体处理队列的名称 ## 运行 ```bash npm install npm start ``` RESTful 服务器在本机3000端口开启 ================================================ FILE: server/app.js ================================================ global.Promise = require('bluebird') const log = require('./utils/log') const Koa = require('koa') const koaRouter = require('koa-router') const mongoRest = require('./mongoRest') const models = require('./model/mongo') const redis = require('./model/redis') const config = require('./conf/config') const configName = process.env.NODE_ENV === '"development"' ? 'dev' : 'prod' const blogpackConfig = require(`./build/blogpack.${configName}.config`) blogpackConfig.models = models blogpackConfig.redis = redis const Blogpack = require('./blogpack') const lifecycle = global.lifecycle = new Blogpack(blogpackConfig) const app = new Koa() const router = koaRouter() module.exports = (async () => { try { await lifecycle.beforeUseRoutes({ config: lifecycle.config, app, router, models, redis }) const beforeRestfulRoutes = lifecycle.getBeforeRestfulRoutes() const afterRestfulRoutes = lifecycle.getAfterRestfulRoutes() const middlewareRoutes = await lifecycle.getMiddlewareRoutes() for (const item of middlewareRoutes) { const middlewares = [...item.middleware] item.needBeforeRoutes && middlewares.unshift(...beforeRestfulRoutes) item.needAfterRoutes && middlewares.push(...afterRestfulRoutes) router[item.method](item.path, ...middlewares) } Object.keys(models).map(name => models[name]).forEach(model => { mongoRest(router, model, '/api', { beforeRestfulRoutes, afterRestfulRoutes }) }) app.use(router.routes()) const beforeServerStartArr = lifecycle.getBeforeServerStartFuncs() for (const middleware of beforeServerStartArr) { await middleware() } app.listen(config.serverPort, () => { log.info(`Koa2 is running at ${config.serverPort}`) }) } catch (err) { log.error(err) } })() ================================================ FILE: server/blogpack.js ================================================ class blogpack { constructor(options) { this.config = options.config || {} this.plugins = options.plugins || [] this.models = options.models this.redis = options.redis } async beforeUseRoutes(...args) { for (const plugin of this.plugins) { plugin.beforeUseRoutes && await plugin.beforeUseRoutes(...args) } } async getMiddlewareRoutes(...args) { const plugins = this.plugins.filter(plugin => plugin['mountingRoute']) const result = [] for (const plugin of plugins) { const routeObj = await plugin.mountingRoute() result.push(Object.assign({}, routeObj, { needBeforeRoutes: routeObj.needBeforeRoutes || false, needAfterRoutes: routeObj.needAfterRoutes || false })) } return result } getBeforeRestfulRoutes() { return this.plugins .filter(plugin => plugin['beforeRestful']) .map(plugin => plugin['beforeRestful']) } getAfterRestfulRoutes() { return this.plugins .filter(plugin => plugin['afterRestful']) .map(plugin => plugin['afterRestful']) } getBeforeServerStartFuncs() { return this.plugins .filter(plugin => plugin['beforeServerStart']) .map(plugin => plugin['beforeServerStart']) } } module.exports = blogpack ================================================ FILE: server/build/blogpack.base.config.js ================================================ var config = require('../conf/config') module.exports = { config, plugins: [] } ================================================ FILE: server/build/blogpack.dev.config.js ================================================ const base = require('./blogpack.base.config') const useRoutesPrefix = '../plugins/beforeUseRoutes' const serverStartPrefix = '../plugins/beforeServerStart' const env = process.env const config = Object.assign({}, base) const BodyParserPlugin = require(`${useRoutesPrefix}/bodyParser`) const LogTimePlugin = require(`${useRoutesPrefix}/logTime`) const RestcPlugin = require(`${useRoutesPrefix}/restc`) const InitOptionPlugin = require(`${serverStartPrefix}/initOption`) const InstallThemePlugin = require(`${serverStartPrefix}/installTheme`) const InitUserPlugin = require(`${serverStartPrefix}/initUser`) const CheckAuthPlugin = require('../plugins/beforeRestful/checkAuth') const QiniuUploadPlugin = require('../plugins/mountingRoute/qiniu') const LoginPlugin = require('../plugins/mountingRoute/login') const LogoutPlugin = require('../plugins/mountingRoute/logout') config.plugins.push( // beforeUseRoutes new BodyParserPlugin(), new LogTimePlugin(), new RestcPlugin(), // beforeRestful new CheckAuthPlugin(), // moutingRoute new QiniuUploadPlugin({ qiniuAccessKey: env.qiniuAccessKey || '', qiniuSecretKey: env.qiniuSecretKey || '', qiniuBucketHost: env.qiniuBucketHost || '', qiniuBucketName: env.qiniuBucketName || '', qiniuPipeline: env.qiniuPipeline || '' }), new LoginPlugin(), new LogoutPlugin(), // beforeServerStart new InitUserPlugin(), new InstallThemePlugin(), new InitOptionPlugin() ) module.exports = config ================================================ FILE: server/build/blogpack.prod.config.js ================================================ const devConfig = require('./blogpack.dev.config') const useRoutesPrefix = '../plugins/beforeUseRoutes' const isTest = process.env.NODE_ENV === 'TEST' const config = Object.assign({}, devConfig) const RatelimitPlugin = require(`${useRoutesPrefix}/ratelimit`) !isTest && config.plugins.unshift( // beforeUseRoutes new RatelimitPlugin({ duration: 1000, errorMessage: 'Slow Down Your Request.', id: ctx => ctx.ip, max: 10 }) ) module.exports = config ================================================ FILE: server/conf/config.tpl ================================================ const env = process.env module.exports = { serverPort: env.serverPort || 3000, mongoHost: env.mongoHost || '127.0.0.1', mongoDatabase: env.mongoDatabase || 'blog', mongoPort: env.mongoPort || 27017, redisHost: env.redisHost || '127.0.0.1', redisPort: env.redisPort || 6379, redisPassword: env.redisPassword || '', tokenSecret: env.tokenSecret || 'test', tokenExpiresIn: env.tokenExpiresIn || 3600, defaultAdminName: env.defaultAdminName || 'admin', defaultAdminPassword: env.defaultAdminPassword || 'admin' } ================================================ FILE: server/conf/option.js ================================================ module.exports = [ { 'key': 'analyzeCode', 'value': '' }, { 'key': 'commentType', 'value': 'disqus' }, { 'key': 'commentName', 'value': '' }, { 'key': 'description', 'value': '' }, { 'key': 'faviconUrl', 'value': '/static/favicon.ico' }, { 'key': 'logoUrl', 'value': '/static/logo.png' }, { 'key': 'githubUrl', 'value': '' }, { 'key': 'keywords', 'value': '', 'desc': '网站关键字' }, { 'key': 'miitbeian', 'value': '' }, { 'key': 'numPerPage', 'value': '' }, { 'key': 'siteUrl', 'value': '' }, { 'key': 'title', 'value': '' }, { 'key': 'weiboUrl', 'value': '' }, { 'key': 'twoFactorAuth', 'value': '' } ] ================================================ FILE: server/entry.js ================================================ require('babel-register')({ plugins: ['transform-async-to-generator'], ignore: function(filename) { if (filename.includes('koa-ratelimit')) return false if (filename.includes('node_modules')) return true return false } }) require('./app.js') ================================================ FILE: server/model/mongo.js ================================================ let config = require('../conf/config') let mongoose = require('mongoose') let log = require('../utils/log') mongoose.Promise = require('bluebird') let mongoUrl = `${config.mongoHost}:${config.mongoPort}/${config.mongoDatabase}` mongoose.connect(mongoUrl) let db = mongoose.connection db.on('error', (err) => { log.error('connect error:', err) }) db.once('open', () => { log.info('MongoDB is ready') }) const Schema = mongoose.Schema let post = new Schema({ type: { type: String, default: '' }, status: { type: Number, default: 0 }, title: String, pathName: { type: String, default: '' }, summary: { type: String }, markdownContent: { type: String }, content: { type: String }, markdownToc: { type: String, default: '' }, toc: { type: String, default: '' }, allowComment: { type: Boolean, default: true }, createdAt: { type: String, default: '' }, updatedAt: { type: String, default: '' }, isPublic: { type: Boolean, default: true }, commentNum: Number, options: Object, category: String, tags: Array }) let category = new Schema({ name: String, pathName: String }) let tag = new Schema({ name: String, pathName: String }) let option = new Schema({ key: String, value: Schema.Types.Mixed, desc: String }) let theme = new Schema({ name: String, author: String, option: Schema.Types.Mixed }) let user = new Schema({ name: String, displayName: String, password: String, email: String }) post = mongoose.model('post', post) category = mongoose.model('category', category) option = mongoose.model('option', option) theme = mongoose.model('theme', theme) tag = mongoose.model('tag', tag) user = mongoose.model('user', user) module.exports = { post, category, option, tag, user, theme } ================================================ FILE: server/model/redis.js ================================================ const config = require('../conf/config') const redis = require('redis') const bluebird = require('bluebird') const log = require('../utils/log') bluebird.promisifyAll(redis.RedisClient.prototype) bluebird.promisifyAll(redis.Multi.prototype) const auth = config.redisPassword ? { password: config.redisPassword } : {} let client = redis.createClient(Object.assign({}, auth, { host: config.redisHost, port: config.redisPort })) client.on('error', function(err) { log.error('Redis Error ' + err) }) client.on('connect', function() { log.info('Redis is ready') }) module.exports = client ================================================ FILE: server/mongoRest/actions.js ================================================ module.exports = function generateActions(model) { return { findAll: async function(ctx, next) { try { let conditions = {} let select = {} let query = ctx.request.query if (query.conditions) { conditions = JSON.parse(query.conditions) } let builder = model.find(conditions) if (query.select) { select = JSON.parse(query.select) builder = builder.select(select) } ['limit', 'skip', 'sort', 'count'].forEach(key => { if (query[key]) { let arg = query[key] if (key === 'limit' || key === 'skip') { arg = parseInt(arg) } if (key === 'sort' && typeof arg === 'string') { arg = JSON.parse(arg) } if (key !== 'count') builder[key](arg) else builder[key]() } }) const result = await builder.exec() return ctx.body = result } catch (error) { return ctx.body = error } }, findById: async function(ctx, next) { try { let select = {} let query = ctx.request.query let builder = model.findById(ctx.params.id) if (query.select) { select = JSON.parse(query.select) builder = builder.select(select) } const result = await builder.exec() return ctx.body = result } catch (error) { return ctx.body = error } }, deleteById: async function(ctx, next) { try { const result = await model.findByIdAndRemove(ctx.params.id).exec() return ctx.body = result } catch (error) { return ctx.body = error } }, replaceById: async function(ctx, next) { try { await model.findByIdAndRemove(ctx.params.id).exec() const newDocument = ctx.request.body newDocument._id = ctx.params.id const result = await model.create(newDocument) return ctx.body = result } catch (error) { return ctx.body = error } }, updateById: async function(ctx, next) { try { const result = await model.findByIdAndUpdate( ctx.params.id, ctx.request.body, { new: true } ).exec() return ctx.body = result } catch (error) { return ctx.body = error } }, create: async function(ctx, next) { try { const result = await model.create(ctx.request.body) ctx.status = 201 return ctx.body = result } catch (error) { return ctx.body = error } } } } ================================================ FILE: server/mongoRest/index.js ================================================ const generateRoutes = require('./routes') const generateActions = require('./actions') module.exports = (router, model, prefix, middlewares) => { const actions = generateActions(model) generateRoutes( router, model.modelName, actions, prefix, middlewares ) } ================================================ FILE: server/mongoRest/routes.js ================================================ module.exports = (router, modelName, actions, prefix, { beforeRestfulRoutes, afterRestfulRoutes }) => { const modelUrl = `${prefix}/${modelName}` const itemUrl = `${prefix}/${modelName}/:id` router.get(modelUrl, ...beforeRestfulRoutes, actions.findAll, ...afterRestfulRoutes) router.get(itemUrl, ...beforeRestfulRoutes, actions.findById, ...afterRestfulRoutes) router.post(modelUrl, ...beforeRestfulRoutes, actions.create, ...afterRestfulRoutes) router.post(itemUrl, ...beforeRestfulRoutes, actions.updateById, ...afterRestfulRoutes) router.del(itemUrl, ...beforeRestfulRoutes, actions.deleteById, ...afterRestfulRoutes) router.put(modelUrl, ...beforeRestfulRoutes, actions.create, ...afterRestfulRoutes) router.put(itemUrl, ...beforeRestfulRoutes, actions.replaceById, ...afterRestfulRoutes) router.patch(itemUrl, ...beforeRestfulRoutes, actions.updateById, ...afterRestfulRoutes) } ================================================ FILE: server/package.json ================================================ { "scripts": { "start": "node entry.js", "lint": "eslint --ext .js *.js */*.js", "test": "cross-env NODE_ENV=TEST node ./node_modules/mocha/bin/mocha --compilers js:babel-core/register --recursive" }, "dependencies": { "axios": "^0.16.1", "babel-plugin-transform-async-to-generator": "^6.8.0", "babel-register": "^6.11.6", "bluebird": "^3.4.6", "chai": "^4.0.1", "chai-as-promised": "^6.0.0", "cross-env": "^5.0.0", "istanbul": "^0.4.5", "jsonwebtoken": "^7.1.9", "koa": "^2.0.0-alpha.5", "koa-bodyparser": "^3.2.0", "koa-router": "^7.0.1", "log4js": "^0.6.38", "mocha": "^3.4.2", "mongoose": "^4.5.9", "qiniu": "^6.1.13", "redis": "^2.6.2", "restc": "^0.0.4", "should": "^11.2.1" }, "devDependencies": { "babel-eslint": "^7.1.1", "babel-plugin-transform-runtime": "^6.15.0", "eslint": "^3.17.0", "eslint-config-standard": "^7.0.0", "eslint-friendly-formatter": "^2.0.7", "eslint-loader": "^1.6.3", "eslint-plugin-html": "^2.0.1", "eslint-plugin-promise": "^3.5.0", "eslint-plugin-standard": "^2.1.1", "install": "^0.10.1", "it-each": "^0.3.1", "koa-ratelimit": "^4.0.0", "npm": "^5.0.2" } } ================================================ FILE: server/plugins/beforeRestful/checkAuth.js ================================================ const redis = require('../../model/redis') const tokenService = require('../../service/token') module.exports = class { async beforeRestful(ctx, next) { const isGettingUser = ctx.url.startsWith('/api/user') const isGettingAdmin = ctx.url.startsWith('/admin/') const isNotGet = ctx.url.startsWith('/api/') && ctx.method !== 'GET' if (!isGettingAdmin && !isGettingUser && !isNotGet) { return next() } const headers = ctx.request.headers let token try { token = headers['authorization'] } catch (err) { return ctx.body = { status: 'fail', description: err } } if (!token) { return ctx.body = { status: 'fail', description: 'Token not found' } } const result = tokenService.verifyToken(token) if (result === false) { return ctx.body = { status: 'fail', description: 'Token verify failed' } } let reply = await redis.getAsync('token') if (reply === token) { return next() } else { return ctx.body = { status: 'fail', description: 'Token invalid' } } } } ================================================ FILE: server/plugins/beforeServerStart/initOption.js ================================================ const log = require('../../utils/log') const options = require('../../conf/option') const models = require('../../model/mongo') module.exports = class { async beforeServerStart() { for (const option of options) { let key = option.key let count = await models.option.find({ key }).count().exec() if (count === 0) { await models.option.create(option) log.info(`Option ${key} created`) } } } } ================================================ FILE: server/plugins/beforeServerStart/initUser.js ================================================ const log = require('../../utils/log') const config = require('../../conf/config') const models = require('../../model/mongo') module.exports = class { async beforeServerStart() { const count = await models.user.find().count().exec() if (count !== 0) return if (config.defaultAdminPassword === 'admin') { log.error('you must change the default password at ./conf/config.js') log.error('koa2 refused to start because of weak password') return process.exit(1) } const result = await models.user.create({ name: config.defaultAdminName, password: config.defaultAdminPassword, displayName: config.defaultAdminName, email: '' }) log.info(`account '${result.name}' is created`) } } ================================================ FILE: server/plugins/beforeServerStart/installTheme.js ================================================ const log = require('../../utils/log') const fs = require('fs') const path = require('path') const resolve = file => path.resolve(__dirname, file) const models = require('../../model/mongo') module.exports = class { async beforeServerStart() { const prefix = '../../theme' let fileArr = fs.readdirSync(resolve(prefix)) for (let i = 0, len = fileArr.length; i < len; i++) { let fileName = fileArr[i] let theme = require(`${prefix}/${fileName}`) let count = await models.theme.find({ name: theme.name }).count().exec() if (count === 0) { await models.theme.create(theme) log.info(`theme ${theme.name} created`) } } } } ================================================ FILE: server/plugins/beforeUseRoutes/bodyParser.js ================================================ const bodyParser = require('koa-bodyparser') module.exports = class { async beforeUseRoutes({ app }) { app.use(bodyParser()) } } ================================================ FILE: server/plugins/beforeUseRoutes/logTime.js ================================================ const log = require('../../utils/log') module.exports = class { async beforeUseRoutes({ app, redis }) { app.use(async (ctx, next) => { const start = new Date() await next() const ms = new Date() - start log.info(`${ctx.method} ${decodeURIComponent(ctx.url)} - ${ms}ms`) }) } } ================================================ FILE: server/plugins/beforeUseRoutes/ratelimit.js ================================================ const ratelimit = require('koa-ratelimit') module.exports = class { constructor(options) { this.options = options } async beforeUseRoutes({ app, redis }) { const config = Object.assign({}, this.options, { db: redis }) app.use(ratelimit(config)) } } ================================================ FILE: server/plugins/beforeUseRoutes/restc.js ================================================ const restc = require('restc') module.exports = class { async beforeUseRoutes({ app }) { app.use(restc.koa2()) } } ================================================ FILE: server/plugins/mountingRoute/login.js ================================================ const redis = require('../../model/redis') const tokenService = require('../../service/token') const { user: model } = require('../../model/mongo') module.exports = class { async mountingRoute() { return { method: 'post', path: '/admin/login', middleware: [middleware] } } } async function middleware(ctx, next) { let users, user try { users = await model.find({ name: ctx.request.body.name }).exec() user = { name: users[0].name, timestamp: (new Date()).valueOf() } let password = users[0].password if (password === ctx.request.body.password) { let token = tokenService.createToken(user) redis.set('token', token, 'EX', tokenService.expiresIn, () => { }) return ctx.body = { status: 'success', token: token } } else { return ctx.body = { status: 'fail', description: 'Get token failed. Check the password' } } } catch (_error) { return ctx.body = { status: 'fail', description: 'Get token failed. Check the name' } } } ================================================ FILE: server/plugins/mountingRoute/logout.js ================================================ const redis = require('../../model/redis') const tokenService = require('../../service/token') module.exports = class { async mountingRoute() { return { method: 'post', path: '/admin/logout', middleware: [middleware] } } } async function middleware(ctx, next) { const headers = ctx.request.headers let token try { token = headers['authorization'] } catch (err) { return ctx.body = { status: 'fail', description: err } } if (!token) { return ctx.body = { status: 'fail', description: 'Token not found' } } const result = tokenService.verifyToken(token) if (result === false) { return ctx.body = { status: 'fail', description: 'Token verify failed' } } else { await redis.del('token') return ctx.body = { status: 'success', description: 'Token deleted' } } } ================================================ FILE: server/plugins/mountingRoute/qiniu.js ================================================ const qiniu = require('qiniu') const fops = 'imageMogr2/format/webp' const policy = (name, fileName, { qiniuBucketName, qiniuPipeline }) => { let encoded = new Buffer(`${qiniuBucketName}:webp/${fileName}`).toString('base64') const persist = {} if (qiniuPipeline !== '') { persist.persistentOps = `${fops}|saveas/${encoded}` persist.persistentPipeline = qiniuPipeline } return Object.assign({}, persist, { scope: name, deadline: new Date().getTime() + 600 }) } const getQiniuTokenFromFileName = (fileName, { qiniuBucketName, qiniuPipeline, qiniuBucketHost }) => { const key = `${qiniuBucketName}:${fileName}` const putPolicy = new qiniu.rs.PutPolicy2(policy(key, fileName, { qiniuPipeline, qiniuBucketName })) const upToken = putPolicy.token() return { upToken, key, bucketHost: qiniuBucketHost, supportWebp: qiniuPipeline !== '' } } module.exports = class { constructor(options) { this.options = options qiniu.conf.ACCESS_KEY = this.options.qiniuAccessKey qiniu.conf.SECRET_KEY = this.options.qiniuSecretKey } async mountingRoute() { return { method: 'post', path: '/admin/qiniu', needBeforeRoutes: true, middleware: [ ({ request, response }, next) => { return response.body = getQiniuTokenFromFileName( request.body.key, this.options ) } ], needAfterRoutes: false } } } ================================================ FILE: server/service/token.js ================================================ const jwt = require('jsonwebtoken') const config = require('../conf/config') let secret = config.tokenSecret let expiresIn = config.tokenExpiresIn module.exports = { createToken(userinfo) { let token = jwt.sign(userinfo, secret, { expiresIn }) return token }, verifyToken(token) { if (!token) { return false } try { let result = jwt.verify(token, secret) return result } catch (err) { return false } }, expiresIn } ================================================ FILE: server/test/data/index.js ================================================ module.exports = { post: require('./post') } ================================================ FILE: server/test/data/post.js ================================================ module.exports = { create: { 'title': '关于', 'markdownContent': 'sswww www\n\nhaha', 'category': '', 'summary': '

sswww www

\n

haha

\n', 'content': '

sswww www

\n

haha

\n', 'tags': [ 'tag' ], 'isPublic': true, 'updatedAt': '2017-04-28 18:55:47', 'createdAt': '2017-04-19 17:32:55', 'allowComment': true, 'toc': '', 'markdownToc': '', 'pathName': '关于', 'status': 0, 'type': 'post' }, patch: { 'status': 1 }, select: { 'status': 1, '_id': 0 } } ================================================ FILE: server/test/index.js ================================================ ================================================ FILE: server/test/integration/postRestful.js ================================================ const should = require('should') const chai = require('chai') const chaiAsPromised = require('chai-as-promised') chai.use(chaiAsPromised) const config = require('../../conf/config') config.mongoDatabase = 'testMongo' const models = require('../../model/mongo') const data = require('../data') const model = models.post const testData = data.post const arr = Object.keys(models).map(name => models[name]) const axios = require('axios') let token = '' let user = {} const getToken = () => token axios.interceptors.request.use((config) => { const token = getToken() if (config.ignore === true) return config if (config.method === 'get' && config.url.indexOf('/api/user') === -1) { return config } if (token !== null && typeof token !== 'undefined') { config.headers['authorization'] = token } return config }, (error) => Promise.reject(error)) const actions = { findAll: async({ request }) => { return axios.get('http://localhost:3000/api/post', { params: request.query }).then(res => res.data) }, create: async({ request }) => { return axios.post('http://localhost:3000/api/post', request.body).then(res => res.data) }, updateById: async({ params, request }) => { return axios.patch('http://localhost:3000/api/post/' + params.id, request.body).then(res => res.data) }, getToken: async(body) => { return axios.post('http://localhost:3000/admin/login', body).then(res => res.data) } } describe(' RESTful TEST', () => { before(async function() { this.timeout(5000) for (const model of arr) { await model.remove({}) } require('../../entry.js') await new Promise((resolve) => { setTimeout(() => resolve(), 2000) }) user = (await models.user.findOne()).toJSON() delete user._id delete user.__v const json = await actions.getToken({ name: user.name, password: user.password }) token = json.token console.log(' Models Dropped and Server Started') }) describe(`Model:${model.modelName.toUpperCase()}`, () => { it('should count 0', (done) => { actions.findAll({ request: { query: { count: 1 } } }).then(body => { should.deepEqual(body, 0) }).then(done).catch(done) }) it('should create doc', (done) => { actions.create({ request: { body: testData.create } }).then(body => { // body = body.toJSON() delete body.__v delete body._id // console.log(body) should.deepEqual(body, testData.create) }).then(done).catch(done) }) it('should count 1', (done) => { actions.findAll({ request: { query: { count: 1 } } }).then(body => { should.deepEqual(body, 1) }).then(done).catch(done) }) it('should throw error when model is wrong', async () => { const testModel = models[model.modelName + 'ErrorTest'] try { testModel.findAll({}) } catch (err) { should.deepEqual(err instanceof Error, true) } }) it('should doc updated', async () => { const data = await actions.findAll({ request: { query: {} } }) should.deepEqual(data.length, 1) const body = await actions.updateById({ params: { id: data[0]._id }, request: { body: testData.patch } }) delete body.__v delete body._id should.deepEqual(body, Object.assign({}, testData.create, testData.patch)) }) it('should find all and count 2', async () => { const body = await actions.create({ request: { body: testData.create } }) delete body.__v delete body._id should.deepEqual(body, testData.create) const data = await actions.findAll({ request: { query: {} } }) should.deepEqual(data.length, 2) }) it('should count 0 when conditions is wrong', async () => { try { const body = await actions.findAll({ request: { query: { conditions: 'testError' } } }) } catch (err) { should.deepEqual(err instanceof Error, true) } }) it('should select fields', async () => { const data = await actions.findAll({ request: { query: { select: JSON.stringify(testData.select) } } }) should.deepEqual(data.length, 2) for (const item of data) { should.deepEqual(Object.keys(item), Object.keys(testData.select).filter(name => name !== '_id')) } }) it('should query by conditions', async () => { const data = await actions.findAll({ request: { query: { conditions: JSON.stringify(testData.patch) } } }) should.deepEqual(data.length, 1) Object.keys(testData.patch).forEach(key => { should.deepEqual(data[0][key], testData.patch[key]) }) }) it('should filter by limit', async () => { const data = await actions.findAll({ request: { query: { limit: 1 } } }) should.deepEqual(data.length, 1) }) it('should sort by status', async () => { const data = await actions.findAll({ request: { query: { sort: JSON.stringify(testData.patch), select: JSON.stringify(testData.patch) } } }) should.deepEqual(data.length, 2) const sortedByHand = data.map(item => item).sort((a, b) => { const field = Object.keys(testData.patch)[0] return a[field] - b[field] }) should.deepEqual(sortedByHand, data.map(item => item)) }) it('should skip by 1', async () => { const data = await actions.findAll({ request: { query: { skip: 1, select: JSON.stringify(testData.patch) } } }) should.deepEqual(data.length, 1) }) it('should notify token not found when query user', async () => { const body = await axios.get('http://localhost:3000/api/user', { ignore: true }) // console.log(body) should.deepEqual(body.data, { status: 'fail', description: 'Token not found' }) }) it('should query user successful when provided token', async () => { const body = await axios.get('http://localhost:3000/api/user') // console.log(body) should.deepEqual(body.data.length, 1) const data = body.data[0] delete data._id delete data.__v should.deepEqual(data, user) }) it('should notify token not found when getting picture uploading token', async () => { const body = await axios.post('http://localhost:3000/admin/qiniu', { key: 'test' }, { ignore: true }) // console.log(body) should.deepEqual(body.data, { status: 'fail', description: 'Token not found' }) }) it('should get picture uploading token successful', async () => { const { data } = await axios.post('http://localhost:3000/admin/qiniu', { key: 'test' }) should.deepEqual(data.upToken.length !== 0, true) }) }) }) ================================================ FILE: server/test/unit/postQuery.js ================================================ const should = require('should') const chai = require('chai') const chaiAsPromised = require('chai-as-promised') chai.use(chaiAsPromised) const config = require('../../conf/config') config.mongoDatabase = 'testMongo' const models = require('../../model/mongo') const data = require('../data') const actions = require('../../mongoRest/actions')(models.post) const model = models.post const testData = data.post describe(' RESTful TEST', () => { before(async () => { await model.remove({}) console.log(' Models Dropped') }) describe(`Model:${model.modelName.toUpperCase()}`, () => { it('should count 0', (done) => { actions.findAll({ request: { query: { count: 1 } } }).then(body => { should.deepEqual(body, 0) }).then(done).catch(done) }) it('should create doc', (done) => { actions.create({ request: { body: testData.create } }).then(body => { body = body.toJSON() delete body.__v delete body._id // console.log(body) should.deepEqual(body, testData.create) }).then(done).catch(done) }) it('should count 1', (done) => { actions.findAll({ request: { query: { count: 1 } } }).then(body => { should.deepEqual(body, 1) }).then(done).catch(done) }) it('should throw error when model is wrong', async () => { const testModel = models[model.modelName + 'ErrorTest'] try { testModel.findAll({}) } catch (err) { should.deepEqual(err instanceof Error, true) } }) it('should doc updated', async () => { const data = await actions.findAll({ request: { query: {} } }) should.deepEqual(data.length, 1) const body = (await actions.updateById({ params: { id: data[0]._id }, request: { body: testData.patch } })).toJSON() delete body.__v delete body._id should.deepEqual(body, Object.assign({}, testData.create, testData.patch)) }) it('should find all and count 2', async () => { const body = (await actions.create({ request: { body: testData.create } })).toJSON() delete body.__v delete body._id should.deepEqual(body, testData.create) const data = await actions.findAll({ request: { query: {} } }) should.deepEqual(data.length, 2) }) it('should count 0 when conditions is wrong', async () => { try { const body = await actions.findAll({ request: { query: { conditions: 'testError' } } }) } catch (err) { should.deepEqual(err instanceof Error, true) } }) it('should select fields', async () => { const data = await actions.findAll({ request: { query: { select: JSON.stringify(testData.select) } } }) should.deepEqual(data.length, 2) for (const item of data) { should.deepEqual(Object.keys(item.toJSON()), Object.keys(testData.select).filter(name => name !== '_id')) } }) it('should query by conditions', async () => { const data = await actions.findAll({ request: { query: { conditions: JSON.stringify(testData.patch) } } }) should.deepEqual(data.length, 1) Object.keys(testData.patch).forEach(key => { should.deepEqual(data[0][key], testData.patch[key]) }) }) it('should filter by limit', async () => { const data = await actions.findAll({ request: { query: { limit: 1 } } }) should.deepEqual(data.length, 1) }) it('should sort by status', async () => { const data = await actions.findAll({ request: { query: { sort: JSON.stringify(testData.patch), select: JSON.stringify(testData.patch) } } }) should.deepEqual(data.length, 2) const sortedByHand = data.map(item => item.toJSON()).sort((a, b) => { const field = Object.keys(testData.patch)[0] return a[field] - b[field] }) should.deepEqual(sortedByHand, data.map(item => item.toJSON())) }) it('should skip by 1', async () => { const data = await actions.findAll({ request: { query: { skip: 1, select: JSON.stringify(testData.patch) } } }) should.deepEqual(data.length, 1) }) }) }) ================================================ FILE: server/theme/firekylin.js ================================================ module.exports = { name: 'firekylin', author: 'github.com/75team/firekylin', // migrated by smallpath option: { logoUrl: '/static/logo.png', sidebarImageUrl: 'https://oebegwmfv.qnssl.com/webp/20161203/120012_20150202200739_vxPHK.png', sidebarMoveCss: 'background 2s ease-in-out;', sidebarFontColor: '#fff !important', menu: [{ 'option': 'home', 'url': '/', 'label': '首页' }, { 'option': 'archive', 'url': '/archive', 'label': '归档' }, { 'option': 'tags', 'url': '/tag', 'label': '标签' }] } } ================================================ FILE: server/utils/log.js ================================================ const log4js = require('log4js') const config = require('../conf/config') let log = log4js.getLogger(config.mongoDatabase) const isTest = process.env.NODE_ENV === 'TEST' module.exports = isTest ? { debug: () => {}, info: () => {}, log: () => {}, error: () => {}, warn: () => {} } : log