core: blog (draft)

This commit is contained in:
undefined committed 2021-12-18 23:03:09 +08:00
1 parent d40f56e676
commit e935558702
17 files changed
+440 -18

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hydrooj",
"version": "3.2.21",
"version": "3.2.22",
"bin": "bin/hydrooj.js",
"main": "src/loader",
"module": "src/loader",
+2 -2
View File
@@ -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 = [
+132
View File
@@ -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;
+1 -1
View File
@@ -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}`);
}
+7
View File
@@ -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([
+1 -1
View File
@@ -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);
+15
View File
@@ -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<string, number>;
}
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'),
+92
View File
@@ -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<ObjectID> {
const payload: Partial<BlogDoc> = {
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<BlogDoc> {
return await document.get('system', document.TYPE_BLOG, did);
}
export function edit(did: ObjectID, title: string, content: string): Promise<BlogDoc> {
const payload = { title, content };
return document.set('system', document.TYPE_BLOG, did, payload);
}
export function inc(did: ObjectID, key: NumberKeys<BlogDoc>, value: number): Promise<BlogDoc | null> {
return document.inc('system', document.TYPE_BLOG, did, key, value);
}
export function del(did: ObjectID): Promise<never> {
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<BlogDoc>) {
return document.count('system', document.TYPE_BLOG, query);
}
export function getMulti(query: FilterQuery<BlogDoc> = {}) {
return document.getMulti('system', document.TYPE_BLOG, query)
.sort({ _id: -1 });
}
export async function addReply(did: ObjectID, owner: number, content: string, ip: string): Promise<ObjectID> {
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,
};
+1 -1
View File
@@ -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,
};
+14 -9
View File
@@ -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,
};
+1 -1
View File
@@ -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<RecordDoc | null>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hydrooj/ui-default",
"version": "4.27.9",
"version": "4.27.10",
"author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0",
"main": "hydro.js",
@@ -1,4 +1,5 @@
.page--discussion_detail
.page--discussion_detail,
.page--blog_detail
.topic__content
margin: rem(20px 0)
@@ -1,5 +1,6 @@
.page--discussion_main,
.page--discussion_node,
.page--blog_main,
.page--user_detail
.discussion__item
border-left: rem(5px) solid transparent
@@ -0,0 +1,66 @@
{% extends "layout/basic.html" %}
{% block content %}
<div class="row">
<div class="medium-9 columns">
<div class="section">
<div class="section__body">
<ul class="supplementary list">
<li>
<a class="discussion-node-tag" href="{{ url('blog_main', uid=udoc._id) }}">
<span class="v-center icon icon-flag"></span>
{{ _("{0}'s blog").format(udoc.uname) }}
</a>
</li>
<li><h2>{{ ddoc.title }}</h2></li>
<li>
{{ user.render_inline(udict[ddoc.owner]) }}
@
{{ datetimeSpan(ddoc._id)|safe }}
</li>
</ul>
<div class="typo topic__content" data-emoji-enabled>
{{ ddoc['content']|markdown|safe }}
</div>
</div>
<ul class="section__footer supplementary dot list">
{% if handler.user.hasPerm(perm.PERM_EDIT_DISCUSSION) or (handler.user.own(ddoc) and handler.user.hasPerm(perm.PERM_EDIT_DISCUSSION_SELF)) %}
<li><a href="{{ url('blog_edit', uid=udoc._id, did=ddoc.docId) }}">
<span class="icon icon-edit"></span> {{ _('Edit') }}
</a></li>
{% endif %}
<li>{{ _('{0} views').format(ddoc.views) }}</li>
{% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
<li><form class="form--inline" method="post">
<input type="hidden" name="operation" value="{% if not dsdoc['star'] %}star{% else %}unstar{% endif %}">
<input type="hidden" name="csrfToken" value="{{ handler.csrfToken }}">
<button class="star{% if dsdoc['star'] %} activated{% endif %}" type="submit">
<span class="starred--hide"><span class="icon icon-star--outline"></span> {{ _('Star Topic') }}</span>
<span class="starred--show"><span class="icon icon-star"></span> {{ _('Unstar Topic') }}</span>
</button>
</form></li>
{% endif %}
<li><a href="{{ url('wiki_help', anchor='contact') }}">
<span class="icon icon-warning"></span> {{ _('Report') }}
</a></li>
</ul>
</div>
</div>
<div class="medium-3 columns">
<div class="section side">
<div class="profile__bg user-profile-bg--1"></div>
<div class="section__body">
<div class="media sidebar-user-profile">
<div class="media__left">
<img src="{{ avatarUrl(udoc.avatar, 80) }}" width="80" height="80" class="medium user-profile-avatar">
</div>
<div class="media__body">
<p>
{{ user.render_inline(udoc, avatar=false, modbadge=false) }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,52 @@
{% import "components/form.html" as form with context %}
{% extends "layout/basic.html" %}
{% block content %}
<div class="row">
<div class="medium-9 columns">
<div class="section">
<div class="section__body">
<form method="post">
<div class="row">
<div class="medium-12 columns">
<label>
{{ _('Title') }}
<input
name="title" placeholder="{{ _('title') }}"
value="{{ ddoc.title|default('') }}"
class="textbox" autofocus>
</label>
</div>
</div>
{{ 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'
}) }}
<div class="row"><div class="columns">
<button name="operation" value="{{ 'update' if ddoc else 'create' }}" type="submit" class="rounded primary button">
{{ _('Update') if ddoc else _('Create') }} (Ctrl+Enter)
</button>
{% if ddoc and (handler.user.own(ddoc) or handler.user.hasPerm(perm.PERM_DELETE_DISCUSSION)) %}
<button name="operation" value="delete" type="submit" class="rounded button">
{{ _('Delete') }}
</button>
{% endif %}
<button type="button" class="rounded button" onclick="window.history.go(-1)">
{{ _('Cancel') }}
</button>
</div></div>
</form>
</div>
</div>
</div>
<div class="medium-3 columns">
{% include 'components/md_hint.html' %}
</div>
</div>
{% endblock %}
@@ -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 %}
<div class="row">
<div class="medium-9 columns">
<div class="section">
{% if not ddocs.length %}
{{ nothing.render('no blog yet...') }}
{% else %}
<ol class="section__list discussion__list">
{%- for ddoc in ddocs -%}
<li class="section__list__item discussion__item">
<div class="media">
<div class="media__left">
<div class="discussion__replies numbox">
<div class="numbox__num large">{{ ddoc.nReply }}</div>
<div class="numbox__text">{{ _('Comments') }}</div>
</div>
</div>
<div class="media__body top">
<h1 class="discussion__title"><a href="{{ url('blog_detail', uid=udoc._id, did=ddoc._id, anchor=ddoc.updateAt.getTime()) }}" data-emoji-enabled>{{ ddoc['title'] }}</a></h1>
<ul class="supplementary list">
<li>{{ _('{0} views').format(ddoc.views) }}</li>
<li>{{ datetimeSpan(ddoc.updateAt)|safe }}</li>
</ul>
</div>
</div>
</li>
{%- endfor -%}
</ol>
{% if page and dpcount %}{{ paginator.render(page, dpcount) }}{% endif %}
{% endif %}
</div>
</div>
<div class="medium-3 columns">
<div class="section side">
{% if udoc.blogpic %}<div class="discussion-node__bg {{ udoc.blogpic }}"></div>{% endif %}
<div class="section__header">
<h1 class="section__title">{{ _("{0}'s blog").format(udoc.uname) }}</h1>
</div>
<div class="section__body">
{% if handler.user.hasPerm(perm.PERM_CREATE_DISCUSSION) and handler.user._id == udoc._id %}
<p><a href="{{ url('blog_create', uid=udoc._id) }}" class="expanded primary button">{{ _('Create a Post') }}</a></p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}