From e935558702fd4404a24eae23dc0d716dcd44c59e Mon Sep 17 00:00:00 2001 From: undefined Date: Sat, 18 Dec 2021 23:03:09 +0800 Subject: [PATCH] core: blog (draft) --- packages/hydrooj/package.json | 2 +- packages/hydrooj/src/entry/common.ts | 4 +- packages/hydrooj/src/handler/blog.ts | 132 ++++++++++++++++++ packages/hydrooj/src/handler/homework.ts | 2 +- packages/hydrooj/src/handler/misc.ts | 7 + packages/hydrooj/src/handler/training.ts | 2 +- packages/hydrooj/src/interface.ts | 15 ++ packages/hydrooj/src/model/blog.ts | 92 ++++++++++++ packages/hydrooj/src/model/discussion.ts | 2 +- packages/hydrooj/src/model/document.ts | 23 +-- packages/hydrooj/src/model/record.ts | 2 +- packages/ui-default/package.json | 2 +- .../pages/discussion_detail.page.styl | 3 +- .../pages/discussion_main.page.styl | 1 + .../ui-default/templates/blog_detail.html | 66 +++++++++ packages/ui-default/templates/blog_edit.html | 52 +++++++ packages/ui-default/templates/blog_main.html | 51 +++++++ 17 files changed, 440 insertions(+), 18 deletions(-) create mode 100644 packages/hydrooj/src/handler/blog.ts create mode 100644 packages/hydrooj/src/model/blog.ts create mode 100644 packages/ui-default/templates/blog_detail.html create mode 100644 packages/ui-default/templates/blog_edit.html create mode 100644 packages/ui-default/templates/blog_main.html diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index a3f7793a1..e6c526380 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "3.2.21", + "version": "3.2.22", "bin": "bin/hydrooj.js", "main": "src/loader", "module": "src/loader", diff --git a/packages/hydrooj/src/entry/common.ts b/packages/hydrooj/src/entry/common.ts index caf416932..ac4114852 100644 --- a/packages/hydrooj/src/entry/common.ts +++ b/packages/hydrooj/src/entry/common.ts @@ -20,13 +20,13 @@ export const builtinModel = [ 'builtin', 'document', 'domain', 'blacklist', 'opcount', 'setting', 'token', 'user', 'storage', 'problem', 'record', 'contest', 'message', 'solution', 'training', - 'discussion', 'system', 'oplog', + 'discussion', 'system', 'oplog', 'blog', ]; export const builtinHandler = [ 'home', 'problem', 'record', 'judge', 'user', 'contest', 'training', 'discussion', 'manage', 'import', - 'misc', 'homework', 'domain', 'status', 'api', + 'misc', 'homework', 'domain', 'status', 'api', 'blog', ]; export const builtinScript = [ diff --git a/packages/hydrooj/src/handler/blog.ts b/packages/hydrooj/src/handler/blog.ts new file mode 100644 index 000000000..c2085cd67 --- /dev/null +++ b/packages/hydrooj/src/handler/blog.ts @@ -0,0 +1,132 @@ +import { ObjectID } from 'mongodb'; +import { DiscussionNotFoundError } from '../error'; +import { BlogDoc } from '../interface'; +import paginate from '../lib/paginate'; +import * as blog from '../model/blog'; +import { PERM, PRIV } from '../model/builtin'; +import * as discussion from '../model/discussion'; +import * as oplog from '../model/oplog'; +import user from '../model/user'; +import { + Handler, param, Route, Types, +} from '../service/server'; + +class BlogHandler extends Handler { + ddoc?: BlogDoc; + + @param('did', Types.ObjectID, true) + async _prepare(domainId: string, did: ObjectID) { + this.checkPerm(PERM.PERM_VIEW_DISCUSSION); + if (did) { + this.ddoc = await blog.get(did); + if (!this.ddoc) throw new DiscussionNotFoundError(domainId, did); + } + } +} + +class BlogUserHandler extends BlogHandler { + @param('uid', Types.Int) + @param('page', Types.PositiveInt, true) + async get(domainId: string, uid: number, page = 1) { + const [ddocs, dpcount] = await paginate( + blog.getMulti({ owner: uid }), + page, + 10, + ); + const udoc = await user.getById(domainId, uid); + this.response.template = 'blog_main.html'; + this.response.body = { + ddocs, + dpcount, + udoc, + page, + }; + } +} + +class BlogDetailHandler extends BlogHandler { + @param('did', Types.ObjectID) + async get(domainId: string, did: ObjectID) { + const dsdoc = this.user.hasPriv(PRIV.PRIV_USER_PROFILE) + ? await blog.getStatus(did, this.user._id) + : null; + const udoc = await user.getById(domainId, this.ddoc.owner); + if (!dsdoc?.view) { + await Promise.all([ + blog.inc(did, 'views', 1), + blog.setStatus(did, this.user._id, { view: true }), + ]); + } + this.response.template = 'blog_detail.html'; + this.response.body = { + ddoc: this.ddoc, dsdoc, udoc, + }; + } + + async post() { + this.checkPriv(PRIV.PRIV_USER_PROFILE); + } + + @param('did', Types.ObjectID) + async postStar(domainId: string, did: ObjectID) { + await blog.setStar(did, this.user._id, true); + this.back({ star: true }); + } + + @param('did', Types.ObjectID) + async postUnstar(domainId: string, did: ObjectID) { + await blog.setStar(did, this.user._id, false); + this.back({ star: false }); + } +} + +class BlogEditHandler extends BlogHandler { + async get() { + this.response.template = 'blog_edit.html'; + this.response.body = { ddoc: this.ddoc }; + } + + @param('title', Types.Title) + @param('content', Types.Content) + async postCreate(domainId: string, title: string, content: string) { + this.checkPerm(PERM.PERM_CREATE_DISCUSSION); + await this.limitRate('add_blog', 3600, 60); + const did = await blog.add(this.user._id, title, content, this.request.ip); + this.response.body = { did }; + this.response.redirect = this.url('blog_detail', { uid: this.user._id, did }); + } + + @param('did', Types.ObjectID) + @param('title', Types.Title) + @param('content', Types.Content) + async postUpdate(domainId: string, did: ObjectID, title: string, content: string) { + if (!this.user.own(this.ddoc)) this.checkPerm(PERM.PERM_EDIT_DISCUSSION); + else this.checkPerm(PERM.PERM_EDIT_DISCUSSION_SELF); + await blog.edit(did, title, content); + await oplog.add({ + ...this.ddoc, operator: this.user._id, type: 'edit', + }); + this.response.body = { did }; + this.response.redirect = this.url('blog_detail', { uid: this.user._id, did }); + } + + @param('did', Types.ObjectID) + async postDelete(domainId: string, did: ObjectID) { + if (!this.user.own(this.ddoc)) this.checkPerm(PERM.PERM_DELETE_DISCUSSION); + else this.checkPerm(PERM.PERM_DELETE_DISCUSSION_SELF); + await discussion.del(domainId, did); + await oplog.add({ + ...this.ddoc, operator: this.user._id, operateIp: this.request.ip, type: 'delete', + }); + this.response.redirect = this.url('blog_main', { uid: this.ddoc.owner }); + } +} + +export async function apply() { + Route('blog_main', '/blog/:uid', BlogUserHandler); + Route('blog_create', '/blog/:uid/create', BlogEditHandler); + Route('blog_detail', '/blog/:uid/:did', BlogDetailHandler); + Route('blog_edit', '/blog/:uid/:did/edit', BlogEditHandler); +} + +global.Hydro.handler.blog = apply; diff --git a/packages/hydrooj/src/handler/homework.ts b/packages/hydrooj/src/handler/homework.ts index 486fd7b46..af7009513 100644 --- a/packages/hydrooj/src/handler/homework.ts +++ b/packages/hydrooj/src/handler/homework.ts @@ -206,7 +206,7 @@ class HomeworkScoreboardDownloadHandler extends Handler { csv: (rows) => `\uFEFF${rows.map((c) => (c.map((i) => i.value).join(','))).join('\n')}`, html: (rows) => this.renderHTML('contest_scoreboard_download_html.html', { rows }), }; - if (!getContent[ext]) throw new ValidationError('ext'); + if (!getContent[ext]) throw new ValidationError('ext', null, 'Unknown file extension'); const [tdoc, rows] = await contest.getScoreboard.call(this, domainId, tid, true, 0); this.binary(await getContent[ext](rows), `${tdoc.title}.${ext}`); } diff --git a/packages/hydrooj/src/handler/misc.ts b/packages/hydrooj/src/handler/misc.ts index eec805dec..f24a03c47 100644 --- a/packages/hydrooj/src/handler/misc.ts +++ b/packages/hydrooj/src/handler/misc.ts @@ -66,6 +66,13 @@ export class FilesHandler extends Handler { this.back(); } + @post('from', Types.String) + @post('to', Types.String) + async postRename(domainId: string, from: string, to: string) { + await storage.rename(`user/${this.user._id}/${from}`, `user/${this.user._id}/${to}`); + this.back(); + } + @post('files', Types.Array) async postDeleteFiles(domainId: string, files: string[]) { await Promise.all([ diff --git a/packages/hydrooj/src/handler/training.ts b/packages/hydrooj/src/handler/training.ts index 6aa8caebc..2787539fd 100644 --- a/packages/hydrooj/src/handler/training.ts +++ b/packages/hydrooj/src/handler/training.ts @@ -189,7 +189,7 @@ class TrainingCreateHandler extends Handler { if (pin) this.checkPerm(PERM.PERM_PIN_TRAINING); const dag = await _parseDagJson(domainId, _dag); const pids = training.getPids(dag); - assert(pids.length, new ValidationError('dag')); + assert(pids.length, new ValidationError('dag', null, 'Please specify at least one problem')); const pdocs = await problem.getMulti(domainId, { docId: { $in: pids } }).sort('_id', 1).toArray(); for (const pdoc of pdocs) { if (pdoc.hidden) this.checkPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN); diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index 1b18bf568..aaaa73b94 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -436,6 +436,20 @@ export interface DiscussionTailReplyDoc { history: HistoryDoc[], } +export interface BlogDoc { + docType: document['TYPE_BLOG']; + docId: ObjectID; + owner: number; + title: string; + content: string; + ip: string; + updateAt: Date; + nReply: number; + views: number; + reply: any[]; + react: Record; +} + export interface TokenDoc { _id: string, tokenType: number, @@ -587,6 +601,7 @@ export interface Collections { export interface Model { blacklist: typeof import('./model/blacklist').default, + blog: typeof import('./model/blog'), builtin: typeof import('./model/builtin'), contest: typeof import('./model/contest'), discussion: typeof import('./model/discussion'), diff --git a/packages/hydrooj/src/model/blog.ts b/packages/hydrooj/src/model/blog.ts new file mode 100644 index 000000000..df53732e7 --- /dev/null +++ b/packages/hydrooj/src/model/blog.ts @@ -0,0 +1,92 @@ +import { omit } from 'lodash'; +import { FilterQuery, ObjectID } from 'mongodb'; +import { BlogDoc } from '../interface'; +import * as bus from '../service/bus'; +import { NumberKeys } from '../typeutils'; +import * as document from './document'; + +export async function add( + owner: number, title: string, content: string, + ip: string | null = null, +): Promise { + const payload: Partial = { + content, + owner, + title, + ip, + nReply: 0, + updateAt: new Date(), + views: 0, + }; + await bus.serial('blog/before-add', payload); + const res = await document.add( + 'system', payload.content!, payload.owner!, document.TYPE_BLOG, + null, null, null, omit(payload, ['domainId', 'content', 'owner']), + ); + payload.docId = res; + await bus.emit('blog/add', payload); + return payload.docId; +} + +export async function get(did: ObjectID): Promise { + return await document.get('system', document.TYPE_BLOG, did); +} + +export function edit(did: ObjectID, title: string, content: string): Promise { + const payload = { title, content }; + return document.set('system', document.TYPE_BLOG, did, payload); +} + +export function inc(did: ObjectID, key: NumberKeys, value: number): Promise { + return document.inc('system', document.TYPE_BLOG, did, key, value); +} + +export function del(did: ObjectID): Promise { + return Promise.all([ + document.deleteOne('system', document.TYPE_BLOG, did), + document.deleteMultiStatus('system', document.TYPE_BLOG, { docId: did }), + ]) as any; +} + +export function count(query: FilterQuery) { + return document.count('system', document.TYPE_BLOG, query); +} + +export function getMulti(query: FilterQuery = {}) { + return document.getMulti('system', document.TYPE_BLOG, query) + .sort({ _id: -1 }); +} + +export async function addReply(did: ObjectID, owner: number, content: string, ip: string): Promise { + const [[, drid]] = await Promise.all([ + document.push('system', document.TYPE_BLOG, did, 'reply', content, owner, { ip }), + document.incAndSet('system', document.TYPE_BLOG, did, 'nReply', 1, { updateAt: new Date() }), + ]); + return drid; +} + +export function setStar(did: ObjectID, uid: number, star: boolean) { + return document.setStatus('system', document.TYPE_BLOG, did, uid, { star }); +} + +export function getStatus(did: ObjectID, uid: number) { + return document.getStatus('system', document.TYPE_BLOG, did, uid); +} + +export function setStatus(did: ObjectID, uid: number, $set) { + return document.setStatus('system', document.TYPE_BLOG, did, uid, $set); +} + +global.Hydro.model.blog = { + add, + get, + inc, + edit, + del, + count, + getMulti, + addReply, + setStar, + getStatus, + setStatus, +}; diff --git a/packages/hydrooj/src/model/discussion.ts b/packages/hydrooj/src/model/discussion.ts index 10945f090..67e77b61b 100644 --- a/packages/hydrooj/src/model/discussion.ts +++ b/packages/hydrooj/src/model/discussion.ts @@ -264,8 +264,8 @@ export async function getVnode(domainId: string, type: number, id: string, uid?: return { ...tdoc, type, id: _id }; } return { - ...await getNode(domainId, id), title: id, + ...await getNode(domainId, id), type, id, }; diff --git a/packages/hydrooj/src/model/document.ts b/packages/hydrooj/src/model/document.ts index 96841cfd9..023049e28 100644 --- a/packages/hydrooj/src/model/document.ts +++ b/packages/hydrooj/src/model/document.ts @@ -4,7 +4,9 @@ import { Cursor, FilterQuery, ObjectID, OnlyFieldsOfType, UpdateQuery, } from 'mongodb'; import { - Content, DiscussionDoc, DiscussionReplyDoc, ProblemDoc, ProblemStatusDoc, Tdoc, TrainingDoc, + BlogDoc, Content, DiscussionDoc, + DiscussionReplyDoc, ProblemDoc, ProblemStatusDoc, + Tdoc, TrainingDoc, } from '../interface'; import * as bus from '../service/bus'; import db from '../service/db'; @@ -25,16 +27,18 @@ export const TYPE_DISCUSSION_REPLY: 22 = 22; export const TYPE_CONTEST: 30 = 30; export const TYPE_TRAINING: 40 = 40; export const TYPE_HOMEWORK: 60 = 60; +export const TYPE_BLOG: 70 = 70; export interface DocType { - [TYPE_PROBLEM]: ProblemDoc, - [TYPE_PROBLEM_SOLUTION]: any, - [TYPE_PROBLEM_LIST]: any, - [TYPE_DISCUSSION_NODE]: any, - [TYPE_DISCUSSION]: DiscussionDoc, - [TYPE_DISCUSSION_REPLY]: DiscussionReplyDoc, - [TYPE_CONTEST]: Tdoc, - [TYPE_TRAINING]: TrainingDoc, + [TYPE_PROBLEM]: ProblemDoc; + [TYPE_PROBLEM_SOLUTION]: any; + [TYPE_PROBLEM_LIST]: any; + [TYPE_DISCUSSION_NODE]: any; + [TYPE_DISCUSSION]: DiscussionDoc; + [TYPE_DISCUSSION_REPLY]: DiscussionReplyDoc; + [TYPE_CONTEST]: Tdoc; + [TYPE_TRAINING]: TrainingDoc; + [TYPE_BLOG]: BlogDoc; } export interface DocStatusType { @@ -479,4 +483,5 @@ global.Hydro.model.document = { TYPE_PROBLEM_LIST, TYPE_PROBLEM_SOLUTION, TYPE_TRAINING, + TYPE_BLOG, }; diff --git a/packages/hydrooj/src/model/record.ts b/packages/hydrooj/src/model/record.ts index e182c49ba..933f5830a 100644 --- a/packages/hydrooj/src/model/record.ts +++ b/packages/hydrooj/src/model/record.ts @@ -30,7 +30,7 @@ class RecordModel { const pending = await task.count({ uid }); const timeRecent = await RecordModel.coll .find({ _id: { $gte: Time.getObjectID(moment().add(-1, 'hour')) }, uid }).project({ time: 1 }).toArray(); - return base - ((pending * 100 + 1) * (sum(timeRecent.map((i) => i.time || 0)) / 10000)); + return base - ((pending * 1000 + 1) * (sum(timeRecent.map((i) => i.time || 0)) / 10000)); } static async get(_id: ObjectID): Promise diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 70004125e..ef087a0f9 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.27.9", + "version": "4.27.10", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", diff --git a/packages/ui-default/pages/discussion_detail.page.styl b/packages/ui-default/pages/discussion_detail.page.styl index 8c2579bad..ee52e4739 100644 --- a/packages/ui-default/pages/discussion_detail.page.styl +++ b/packages/ui-default/pages/discussion_detail.page.styl @@ -1,4 +1,5 @@ -.page--discussion_detail +.page--discussion_detail, +.page--blog_detail .topic__content margin: rem(20px 0) diff --git a/packages/ui-default/pages/discussion_main.page.styl b/packages/ui-default/pages/discussion_main.page.styl index fb6c3c45d..4c720f812 100644 --- a/packages/ui-default/pages/discussion_main.page.styl +++ b/packages/ui-default/pages/discussion_main.page.styl @@ -1,5 +1,6 @@ .page--discussion_main, .page--discussion_node, +.page--blog_main, .page--user_detail .discussion__item border-left: rem(5px) solid transparent diff --git a/packages/ui-default/templates/blog_detail.html b/packages/ui-default/templates/blog_detail.html new file mode 100644 index 000000000..d70357408 --- /dev/null +++ b/packages/ui-default/templates/blog_detail.html @@ -0,0 +1,66 @@ +{% extends "layout/basic.html" %} +{% block content %} +
+
+
+
+ +
+ {{ ddoc['content']|markdown|safe }} +
+
+ +
+
+
+
+ +
+ +
+
+
+
+{% endblock %} diff --git a/packages/ui-default/templates/blog_edit.html b/packages/ui-default/templates/blog_edit.html new file mode 100644 index 000000000..a22dfd0ad --- /dev/null +++ b/packages/ui-default/templates/blog_edit.html @@ -0,0 +1,52 @@ +{% import "components/form.html" as form with context %} +{% extends "layout/basic.html" %} +{% block content %} +
+
+
+
+
+
+
+ +
+
+ {{ form.form_textarea({ + columns:12, + label:'Content', + name:'content', + value:ddoc.content|default(''), + hotkeys:'ctrl+enter:submit', + markdown:true, + required:true, + extra_style:'height: 500px', + extra_textarea_class:'auto-resize' + }) }} +
+ + {% if ddoc and (handler.user.own(ddoc) or handler.user.hasPerm(perm.PERM_DELETE_DISCUSSION)) %} + + {% endif %} + +
+
+
+
+
+
+ {% include 'components/md_hint.html' %} +
+
+{% endblock %} diff --git a/packages/ui-default/templates/blog_main.html b/packages/ui-default/templates/blog_main.html new file mode 100644 index 000000000..2d232ae3f --- /dev/null +++ b/packages/ui-default/templates/blog_main.html @@ -0,0 +1,51 @@ +{% import "components/nothing.html" as nothing with context %} +{% import "components/user.html" as user with context %} +{% import "components/paginator.html" as paginator with context %} +{% extends "layout/basic.html" %} +{% block content %} +
+
+
+ {% if not ddocs.length %} + {{ nothing.render('no blog yet...') }} + {% else %} +
    + {%- for ddoc in ddocs -%} +
  1. +
    +
    +
    +
    {{ ddoc.nReply }}
    +
    {{ _('Comments') }}
    +
    +
    +
    +

    {{ ddoc['title'] }}

    +
      +
    • {{ _('{0} views').format(ddoc.views) }}
    • +
    • {{ datetimeSpan(ddoc.updateAt)|safe }}
    • +
    +
    +
    +
  2. + {%- endfor -%} +
+ {% if page and dpcount %}{{ paginator.render(page, dpcount) }}{% endif %} + {% endif %} +
+
+
+
+ {% if udoc.blogpic %}
{% endif %} +
+

{{ _("{0}'s blog").format(udoc.uname) }}

+
+
+ {% if handler.user.hasPerm(perm.PERM_CREATE_DISCUSSION) and handler.user._id == udoc._id %} +

{{ _('Create a Post') }}

+ {% endif %} +
+
+
+
+{% endblock %}