diff --git a/packages/hydrooj/locales/zh.yaml b/packages/hydrooj/locales/zh.yaml index 16f6847f9..cf39a2652 100644 --- a/packages/hydrooj/locales/zh.yaml +++ b/packages/hydrooj/locales/zh.yaml @@ -321,7 +321,9 @@ Extension Score Penalty: 延期递交扣分规则 Failed to join the domain. You are already a member.: 加入域失败,您已是该域的成员。 Feedback: 反馈 Field {0} or {1} validation failed.: 字段 {0} 或 {1} 验证失败。 +Field {0} or {1} validation failed. ({2}): 字段 {0} 或 {1} 验证失败。 ({2}) Field {0} validation failed.: 字段 {0} 验证失败。 +Field {0} validation failed. ({2}): 字段 {0} 验证失败。 ({2}) Filter: 过滤 Footer: 页脚 Forgot Password and/or Username: 忘记密码和/或用户名 diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index ae12d6f44..086692e92 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -28,9 +28,11 @@ "koa-static-cache": "^5.1.4", "lodash": "^4.17.21", "lru-cache": "^6.0.0", + "mime-types": "^2.1.32", "minio": "7.0.17", "moment-timezone": "^0.5.33", "mongodb": "^3.6.11", + "nanoid": "^3.1.25", "nodemailer": "^6.6.3", "p-queue": "^7.1.0", "reflect-metadata": "^0.1.13", @@ -50,6 +52,7 @@ "@types/koa-static-cache": "^5.1.1", "@types/lodash": "^4.14.172", "@types/lru-cache": "^5.1.1", + "@types/mime-types": "^2.1.1", "@types/minio": "^7.0.9", "@types/mongodb": "^3.6.20", "@types/nodemailer": "^6.4.4", diff --git a/packages/hydrooj/src/entry/common.ts b/packages/hydrooj/src/entry/common.ts index d8f22a642..7be2d1336 100644 --- a/packages/hydrooj/src/entry/common.ts +++ b/packages/hydrooj/src/entry/common.ts @@ -30,8 +30,7 @@ export const builtinHandler = [ ]; export const builtinScript = [ - 'rating', 'problemStat', 'blacklist', 'deleteUser', 'updateFilelist', - 'storageUsage', + 'rating', 'problemStat', 'blacklist', 'deleteUser', 'storageUsage', ]; function getFiles(folder: string, base = ''): string[] { diff --git a/packages/hydrooj/src/error.ts b/packages/hydrooj/src/error.ts index 2e2e8af33..15880844c 100644 --- a/packages/hydrooj/src/error.ts +++ b/packages/hydrooj/src/error.ts @@ -56,21 +56,27 @@ export const InvalidTokenError = Err('InvalidTokenError', ForbiddenError); export const BlacklistedError = Err('BlacklistedError', ForbiddenError, 'Address or user {0} is blacklisted.'); export const VerifyPasswordError = Err('VerifyPasswordError', ForbiddenError, "Passwords don't match."); export const OpcountExceededError = Err('OpcountExceededError', ForbiddenError, 'Too frequent operations of {0} (limit: {2} operations in {1} seconds).'); -export const PermissionError = Err('PermissionError', ForbiddenError, function () { +export const PermissionError = Err('PermissionError', ForbiddenError, function (this: HydroError) { if (typeof this.params[0] === 'bigint') { this.params[0] = require('./model/builtin').PERMS.find(({ key }) => key === this.params[0])?.desc || this.params[0]; } return "You don't have the required permission ({0}) in this domain."; }); -export const PrivilegeError = Err('PrivilegeError', ForbiddenError, function () { +export const PrivilegeError = Err('PrivilegeError', ForbiddenError, function (this: HydroError) { if (this.params.includes(global.Hydro.model.builtin.PRIV.PRIV_USER_PROFILE)) { return "You're not logged in."; } return "You don't have the required privilege."; }); -export const ValidationError = Err('ValidationError', ForbiddenError, function () { - if (this.params.length === 1) return 'Field {0} validation failed.'; - return 'Field {0} or {1} validation failed.'; +export const ValidationError = Err('ValidationError', ForbiddenError, function (this: HydroError) { + if (this.params.length === 3) { + return this.params[1] + ? 'Field {0} or {1} validation failed. ({2})' + : 'Field {0} validation failed. ({2})'; + } + return this.params[1] + ? 'Field {0} or {1} validation failed.' + : 'Field {0} validation failed.'; }); export const ContestNotAttendedError = Err('ContestNotAttendedError', ForbiddenError, "You haven't attended this contest yet."); export const RequireProError = Err('RequireProError', ForbiddenError, 'RequireProError'); diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 1ef3ff450..0feb46392 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -2,9 +2,12 @@ import moment from 'moment-timezone'; import { ObjectID } from 'mongodb'; import AdmZip from 'adm-zip'; import { Time } from '@hydrooj/utils/lib/utils'; +import { lookup } from 'mime-types'; import { ContestNotLiveError, ValidationError, ProblemNotFoundError, - ContestNotAttendedError, PermissionError, BadRequestError, ContestNotFoundError, InvalidTokenError, RecordNotFoundError, + ContestNotAttendedError, PermissionError, BadRequestError, + ContestNotFoundError, InvalidTokenError, RecordNotFoundError, + NotFoundError, } from '../error'; import { ProblemDoc, Tdoc, User } from '../interface'; import paginate from '../lib/paginate'; @@ -315,10 +318,21 @@ export class ContestProblemFileDownloadHandler extends ContestProblemHandler { async get(domainId: string, filename: string, noDisposition = false) { // @ts-ignore if (typeof this.pdoc.docId === 'string') this.pdoc.docId = this.pdoc.docId.split(':')[1]; - this.response.redirect = await storage.signDownloadLink( - `problem/${this.pdoc.domainId}/${this.pdoc.docId}/additional_file/${filename}`, - noDisposition ? undefined : filename, false, 'user', - ); + const target = `problem/${this.pdoc.domainId}/${this.pdoc.docId}/additional_file/${filename}`; + const file = await storage.getMeta(target); + if (!file) throw new NotFoundError(filename); + this.response.etag = file.etag; + const type = lookup(filename).toString(); + const shouldProxy = ['image', 'video', 'audio', 'pdf', 'vnd'].filter((i) => type.includes(i)).length; + if (shouldProxy && file.size! < 32 * 1024 * 1024) { + this.response.body = await storage.get(target); + this.response.type = file['Content-Type'] || type; + if (!noDisposition) this.response.addHeader('Content-Disposition', `attachment; filename=${filename}`); + } else { + this.response.redirect = await storage.signDownloadLink( + target, noDisposition ? undefined : filename, false, 'user', + ); + } } } diff --git a/packages/hydrooj/src/handler/homework.ts b/packages/hydrooj/src/handler/homework.ts index 713113492..ed2c131fa 100644 --- a/packages/hydrooj/src/handler/homework.ts +++ b/packages/hydrooj/src/handler/homework.ts @@ -3,9 +3,10 @@ import moment from 'moment-timezone'; import { ObjectID } from 'mongodb'; import AdmZip from 'adm-zip'; import { Time } from '@hydrooj/utils/lib/utils'; +import { lookup } from 'mime-types'; import { ValidationError, HomeworkNotLiveError, ProblemNotFoundError, - HomeworkNotAttendedError, BadRequestError, + HomeworkNotAttendedError, BadRequestError, NotFoundError, } from '../error'; import { PenaltyRules, Tdoc, ProblemDoc, User, @@ -181,10 +182,21 @@ export class HomeworkProblemFileDownloadHandler extends HomeworkDetailProblemHan async get(domainId: string, filename: string, noDisposition = false) { // @ts-ignore if (typeof this.pdoc.docId === 'string') this.pdoc.docId = this.pdoc.docId.split(':')[1]; - this.response.redirect = await storage.signDownloadLink( - `problem/${this.pdoc.domainId}/${this.pdoc.docId}/additional_file/${filename}`, - noDisposition ? undefined : filename, false, 'user', - ); + const target = `problem/${this.pdoc.domainId}/${this.pdoc.docId}/additional_file/${filename}`; + const file = await storage.getMeta(target); + if (!file) throw new NotFoundError(filename); + this.response.etag = file.etag; + const type = lookup(filename).toString(); + const shouldProxy = ['image', 'video', 'audio', 'pdf', 'vnd'].filter((i) => type.includes(i)).length; + if (shouldProxy && file.size! < 32 * 1024 * 1024) { + this.response.body = await storage.get(target); + this.response.type = file['Content-Type'] || type; + if (!noDisposition) this.response.addHeader('Content-Disposition', `attachment; filename=${filename}`); + } else { + this.response.redirect = await storage.signDownloadLink( + target, noDisposition ? undefined : filename, false, 'user', + ); + } } } diff --git a/packages/hydrooj/src/handler/misc.ts b/packages/hydrooj/src/handler/misc.ts index 5435588b0..38e07d494 100644 --- a/packages/hydrooj/src/handler/misc.ts +++ b/packages/hydrooj/src/handler/misc.ts @@ -1,6 +1,11 @@ /* eslint-disable camelcase */ import { pick } from 'lodash'; -import { BadRequestError, ForbiddenError, ValidationError } from '../error'; +import { lookup } from 'mime-types'; +import { + BadRequestError, ForbiddenError, NotFoundError, + ValidationError, +} from '../error'; +import { sortFiles } from '../utils'; import { PRIV } from '../model/builtin'; import storage from '../model/storage'; import * as system from '../model/system'; @@ -8,7 +13,6 @@ import user from '../model/user'; import { Route, Handler, Types, param, post, } from '../service/server'; -import { sortFiles } from '../utils'; class CheckInHandler extends Handler { async prepare() { @@ -78,7 +82,7 @@ export class FilesHandler extends Handler { throw new ForbiddenError('File size limit exceeded.'); } if (!filename) filename = this.request.files.file.name || String.random(16); - if (filename.includes('/') || filename.includes('..')) throw new ValidationError('filename', 'Bad filename'); + if (filename.includes('/') || filename.includes('..')) throw new ValidationError('filename', null, 'Bad filename'); if (this.user._files.filter((i) => i.name === filename).length) throw new BadRequestError('file exists'); await storage.put(`user/${this.user._id}/${filename}`, this.request.files.file.path); const meta = await storage.getMeta(`user/${this.user._id}/${filename}`); @@ -104,9 +108,22 @@ export class FSDownloadHandler extends Handler { @param('filename', Types.Name) @param('noDisposition', Types.Boolean) async get(domainId: string, uid: number, filename: string, noDisposition = false) { - this.response.redirect = await storage.signDownloadLink( - `user/${uid}/${filename}`, noDisposition ? undefined : filename, false, 'user', - ); + this.response.addHeader('Cache-Control', 'public'); + const target = `user/${uid}/${filename}`; + const file = await storage.getMeta(target); + if (!file) throw new NotFoundError(filename); + this.response.etag = file.etag; + const type = lookup(filename).toString(); + const shouldProxy = ['image', 'video', 'audio', 'pdf', 'vnd'].filter((i) => type.includes(i)).length; + if (shouldProxy && file.size! < 32 * 1024 * 1024) { + this.response.body = await storage.get(target); + this.response.type = file['Content-Type'] || type; + if (!noDisposition) this.response.addHeader('Content-Disposition', `attachment; filename=${filename}`); + } else { + this.response.redirect = await storage.signDownloadLink( + target, noDisposition ? undefined : filename, false, 'user', + ); + } } } diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index 378908554..07b332259 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -2,10 +2,11 @@ import { isSafeInteger, flatten } from 'lodash'; import { FilterQuery, ObjectID } from 'mongodb'; import AdmZip from 'adm-zip'; import { sortFiles } from '@hydrooj/utils/lib/utils'; +import { lookup } from 'mime-types'; import { NoProblemError, PermissionError, ValidationError, SolutionNotFoundError, ProblemNotFoundError, BadRequestError, - ForbiddenError, + ForbiddenError, NotFoundError, } from '../error'; import { ProblemDoc, User, ProblemStatusDoc, @@ -373,7 +374,7 @@ export class ProblemFilesHandler extends ProblemDetailHandler { throw new ForbiddenError('File size limit exceeded.'); } if (!filename) filename = this.request.files.file.name || String.random(16); - if (filename.includes('/') || filename.includes('..')) throw new ValidationError('filename', 'Bad filename'); + if (filename.includes('/') || filename.includes('..')) throw new ValidationError('filename', null, 'Bad filename'); if (!this.user.own(this.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) this.checkPerm(PERM.PERM_EDIT_PROBLEM); if (filename.endsWith('.zip')) { const zip = new AdmZip(this.request.files.file.path); @@ -414,10 +415,21 @@ export class ProblemFileDownloadHandler extends ProblemDetailHandler { if (type === 'testdata' && !this.user.own(this.pdoc)) { if (!this.user.hasPriv(PRIV.PRIV_READ_PROBLEM_DATA)) this.checkPerm(PERM.PERM_READ_PROBLEM_DATA); } - this.response.redirect = await storage.signDownloadLink( - `problem/${this.pdoc.domainId}/${this.pdoc.docId}/${type}/${filename}`, - noDisposition ? undefined : filename, false, 'user', - ); + const target = `problem/${domainId}/${this.pdoc.docId}/${type}/${filename}`; + const file = await storage.getMeta(target); + if (!file) throw new NotFoundError(filename); + this.response.etag = file.etag; + const type = lookup(filename).toString(); + const shouldProxy = ['image', 'video', 'audio', 'pdf', 'vnd'].filter((i) => type.includes(i)).length; + if (shouldProxy && file.size! < 32 * 1024 * 1024) { + this.response.body = await storage.get(target); + this.response.type = file['Content-Type'] || type; + if (!noDisposition) this.response.addHeader('Content-Disposition', `attachment; filename=${filename}`); + } else { + this.response.redirect = await storage.signDownloadLink( + target, noDisposition ? undefined : filename, false, 'user', + ); + } } } diff --git a/packages/hydrooj/src/handler/training.ts b/packages/hydrooj/src/handler/training.ts index 07f2c43f7..7e3a1dae3 100644 --- a/packages/hydrooj/src/handler/training.ts +++ b/packages/hydrooj/src/handler/training.ts @@ -50,7 +50,7 @@ async function _parseDagJson(domainId: string, _dag: string): Promise { - report({ message: `${pdoc.domainId}/${pdoc.docId}` }); - const [data, additional_file] = await Promise.all([ - storage.list(`problem/${pdoc.domainId}/${pdoc.docId}/testdata/`), - storage.list(`problem/${pdoc.domainId}/${pdoc.docId}/additional_file/`), - ]) as any; - for (let i = 0; i < data.length; i++) { - data[i]._id = data[i].name; - } - for (let i = 0; i < additional_file.length; i++) { - additional_file[i]._id = additional_file[i].name; - } - return { data, additional_file }; - }; - if (domainId) await iterateAllProblemInDomain(domainId, [], cb); - else await iterateAllProblem([], cb); - return true; -} - -export const validate = { - domainId: 'string?', -}; - -global.Hydro.script.updateFilelist = { run, description, validate }; diff --git a/packages/hydrooj/src/service/server.ts b/packages/hydrooj/src/service/server.ts index b9797f813..22b97c5cb 100644 --- a/packages/hydrooj/src/service/server.ts +++ b/packages/hydrooj/src/service/server.ts @@ -409,7 +409,9 @@ export class Handler extends HandlerCommon { template?: string, redirect?: string, disposition?: string, + etag?: string, attachment: (name: string, stream?: any) => void, + addHeader: (name: string, value: string) => void, }; csrfToken: string; @@ -452,6 +454,7 @@ export class Handler extends HandlerCommon { ctx.body = streamOrBuffer.pipe(new PassThrough()); } }, + addHeader: (name: string, value: string) => this.ctx.set(name, value), disposition: null, }; this.UiContext = cloneDeep(UiContextBase); @@ -592,18 +595,23 @@ export class Handler extends HandlerCommon { async putResponse() { if (this.response.disposition) this.ctx.set('Content-Disposition', this.response.disposition); if (!this.response.body) return; - if (this.response.redirect && !this.request.json) { + if (this.response.etag) { + this.ctx.set('ETag', this.response.etag); + this.ctx.set('Cache-Control', 'public'); + } + if (this.response.etag && this.request.headers['if-none-match'] === this.response.etag) { + this.ctx.response.status = 304; + } else if (this.response.redirect && !this.request.json) { this.ctx.response.type = 'application/octet-stream'; this.ctx.response.status = 302; this.ctx.redirect(this.response.redirect); } else { - this.ctx.response.body = this.response.body; + this.ctx.body = this.response.body; this.ctx.response.status = this.response.status || 200; - this.ctx.response.type = this.request.json - ? 'application/json' - : this.response.type - ? this.response.type - : this.ctx.response.type; + this.ctx.response.type = this.response.type + || (this.request.json + ? 'application/json' + : this.ctx.response.type); } } @@ -731,7 +739,7 @@ const Checker = (permPrivChecker) => { perm = item; } } - return function check() { + return function check(this: Handler) { checker(); if (perm) this.checkPerm(perm); // lgtm [js/trivial-conditional] if (priv) this.checkPriv(priv); diff --git a/packages/hydrooj/src/service/storage.ts b/packages/hydrooj/src/service/storage.ts index f80c0b02e..4d9130fda 100644 --- a/packages/hydrooj/src/service/storage.ts +++ b/packages/hydrooj/src/service/storage.ts @@ -195,11 +195,10 @@ class StorageService { try { const headers: Record = {}; if (filename) headers['response-content-disposition'] = `attachment; filename="${encodeRFC5987ValueChars(filename)}"`; - if (/\.(jpe?g|png|gif)$/i.test(target)) headers['cache-control'] = 'max-age=604800'; const url = await this.client.presignedGetObject( this.opts.bucket, target, - noExpire ? 24 * 60 * 60 * 7 : 30 * 60, + noExpire ? 24 * 60 * 60 * 7 : 10 * 60, headers, ); if (useAlternativeEndpointFor) return this.replaceWithAlternativeUrlFor[useAlternativeEndpointFor](url); diff --git a/packages/import-qduoj/handler.ts b/packages/import-qduoj/handler.ts index 6d3b75288..43b3126ba 100644 --- a/packages/import-qduoj/handler.ts +++ b/packages/import-qduoj/handler.ts @@ -122,7 +122,7 @@ class ImportQduojHandler extends Handler { async post({ domainId }) { if (!this.request.files.file) throw new ValidationError('file'); const stat = await fs.stat(this.request.files.file.path); - if (stat.size > 128 * 1024 * 1024) throw new BadRequestError('File too large'); + if (stat.size > 128 * 1024 * 1024) throw new ValidationError('file', 'File too large'); await this.ImportFromFile(domainId, this.request.files.file.path); this.response.redirect = this.url('problem_main'); } diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 25a820902..9f06badac 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.17.5", + "version": "4.17.6", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", diff --git a/packages/ui-default/utils/request.js b/packages/ui-default/utils/request.js index 5d76634b2..05e645148 100644 --- a/packages/ui-default/utils/request.js +++ b/packages/ui-default/utils/request.js @@ -9,6 +9,9 @@ const request = { $ .ajax({ dataType: 'json', + headers: { + Accept: 'application/json', + }, ...options, }) .fail((jqXHR, textStatus, errorThrown) => {