├── .gitignore ├── .npmignore ├── Dockerfile ├── LICENSE ├── README.md ├── app.js ├── configs.json ├── libs └── utils.js ├── models ├── board.js ├── config.js ├── index.js ├── media.js ├── thread.js └── user.js ├── package.json ├── public └── logo.jpg └── routes ├── admin.js ├── board.js ├── home.js ├── index.js ├── media.js ├── member.js ├── sign.js └── thread.js /.gitignore: -------------------------------------------------------------------------------- 1 | *.sublime* 2 | *.DS_* 3 | node_* 4 | thumb 5 | psd 6 | *.backup 7 | backup.* 8 | *.log 9 | sftp-* 10 | public/uploads 11 | public/stylesheets/duoshuo-embed.css 12 | database.json 13 | *.getcandy.* 14 | getcandy.js 15 | public/candy-theme-* 16 | *_old 17 | .idea/ 18 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.sublime* 2 | *.DS_* 3 | node_* 4 | thumb 5 | psd 6 | backup.* 7 | *.log 8 | sftp-* 9 | *.backup 10 | public/uploads 11 | database.json 12 | *.getcandy.* 13 | getcandy.js 14 | public/candy-theme-* -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # DOCKER-VERSION 1.0.0 2 | 3 | FROM ubuntu:14.04 4 | 5 | # install nodejs and npm 6 | RUN apt-get update 7 | RUN apt-get install -y nodejs-legacy 8 | RUN apt-get install -y npm 9 | RUN apt-get install -y git 10 | RUN apt-get install -y gcc-4.8 11 | 12 | # add src code to this image 13 | RUN mkdir /var/www 14 | ADD . /var/www 15 | 16 | # install deps 17 | # install npm if it do not exist in local deps 18 | RUN cd /var/www; npm install 19 | RUN cd /var/www; npm install npm 20 | 21 | # expose 3000 port to its master 22 | EXPOSE 3000 23 | CMD ["nodejs", "/var/www/app.js"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 turing 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Candy](./public/logo.jpg)](http://getcandy.org) 2 | --- 3 | ![](https://badge.fury.io/js/candy.png) 4 | 5 | [Candy](http://getcandy.org) 是基于多说社交评论的社会化论坛系统,采用 Node.js/Mongodb 构建。营造简洁、实用、具有人情味的下一代论坛系统,是 Candy 的设计目标。 6 | 7 | ![screenshot@0.1.7](http://ww3.sinaimg.cn/large/61ff0de3gw1eecbmchccdj20zq0nwtcu.jpg) 8 | 9 | ### 安装 Candy 10 | 11 | 你可以选择两种方式安装 Candy,将 Candy 视为一个 NPM 模块,在外部使用启动脚本启动。或者将整个仓库复制到本地直接运行启动文件。 12 | 这两种方式各有各的好处,如果你是个谨慎的使用者,并不希望频繁升级 Candy 核心文件,我推荐你采用第二种方式安装。 13 | > 在尝试下面两种安装方式前,您首先需要安装Node.js和MongoDB到本地 14 | 15 | 1.将 Candy 仓库复制到本地并运行启动脚本 16 | ``` 17 | $ git clone https://github.com/turingou/candy.git 18 | $ cd candy 19 | $ npm install 20 | $ PORT=3001 node app.js 21 | ``` 22 | 23 | 2.将 Candy 视为 NPM 模块安装,在外部使用启动脚本启动,我已经为你准备了一个现成的启动脚本: 24 | ```` 25 | $ mkdir candy && cd candy 26 | $ npm install candy 27 | $ cp node_modules/candy/app.sample.js ./app.js 28 | $ PORT=3001 node app.js 29 | ```` 30 | 无论你以何种方式启动,你将在默认的端口看到一个全新的 Candy 正在静候你的初次访问。现在,使用浏览器访问 [localhost:3000](http://localhost:3000) 你将能看到一个全新的 Candy Demo。 31 | 32 | ### 升级 Candy 33 | 34 | 无论你使用何种方式安装 Candy,都可以使用 NPM 或 Git 方便地进行升级。升级操作通常需要在 Candy 的安装目录进行,我们假设你的安装目录是 `/www/candy`,让我们将 Candy 升级到最新版本吧: 35 | 36 | 如果你使用 Git clone Candy: 37 | ``` 38 | $ cd /www/candy 39 | $ git pull 40 | ``` 41 | 42 | 如果你使用 NPM 安装 Candy,采用这种方法升级可能会导致你在 `/www/candy/node_modules/candy/node_modules` 文件夹下的自定义主题被覆盖,所以应确保在升级之前,备份你的自定义主题。 43 | ``` 44 | $ cd /www/candy 45 | $ npm install candy@latest 46 | ``` 47 | 48 | ### 配置 Candy 49 | 50 | 配置 Candy 是一条必经之路,没有一个主题或者配置清单可以适应所有的使用环境。因此,在将你的论坛搭建上生产环境服务器之前,确保通读这份配置指引。 51 | 52 | #### 管理员用户 53 | 第一个登录的用户会是 Candy 的管理员用户,确保你使用正确的社交网络账户登录 Candy,就可以开始自定义论坛了。 54 | 你可以在登录后的右侧菜单找到进入 管理面板 的入口,或者访问 [localhost:3000/admin](http://localhost:3000/admin) 进入管理面板。 55 | 56 | #### 启动脚本 `app.js` 57 | 58 | 配置脚本负责启动你的 Candy 论坛。这意味着在变更某些配置之后,你可能需要重新启动配置脚本。这个文件通常是 `app.js`。 59 | 60 | **注意**: 在生产环境启动 candy 时,请配置相应的环境变量以启动服务: 61 | 62 | ``` 63 | // 在 3001 端口启动服务,配置当前环境为 test: 64 | $ PORT=3001 NODE_ENV='test' node app.js 65 | ``` 66 | 67 | #### 使用 Docker build 启动镜像 68 | 69 | Candy 提供了一份 Dockerfile 以便于使用 Docker 部署服务的使用者可以快速根据 ubuntu:14.04 的原始镜像编译出一份在 3000 端口暴露服务的 Docker Container: 70 | 71 | 1. 通过 Candy 提供的 Dockerfile 编译镜像 72 | ``` 73 | $ sudo docker build -t /candy 74 | ``` 75 | 2. 启动镜像 76 | ``` 77 | // 在宿主机上绑定 8080 端口到此容器的 3000 端口,也就是 Candy 服务的端口 78 | $ sudo docker run -p 8080:3000 -d /candy 79 | ``` 80 | 3. 找到镜像 ID (一串哈希或它的前三位) 81 | ``` 82 | $ sudo docker ps -l 83 | ``` 84 | 4. 打印镜像活动日志 85 | ``` 86 | $ sudo docker logs 87 | ``` 88 | 89 | #### 测试 Candy (Debug 模式) 90 | 91 | 如果你启动的 Candy 实例遇到问题,可以开启 Debug 模式边运行边打印测试结果。你可以通过这样的方式启动 debug 模式: 92 | 93 | ``` 94 | // 在 3001 端口启动 debug 模式的 candy 实例: 95 | $ DEBUG=candy PORT=3000 node app.js 96 | // 或者直接使用快捷方式 97 | $ cd candy 98 | $ npm test 99 | ``` 100 | 101 | #### 配置文件 `configs.json` 102 | 103 | 配置文件是启动脚本使用的初始配置,Candy 使用一个 `json` 文件当做配置文件。这个文件里规定了论坛初始化时的名字,简介,永久链接,运行环境,数据库信息等等内容。 104 | 105 | 如果你采用直接 clone 仓库的方式安装 Candy,配置文件为 `./configs.json`,当然,你也可以将 Candy 视为 NPM 模块安装。这样的话,你可以在启动文件中传入配置参数,这里的配置参数与配置文件中的参数名称、规则相同。 106 | 107 | 这是配置文件 `configs.json` 的范例: 108 | ````javascript 109 | { 110 | "name": 'Candy', 111 | "desc": 'some description for your very new Candy forum', 112 | "public": "./public", 113 | "uploads": "./public/uploads", 114 | "views": "./node_modules/candy-theme-flat", 115 | "database": { 116 | "name": 'candy' 117 | }, 118 | "session": { 119 | "store": true 120 | }, 121 | "duoshuo": { 122 | "short_name": 'xxx', 123 | "secret": 'xxx' 124 | } 125 | }); 126 | ```` 127 | 让我们来看看配置文件 `configs.json` 中的具体参数 128 | 129 | ##### configs#name [String] 站点名称 130 | ##### configs#desc [String] 站点介绍 131 | ##### configs#public [String] 静态资源目录 (默认为 `./public`) 132 | ##### configs#uploads [String] 附件上传目录 (默认为 `./public/uploads`) 133 | ##### configs#views [String] 默认主题目录 (默认为 `./node_modules/candy-theme-flat`) 134 | ##### configs#url [String] 站点永久链接 135 | 这是一个以 http 或 https 开头的 URL,这个 URL 只有在你将站点的运行环境设定为 `production` 时才会变成站点的根。 136 | 137 | ##### configs#database [Object] 数据库信息 138 | Candy 采用 Mongodb 构建。这是储存论坛数据库信息的对象。包括 139 | - database.name [String] 数据库名称(必要) 140 | - database.host [String] 数据库地址 141 | - database.port [Number] 数据库运行端口 142 | - database.options [Object] 数据库配置选项(参见 [moogoose 文档](http://mongoosejs.com/docs/connections.html)) 143 | 144 | ##### configs#session [Object] session 持久化相关信息 145 | - session.store [Boolean] 是否使用 Conenct-Mongo 做 session 持久化 (默认为 `false`) 146 | 147 | ##### configs#duoshuo [Object] 多说相关信息 148 | Candy 基于多说社交评论构建,因此,你需要提供一个多说 `short_name` 和相应的 `secret` 149 | 150 | - duoshuo.short_name [String] 多说 `short_name` (必要) 151 | - duoshuo.secret [String] 多说 `secret`(必要) 152 | 153 | ### Candy 主题系统 154 | 155 | Candy 构建于 Theme 主题系统之上。这意味着你可以编写 NPM 模块,作为 Candy 的主题,并发布到 NPM,使所有使用 Candy 的用户受益。 156 | 157 | - 所有主题遵守一套命名约定,必须采用 `candy-theme-xxx` 的命名规则进行命名,并发布到 NPM。 158 | - 所有主题都必须按照一定规范书写 `package.json` 可以参照 Candy 的默认主题 [candy-theme-flat](https://github.com/turingou/candy-theme-flat) 书写你的主题描述文件。 159 | - 所有主题的静态资源文件夹都推荐放置于主题文件夹下的 `static` 目录。 160 | - 所有主题模块均应位于 './node_modules' 文件夹内,小心升级模块时造成的文件覆盖。 161 | 162 | ### 欢迎提交 Pull Request 163 | 164 | Candy 仍未达到我们期望的完善程度,欢迎一起来完善这个有趣的社会化论坛实验。 165 | 166 | - Fork Candy 167 | - 在不破坏基本结构的情况下,添加功能。 168 | - 确保自己添加的功能经过 人工 或者 单元测试 169 | - 发起 Pull Request 170 | 171 | ### MIT license 172 | Copyright (c) 2013 turing <o.u.turing@gmail.com> 173 | 174 | Permission is hereby granted, free of charge, to any person obtaining a copy 175 | of this software and associated documentation files (the "Software"), to deal 176 | in the Software without restriction, including without limitation the rights 177 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 178 | copies of the Software, and to permit persons to whom the Software is 179 | furnished to do so, subject to the following conditions: 180 | 181 | The above copyright notice and this permission notice shall be included in 182 | all copies or substantial portions of the Software. 183 | 184 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 185 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 186 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 187 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 188 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 189 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 190 | THE SOFTWARE. 191 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | // __ 2 | // _________ _____ ____/ /_ __ 3 | // / ___/ __ `/ __ \/ __ / / / / 4 | // / /__/ /_/ / / / / /_/ / /_/ / 5 | // \___/\__,_/_/ /_/\__,_/\__, / 6 | // /____/ 7 | // 8 | // @brief : a micro bbs system based on duoshuo.com apis 9 | // @author : 新浪微博@郭宇 [turing](http://guoyu.me) 10 | 11 | require('babel/register') 12 | 13 | // Global dependencies 14 | import express from 'express' 15 | import morgan from "morgan" 16 | import multer from 'multer' 17 | import bodyParser from 'body-parser' 18 | import cookieParser from 'cookie-parser' 19 | 20 | // Local dependencies 21 | import routes from './routes' 22 | import { findPath } from './libs/utils' 23 | 24 | // Local modules 25 | import configs from './configs.json' 26 | 27 | const app = express() 28 | const env = process.env.NODE_ENV || 'development' 29 | const production = (env === 'production') 30 | 31 | // Environments 32 | app.set('env', env) 33 | app.set('views', configs.views) 34 | app.set('view engine', configs['view engine']) 35 | app.set('port', process.env.PORT || 3000) 36 | app.set('view cache', production) 37 | 38 | // Middlewares 39 | app.use(morgan(production ? configs.log : 'dev')) 40 | app.use(bodyParser.urlencoded({ extended: false })) // `application/x-www-form-urlencoded` 41 | app.use(bodyParser.json()) // `application/json` 42 | app.use(multer({ dest: configs.uploads })) 43 | app.use(cookieParser(configs.secret)) 44 | app.use(express.static(findPath(process.cwd(), './public'))) 45 | 46 | // Locals 47 | app.locals.URI = production ? 48 | (configs.URI || 'http://127.0.0.1') : 49 | `http://127.0.0.1:${ app.get('port') }`; 50 | 51 | // Connect to database 52 | connect(configs.db) 53 | .then(() => { 54 | // Init routes 55 | routes(app) 56 | 57 | // Run 58 | app.listen(app.get('port'), () => 59 | console.log('Candy is running now')) 60 | }) 61 | .catch(err => { 62 | throw err 63 | }) 64 | -------------------------------------------------------------------------------- /configs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Candy", 3 | "desc": "简单优雅的次世代论坛", 4 | "url": "http://yourCandyApp.com", 5 | "views": "./node_modules/candy-theme-flat", 6 | "public": "./public", 7 | "uploads": "./public/uploads", 8 | "theme": "flat", 9 | "database": { 10 | "name": "candy" 11 | }, 12 | "session": { 13 | "store": true 14 | }, 15 | "duoshuo": { 16 | "short_name": "candydemo", 17 | "secret": "055834753bf452f248602e26221a8345" 18 | } 19 | } -------------------------------------------------------------------------------- /libs/utils.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | export function findPath(basePath = process.cwd(), relativePath) { 4 | return path.resolve(basePath, relativePath) 5 | } 6 | 7 | export function isPage(p) { 8 | if (!p) 9 | return false 10 | 11 | var n = parseInt(p) 12 | 13 | if (isNaN(n)) 14 | return false 15 | 16 | return n 17 | } -------------------------------------------------------------------------------- /models/board.js: -------------------------------------------------------------------------------- 1 | import { Schema } from 'mongoose' 2 | import { Thread } from './' 3 | 4 | const Board = new Scheme({ 5 | name: String, 6 | desc: String, 7 | banner: String, 8 | created: { 9 | type: Date, 10 | default: Date.now 11 | }, 12 | url: { 13 | type: String, 14 | unique: true 15 | }, 16 | threads: [{ 17 | type: Schema.Types.ObjectId, 18 | ref: 'thread' 19 | }], 20 | bz: [{ 21 | type: Schema.Types.ObjectId, 22 | ref: 'user' 23 | }] 24 | }) 25 | 26 | Board.static('add', ({ moderator, baby }, fn) => { 27 | baby.bz.push(moderator) 28 | return this.create(baby, fn) 29 | }) 30 | 31 | Board.static('addThreadId', (id, tid, fn) => { 32 | return this.findByIdAndUpdate(id, { 33 | $push: { 34 | threads: tid 35 | } 36 | }, fn) 37 | }) 38 | 39 | Board.static('findByIdAndPop', (id, fn) => { 40 | return this.findById(id) 41 | .populate('threads') 42 | .populate('bz') 43 | .exec(fn) 44 | }) 45 | 46 | Board.static('list', fn => { 47 | return this.find({}) 48 | .populate('bz') 49 | .populate('threads') 50 | .exec(fn) 51 | }) 52 | 53 | Board.static('latest', fn => 54 | return this.findOne({}).exec(fn)) 55 | 56 | Board.static('fetch', ({ query }, fn) => { 57 | this.findOne(query) 58 | .populate('bz') 59 | .exec(filters) 60 | 61 | function filters(err, target) { 62 | if (err) 63 | return fn(err) 64 | if (!target) 65 | return fn(null, null) 66 | 67 | const q = { 68 | board: target._id 69 | } 70 | 71 | var cursor = Thread.page(page, limit, q) 72 | 73 | return cursor.count.exec((err, count) => { 74 | if (err) 75 | return fn(err) 76 | 77 | cursor.pager.max = Math.round((count + limit - 1) / limit) 78 | cursor.query 79 | .populate('lz') 80 | .populate('board') 81 | .sort('-pined') 82 | .sort('-pubdate') 83 | .exec((err, threads) => { 84 | return fn(err, { 85 | threads, 86 | board: target, 87 | page: cursor.pager 88 | }) 89 | }) 90 | }) 91 | } 92 | }) 93 | 94 | export default Board 95 | -------------------------------------------------------------------------------- /models/config.js: -------------------------------------------------------------------------------- 1 | import { Schema } from 'mongoose' 2 | 3 | const Config = new Schema({ 4 | name: String, 5 | desc: String, 6 | theme: { 7 | type: String, 8 | default: 'flat' 9 | }, 10 | duoshuo: { 11 | short_name: String, 12 | secret: String 13 | }, 14 | created: { 15 | type: Date, 16 | default: Date.now 17 | } 18 | }) 19 | 20 | export default Config 21 | -------------------------------------------------------------------------------- /models/index.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import Promise from 'bluebird' 3 | 4 | Promise.promisifyAll(mongoose) 5 | 6 | const models = {} 7 | const files = ['user', 'board', 'thread', 'media', 'config'] 8 | 9 | files.forEach(item => 10 | models[toUpperCase(item)] = mongoose.model(item, require(`./${item}`))) 11 | 12 | export default models 13 | 14 | function toUpperCase(str) { 15 | return str.charAt(0).toUpperCase() + str.substr(1) 16 | } 17 | -------------------------------------------------------------------------------- /models/media.js: -------------------------------------------------------------------------------- 1 | import { Schema } from 'mongoose' 2 | 3 | const media = new Schema({ 4 | name: String, 5 | src: String, 6 | url: String, 7 | cdn: String, 8 | type: String, 9 | size: Number, 10 | download: { 11 | type: Number, 12 | default: 0 13 | }, 14 | share: { 15 | type: Number, 16 | default: 0 17 | }, 18 | status: { 19 | type: String, 20 | default: 'public' 21 | }, 22 | pubdate: { 23 | type: Date, 24 | default: Date.now 25 | }, 26 | user: { 27 | type: Schema.Types.ObjectId, 28 | ref: 'user' 29 | } 30 | }) 31 | 32 | media.static('read', (id, fn) => { 33 | this.findById(id) 34 | .populate('threads') 35 | .populate('user') 36 | .exec(fn) 37 | }) 38 | 39 | // Should be rewited with $inc 40 | media.static('count', (file, fn) => { 41 | if (file.count) { 42 | file.count.download ++ 43 | } else { 44 | file.download ++ 45 | } 46 | 47 | return file.save(fn) 48 | }) 49 | 50 | export default media 51 | -------------------------------------------------------------------------------- /models/thread.js: -------------------------------------------------------------------------------- 1 | import { Schema } from 'mongoose' 2 | import * as Models from './' 3 | 4 | const Thread = new Schema({ 5 | name: String, 6 | content: String, 7 | pined: { 8 | type: Boolean, 9 | default: false 10 | }, 11 | level: { 12 | type: Number, 13 | default: 0 14 | }, 15 | views: { 16 | type: Number, 17 | default: 0 18 | }, 19 | pubdate: { 20 | type: Date, 21 | default: Date.now 22 | }, 23 | lz: { 24 | type: Schema.Types.ObjectId, 25 | ref: 'user' 26 | }, 27 | board: { 28 | type: Schema.Types.ObjectId, 29 | ref: 'board' 30 | }, 31 | media: [{ 32 | type: Schema.Types.ObjectId, 33 | ref: 'media' 34 | }] 35 | }) 36 | 37 | Thread.static('add', (baby, fn) => { 38 | const keysMap = { 39 | 'Board': 'board', 40 | 'User': 'lz' 41 | } 42 | 43 | this.createAsync(baby) 44 | .then(thread => { 45 | return Promise.all(['Board', 'User'].forEach(item => { 46 | return Models[item].addThreadId( 47 | thread[keysMap[item]], 48 | thread._id 49 | ) 50 | })) 51 | }) 52 | .then(ret => fn(null, ret)) 53 | .catch(fn) 54 | }) 55 | 56 | Thread.static('read', (id, fn) => { 57 | return this.findById(id) 58 | .populate('lz') 59 | .populate('board') 60 | .populate('media') 61 | .exec(fn) 62 | }) 63 | 64 | // 这里有冗余查询逻辑 65 | Thread.static('fetch', ({ query }, fn) => { 66 | // `Page` should be rewrited as a mongoose plugin 67 | var cursor = this.page(page, limit, query) 68 | 69 | cursor.count.exec((err, count) => { 70 | if (err) 71 | return fn(err) 72 | 73 | cursor.pager.max = Math.ceil(count / limit) 74 | cursor.query 75 | .populate('lz') 76 | .populate('board') 77 | .sort('-pined') 78 | .sort('-pubdate') 79 | .exec(function(err, threads) { 80 | fn(err, threads, cursor.pager) 81 | }) 82 | }) 83 | }) 84 | 85 | Thread.static('ifGranted', (threadId, userId, fn) => { 86 | this 87 | .findByIdAsync(threadId) 88 | .then(thread => { 89 | if (!thread) 90 | return fn(null, false) 91 | 92 | return fn(null, thread.lz == userId) 93 | }) 94 | .catch(fn) 95 | }) 96 | 97 | export default Thread -------------------------------------------------------------------------------- /models/user.js: -------------------------------------------------------------------------------- 1 | import { Schema } from 'mongoose' 2 | import Duoshuo from 'duoshuo' 3 | import moment from 'moment' 4 | 5 | const User = new Schema({ 6 | url: String, 7 | email: String, 8 | phone: String, 9 | avatar: String, 10 | nickname: String, 11 | password: String, 12 | email_notification: String, 13 | social_networks: {}, 14 | created: { 15 | type: Date, 16 | default: Date.now 17 | }, 18 | type: { 19 | type: String, 20 | default: 'normal' 21 | }, 22 | threads: [{ 23 | type: Schema.Types.ObjectId, 24 | ref: 'thread' 25 | }], 26 | duoshuo: { 27 | user_id: { 28 | type: String, 29 | unique: true 30 | }, 31 | access_token: String 32 | } 33 | }) 34 | 35 | User.static('read', id => { 36 | const query = { 37 | 'duoshuo.user_id': id 38 | } 39 | 40 | return this.findOneAsync(query) 41 | }) 42 | 43 | User.static('addThreadId', (id, tid) => { 44 | const query = { 45 | $push: { 46 | threads: tid 47 | } 48 | } 49 | 50 | return this.findByIdAndUpdateAsync(id, query) 51 | }) 52 | 53 | User.static('sync', id => { 54 | const duoshuo = new Duoshuo(config) 55 | const typeMap = { 56 | admin: 'administrator', 57 | editor: 'editor', 58 | author: 'author', 59 | normal: 'user' 60 | } 61 | 62 | return this.findByIdAsync(id).then(user => { 63 | let ds = duoshuo.getClient(user.duoshuo.access_token) 64 | 65 | // Any other better choice ? 66 | return new Promise((res, rej) => { 67 | ds.join({ 68 | info: { 69 | user_key: user._id, 70 | name: user.nickname, 71 | role: typeMap[user.type], 72 | avatar_url: user.avatar, 73 | url: user.url, 74 | created_at: moment(user.created).format('YYYY-MM-DD hh:MM:ss') 75 | } 76 | }, (err, ret) => { 77 | if (err) 78 | return rej(err) 79 | 80 | res(ret) 81 | }) 82 | }) 83 | }) 84 | }) 85 | 86 | User.virtual('admin').get(() => { 87 | return this.type === 'admin' 88 | }) 89 | 90 | export default User 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Candy", 3 | "version": "1.0.0", 4 | "description": "a micro forum using duoshuo apis as a backend database", 5 | "main": "app.js", 6 | "author": "turing ", 7 | "license": "MIT", 8 | "scripts": { 9 | "start": "node_modules/.bin/babel-node app.js", 10 | "test": "DEBUG=candy:*,candy:* node_modules/.bin/babel-node app.js" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/turingou/candy.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/turingou/candy/issues" 18 | }, 19 | "keywords": [ 20 | "candy", 21 | "bbs", 22 | "forum", 23 | "duoshuo", 24 | "social", 25 | "sns" 26 | ], 27 | "dependencies": { 28 | "bluebird": "^2.9.34", 29 | "candy-theme-flat": "0.1.0", 30 | "duoshuo": "0.3", 31 | "express": "4.13.1", 32 | "highlight.js": "8.6.0", 33 | "marked": "0.3.3", 34 | "moment": "2.10.3", 35 | "mongoose": "^4.0.7", 36 | "theme": "0.1.0", 37 | "underscore": "^1.8.3" 38 | }, 39 | "devDependencies": { 40 | "babel": "^5.6.23" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /public/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guo-yu/candy/53b01a9a5d63bd6981e7bceedcd0034f09c8f199/public/logo.jpg -------------------------------------------------------------------------------- /routes/admin.js: -------------------------------------------------------------------------------- 1 | export default function() { 2 | var ctrlers = deps.ctrlers 3 | var express = deps.express 4 | var theme = deps.theme 5 | 6 | var Admin = express.Router() 7 | var config = ctrlers.config 8 | var user = ctrlers.user 9 | var board = ctrlers.board 10 | var thread = ctrlers.thread 11 | 12 | // => /admin 13 | Admin.route('/') 14 | // PAGE: Home of Admin panel 15 | .get(checkAdmin, function(req, res, next) { 16 | fetchData(function(err, info) { 17 | if (err) 18 | return next(err) 19 | 20 | theme.render('flat/admin/index', info, function(err, html) { 21 | if (err) 22 | return next(err) 23 | 24 | res.send(html) 25 | }) 26 | }) 27 | }) 28 | // API: update Settings 29 | .post(checkAdmin, function(req, res, next) { 30 | if (!req.body.setting) 31 | return next(new Error('缺少表单')) 32 | 33 | var id = req.body.setting._id 34 | var settings = req.body.setting 35 | 36 | if (settings._id) 37 | delete settings._id; 38 | 39 | config.update(id, settings, function(err, site) { 40 | if (err) 41 | return next(err) 42 | 43 | deps.locals.site = site 44 | return res.json(site) 45 | }) 46 | }) 47 | 48 | return Admin 49 | 50 | function fetchData(done) { 51 | async.parallel({ 52 | config: function(callback) { 53 | config.read(callback) 54 | }, 55 | users: function(callback) { 56 | user.list(callback) 57 | }, 58 | boards: function(callback) { 59 | board.list('all', callback) 60 | }, 61 | threads: function(callback) { 62 | thread.list(callback) 63 | }, 64 | themes: function(callback) { 65 | theme.list(callback) 66 | }, 67 | newbies: function(callback) { 68 | async.parallel({ 69 | user: function(cb){ 70 | user.newbies('created', 'day', cb); 71 | }, 72 | board: function(cb){ 73 | board.newbies('created', 'day', cb); 74 | }, 75 | thread: function(cb) { 76 | thread.newbies('pubdate', 'day', cb); 77 | } 78 | }, callback) 79 | } 80 | }, done) 81 | } 82 | 83 | function checkAdmin(req, res, next) { 84 | if (!res.locals.user) 85 | return res.redirect('/') 86 | if (res.locals.user.type != 'admin') 87 | return res.redirect('/') 88 | 89 | return next() 90 | } 91 | 92 | } 93 | -------------------------------------------------------------------------------- /routes/board.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird' 2 | import { isPage } from '../libs/utils' 3 | 4 | export default function({ app, express, Board, Thread, theme }) { 5 | var Route = express.Router() 6 | var pagelimit = locals.site.pagelimit || 20 7 | 8 | // => /board 9 | Route.route('/') 10 | // API: list all public board 11 | .get((req, res, next) => { 12 | Board.list('name url') 13 | .then(boards => { 14 | res.json({ 15 | stat: 'ok', 16 | boards 17 | }) 18 | }).catch(next) 19 | }) 20 | // API: create a baby board 21 | .post((req, res, next) => { 22 | if (!res.locals.user) 23 | return next(new Error('signin required')) 24 | 25 | Board 26 | .create(res.locals.user._id, req.body.board) 27 | .then(done) 28 | .catch(next) 29 | 30 | function done(baby) { 31 | res.json({ 32 | stat: 'ok', 33 | board: baby 34 | }) 35 | } 36 | }) 37 | 38 | // => /board/:board 39 | Route.route('/:board') 40 | // PAGE: show the query board 41 | .get(readBoard) 42 | // API: update board infomation 43 | .put((req, res, next) => { 44 | if (!res.locals.user) 45 | return next(new Error('signin required')) 46 | 47 | Board 48 | .update(req.params.board, req.body.board) 49 | .then(done) 50 | .catch(next) 51 | 52 | function done(board) { 53 | res.json({ 54 | stat: 'ok', 55 | board: board 56 | }) 57 | } 58 | }) 59 | // API: remove target board 60 | .delete((req, res, next) => { 61 | if (!res.locals.user) 62 | return next(new Error('signin required')) 63 | 64 | Board.remove(req.params.board) 65 | .then(done) 66 | .catch(next) 67 | 68 | function done(bid) { 69 | res.json({ 70 | stat: 'ok', 71 | bid: bid 72 | }) 73 | } 74 | }) 75 | 76 | // => /board/:board/page/:page 77 | Route.get('/:board/page/:page', (req, res, next) => { 78 | var page = isPage(req.params.page) || 1 79 | // pager of board 80 | const query = { 81 | url: req.params.board 82 | } 83 | 84 | Board.fetch(page, pagelimit, query) 85 | .then(done) 86 | .catch(next) 87 | 88 | function done(result) { 89 | if (!result) 90 | return next(new Error('404')) 91 | if (result.page.max > 1 && result.threads.length === 0) 92 | return next(new Error('404')) 93 | 94 | theme.render('/board/index', result, function(err, html) { 95 | if (err) 96 | return next(err) 97 | 98 | return res.send(html) 99 | }) 100 | } 101 | }) 102 | 103 | return Board 104 | } 105 | -------------------------------------------------------------------------------- /routes/home.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird' 2 | 3 | export default function({ app, express, Thread }) { 4 | var Home = express.Router() 5 | 6 | Home.get('/', readThreads) 7 | Home.get('/page/:page', readThreads) 8 | 9 | return Home 10 | 11 | function readThreads(req, res, next) { 12 | var page = isPage(req.params.page) || 1 13 | var pagelimit = locals.site.pagelimit || 20 14 | 15 | Thread.fetch(page, pagelimit) 16 | .then(({ threads, pager }) { 17 | if (!threads) 18 | return next(new Error('404')) 19 | if (pager.max > 1 && threads.length === 0) 20 | return next(new Error('404')) 21 | 22 | theme.render('/index', { 23 | threads, 24 | page: pager 25 | }).then(html => { 26 | res.send(html) 27 | }).catch(next) 28 | }).catch(next) 29 | } 30 | 31 | } 32 | 33 | function isPage(p) { 34 | if (!p) 35 | return false 36 | 37 | var n = parseInt(p) 38 | 39 | if (isNaN(n)) 40 | return false 41 | 42 | return n 43 | } 44 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | // Global Dependencies 2 | import path from 'path' 3 | import Theme from 'theme' 4 | import express from 'express' 5 | 6 | // Local Dependencies 7 | import pkg from '../package.json' 8 | import models from '../models' 9 | 10 | const home = path.resolve(__dirname, '../') 11 | 12 | // routes 13 | const routes = [ 14 | 'home', 15 | 'sign', 16 | 'media', 17 | 'board', 18 | 'thread', 19 | 'member', 20 | 'admin' 21 | ] 22 | 23 | routes.forEach(item => { 24 | import item from `./${ item }` 25 | }) 26 | 27 | // models, ctrlers, middlewares, express 28 | export default function(app) { 29 | var locals = {} 30 | 31 | // Ensure res.render output correct `sys` locals 32 | app.locals.sys = pkg 33 | // Ensure theme.render output correct `sys` locals 34 | locals.sys = pkg 35 | locals.site = app.locals.site 36 | // This URL will be changed in different environment: 37 | // In Dev env , it will be http://localhost:[port] 38 | // In Production mode, It will be `app.locals.url` 39 | locals.url = app.locals.url 40 | 41 | // Init themeloader 42 | var theme = new Theme(home, locals, app.locals.site.theme || 'flat') 43 | 44 | const deps = { 45 | app, 46 | express, 47 | theme, 48 | locals: app.locals 49 | } 50 | 51 | // Inject models dep 52 | Object.keys(models).forEach(item => 53 | deps[item] = models[item]) 54 | 55 | // Init routers 56 | const routers = initRoutes(routes, deps) 57 | 58 | // APIs 59 | routes.forEach(route => { 60 | if (route === 'home') 61 | return app.use('/', routers.home) 62 | 63 | app.use(`/${route}`, routers[route]) 64 | }) 65 | } 66 | 67 | function initRoutes(routes, deps) { 68 | var ret = {} 69 | routes.forEach(route => { 70 | ret[route] = require(`./${ route }`)(deps) 71 | }) 72 | return ret 73 | } 74 | -------------------------------------------------------------------------------- /routes/media.js: -------------------------------------------------------------------------------- 1 | export default function({ app, express, Thread, Media }) { 2 | var Route = express.Router() 3 | 4 | // => /media 5 | // API: create media file 6 | Route.post('/', (req, res, next) => { 7 | if (!res.locals.user) 8 | return next(new Error('signin required')) 9 | if (!req.files.media) 10 | return next(new Error('404')) 11 | 12 | var file = req.files.media 13 | 14 | media.create({ 15 | name: file.name, 16 | type: file.mimetype, 17 | src: file.path, 18 | url: file.path.substr(file.path.lastIndexOf('/uploads')), 19 | user: res.locals.user._id, 20 | size: file.size 21 | }).then(baby => { 22 | res.json({ 23 | stat: 'ok', 24 | file: baby 25 | }) 26 | }).catch(next) 27 | }) 28 | 29 | // => /media/:media 30 | // TODO: 这里还要控制一个如果保存在云上的话,要重定向到云,或者从云上拿下来返回 31 | Route.get('/:media', (req, res, next) => { 32 | if (!req.params.media) 33 | return next(new Error('404')) 34 | 35 | media.findByIdAsync(req.params.media) 36 | .then(done) 37 | .catch(next) 38 | 39 | function done(file) { 40 | if (!file) 41 | return next(new Error('404')) 42 | 43 | var isPublicFile = (file.status && file.status === 'public') || (file.stat && file.stat === 'public') 44 | if (!isPublicFile) 45 | return next(new Error('抱歉,此文件不公开...')) 46 | 47 | media.countDownload(file) 48 | .then(() => { 49 | res.download(file.src, file.name) 50 | }) 51 | .catch(next) 52 | } 53 | }) 54 | 55 | return Route 56 | } 57 | -------------------------------------------------------------------------------- /routes/member.js: -------------------------------------------------------------------------------- 1 | const roles = { 2 | admin: '(管理员)' 3 | } 4 | 5 | export default function({ app, express, Thread, theme, Member }) { 6 | var Route = express.Router() 7 | 8 | // => /member/:member 9 | Route.route('/:member') 10 | // PAGE: show a member's homepage 11 | .get((req, res, next) => { 12 | if (!req.params.member) 13 | return next(new Error('404')) 14 | 15 | Member.read(req.params.member) 16 | .then(done) 17 | .catch(next) 18 | 19 | function done(u) { 20 | if (!u) 21 | return next(new Error('404')) 22 | 23 | var isMe = res.locals.user && res.locals.user._id == req.params.member; 24 | var freshman = isMe && !res.locals.user.nickname 25 | 26 | u.showname = u.nickname || '匿名用户'; 27 | if (!u.avatar) 28 | u.avatar = locals.url + '/images/avatar.png'; 29 | 30 | if (!u.url) 31 | u.url = locals.url + '/member/' + u._id; 32 | 33 | u.role = roles[u.type] || '' 34 | 35 | theme.render('/member/single', { 36 | member: u, 37 | isMe: isMe, 38 | freshman: freshman 39 | }).then(html => { 40 | res.send(html) 41 | }).catch(next) 42 | } 43 | }) 44 | // API: remove a vaild user. 45 | .delete((req, res, next) => { 46 | if (!res.locals.user) 47 | return next(new Error('signin required')) 48 | if (res.locals.user.type !== 'admin') 49 | return next(new Error('signin required')) 50 | 51 | Member.remove(req.params.member) 52 | .then(done) 53 | .catch(next) 54 | 55 | function done() { 56 | res.json({ 57 | stat: 'ok' 58 | }) 59 | } 60 | }) 61 | 62 | // => /member/sync 63 | Route.post('/sync', function(req, res, next) { 64 | var member = req.body.user 65 | 66 | if (!(member && typeof(member) == 'object')) 67 | return next(new Error('user required')) 68 | 69 | Member.findByIdAsync(req.session.user._id) 70 | .then(done) 71 | .catch(next) 72 | 73 | function done(user) { 74 | user.nickname = member.name 75 | user.url = member.url 76 | user.avatar = member.avatar 77 | 78 | user.save() 79 | .then(done) 80 | .catch(next) 81 | 82 | function done(err) { 83 | if (err) 84 | return next(err) 85 | 86 | // sync a member infomation to Duoshuo 87 | Member.sync(locals.site.duoshuo, user) 88 | .then(done) 89 | .catch(next) 90 | 91 | function done(result) { 92 | // just ignore the sync error for a while. 93 | // cause api 404. 94 | // var result = result.body; 95 | // if (result.code !== 0) return next(new Error('多说用户同步失败,请稍后再试,详细错误:' + result.errorMessage)); 96 | req.session.user = user 97 | res.json({ 98 | stat: 'ok', 99 | user 100 | }) 101 | } 102 | } 103 | } 104 | }) 105 | 106 | return Route 107 | } 108 | -------------------------------------------------------------------------------- /routes/sign.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird' 2 | import Duoshuo from 'duoshuo' 3 | 4 | export default function({ app, express, theme, Member }) { 5 | var Route = express.Router() 6 | var user = ctrlers.user 7 | var duoshuo = new Duoshuo(locals.site.duoshuo) 8 | 9 | // => /sign 10 | // PAGE: show sign in page. 11 | Route.get('/', (req, res, next) => { 12 | if (res.locals.user) 13 | return res.redirect('/') 14 | 15 | theme.render('/sign') 16 | .then(done) 17 | .catch(next) 18 | 19 | function done(html) { 20 | res.send(html) 21 | } 22 | }) 23 | 24 | // => /sign/in 25 | // PAGE: signin via duoshuo 26 | Route.get('/in', duoshuo.signin(), (req, res, next) => { 27 | if (!res.locals.duoshuo) 28 | return next(new Error('多说登录失败')) 29 | 30 | var result = res.locals.duoshuo 31 | var isValidUser = !!(result.access_token && result.user_id) 32 | 33 | async.waterfall([ 34 | // read a user by duoshuo.user_id 35 | function(callback) { 36 | Member.read(result.user_id, callback) 37 | }, 38 | // check if a vaild exist user. 39 | function(exist, callback) { 40 | if (!exist) 41 | return callback(null) 42 | 43 | req.session.user = exist 44 | return res.redirect('back') 45 | }, 46 | // fetch new uses' infomation 47 | function(callback) { 48 | if (!isValidUser) 49 | return countUser(null, callback) 50 | 51 | // if access_token vaild 52 | var ds = duoshuo.getClient(result.access_token) 53 | 54 | ds.userProfile({ 55 | user_id: result.user_id 56 | }, function(err, body) { 57 | if (err) return countUser(null, callback) 58 | if (body.code !== 0) return countUser(null, callback) 59 | var userinfo = body.response 60 | if (!userinfo) return countUser(null, callback) 61 | return countUser(userinfo, callback) 62 | }) 63 | }, 64 | // born a user 65 | function(count, userinfo, callback) { 66 | var newbie = {}; 67 | newbie.type = (count == 0) ? 'admin' : 'normal'; 68 | 69 | if (isValidUser) { 70 | newbie.duoshuo = {} 71 | newbie.duoshuo.user_id = result.user_id 72 | newbie.duoshuo.access_token = result.access_token 73 | } 74 | 75 | if (userinfo) { 76 | newbie.nickname = userinfo.name 77 | newbie.url = userinfo.url 78 | newbie.avatar = userinfo.avatar_url 79 | newbie.email_notification = userinfo.email_notification 80 | 81 | if (userinfo.social_uid && userinfo.connected_services) { 82 | newbie.social_networks = mergeSameNetwork(userinfo.social_uid, userinfo.connected_services) 83 | } 84 | } 85 | 86 | Member.create(newbie, function(err, baby) { 87 | callback(err, count, baby) 88 | }) 89 | } 90 | ], function(err, count, baby) { 91 | if (err) 92 | return next(err) 93 | 94 | req.session.user = baby 95 | 96 | if (count == 0) 97 | return res.redirect('/admin/') 98 | 99 | res.redirect('/member/' + req.session.user._id) 100 | }) 101 | }) 102 | 103 | // => /sign/out 104 | Route.get('/out', deps.middlewares.passport.signout) 105 | 106 | return Sign 107 | 108 | function mergeSameNetwork(social_uid, connected_services) { 109 | Object.keys(connected_services).forEach(function(item){ 110 | if (!social_uid[item]) 111 | return 112 | 113 | connected_services[item]['social_uid'] = social_uid[item] 114 | }) 115 | 116 | return connected_services 117 | } 118 | 119 | function countUser(userinfo, callback) { 120 | return Member.count(function(err, counts) { 121 | return callback(err, counts, userinfo) 122 | }) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /routes/thread.js: -------------------------------------------------------------------------------- 1 | import marked from 'marked' 2 | import hljs from 'highlight.js' 3 | 4 | marked.setOptions({ 5 | sanitize: true, 6 | highlight: function(code, lang) { 7 | return hljs.highlightAuto(code).value 8 | } 9 | }) 10 | 11 | export default function({ app, express, Thread, Board, theme}) { 12 | var Route = express.Router() 13 | 14 | // => /thread 15 | Route.route('/') 16 | // API:创建话题 17 | .post((req, res, next) => { 18 | if (!res.locals.user) 19 | return next(new Error('signin required')) 20 | if (!req.body.thread) 21 | return next(new Error('id required')) 22 | 23 | Thread.create(req.body.thread) 24 | .then(done) 25 | .catch(next) 26 | 27 | function(baby) { 28 | res.json({ 29 | stat: 'ok', 30 | thread: baby 31 | }) 32 | } 33 | }) 34 | 35 | // => /thread/new 36 | // PAGE: 查看话题页面 37 | Route.get('/new', (req, res, next) => { 38 | if (!res.locals.user) 39 | return res.redirect('/sign') 40 | 41 | Board.read(req.query.bid) 42 | .then(done) 43 | .catch(next) 44 | 45 | function done(board) { 46 | const locals = { 47 | board 48 | } 49 | 50 | theme.render('/thread/new', locals) 51 | .then(html => res.send(html)) 52 | .catch(next) 53 | } 54 | }) 55 | 56 | // => /thread/:thread 57 | Route.route('/:thread') 58 | // PAGE: 查看话题页面 59 | .get((req, res, next) => { 60 | if (!req.params.thread) 61 | return next(new Error('id required')) 62 | 63 | if (!thread.checkId(req.params.thread)) 64 | return next(new Error('404')) 65 | 66 | thread.read(req.params.thread) 67 | .then(done) 68 | .catch(next) 69 | 70 | function done(err, thread) { 71 | if (!thread) 72 | return next(new Error('404')) 73 | 74 | thread.views += 1 75 | thread.save(function(err) { 76 | const locals = { 77 | thread, 78 | marked 79 | } 80 | 81 | theme.render('/thread/index', locals) 82 | .then(html => res.send(html)) 83 | .catch(next) 84 | }) 85 | } 86 | }) 87 | // API:更新话题 88 | .put((req, res, next) => { 89 | if (!res.locals.user) 90 | return next(new Error('signin required')) 91 | 92 | if (!req.params.thread) 93 | return next(new Error('id required')) 94 | 95 | var tid = req.params.thread 96 | var user = res.locals.user 97 | 98 | Thread.checkLz(tid, user._id) 99 | .then(done) 100 | .catch(next) 101 | 102 | function done(lz, th) { 103 | if (!lz) 104 | return next(new Error('authed required')) 105 | 106 | if (req.body.pin) { 107 | if (user.type !== 'admin') 108 | return next(new Error('authed required')) 109 | 110 | Thread.update(tid, { 111 | pined: req.body.pined, 112 | level: req.body.level || 0 113 | }, thread => { 114 | return res.json({ 115 | stat: 'ok', 116 | thread: thread 117 | }) 118 | }).catch(next) 119 | 120 | return 121 | } 122 | 123 | var updatedThread = { 124 | name: req.body.thread.name, 125 | content: req.body.thread.content, 126 | pubdate: th.pubdate, 127 | views: th.views, 128 | board: th.board, 129 | lz: th.lz 130 | } 131 | 132 | if (req.body.thread.media) 133 | updatedThread.media = req.body.thread.media 134 | 135 | Thread.update(tid, updatedThread) 136 | .then(done) 137 | .catch(next) 138 | 139 | function done(thread) { 140 | res.json({ 141 | stat: 'ok', 142 | thread 143 | }) 144 | } 145 | } 146 | }) 147 | // API:删除话题 148 | .delete((req, res, next) => { 149 | if (!res.locals.user) 150 | return next(new Error('signin required')) 151 | 152 | if (!req.params.thread) 153 | return next(new Error('id required')) 154 | 155 | Thread.checkLz(req.params.thread, res.locals.user._id) 156 | .then(done) 157 | .catch(next) 158 | 159 | function done(lz, th) { 160 | if (!lz) 161 | return next(new Error('authed required')) 162 | 163 | Thread.remove(req.params.thread) 164 | .then(done) 165 | .catch(next) 166 | 167 | function done(tid) { 168 | res.json({ 169 | stat: 'ok', 170 | tid 171 | }) 172 | } 173 | } 174 | }); 175 | 176 | // => /thread/:thread/edit 177 | // PAGE: 更新帖子页面 178 | Route.get('/:thread/edit', (req, res, next) => { 179 | if (!res.locals.user) 180 | return res.redirect('/sign') 181 | 182 | if (!req.params.thread) 183 | return next(new Error('id required')) 184 | 185 | Thread.checkLz(req.params.thread, res.locals.user._id) 186 | .then(done) 187 | .catch(next) 188 | 189 | function done(lz, thread) { 190 | if (!lz) 191 | return next(new Error('404')) 192 | 193 | const locals = { 194 | thread 195 | } 196 | 197 | theme.render('/thread/edit', locals) 198 | .then(html => res.send(html)) 199 | .catch(next) 200 | } 201 | }) 202 | 203 | return Route 204 | } 205 | --------------------------------------------------------------------------------