diff --git a/README.md b/README.md index 7fa07d6..c595163 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,12 @@ pm2 start node --name kana -- index ## 特征 - `/:name/:_id` -RESTful 风格 API, URL形式为两段 name是对象类型, _id是对象id +RESTful 风格 API, url 形式为两段 name是对象类型, _id是对象id. (与 vue 的 router 类同) + +如发表一篇文章, 文章类型是 article, 文章 id 是 2333, 则 url为 `/article/2333` -如发表一篇文章, @@ -78,7 +78,7 @@ fetch('/user', { } ``` -* 创建的第一个账户默认为管理员 +* 创建的第一个账户默认为管理员账户 * 可以使用管理员权限设置其他账户为管理员 * 默认并没有验证邮箱等检查步骤, 允许直接设置, 也允许用户重名 @@ -86,6 +86,8 @@ fetch('/user', { #### 登录会话 +登录行为被认为是创建一个终端到服务器的会话. 因此不是使用 login 或 signin, 而是 session. + ```javascript fetch('/session', { method: 'POST', @@ -103,6 +105,8 @@ fetch('/session', { #### 会话列表 +因此, 可以查看和管理自己所有的终端会话 + ```javascript fetch('/session', { method: 'GET', @@ -120,12 +124,11 @@ fetch('/session', { - - #### 注销会话 -注销当前会话 +相应地, 退出行为被认为是删除一个终端到服务端的会话, 因此不使用 loguot 或 signout, 而是 session. +注销当前会话 ```javascript fetch('/session', { method: 'DELETE', @@ -176,6 +179,9 @@ fetch('/user/ApSXNLoUy', { #### 上传头像 +实际分为两步, +第一步先上传附件到自己的账户 + ```html @@ -196,6 +202,7 @@ function upload() { ``` +第二步修改自己的头像路径为返回的图像路径, (!注意此处未作安全检查) ```javascript fetch('/user/ApSXNLoUy', { method: 'PATCH', @@ -212,6 +219,7 @@ fetch('/user/ApSXNLoUy', { #### 删除用户 +管理员可以直接删除指定用户, 普通用户可以删除自己 ```javascript fetch('/user/ApSXNLoUy', { method: 'DELETE', @@ -224,6 +232,8 @@ fetch('/user/ApSXNLoUy', { #### 创建文章 +此处 book 路径是未作限制的, 也可以是其他未被限制的路径. + ```javascript fetch('/book', { method: 'POST', @@ -248,6 +258,11 @@ fetch('/book', { #### 评论文章 +这里假设文章类型是 book, 文章 id 是 ppNXLoUK + +attach 意为附属于指定对象类型 +aid 意为附属于指定对象 id + ```javascript fetch('/post?attach=book&aid=ppNXLoUK', { method: 'POST', @@ -265,10 +280,34 @@ fetch('/post?attach=book&aid=ppNXLoUK', { } ``` +#### 评论评论 +(二级评论) +这里假设评论类型是 post, 评论 id 是 spNkjLA +受益于评论的实现结构, 也可以对二级评论继续增加三级评论, 也可以无限深度 + +```javascript +fetch('/post?attach=post&aid=spNkjLA', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + data: 'yaaaaaaaaaa~' + }), +}).then(Response => Response.json()).then(data => { + console.log(data) +}) + +{ + _id: 'adjkasj', + data: 'ahahahha~' +} +``` + #### 点赞评论 +受益于评论的实现结构, 也可以对二级评论作点赞操作, 也可以对任意对象点赞操作 + ```javascript fetch('/like?attach=post&aid=spNkjLA', { method: 'POST', @@ -299,6 +338,9 @@ fetch('/like/SOAPSAdaw', { #### 调频广播 +通过频道订阅模式实现广播, 其中心思想是保障一个终端只维持一个 websocket 连接, +所有消息都通过一个连接通道发送到终端, 如聊天室频道, 系统消息, 全局广播, 消息盒子通知等 + ```javascript let socket = new WebSocket("ws://localhost:2333"); @@ -355,7 +397,6 @@ socket.onerror = function(error) { #### 游客广播 允许未登录会话加入订阅, 将未登录会话加入到游客账户 - 因此不能再通过账户登录状态进行拦截 @@ -368,8 +409,8 @@ socket.onerror = function(error) { ```javascript { - page: Number, // 当前页码(默认为1) - pagesize: Number, // 分页大小(默认20) + page: Number, // 当前页码(默认为1) + pagesize: Number, // 分页大小(默认20) sort: string, // 排序方式(只能是对象的通用属性名) desc: Number, // 0或1, 正序和倒序 uid: string, // 指定发布者uid查询 diff --git a/admin/home.js b/admin/home.js new file mode 100644 index 0000000..e69de29 diff --git a/fmhub.js b/fmhub.js index 5f26231..dba83e6 100644 --- a/fmhub.js +++ b/fmhub.js @@ -1,69 +1,42 @@ +import interrelated from 'interrelated' + export default class { constructor() { - this.channels = new Map() - this.users = new Map() + this.用户订阅 = new interrelated() + this.用户会话 = new interrelated() } 订阅频道(fid, uid) { - console.log(`用户 ${uid} 订阅了 ${fid}`) - let channel = this.channels.get(fid) || new Map() - if (!channel.size) this.channels.set(fid, channel) - channel.set(uid, true) + this.用户订阅.set(fid, uid) } 取消订阅(fid, uid) { - console.log(`用户 ${uid} 取消订阅 ${fid} 频道`) - let channel = this.channels.get(fid) - channel.delete(uid) - // TODO: 如果此频道没有用户订阅了, 则将此频道也移除 - // TODO: 如果此用户没有任何订阅了, 则将此用户也移除 + this.用户订阅.delete(fid, uid) } 增加会话(uid, ws) { - console.log(`用户 ${uid} 建立了新的会话连接`) - let user = this.users.get(uid) || new Map() - if (!user.size) this.users.set(uid, user) - user.set(ws, true) + this.用户会话.set(uid, ws) } 移除会话(uid, ws) { - console.log(`用户 ${uid} 结束了当前会话连接`) - let user = this.users.get(uid) || new Map() - user.delete(ws) - console.log(`此用户还剩 ${user.size} 个 ws 连接`) - if (user.size < 1) { - console.log(`由于用户 ${uid} 已经没有会话, 直接移除此用户记录`) - this.users.delete(uid) - // 理论上在会话结束后移除 (但为了避免反复遍历, 可以放在发送消息时) - this.channels.forEach((channel, fm) => { - channel.delete(uid) - }) - } + this.用户会话.delete(uid, ws) } 发送消息(fm, uid, data) { - console.log("发送消息", fm, uid, data) let msg = JSON.stringify({ fm, uid, data }) - let channel = this.channels.get(fm) || new Map() - if (!channel.size) this.channels.set(fm, channel) - channel.forEach((value, userid) => { - //console.log(userid, value) - let user = this.users.get(userid) || new Map() - if (!user.size) { - return console.log(`订阅频道的用户 ${userid} 没有会话连接, 应移除此订阅记录`); - //console.log(user, "在这里移除可能更安全点, 因为用户可能只是断线, 立即就会重连") - //console.log("但是, 就不能清除闲置频道占用") - //return channel.delete(userid) - } - user.forEach((value, ws) => { + this.用户订阅.atob(fm, (uid) => { + //console.log(`用户 ${uid} 订阅的所有频道`) + this.用户会话.atob(uid, (ws) => { + //console.log(`用户 ${uid} 的会话`) ws.send(msg) }) }) + //console.log(`用户 ${uid} 订阅的所有频道`) + //this.用户订阅.aall(uid, (fid) => { + // console.log(fid) + //}) + //console.log(`频道 ${fm} 下的所有用户`) + //this.用户订阅.ball(fm, (uid) => { + // console.log(uid) + //}) } 移除用户(uid) { - console.log(`移除 ${uid} 的所有会话`) - let user = this.users.get(uid) - if (user) { - user.forEach((value, ws) => ws.close()) // 断开用户所有会话 - this.users.delete(uid) // 删除用户(所有会话) - } - // TODO: 移除所有频道中的此用户 - // TODO: 移除此用户的记录 - // TODO: 断开所有此用户的连接 + this.用户订阅.adelete(uid) + this.用户会话.adelete(uid) } } diff --git a/index.js b/index.js index 2d34d90..07010ce 100644 --- a/index.js +++ b/index.js @@ -7,9 +7,7 @@ import random from 'string-random' import formidable from 'formidable' import md5 from 'md5-node' import HUB from './fmhub.js' -//import site from './collection.js' -const app = expressWs(express()).app const databases = new Map() // 所有数据库 const FM = new HUB() // 频道消息分发器 @@ -228,6 +226,27 @@ const object_create = async function (req, res) { req.body.views = 0 // 再生计数 } + // 如果包含标签 + if (req.body.tags && Array.isArray(req.body.tags)) { + req.body.tags.forEach(item => { + // 先查询是否存在, 存在则使用返回的_id进行挂载, 不存在则创建新的 + db('tag').findOne({ name: item }, function (err, doc) { + if (err && !doc) { + return // 创建新的 + } else { + return // 使用这个 _id, 向它写入 + } + }) + }) + + // 是否可以创建一个复杂关系型数据库? + // 以应对映射的共同对象 + // 例如在使用 tag 时, 向 idea 表的 tag 段读写, 即是 tag表的 idea 索引范围 + // (自动构建和维护双向索引) + // 当删除此 idea 时, 也自动清理掉 tag 对 idea 的连接 + + } + // 如果是挂载对象到指定目标 if (req.body.attach && req.body.aid) { let count = await count_load(req.body.attach, { _id: req.body.aid }) @@ -247,6 +266,40 @@ const object_create = async function (req, res) { }) } +// 修改对象 +const object_patch = function (req, res) { + return db(req.params.name).findOne({ _id: req.params._id }, function (err, doc) { + if (!doc) return res.status(404).send('目标对象不存在') + // 如果是 user 做一些特殊处理 + if (req.params.name === 'user') { + if (req.session.account.uid !== doc._id && req.session.account.gid !== 1) { + return res.status(403).send('没有权限修改账户') + } + if (req.body.gid && req.session.account.gid !== 1) { + return res.status(403).send('没有权限修改权限') + } + if (req.body.password) { + req.body.salt = random(32) // 密码加盐 + req.body.password = md5(req.body.password + req.body.salt) // 设置密码 + } + if (req.body.name) { + // 检查用户名是否可用 + } + } else { + if (req.session.account.uid !== doc.uid && req.session.account.gid !== 1) { + return res.status(403).send('没有权限修改对象') + } + if (req.body.uid && req.session.account.gid !== 1) { + return res.status(403).send('没有权限修改归属') + } + } + return db(req.params.name).update({ _id: req.params._id }, { $set: req.body }, function (err, count) { + if (!count) return res.status(500).send('修改失败') + return res.send('修改成功') + }) + }) +} + // 删除对象 const object_remove = function (req, res) { return db(req.params.name).findOne({ _id: req.params._id }, async function (err, doc) { @@ -298,40 +351,6 @@ const object_load = function (req, res) { }) } -// 修改对象 -const object_patch = function (req, res) { - return db(req.params.name).findOne({ _id: req.params._id }, function (err, doc) { - if (!doc) return res.status(404).send('目标对象不存在') - // 如果是 user 做一些特殊处理 - if (req.params.name === 'user') { - if (req.session.account.uid !== doc._id && req.session.account.gid !== 1) { - return res.status(403).send('没有权限修改账户') - } - if (req.body.gid && req.session.account.gid !== 1) { - return res.status(403).send('没有权限修改权限') - } - if (req.body.password) { - req.body.salt = random(32) // 密码加盐 - req.body.password = md5(req.body.password + req.body.salt) // 设置密码 - } - if (req.body.name) { - // 检查用户名是否可用 - } - } else { - if (req.session.account.uid !== doc.uid && req.session.account.gid !== 1) { - return res.status(403).send('没有权限修改对象') - } - if (req.body.uid && req.session.account.gid !== 1) { - return res.status(403).send('没有权限修改归属') - } - } - return db(req.params.name).update({ _id: req.params._id }, { $set: req.body }, function (err, count) { - if (!count) return res.status(500).send('修改失败') - return res.send('修改成功') - }) - }) -} - // 附件上传 const file_upload = function (req, res) { return db(req.params.name).findOne({ _id: req.params._id }, function (err, doc) { @@ -355,18 +374,55 @@ const file_upload = function (req, res) { }) } +// 头像上传 +const uploadavatar = function (req, res) { + + let idable = formidable({ + multiples: true, + uploadDir: 'data/file', + keepExtensions: true, + maxFieldsSize: 200 * 1024 * 1024, + }) + + idable.parse(req, (err, fields, files) => { + + let list = [] + for (let key in files) { + (Array.isArray(files[key]) ? files[key] : [files[key]]).map((data) => { + let { filepath, mimetype, newFilename, originalFilename, size } = data + list.push({ filepath, mimetype, newFilename, originalFilename, size }) + }) + } + + if (!list[0]) return res.status(400).send('未获得图像') + + let query = { _id: req.session.account.uid } + let data = { + $addToSet: { file: { $each: list } }, // 保存记录 + $set: { avatar: '/data/file/' + list[0].newFilename }, // 替换头像 + } + + db('user').update(query, data, (err, count) => { + if (!count) return res.status(500).send('附件挂载对象失败') + res.json(list[0]) // 返回唯一图像 + }) + + }) +} + const db_compact = function (req, res) { db(req.params.name).persistence.compactDatafile() return res.send("ok") } +const app = expressWs(express()).app app.use(express.json()) app.use(express.urlencoded({ extended: false })) app.use(session({ secret: 'kana', name: 'sid', resave: false, saveUninitialized: false, cookie: { maxAge: 180 * 24 * 3600000 }, store: session_store })) app.use('/data/file/', express.static('data/file')) app.ws('/', websocketer) app.route('/').get((req, res) => res.send(`

Hello World

`)) -app.route('/account').get(profile) +app.route('/account').get(profile).post(online, uploadavatar) app.route('/session').get(online, session_list).post(session_create).delete(online, sessionDeleteSelf) app.route('/session/:sid').delete(online, session_delete) app.route('/:name').get(object_list).post(object_create).put(db_compact) diff --git a/package.json b/package.json index e2bf82a..f092484 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "express-session-nedb": "^1.0.1", "express-ws": "^5.0.2", "formidable": "^2.0.1", + "interrelated": "^2.0.0-0", "md5-node": "^1.0.1", "nedb": "^1.8.0", "string-random": "^0.1.3" diff --git a/start.sh b/start.sh index adf6cde..93306fd 100755 --- a/start.sh +++ b/start.sh @@ -4,3 +4,19 @@ chmod 777 start.sh pm2 delete kana pm2 start node --name kana -- index + + +if [ ! -d "./data/" ];then + mkdir ./data + chmod 777 ./data +fi + +if [ ! -d "./data/db/" ];then + mkdir ./data/db + chmod 777 ./data/db +fi + +if [ ! -d "./data/file/" ];then + mkdir ./data/file + chmod 777 ./data/file +fi diff --git a/yarn.lock b/yarn.lock index 26a142b..f8453b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -274,6 +274,11 @@ inherits@2.0.4: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +interrelated@^2.0.0-0: + version "2.0.0-0" + resolved "https://registry.yarnpkg.com/interrelated/-/interrelated-2.0.0-0.tgz#5e7798c4035051fa5f53efbf94ea27bf491d0162" + integrity sha512-FfLlAsmLBiJRLVdIPhuAiwy6q0QpZIl7fAQTBs4WPAE1/8PdvEpA0OzThvN+3x1pt3bVPYzghTWMhzwrjlfBIw== + ipaddr.js@1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"