├── .editorconfig ├── .eslintrc.yml ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── check.yaml │ └── publish.yaml ├── .gitignore ├── .husky └── pre-commit ├── .nvmrc ├── .prettierrc.yml ├── .vscode └── settings.json ├── .yarnrc.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── SECURITY.md ├── apps ├── README.md ├── frontend │ ├── .browserslistrc │ ├── .eslintrc.yml │ ├── .gitignore │ ├── README.md │ ├── env.d.ts │ ├── index.html │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ └── stage0.js │ ├── src │ │ ├── App.vue │ │ ├── assets │ │ │ └── logo.svg │ │ ├── components │ │ │ ├── admin │ │ │ │ ├── AdminSettingsInput.vue │ │ │ │ └── misc │ │ │ │ │ ├── DefaultOrgInput.vue │ │ │ │ │ ├── MilestoneSettingsInput.vue │ │ │ │ │ └── TitleURLInput.vue │ │ │ ├── announcement │ │ │ │ ├── AnnouncementList.vue │ │ │ │ └── fmtdate.ts │ │ │ ├── aoi │ │ │ │ ├── AoiBar.vue │ │ │ │ ├── AoiBarAddMenu.vue │ │ │ │ ├── AoiBarUserMenu.vue │ │ │ │ ├── AoiFooter.vue │ │ │ │ ├── AoiGravatar.vue │ │ │ │ ├── AoiLogo.vue │ │ │ │ ├── AoiNavDrawer.vue │ │ │ │ ├── AoiNavOrgSelector.vue │ │ │ │ └── AoiNotFound.vue │ │ │ ├── app │ │ │ │ ├── AppList.vue │ │ │ │ ├── AppSettingsInput.vue │ │ │ │ └── types.ts │ │ │ ├── common │ │ │ │ ├── CommonTagDialog.ts │ │ │ │ ├── CommonTagDialog.vue │ │ │ │ ├── RuleEditor.vue │ │ │ │ └── RulesEditor.vue │ │ │ ├── contest │ │ │ │ ├── ContestList.vue │ │ │ │ ├── ContestProblemIdInput.vue │ │ │ │ ├── ContestProblemRecommender.vue │ │ │ │ ├── ContestProblemSettingsInput.vue │ │ │ │ ├── ContestProgressBar.ts │ │ │ │ ├── ContestProgressBar.vue │ │ │ │ ├── ContestStageActionInput.vue │ │ │ │ ├── ContestStageChip.vue │ │ │ │ ├── ContestStageSettingsInput.vue │ │ │ │ ├── ContestStageTagRulesInput.vue │ │ │ │ ├── ContestTabs.vue │ │ │ │ ├── ProblemJumpBtn.vue │ │ │ │ ├── ProblemTabAdmin.vue │ │ │ │ ├── ProblemTabAttachments.vue │ │ │ │ ├── RanklistExportBtn.vue │ │ │ │ ├── RanklistExportBtnV2.vue │ │ │ │ ├── RanklistPublicSettings.vue │ │ │ │ ├── RanklistPublicSettingsInput.vue │ │ │ │ ├── RanklistRenderer.ts │ │ │ │ ├── RanklistRenderer.vue │ │ │ │ ├── RanklistSettings.vue │ │ │ │ ├── RanklistSettingsInput.vue │ │ │ │ ├── RanklistTopstars.vue │ │ │ │ ├── RanklistViewer.vue │ │ │ │ └── types.ts │ │ │ ├── group │ │ │ │ └── types.ts │ │ │ ├── homepage │ │ │ │ ├── AnnouncementsCard.vue │ │ │ │ ├── FriendLinksCard.vue │ │ │ │ ├── PlanCard.vue │ │ │ │ ├── PlanCardsWrapper.vue │ │ │ │ ├── PosterCarousel.vue │ │ │ │ ├── RecentContestsCard.vue │ │ │ │ ├── SearchBox.vue │ │ │ │ ├── SiteLogo.vue │ │ │ │ └── TimeLabel.vue │ │ │ ├── initial │ │ │ │ └── InitialPasswordInput.vue │ │ │ ├── instance │ │ │ │ ├── InstanceCreateBtn.ts │ │ │ │ ├── InstanceCreateBtn.vue │ │ │ │ ├── InstanceDeleteBtn.ts │ │ │ │ ├── InstanceDeleteBtn.vue │ │ │ │ ├── InstanceFilter.ts │ │ │ │ ├── InstanceFilter.vue │ │ │ │ ├── InstanceList.ts │ │ │ │ ├── InstanceList.vue │ │ │ │ ├── InstanceStateChip.vue │ │ │ │ └── types.ts │ │ │ ├── locale │ │ │ │ └── LocaleSelectBtn.vue │ │ │ ├── org │ │ │ │ ├── admin │ │ │ │ │ └── RunnerInfoInput.vue │ │ │ │ └── home │ │ │ │ │ ├── OrgInfoCard.vue │ │ │ │ │ ├── RecentContestsCard.vue │ │ │ │ │ └── RecentPlansCard.vue │ │ │ ├── plan │ │ │ │ ├── ContestTabAdmin.vue │ │ │ │ ├── PlanContestSettingsInput.vue │ │ │ │ ├── PlanList.vue │ │ │ │ ├── PlanSettingsInput.vue │ │ │ │ └── types.ts │ │ │ ├── problem │ │ │ │ ├── DataUpload.vue │ │ │ │ ├── ProblemList.vue │ │ │ │ ├── ProblemSettingsInput.vue │ │ │ │ ├── ProblemStatus.vue │ │ │ │ ├── ProblemSubmit.vue │ │ │ │ ├── ProblemTagGroup.vue │ │ │ │ ├── submit │ │ │ │ │ ├── SubmitDir.vue │ │ │ │ │ ├── SubmitFile.vue │ │ │ │ │ ├── SubmitForm.vue │ │ │ │ │ └── form │ │ │ │ │ │ ├── FormEditor.vue │ │ │ │ │ │ └── FormMetadata.vue │ │ │ │ └── types.ts │ │ │ ├── solution │ │ │ │ ├── SolutionDetails.vue │ │ │ │ ├── SolutionDetailsRenderer.vue │ │ │ │ ├── SolutionFilter.ts │ │ │ │ ├── SolutionFilter.vue │ │ │ │ ├── SolutionList.ts │ │ │ │ ├── SolutionList.vue │ │ │ │ ├── SolutionScoreDisplay.vue │ │ │ │ ├── SolutionStateChip.vue │ │ │ │ ├── SolutionStatus.ts │ │ │ │ ├── SolutionStatusChip.vue │ │ │ │ ├── SolutionView.ts │ │ │ │ ├── SolutionView.vue │ │ │ │ └── types.ts │ │ │ ├── user │ │ │ │ ├── UserAuth.vue │ │ │ │ ├── UserAuthIaaa.vue │ │ │ │ ├── UserAuthMail.vue │ │ │ │ ├── UserAuthPassword.vue │ │ │ │ ├── UserAuthSms.vue │ │ │ │ ├── UserAuthUaaa.vue │ │ │ │ ├── UserInfoBoard.vue │ │ │ │ ├── UserProfileInput.vue │ │ │ │ └── types.ts │ │ │ └── utils │ │ │ │ ├── AccessLevelBadge.vue │ │ │ │ ├── AccessLevelChip.vue │ │ │ │ ├── AccessLevelEditor.vue │ │ │ │ ├── AccessLevelInput.vue │ │ │ │ ├── AssociationEditor.vue │ │ │ │ ├── AsyncState.vue │ │ │ │ ├── CapabilityChips.vue │ │ │ │ ├── CapabilityInput.vue │ │ │ │ ├── DateTimeInput.vue │ │ │ │ ├── DownloadBtn.vue │ │ │ │ ├── HelpBtn.vue │ │ │ │ ├── IdInput.vue │ │ │ │ ├── JsonViewer.vue │ │ │ │ ├── LimitChips.vue │ │ │ │ ├── ListInput.vue │ │ │ │ ├── MarkdownEditor.vue │ │ │ │ ├── MarkdownRenderer.vue │ │ │ │ ├── MonacoEditor.vue │ │ │ │ ├── NotFound.vue │ │ │ │ ├── OptionalInput.vue │ │ │ │ ├── OrgProfile.vue │ │ │ │ ├── PrincipalInput.vue │ │ │ │ ├── PrincipalProfile.vue │ │ │ │ ├── RegisterBtn.vue │ │ │ │ ├── SettingsEditor.ts │ │ │ │ ├── SettingsEditor.vue │ │ │ │ ├── UserIdInput.vue │ │ │ │ └── zip │ │ │ │ ├── ZipAutoViewer.vue │ │ │ │ ├── ZipFileViewer.vue │ │ │ │ └── ZipViewer.vue │ │ ├── layouts │ │ │ └── default │ │ │ │ ├── Default.vue │ │ │ │ └── View.vue │ │ ├── locales │ │ │ ├── en.yaml │ │ │ └── zh-Hans.yml │ │ ├── main.ts │ │ ├── pages │ │ │ ├── about.vue │ │ │ ├── admin.vue │ │ │ ├── admin │ │ │ │ ├── index.vue │ │ │ │ ├── misc.vue │ │ │ │ └── user.vue │ │ │ ├── announcement │ │ │ │ ├── [articleId].vue │ │ │ │ ├── [articleId] │ │ │ │ │ ├── edit.vue │ │ │ │ │ └── index.vue │ │ │ │ ├── index.vue │ │ │ │ └── new.vue │ │ │ ├── auth │ │ │ │ ├── login.vue │ │ │ │ ├── login │ │ │ │ │ ├── iaaa.vue │ │ │ │ │ ├── index.vue │ │ │ │ │ ├── mail.vue │ │ │ │ │ ├── password.vue │ │ │ │ │ └── uaaa.vue │ │ │ │ ├── verify.vue │ │ │ │ └── verify │ │ │ │ │ ├── iaaa.vue │ │ │ │ │ ├── index.vue │ │ │ │ │ ├── mail.vue │ │ │ │ │ ├── password.vue │ │ │ │ │ ├── sms.vue │ │ │ │ │ └── uaaa.vue │ │ │ ├── debug.vue │ │ │ ├── index.vue │ │ │ ├── initial.vue │ │ │ ├── oauth │ │ │ │ ├── authorize.vue │ │ │ │ └── device.vue │ │ │ ├── org │ │ │ │ ├── [orgId].vue │ │ │ │ ├── [orgId] │ │ │ │ │ ├── [...all].vue │ │ │ │ │ ├── admin.vue │ │ │ │ │ ├── admin │ │ │ │ │ │ ├── access.vue │ │ │ │ │ │ ├── batch-import.vue │ │ │ │ │ │ ├── index.vue │ │ │ │ │ │ ├── member.vue │ │ │ │ │ │ ├── runner.vue │ │ │ │ │ │ └── settings.vue │ │ │ │ │ ├── app │ │ │ │ │ │ ├── [appId].vue │ │ │ │ │ │ ├── [appId] │ │ │ │ │ │ │ ├── admin.vue │ │ │ │ │ │ │ ├── admin │ │ │ │ │ │ │ │ ├── access.vue │ │ │ │ │ │ │ │ ├── content.vue │ │ │ │ │ │ │ │ └── index.vue │ │ │ │ │ │ │ └── index.vue │ │ │ │ │ │ ├── index.vue │ │ │ │ │ │ ├── new.vue │ │ │ │ │ │ ├── search.vue │ │ │ │ │ │ └── tag │ │ │ │ │ │ │ └── [:tag].vue │ │ │ │ │ ├── contest │ │ │ │ │ │ ├── [contestId].vue │ │ │ │ │ │ ├── [contestId] │ │ │ │ │ │ │ ├── admin.vue │ │ │ │ │ │ │ ├── admin │ │ │ │ │ │ │ │ ├── access.vue │ │ │ │ │ │ │ │ ├── content.vue │ │ │ │ │ │ │ │ ├── index.vue │ │ │ │ │ │ │ │ ├── rule.vue │ │ │ │ │ │ │ │ └── stage.vue │ │ │ │ │ │ │ ├── attachment.vue │ │ │ │ │ │ │ ├── index.vue │ │ │ │ │ │ │ ├── instance.vue │ │ │ │ │ │ │ ├── participant.vue │ │ │ │ │ │ │ ├── participant │ │ │ │ │ │ │ │ ├── [userId].vue │ │ │ │ │ │ │ │ ├── admin.vue │ │ │ │ │ │ │ │ └── index.vue │ │ │ │ │ │ │ ├── problem.vue │ │ │ │ │ │ │ ├── problem │ │ │ │ │ │ │ │ ├── [problemId].vue │ │ │ │ │ │ │ │ ├── index.vue │ │ │ │ │ │ │ │ └── new.vue │ │ │ │ │ │ │ ├── ranklist.vue │ │ │ │ │ │ │ ├── ranklist │ │ │ │ │ │ │ │ ├── [ranklistKey].vue │ │ │ │ │ │ │ │ ├── index.vue │ │ │ │ │ │ │ │ └── new.vue │ │ │ │ │ │ │ ├── solution.vue │ │ │ │ │ │ │ └── solution │ │ │ │ │ │ │ │ ├── [solutionId].vue │ │ │ │ │ │ │ │ └── index.vue │ │ │ │ │ │ ├── index.vue │ │ │ │ │ │ ├── new.vue │ │ │ │ │ │ ├── search.vue │ │ │ │ │ │ └── tag │ │ │ │ │ │ │ └── [:tag].vue │ │ │ │ │ ├── group │ │ │ │ │ │ ├── [groupId].vue │ │ │ │ │ │ ├── [groupId] │ │ │ │ │ │ │ ├── index.vue │ │ │ │ │ │ │ ├── member.vue │ │ │ │ │ │ │ └── settings.vue │ │ │ │ │ │ ├── index.vue │ │ │ │ │ │ └── new.vue │ │ │ │ │ ├── index.vue │ │ │ │ │ ├── instance │ │ │ │ │ │ └── index.vue │ │ │ │ │ ├── plan │ │ │ │ │ │ ├── [planId].vue │ │ │ │ │ │ ├── [planId] │ │ │ │ │ │ │ ├── admin.vue │ │ │ │ │ │ │ ├── admin │ │ │ │ │ │ │ │ ├── access.vue │ │ │ │ │ │ │ │ ├── content.vue │ │ │ │ │ │ │ │ └── index.vue │ │ │ │ │ │ │ ├── contest.vue │ │ │ │ │ │ │ ├── contest │ │ │ │ │ │ │ │ ├── [contestId].vue │ │ │ │ │ │ │ │ ├── index.vue │ │ │ │ │ │ │ │ └── new.vue │ │ │ │ │ │ │ └── index.vue │ │ │ │ │ │ ├── index.vue │ │ │ │ │ │ ├── new.vue │ │ │ │ │ │ ├── search.vue │ │ │ │ │ │ └── tag │ │ │ │ │ │ │ └── [:tag].vue │ │ │ │ │ ├── problem │ │ │ │ │ │ ├── [problemId].vue │ │ │ │ │ │ ├── [problemId] │ │ │ │ │ │ │ ├── admin.vue │ │ │ │ │ │ │ ├── admin │ │ │ │ │ │ │ │ ├── access.vue │ │ │ │ │ │ │ │ ├── content.vue │ │ │ │ │ │ │ │ ├── index.vue │ │ │ │ │ │ │ │ └── rule.vue │ │ │ │ │ │ │ ├── attachment.vue │ │ │ │ │ │ │ ├── data.vue │ │ │ │ │ │ │ ├── index.vue │ │ │ │ │ │ │ ├── instance.vue │ │ │ │ │ │ │ ├── solution.vue │ │ │ │ │ │ │ ├── solution │ │ │ │ │ │ │ │ ├── [solutionId].vue │ │ │ │ │ │ │ │ └── index.vue │ │ │ │ │ │ │ └── submit.vue │ │ │ │ │ │ ├── index.vue │ │ │ │ │ │ ├── new.vue │ │ │ │ │ │ ├── search.vue │ │ │ │ │ │ └── tag │ │ │ │ │ │ │ └── [:tag].vue │ │ │ │ │ └── solution │ │ │ │ │ │ └── index.vue │ │ │ │ └── new.vue │ │ │ ├── rk │ │ │ │ ├── [ranklistId].vue │ │ │ │ └── [ranklistId] │ │ │ │ │ └── index.vue │ │ │ ├── signup.vue │ │ │ └── user │ │ │ │ ├── [userId].vue │ │ │ │ └── [userId] │ │ │ │ ├── index.vue │ │ │ │ └── settings.vue │ │ ├── plugins │ │ │ ├── i18n.ts │ │ │ ├── index.ts │ │ │ ├── toast.ts │ │ │ └── vuetify.ts │ │ ├── router │ │ │ └── index.ts │ │ ├── stores │ │ │ ├── app.ts │ │ │ └── index.ts │ │ ├── styles │ │ │ └── main.css │ │ ├── types │ │ │ ├── index.ts │ │ │ └── server.ts │ │ └── utils │ │ │ ├── admin │ │ │ └── version.ts │ │ │ ├── app │ │ │ └── inject.ts │ │ │ ├── async.ts │ │ │ ├── avatar.ts │ │ │ ├── build.ts │ │ │ ├── capability.ts │ │ │ ├── colors.ts │ │ │ ├── contest │ │ │ ├── action.ts │ │ │ ├── inject.ts │ │ │ ├── participant.ts │ │ │ └── problem │ │ │ │ └── inject.ts │ │ │ ├── editor.ts │ │ │ ├── files │ │ │ ├── hash.ts │ │ │ ├── hash.worker.ts │ │ │ └── index.ts │ │ │ ├── flags.ts │ │ │ ├── http.ts │ │ │ ├── markdown │ │ │ ├── alerts.ts │ │ │ ├── index.ts │ │ │ ├── katex.d.ts │ │ │ └── katex.js │ │ │ ├── menus.ts │ │ │ ├── monaco.ts │ │ │ ├── org │ │ │ └── runner.ts │ │ │ ├── pagination.ts │ │ │ ├── persist.ts │ │ │ ├── plan │ │ │ └── inject.ts │ │ │ ├── platform │ │ │ ├── index.ts │ │ │ └── stage1.ts │ │ │ ├── problem │ │ │ ├── data.ts │ │ │ ├── inject.ts │ │ │ └── submit.ts │ │ │ ├── profile.ts │ │ │ ├── slug.ts │ │ │ ├── solution │ │ │ └── index.ts │ │ │ ├── time.ts │ │ │ ├── title.ts │ │ │ └── user │ │ │ ├── email.ts │ │ │ ├── iaaa.ts │ │ │ ├── password.ts │ │ │ ├── sms.ts │ │ │ └── uaaa.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── uno.config.ts │ └── vite.config.ts └── server │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── src │ ├── auth │ │ ├── base.ts │ │ ├── iaaa.ts │ │ ├── index.ts │ │ ├── mail.ts │ │ ├── password.ts │ │ ├── sms.ts │ │ └── uaaa.ts │ ├── cache │ │ ├── base.ts │ │ ├── index.ts │ │ ├── mongo.ts │ │ └── redis.ts │ ├── cli │ │ ├── index.ts │ │ └── updater.ts │ ├── db │ │ ├── announcement.ts │ │ ├── app.ts │ │ ├── common.ts │ │ ├── contest.ts │ │ ├── group.ts │ │ ├── index.ts │ │ ├── info.ts │ │ ├── instance.ts │ │ ├── org.ts │ │ ├── plan.ts │ │ ├── problem.ts │ │ ├── publicRanklist.ts │ │ ├── runner.ts │ │ ├── solution.ts │ │ └── user.ts │ ├── index.ts │ ├── oss │ │ ├── index.ts │ │ └── key.ts │ ├── routes │ │ ├── admin │ │ │ ├── index.ts │ │ │ └── user.ts │ │ ├── announcement │ │ │ ├── index.ts │ │ │ └── scoped.ts │ │ ├── app │ │ │ ├── admin.ts │ │ │ ├── index.ts │ │ │ ├── inject.ts │ │ │ └── scoped.ts │ │ ├── auth │ │ │ └── index.ts │ │ ├── common │ │ │ ├── access.ts │ │ │ ├── content.ts │ │ │ ├── files.ts │ │ │ ├── index.ts │ │ │ ├── rule.ts │ │ │ └── settings.ts │ │ ├── contest │ │ │ ├── admin.ts │ │ │ ├── attachment.ts │ │ │ ├── index.ts │ │ │ ├── inject.ts │ │ │ ├── participant │ │ │ │ └── index.ts │ │ │ ├── problem │ │ │ │ ├── admin.ts │ │ │ │ ├── common.ts │ │ │ │ └── index.ts │ │ │ ├── ranklist │ │ │ │ ├── admin.ts │ │ │ │ └── index.ts │ │ │ ├── scoped.ts │ │ │ └── solution │ │ │ │ └── index.ts │ │ ├── group │ │ │ ├── index.ts │ │ │ └── scoped.ts │ │ ├── index.ts │ │ ├── info │ │ │ └── index.ts │ │ ├── instance │ │ │ ├── index.ts │ │ │ └── scoped.ts │ │ ├── oauth │ │ │ ├── device.ts │ │ │ ├── githubCompat.ts │ │ │ ├── iaaaCompat.ts │ │ │ └── index.ts │ │ ├── org │ │ │ ├── admin │ │ │ │ ├── index.ts │ │ │ │ ├── member.ts │ │ │ │ └── runner.ts │ │ │ ├── index.ts │ │ │ ├── inject.ts │ │ │ └── scoped.ts │ │ ├── plan │ │ │ ├── admin.ts │ │ │ ├── contest │ │ │ │ ├── admin.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── inject.ts │ │ │ └── scoped.ts │ │ ├── plugins │ │ │ ├── auth.ts │ │ │ ├── health.ts │ │ │ ├── index.ts │ │ │ ├── inject.ts │ │ │ └── ratelimit.ts │ │ ├── problem │ │ │ ├── admin.ts │ │ │ ├── attachment.ts │ │ │ ├── data.ts │ │ │ ├── index.ts │ │ │ ├── inject.ts │ │ │ ├── scoped.ts │ │ │ └── solution.ts │ │ ├── public │ │ │ └── index.ts │ │ ├── pubrk │ │ │ ├── index.ts │ │ │ └── scoped.ts │ │ ├── runner │ │ │ ├── index.ts │ │ │ ├── inject.ts │ │ │ ├── instance.ts │ │ │ ├── ranklist.ts │ │ │ └── solution.ts │ │ ├── solution │ │ │ ├── index.ts │ │ │ └── scoped.ts │ │ └── user │ │ │ ├── index.ts │ │ │ └── scoped.ts │ ├── schemas │ │ ├── api.ts │ │ ├── app.ts │ │ ├── common.ts │ │ ├── contest.ts │ │ ├── formats.ts │ │ ├── group.ts │ │ ├── index.ts │ │ ├── org.ts │ │ ├── plan.ts │ │ ├── problem.ts │ │ └── user.ts │ ├── server │ │ ├── index.ts │ │ └── schemas.ts │ └── utils │ │ ├── capability.ts │ │ ├── config.ts │ │ ├── index.ts │ │ ├── inject.ts │ │ ├── logger.ts │ │ ├── module.ts │ │ ├── package.ts │ │ ├── pagination.ts │ │ ├── rule.ts │ │ ├── search.ts │ │ └── types.ts │ ├── tsconfig.json │ └── tsconfig.package.json ├── docker ├── compose │ └── server │ │ └── docker-compose.yml └── dockerfiles │ └── server.dockerfile ├── docs ├── .vitepress │ ├── config.mts │ └── theme │ │ ├── custom.css │ │ └── index.ts ├── admin-guide.md ├── basic-concepts.md ├── dev-guide.md ├── en │ ├── admin-guide.md │ ├── dev-guide.md │ ├── getting-started.md │ └── index.md ├── getting-started.md ├── index.md ├── public │ ├── favicon.ico │ └── logo.svg ├── rule.md └── user-guide.md ├── libs ├── README.md ├── common │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── schemas │ │ │ ├── index.ts │ │ │ ├── problem.ts │ │ │ ├── ranklist.ts │ │ │ └── solution.ts │ └── tsconfig.json └── rule │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── src │ ├── index.spec.ts │ └── index.ts │ └── tsconfig.json ├── manifest.yml ├── package.json ├── scripts ├── local │ └── .gitkeep └── publish.mjs └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,json,yml}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es2021: true 4 | node: true 5 | extends: 6 | - eslint:recommended 7 | - plugin:@typescript-eslint/recommended 8 | - plugin:import/recommended 9 | - plugin:import/typescript 10 | - prettier 11 | parser: '@typescript-eslint/parser' 12 | parserOptions: 13 | ecmaVersion: latest 14 | sourceType: module 15 | plugins: 16 | - '@typescript-eslint' 17 | rules: 18 | '@typescript-eslint/no-empty-interface': 19 | - 'error' 20 | - allowSingleExtends: true 21 | 'import/order': 22 | - 'error' 23 | - alphabetize: 24 | order: 'asc' 25 | caseInsensitive: true 26 | newlines-between: always 27 | 'import/no-unresolved': 'off' 28 | settings: 29 | import/resolver: 30 | typescript: true 31 | node: true 32 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/** linguist-vendored 2 | /.yarn/releases/* binary 3 | /.yarn/plugins/**/* binary 4 | /.pnp.* binary linguist-generated 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | 15 | Steps to reproduce the behavior: 16 | 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | **Expected behavior** 23 | 24 | A clear and concise description of what you expected to happen. 25 | 26 | **Screenshots** 27 | 28 | If applicable, add screenshots to help explain your problem. 29 | 30 | **Desktop (please complete the following information):** 31 | 32 | - OS: [e.g. Windows 11] 33 | - Browser [e.g. Google Chrome 122.0.0.0] 34 | - Version [e.g. v1.0.10] 35 | 36 | **Smartphone (please complete the following information):** 37 | 38 | - Device: [e.g. Samsung Galaxy S23 Ultra] 39 | - OS: [e.g. OneUI 6.0] 40 | - Browser [e.g. Chrome 122.0.0.0] 41 | - Version [e.g. v1.0.10] 42 | 43 | **Additional context** 44 | 45 | Add any other context about the problem here. 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | 15 | A clear and concise description of what you want to happen. 16 | 17 | **Describe alternatives you've considered** 18 | 19 | A clear and concise description of any alternative solutions or features you've considered. 20 | 21 | **Additional context** 22 | 23 | Add any other context or screenshots about the feature request here. 24 | -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: Code Checks 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | pull_request: 7 | branches: ['main'] 8 | 9 | jobs: 10 | check: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Setup Node 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version-file: '.nvmrc' 22 | 23 | - name: Get yarn cache directory path 24 | id: yarn-cache-dir-path 25 | run: echo "dir=$(corepack yarn config get cacheFolder)" >> $GITHUB_OUTPUT 26 | 27 | - uses: actions/cache@v4 28 | id: yarn-cache 29 | with: 30 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 31 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 32 | restore-keys: | 33 | ${{ runner.os }}-yarn- 34 | 35 | - name: Install dependencies 36 | run: corepack yarn 37 | 38 | - name: Check version policies 39 | if: github.event_name == 'pull_request' 40 | run: corepack yarn version check 41 | 42 | - name: Build packages 43 | run: corepack yarn workspaces foreach -Ap --topological-dev --exclude @aoi-js/frontend run build 44 | 45 | - name: Run checks 46 | run: corepack yarn all:check 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .yarn/* 2 | !.yarn/patches 3 | !.yarn/plugins 4 | !.yarn/releases 5 | !.yarn/sdks 6 | !.yarn/versions 7 | 8 | # Swap the comments on the following lines if you don't wish to use zero-installs 9 | # Documentation here: https://yarnpkg.com/features/zero-installs 10 | # !.yarn/cache 11 | .pnp.* 12 | node_modules 13 | 14 | *.tsbuildinfo 15 | package.tgz 16 | 17 | /scripts/local/* 18 | !/scripts/local/.gitkeep 19 | 20 | docs/.vitepress/dist 21 | docs/.vitepress/cache 22 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | corepack yarn git:precommit 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.12.0 2 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | semi: false 3 | printWidth: 100 4 | trailingComma: none 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "unocss.root": [ 3 | "apps/frontend" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | nodeLinker: node-modules 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | # The **AOI** Project 6 | 7 | > The ultimate, scalable, performant online judge infrastructure 8 | 9 | [![Publish](https://github.com/fedstack-org/aoi/actions/workflows/publish.yaml/badge.svg)](https://github.com/fedstack-org/aoi/actions/workflows/publish.yaml) 10 | 11 |
12 | -------------------------------------------------------------------------------- /apps/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedstack-org/aoi/d84e85ed799eebd905ccd93f028657f799971021/apps/README.md -------------------------------------------------------------------------------- /apps/frontend/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | not ie 11 5 | -------------------------------------------------------------------------------- /apps/frontend/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - 'plugin:vue/vue3-essential' 3 | - 'eslint:recommended' 4 | - '@vue/eslint-config-typescript' 5 | - '@vue/eslint-config-prettier/skip-formatting' 6 | parserOptions: 7 | ecmaVersion: latest 8 | rules: 9 | '@typescript-eslint/no-unused-vars': 'off' 10 | 'vue/multi-word-component-names': 'off' 11 | 'import/default': 'off' 12 | -------------------------------------------------------------------------------- /apps/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /apps/frontend/README.md: -------------------------------------------------------------------------------- 1 | # AOI UI 2 | 3 | This is the frontend for the AOI project. 4 | 5 | ## Configuration 6 | 7 | All configuration is done at build time. The following environment variables are available: 8 | 9 | | Variable | Description | Default | 10 | | -------------------- | --------------------------- | -------------------------- | 11 | | `VITE_APP_NAME` | The name of the application | `AOI` | 12 | | `VITE_GRAVATAR_BASE` | The base URL for Gravatar | `https://www.gravatar.com` | 13 | 14 | To customize the configuration, set the environment variables before building the application. 15 | -------------------------------------------------------------------------------- /apps/frontend/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | declare const __GIT_HASH__: string 5 | declare const __BUILD_TIME__: string 6 | declare const __GIT_BRANCH__: string 7 | -------------------------------------------------------------------------------- /apps/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | AoiUI 8 | %VITE_EXTRA_HEAD% 9 | 10 | 11 | 12 | %VITE_EXTRA_BODY% 13 | 19 |
20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /apps/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedstack-org/aoi/d84e85ed799eebd905ccd93f028657f799971021/apps/frontend/public/favicon.ico -------------------------------------------------------------------------------- /apps/frontend/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /apps/frontend/src/components/admin/AdminSettingsInput.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 12 | -------------------------------------------------------------------------------- /apps/frontend/src/components/admin/misc/DefaultOrgInput.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | 12 | 13 | en: 14 | default-org-id: Default Organization ID 15 | zh-Hans: 16 | default-org-id: 默认组织 ID 17 | 18 | -------------------------------------------------------------------------------- /apps/frontend/src/components/admin/misc/MilestoneSettingsInput.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 29 | 30 | 31 | en: 32 | misc-date-format: Date format should be YYYY-MM-DD 33 | misc-date-invalid: Invalid date 34 | misc-date-past: Date should not be in the past 35 | misc-csp: CSP Date 36 | misc-noip: NOIP Date 37 | zh-Hans: 38 | misc-date-format: 日期格式应为 YYYY-MM-DD 39 | misc-date-invalid: 无效的日期 40 | misc-date-past: 日期不能在过去 41 | misc-csp: CSP 日期 42 | misc-noip: NOIP 日期 43 | 44 | -------------------------------------------------------------------------------- /apps/frontend/src/components/admin/misc/TitleURLInput.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 47 | -------------------------------------------------------------------------------- /apps/frontend/src/components/announcement/fmtdate.ts: -------------------------------------------------------------------------------- 1 | export const fmtDate = (src: string) => { 2 | const date = new Date(src) 3 | return date.toLocaleString('zh-CN', { 4 | year: 'numeric', 5 | month: 'short', 6 | day: 'numeric', 7 | hour: 'numeric', 8 | minute: 'numeric' 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /apps/frontend/src/components/aoi/AoiBarAddMenu.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | 18 | 19 | en: 20 | new-organization: New organization 21 | new-problem: New problem 22 | new-contest: New contest 23 | new-plan: New plan 24 | new-group: New group 25 | new-app: New app 26 | zh-Hans: 27 | new-organization: 新建组织 28 | new-problem: 新建题目 29 | new-contest: 新建比赛 30 | new-plan: 新建计划 31 | new-group: 新建小组 32 | new-app: 新建应用 33 | 34 | -------------------------------------------------------------------------------- /apps/frontend/src/components/aoi/AoiBarUserMenu.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 34 | -------------------------------------------------------------------------------- /apps/frontend/src/components/aoi/AoiFooter.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 25 | 26 | 27 | en: 28 | product: 'The AOI Project' 29 | powered-by: 'Powered by {product}' 30 | zh-Hans: 31 | product: 'AOI 项目' 32 | powered-by: '由 {product} 提供支持' 33 | 34 | -------------------------------------------------------------------------------- /apps/frontend/src/components/aoi/AoiGravatar.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 22 | -------------------------------------------------------------------------------- /apps/frontend/src/components/aoi/AoiLogo.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /apps/frontend/src/components/aoi/AoiNavDrawer.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 40 | -------------------------------------------------------------------------------- /apps/frontend/src/components/aoi/AoiNotFound.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 21 | -------------------------------------------------------------------------------- /apps/frontend/src/components/app/types.ts: -------------------------------------------------------------------------------- 1 | import type { IApp, IAppSettings } from '@aoi-js/server' 2 | 3 | import type { MapEntity } from '@/types/server' 4 | 5 | export interface IAppDTO extends MapEntity { 6 | capability: string 7 | settings: IAppSettings 8 | } 9 | 10 | export interface IAppSettingsDTO extends IAppSettings {} 11 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/CommonTagDialog.ts: -------------------------------------------------------------------------------- 1 | import { useAsyncState } from '@vueuse/core' 2 | 3 | import { http } from '@/utils/http' 4 | 5 | export interface ICommonTagDialogProps { 6 | endpoint: string 7 | target: string 8 | query?: Record 9 | } 10 | 11 | export function useCommonTagDialog(props: ICommonTagDialogProps) { 12 | const tags = useAsyncState( 13 | async () => { 14 | const tags = await http.get(props.endpoint, { searchParams: props.query }).json() 15 | return tags.map((tag) => ({ label: tag, to: props.target.replace(/:tag/g, tag) })) 16 | }, 17 | [], 18 | { immediate: true } 19 | ) 20 | return { tags } 21 | } 22 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/CommonTagDialog.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 41 | -------------------------------------------------------------------------------- /apps/frontend/src/components/common/RuleEditor.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 39 | -------------------------------------------------------------------------------- /apps/frontend/src/components/contest/ContestProblemIdInput.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 35 | -------------------------------------------------------------------------------- /apps/frontend/src/components/contest/ContestStageActionInput.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 21 | -------------------------------------------------------------------------------- /apps/frontend/src/components/contest/ContestStageTagRulesInput.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 22 | -------------------------------------------------------------------------------- /apps/frontend/src/components/contest/ProblemJumpBtn.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 45 | 46 | 47 | en: 48 | jump-to-problem: Jump to problem 49 | zh-Hans: 50 | jump-to-problem: 跳转到原题目 51 | 52 | -------------------------------------------------------------------------------- /apps/frontend/src/components/contest/RanklistPublicSettingsInput.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 15 | -------------------------------------------------------------------------------- /apps/frontend/src/components/contest/RanklistRenderer.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | 3 | import { useContestCapability, useContestData } from '@/utils/contest/inject' 4 | 5 | export function useRanklistRenderer() { 6 | try { 7 | const admin = useContestCapability('admin') 8 | const contest = useContestData() 9 | const participantUrl = (userId: string) => 10 | `/org/${contest.value.orgId}/contest/${contest.value._id}/participant/${userId}` 11 | const problemUrl = (problemId?: string) => 12 | `/org/${contest.value.orgId}/contest/${contest.value._id}/problem/${problemId}` 13 | const solutionUrl = (solutionId?: string) => 14 | `/org/${contest.value.orgId}/contest/${contest.value._id}/solution/${solutionId}` 15 | return { admin, participantUrl, problemUrl, solutionUrl } 16 | } catch { 17 | return { 18 | admin: ref(false), 19 | participantUrl: () => '', 20 | problemUrl: () => '', 21 | solutionUrl: () => '' 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /apps/frontend/src/components/contest/RanklistSettingsInput.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 24 | 25 | 26 | en: 27 | ranklist-config: Ranklist Config 28 | ranklist-config-placeholder: Please refer to the documentation for the configuration format 29 | zh-Hans: 30 | ranklist-config: 排行榜配置 31 | ranklist-config-placeholder: 请参考文档查看配置格式 32 | 33 | -------------------------------------------------------------------------------- /apps/frontend/src/components/contest/RanklistViewer.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 32 | 33 | 34 | en: 35 | ranklist-settings: Ranklist Settings 36 | ranklist-show: Ranklist Details 37 | ranklist-waiting-in-progress: Generating ranklist, please refresh the page later. 38 | zh-Hans: 39 | ranklist-settings: 设置 40 | ranklist-show: 排行榜 41 | ranklist-waiting-in-progress: 正在生成排行榜,请稍后刷新页面。 42 | 43 | -------------------------------------------------------------------------------- /apps/frontend/src/components/contest/types.ts: -------------------------------------------------------------------------------- 1 | import type { ProblemConfig } from '@aoi-js/common' 2 | 3 | import type { IContestProblemSettings, IContestStage } from '@/types' 4 | 5 | export interface IContestDTO { 6 | _id: string 7 | orgId: string 8 | accessLevel: number 9 | slug: string 10 | title: string 11 | description: string 12 | tags: string[] 13 | capability: string 14 | start: number 15 | end: number 16 | stages: { name: string; start: number }[] 17 | 18 | currentStage: IContestStage 19 | } 20 | 21 | export interface IContestProblemListDTO { 22 | _id: string 23 | title: string 24 | tags?: string[] 25 | settings: IContestProblemSettings 26 | } 27 | 28 | export interface IContestProblemDTO { 29 | _id: string 30 | title: string 31 | description: string 32 | tags?: string[] 33 | attachments: [ 34 | { 35 | key: string 36 | name: string 37 | description: string 38 | } 39 | ] 40 | currentDataHash: string 41 | config: ProblemConfig 42 | } 43 | 44 | export interface IContestParticipantDTO { 45 | results: Record< 46 | string, 47 | { 48 | solutionCount: number 49 | } 50 | > 51 | } 52 | -------------------------------------------------------------------------------- /apps/frontend/src/components/group/types.ts: -------------------------------------------------------------------------------- 1 | export interface IGroupDTO { 2 | _id: string 3 | orgId: string 4 | profile: { 5 | name: string 6 | email: string 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /apps/frontend/src/components/homepage/PlanCardsWrapper.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 31 | -------------------------------------------------------------------------------- /apps/frontend/src/components/homepage/PosterCarousel.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 35 | -------------------------------------------------------------------------------- /apps/frontend/src/components/homepage/SearchBox.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 35 | -------------------------------------------------------------------------------- /apps/frontend/src/components/homepage/SiteLogo.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /apps/frontend/src/components/homepage/TimeLabel.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 40 | -------------------------------------------------------------------------------- /apps/frontend/src/components/initial/InitialPasswordInput.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 39 | 40 | 41 | en: 42 | hint: 43 | violate-password-rule: Password must be at least 8 characters. 44 | old-password: Old Password 45 | new-password: New Password 46 | zh-Hans: 47 | hint: 48 | violate-password-rule: 密码至少需要8个字符 49 | old-password: 旧密码 50 | new-password: 新密码 51 | 52 | -------------------------------------------------------------------------------- /apps/frontend/src/components/instance/InstanceCreateBtn.ts: -------------------------------------------------------------------------------- 1 | import { computed, nextTick } from 'vue' 2 | import { useRouter } from 'vue-router' 3 | 4 | import { useAsyncTask } from '@/utils/async' 5 | import { http } from '@/utils/http' 6 | 7 | export interface IInstanceCreateBtnProps { 8 | orgId: string 9 | problemId: string 10 | contestId?: string 11 | } 12 | 13 | export function useInstanceCreateBtn(props: IInstanceCreateBtnProps) { 14 | const router = useRouter() 15 | const canCreateInstance = computed(() => props.problemId) 16 | const createInstanceTask = useAsyncTask(async () => { 17 | if (props.contestId) { 18 | await http.post(`contest/${props.contestId}/problem/${props.problemId}/instance`) 19 | nextTick(() => router.push(`/org/${props.orgId}/contest/${props.contestId}/instance`)) 20 | } else { 21 | await http.post(`problem/${props.problemId}/instance`) 22 | nextTick(() => router.push(`/org/${props.orgId}/problem/${props.problemId}/instance`)) 23 | } 24 | }) 25 | 26 | return { 27 | canCreateInstance, 28 | createInstanceTask 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/frontend/src/components/instance/InstanceCreateBtn.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 22 | 23 | 24 | en: 25 | create-instance: "Create Instance" 26 | zh-Hans: 27 | create-instance: "创建实例" 28 | 29 | -------------------------------------------------------------------------------- /apps/frontend/src/components/instance/InstanceDeleteBtn.ts: -------------------------------------------------------------------------------- 1 | import { computed } from 'vue' 2 | 3 | import { useAsyncTask } from '@/utils/async' 4 | import { http } from '@/utils/http' 5 | 6 | export interface IInstanceDeleteBtnProps { 7 | instanceId: string 8 | onDeleted?: () => void 9 | } 10 | 11 | export function useInstanceDeleteBtn(props: IInstanceDeleteBtnProps) { 12 | const deleteInstanceTask = useAsyncTask(async () => { 13 | await http.post(`instance/${props.instanceId}/destroy`) 14 | props.onDeleted?.() 15 | }) 16 | 17 | return { 18 | deleteInstanceTask 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/frontend/src/components/instance/InstanceDeleteBtn.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | -------------------------------------------------------------------------------- /apps/frontend/src/components/instance/InstanceFilter.ts: -------------------------------------------------------------------------------- 1 | import { computed, reactive, watch } from 'vue' 2 | 3 | export interface IInstanceFilter { 4 | userId: { 5 | value: string 6 | } 7 | problemId: { 8 | value: string 9 | } 10 | contestId: { 11 | value: string 12 | } 13 | state: { 14 | value: string 15 | } 16 | } 17 | 18 | export function useInstanceFilter(filter: IInstanceFilter) { 19 | const local = reactive({ 20 | userId: '', 21 | problemId: '', 22 | contestId: '', 23 | state: '' 24 | }) 25 | 26 | function reset() { 27 | local.userId = filter.userId.value 28 | local.problemId = filter.problemId.value 29 | local.contestId = filter.contestId.value 30 | local.state = filter.state.value 31 | } 32 | 33 | function apply() { 34 | filter.userId.value = local.userId ?? '' 35 | filter.problemId.value = local.problemId ?? '' 36 | filter.contestId.value = local.contestId ?? '' 37 | filter.state.value = local.state ?? '' 38 | } 39 | 40 | reset() 41 | watch(() => Object.values(filter).map((v) => v.value), reset) 42 | 43 | const filterActive = computed(() => Object.values(filter).some((v) => v.value)) 44 | 45 | return { local, reset, apply, filterActive } 46 | } 47 | -------------------------------------------------------------------------------- /apps/frontend/src/components/instance/InstanceStateChip.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 42 | -------------------------------------------------------------------------------- /apps/frontend/src/components/instance/types.ts: -------------------------------------------------------------------------------- 1 | export interface IInstanceDTO { 2 | _id: string 3 | userId: string 4 | problemId: string 5 | contestId?: string 6 | problemTitle: string 7 | contestTitle?: string 8 | slotNo: number 9 | state: InstanceState 10 | taskState: InstanceTaskState 11 | message: string 12 | createdAt: number 13 | updatedAt?: number 14 | allocatedAt?: number 15 | destroyedAt?: number 16 | } 17 | 18 | export enum InstanceState { 19 | DESTROYED = 0, 20 | DESTROYING = 1, 21 | ALLOCATED = 2, 22 | ALLOCATING = 3, 23 | ERROR = 4 24 | } 25 | 26 | export enum InstanceTaskState { 27 | PENDING = 0, 28 | QUEUED = 1, 29 | IN_PROGRESS = 2 30 | } 31 | -------------------------------------------------------------------------------- /apps/frontend/src/components/locale/LocaleSelectBtn.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 30 | -------------------------------------------------------------------------------- /apps/frontend/src/components/org/admin/RunnerInfoInput.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 15 | -------------------------------------------------------------------------------- /apps/frontend/src/components/org/home/OrgInfoCard.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 37 | -------------------------------------------------------------------------------- /apps/frontend/src/components/plan/PlanSettingsInput.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | 18 | en: 19 | registration-enabled: Registration Enabled 20 | registration-allow-public: Allow Public Registration 21 | promotion: Demonstrate on Homepage 22 | zh-Hans: 23 | registration-enabled: 允许注册 24 | registration-allow-public: 允许公开注册 25 | promotion: 在主页展示 26 | 27 | -------------------------------------------------------------------------------- /apps/frontend/src/components/plan/types.ts: -------------------------------------------------------------------------------- 1 | import type { IPlan, IPlanContest } from '@aoi-js/server' 2 | 3 | import type { MapEntity } from '@/types/server' 4 | 5 | export interface IPlanDTO extends MapEntity { 6 | capability: string 7 | } 8 | 9 | export interface IPlanContestDTO extends Pick, 'settings'> { 10 | _id: string 11 | title: string 12 | slug: string 13 | description: string 14 | tags: string[] 15 | stages: { name: string; start: number }[] 16 | 17 | currentStage: { 18 | name: string 19 | start: number 20 | settings: { 21 | registrationEnabled: boolean 22 | registrationAllowPublic: boolean 23 | problemEnabled: boolean 24 | problemShowTags: boolean 25 | solutionEnabled: boolean 26 | solutionAllowSubmit: boolean 27 | solutionShowOther: boolean 28 | solutionShowDetails: boolean 29 | solutionShowOtherDetails: boolean 30 | solutionShowOtherData: boolean 31 | ranklistEnabled: boolean 32 | participantEnabled: boolean 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /apps/frontend/src/components/problem/ProblemStatus.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 23 | -------------------------------------------------------------------------------- /apps/frontend/src/components/problem/submit/SubmitDir.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 46 | -------------------------------------------------------------------------------- /apps/frontend/src/components/problem/submit/SubmitFile.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 36 | -------------------------------------------------------------------------------- /apps/frontend/src/components/problem/submit/form/FormEditor.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | -------------------------------------------------------------------------------- /apps/frontend/src/components/problem/types.ts: -------------------------------------------------------------------------------- 1 | import type { ProblemConfig } from '@aoi-js/common' 2 | import type { IProblemSettings, IProblemStatus } from '@aoi-js/server' 3 | 4 | export interface IProblemStatusDTO extends Omit {} 5 | 6 | export interface IProblemDTO { 7 | _id: string 8 | orgId: string 9 | accessLevel: number 10 | slug: string 11 | title: string 12 | description: string 13 | capability: string 14 | tags: string[] 15 | currentDataHash: string 16 | config: ProblemConfig 17 | settings: IProblemSettings 18 | status?: IProblemStatusDTO 19 | } 20 | -------------------------------------------------------------------------------- /apps/frontend/src/components/solution/SolutionDetails.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 32 | -------------------------------------------------------------------------------- /apps/frontend/src/components/solution/SolutionScoreDisplay.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 23 | -------------------------------------------------------------------------------- /apps/frontend/src/components/solution/SolutionStateChip.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 24 | -------------------------------------------------------------------------------- /apps/frontend/src/components/solution/SolutionStatus.ts: -------------------------------------------------------------------------------- 1 | import { computed } from 'vue' 2 | 3 | export interface ISolutionStatusProps { 4 | status?: string 5 | to?: string 6 | abbrev?: boolean 7 | score?: number 8 | } 9 | 10 | export function useSolutionStatus(props: ISolutionStatusProps) { 11 | const knownStatus: Record = { 12 | Accepted: ['mdi-check', 'success'], 13 | Success: ['mdi-check', 'info'], 14 | 'Memory Limit Exceeded': ['mdi-database-alert-outline', 'error'], 15 | 'Time Limit Exceeded': ['mdi-timer-alert-outline', 'error'], 16 | 'Wrong Answer': ['mdi-close', 'error'], 17 | 'Compile Error': ['mdi-code-braces', 'error'], 18 | 'Internal Error': ['mdi-help-circle-outline', ''], 19 | 'Runtime Error': ['mdi-alert-decagram-outline', 'error'], 20 | Running: ['mdi-play', 'indigo'], 21 | Queued: ['mdi-timer-sand', 'indigo'] 22 | } 23 | const display = computed( 24 | () => knownStatus[props.status ?? ''] ?? ['mdi-circle-outline', 'warning'] 25 | ) 26 | const status = computed(() => { 27 | const status = props.status || 'Unknown' 28 | if (!props.abbrev) return status 29 | const abbrev = status 30 | .split(' ') 31 | .map((word) => word[0].toUpperCase()) 32 | .join('') 33 | return abbrev.length > 1 ? abbrev : status.slice(0, 2).toUpperCase() 34 | }) 35 | return { display, status } 36 | } 37 | -------------------------------------------------------------------------------- /apps/frontend/src/components/solution/SolutionStatusChip.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 24 | -------------------------------------------------------------------------------- /apps/frontend/src/components/solution/types.ts: -------------------------------------------------------------------------------- 1 | export interface ISolutionDTO { 2 | _id: string 3 | problemId?: string 4 | problemTitle?: string 5 | contestId?: string 6 | contestTitle?: string 7 | userId: string 8 | problemDataHash: string 9 | label: string 10 | state: number 11 | score: number 12 | metrics: Record 13 | status: string 14 | message: string 15 | createdAt: number 16 | submittedAt?: number 17 | completedAt?: number 18 | preferPrivate?: boolean 19 | } 20 | -------------------------------------------------------------------------------- /apps/frontend/src/components/user/UserAuthIaaa.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 23 | 24 | 25 | en: 26 | rebind-iaaa: Rebind IAAA 27 | zh-Hans: 28 | rebind-iaaa: 重新绑定 IAAA 29 | 30 | -------------------------------------------------------------------------------- /apps/frontend/src/components/user/UserAuthMail.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 40 | 41 | 42 | en: 43 | email-code: Email Code 44 | action: 45 | send-email: Send Email 46 | zh-Hans: 47 | email-code: 邮箱验证码 48 | action: 49 | send-email: 发送邮件 50 | 51 | -------------------------------------------------------------------------------- /apps/frontend/src/components/user/UserAuthPassword.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 33 | 34 | 35 | en: 36 | old-password: Old Password 37 | new-password: New Password 38 | zh-Hans: 39 | old-password: 旧密码 40 | new-password: 新密码 41 | 42 | -------------------------------------------------------------------------------- /apps/frontend/src/components/user/UserAuthUaaa.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 23 | 24 | 25 | en: 26 | rebind-uaaa: Rebind UAAA 27 | uaaa-wip: Currently, UAAA is not supported to be bind 28 | zh-Hans: 29 | rebind-uaaa: 绑定统合身份认证 30 | uaaa-wip: 当前不支持修改统合身份认证绑定状态 31 | 32 | -------------------------------------------------------------------------------- /apps/frontend/src/components/user/types.ts: -------------------------------------------------------------------------------- 1 | export type userInfoProfile = { 2 | name: string 3 | email: string 4 | realname: string 5 | telephone?: string 6 | school?: string 7 | studentGrade?: string 8 | } 9 | -------------------------------------------------------------------------------- /apps/frontend/src/components/utils/AccessLevelBadge.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 31 | -------------------------------------------------------------------------------- /apps/frontend/src/components/utils/AccessLevelChip.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /apps/frontend/src/components/utils/AccessLevelEditor.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 57 | -------------------------------------------------------------------------------- /apps/frontend/src/components/utils/AccessLevelInput.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 15 | -------------------------------------------------------------------------------- /apps/frontend/src/components/utils/AsyncState.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 31 | -------------------------------------------------------------------------------- /apps/frontend/src/components/utils/CapabilityChips.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 28 | -------------------------------------------------------------------------------- /apps/frontend/src/components/utils/CapabilityInput.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 38 | -------------------------------------------------------------------------------- /apps/frontend/src/components/utils/DateTimeInput.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /apps/frontend/src/components/utils/DownloadBtn.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 23 | -------------------------------------------------------------------------------- /apps/frontend/src/components/utils/HelpBtn.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 14 | -------------------------------------------------------------------------------- /apps/frontend/src/components/utils/LimitChips.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 28 | -------------------------------------------------------------------------------- /apps/frontend/src/components/utils/ListInput.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 41 | 42 | 43 | en: 44 | ith-item: "item {i}" 45 | total-items: "{num} items in total" 46 | add: "Add" 47 | 48 | zh-Hans: 49 | ith-item: "第{i}项" 50 | total-items: "共{num}项" 51 | add: "添加" 52 | 53 | -------------------------------------------------------------------------------- /apps/frontend/src/components/utils/MarkdownEditor.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 34 | -------------------------------------------------------------------------------- /apps/frontend/src/components/utils/MarkdownRenderer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 18 | 19 | 24 | -------------------------------------------------------------------------------- /apps/frontend/src/components/utils/MonacoEditor.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 17 | -------------------------------------------------------------------------------- /apps/frontend/src/components/utils/OptionalInput.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 34 | 35 | 36 | en: 37 | is-not-set: "{field} is not set" 38 | this-field: "This field" 39 | zh-Hans: 40 | is-not-set: "{field}未设置" 41 | this-field: "该字段" 42 | 43 | -------------------------------------------------------------------------------- /apps/frontend/src/components/utils/OrgProfile.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 43 | -------------------------------------------------------------------------------- /apps/frontend/src/components/utils/PrincipalProfile.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 40 | -------------------------------------------------------------------------------- /apps/frontend/src/components/utils/RegisterBtn.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 34 | 35 | 36 | en: 37 | contest-register: Register 38 | already-registered: Already Registered 39 | zh-Hans: 40 | contest-register: 报名 41 | already-registered: 已报名 42 | 43 | -------------------------------------------------------------------------------- /apps/frontend/src/layouts/default/Default.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /apps/frontend/src/layouts/default/View.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /apps/frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | import 'virtual:uno.css' 2 | import '@/styles/main.css' 3 | import { createApp } from 'vue' 4 | 5 | import App from '@/App.vue' 6 | import { registerPlugins } from '@/plugins/index' 7 | 8 | const app = createApp(App) 9 | 10 | registerPlugins(app) 11 | 12 | app.mount('#app') 13 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/admin.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 24 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/admin/index.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 15 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/announcement/[articleId].vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 41 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/announcement/[articleId]/index.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 46 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/announcement/index.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 33 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/auth/login/iaaa.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 45 | 46 | 47 | en: 48 | iaaa-wait: Waiting for IAAA login... 49 | zh-Hans: 50 | iaaa-wait: 等待 IAAA 登录... 51 | 52 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/auth/verify/iaaa.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 12 | en: 13 | iaaa-not-supported: IAAA is not supported for verification. 14 | zh-Hans: 15 | iaaa-not-supported: IAAA 不支持验证。 16 | 17 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/index.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 38 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/oauth/device.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 39 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId].vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 26 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/[...all].vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/admin.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 43 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/admin/access.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 19 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/admin/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 23 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/app/[appId]/admin.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 39 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/app/[appId]/admin/access.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 14 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/app/[appId]/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 24 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/app/index.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 55 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/app/search.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 31 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/app/tag/[:tag].vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 28 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/contest/[contestId]/admin/access.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 20 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/contest/[contestId]/admin/rule.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/contest/[contestId]/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/contest/[contestId]/instance.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 20 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/contest/[contestId]/participant/admin.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 42 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/contest/[contestId]/problem/index.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 33 | 34 | 35 | en: 36 | problem-msg: | 37 | There are {count} problems in this contest. Select one from left to start. 38 | zh-Hans: 39 | problem-msg: | 40 | 这场比赛有 {count} 道题目。从左侧选择一道开始。 41 | 42 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/contest/[contestId]/ranklist/index.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 35 | 36 | 37 | en: 38 | ranklist-msg: | 39 | There are {count} ranklists in this contest. Select one from left to start. 40 | zh-Hans: 41 | ranklist-msg: | 42 | 这场比赛有 {count} 个排行榜。从左侧选择一个开始。 43 | 44 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/contest/[contestId]/solution.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/contest/[contestId]/solution/[solutionId].vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 17 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/contest/[contestId]/solution/index.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 23 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/contest/search.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 31 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/contest/tag/[:tag].vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 29 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/group/[groupId]/index.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/index.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 46 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/instance/index.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 31 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/plan/[planId]/admin.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 42 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/plan/[planId]/admin/access.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/plan/[planId]/contest/index.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 34 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/plan/[planId]/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 23 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/plan/search.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 31 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/plan/tag/[:tag].vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 29 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/problem/[problemId]/admin/access.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 20 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/problem/[problemId]/admin/rule.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/problem/[problemId]/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/problem/[problemId]/instance.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 24 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/problem/[problemId]/solution.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/problem/[problemId]/solution/[solutionId].vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 17 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/problem/[problemId]/solution/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 22 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/problem/[problemId]/submit.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/problem/search.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 31 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/problem/tag/[:tag].vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 29 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/[orgId]/solution/index.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 31 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/org/new.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 42 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/rk/[ranklistId].vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/rk/[ranklistId]/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/user/[userId].vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/user/[userId]/index.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 40 | -------------------------------------------------------------------------------- /apps/frontend/src/pages/user/[userId]/settings.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 38 | 39 | 40 | en: 41 | back-to-user-info: Back to user info 42 | zh-Hans: 43 | back-to-user-info: 返回用户界面 44 | 45 | -------------------------------------------------------------------------------- /apps/frontend/src/plugins/i18n.ts: -------------------------------------------------------------------------------- 1 | import messages from '@intlify/unplugin-vue-i18n/messages' 2 | import { createI18n } from 'vue-i18n' 3 | 4 | export default createI18n({ 5 | legacy: false, 6 | locale: 'zh-Hans', 7 | fallbackLocale: 'en', 8 | fallbackFormat: false, 9 | fallbackWarn: false, 10 | missingWarn: false, 11 | messages 12 | }) 13 | 14 | export const supportedLocales = [ 15 | ['en', 'English'], 16 | ['zh-Hans', '简体中文'] 17 | ] as const 18 | -------------------------------------------------------------------------------- /apps/frontend/src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | import type { App } from 'vue' 2 | 3 | import router from '../router' 4 | import pinia from '../stores' 5 | 6 | import i18n from './i18n' 7 | import toast, { toastOptions } from './toast' 8 | import vuetify from './vuetify' 9 | 10 | export function registerPlugins(app: App) { 11 | app.use(i18n).use(vuetify).use(toast, toastOptions).use(router).use(pinia) 12 | } 13 | -------------------------------------------------------------------------------- /apps/frontend/src/plugins/toast.ts: -------------------------------------------------------------------------------- 1 | import Toast, { POSITION, type PluginOptions } from 'vue-toastification' 2 | import 'vue-toastification/dist/index.css' 3 | 4 | export default Toast 5 | 6 | export const toastOptions: PluginOptions = { 7 | position: POSITION.BOTTOM_RIGHT 8 | } 9 | -------------------------------------------------------------------------------- /apps/frontend/src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | import '@mdi/font/css/materialdesignicons.css' 2 | import 'vuetify/styles' 3 | import { createVuetify } from 'vuetify' 4 | import { md3 } from 'vuetify/blueprints' 5 | import { zhHans, en } from 'vuetify/locale' 6 | 7 | export default createVuetify({ 8 | blueprint: md3, 9 | locale: { 10 | locale: 'zh-Hans', 11 | fallback: 'en', 12 | messages: { 'zh-Hans': zhHans, en } 13 | }, 14 | icons: { 15 | sets: { 16 | svg: {} as never 17 | } 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /apps/frontend/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | 3 | import { useAppState } from '@/stores/app' 4 | import routes from '~pages' 5 | 6 | const router = createRouter({ 7 | history: createWebHistory(import.meta.env.BASE_URL), 8 | routes: [ 9 | ...routes, 10 | { 11 | path: '/:pathMatch(.*)*', 12 | component: () => import('@/components/utils/NotFound.vue') 13 | } 14 | ] 15 | }) 16 | 17 | router.beforeEach((to, from, next) => { 18 | const appState = useAppState() 19 | if (to.path.startsWith('/org') && !appState.loggedIn) 20 | return next({ path: '/auth/login', query: { redirect: to.fullPath } }) 21 | if (to.path.includes(':self') && !appState.loggedIn) 22 | return next({ path: '/auth/login', query: { redirect: to.fullPath } }) 23 | if (to.path.includes(':self')) 24 | return next({ path: to.path.replace(':self', appState.userId), query: to.query, hash: to.hash }) 25 | return next() 26 | }) 27 | 28 | export default router 29 | -------------------------------------------------------------------------------- /apps/frontend/src/stores/index.ts: -------------------------------------------------------------------------------- 1 | import { createPinia } from 'pinia' 2 | 3 | export default createPinia() 4 | -------------------------------------------------------------------------------- /apps/frontend/src/styles/main.css: -------------------------------------------------------------------------------- 1 | a { 2 | color: #1966c0; 3 | text-decoration: none; 4 | transition: 0.25s; 5 | } 6 | -------------------------------------------------------------------------------- /apps/frontend/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | IOrgProfile, 3 | IProblem, 4 | IOrgSettings, 5 | IContestStage, 6 | IPlanContestSettings, 7 | IProblemSettings, 8 | IPlanSettings, 9 | IUserProfile, 10 | IContestAction, 11 | IContestProblemSettings 12 | } from '@aoi-js/server' 13 | 14 | export interface IProfile { 15 | name: string 16 | email: string 17 | } 18 | 19 | export interface IAssociation { 20 | principalId: string 21 | capability: string 22 | } 23 | 24 | export interface IRunner { 25 | _id: string 26 | labels: string[] 27 | name: string 28 | createdAt: number 29 | accessedAt: number 30 | } 31 | -------------------------------------------------------------------------------- /apps/frontend/src/types/server.ts: -------------------------------------------------------------------------------- 1 | import type { AccessLevel } from '@aoi-js/server' 2 | import type { UUID, Long } from 'mongodb' 3 | 4 | export type MapEntity = { 5 | [key in keyof T]: T[key] extends UUID 6 | ? string 7 | : T[key] extends Long 8 | ? string 9 | : T[key] extends AccessLevel 10 | ? number 11 | : T[key] 12 | } 13 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/admin/version.ts: -------------------------------------------------------------------------------- 1 | import { computed, ref, type Ref } from 'vue' 2 | 3 | import { appBuildInfo } from '../build' 4 | import { http } from '../http' 5 | 6 | export interface IServerInfo { 7 | serverVersion: string 8 | } 9 | 10 | export function useServerInfo() { 11 | const info = ref({ 12 | serverVersion: 'loading' 13 | }) 14 | http 15 | .get('admin') 16 | .json() 17 | .then((j) => (info.value = j)) 18 | .catch(() => (info.value = { serverVersion: 'error' })) 19 | return { info } 20 | } 21 | 22 | export function useVersionImgs(info: Ref) { 23 | const escape = (str: string) => str.replace(/-/g, '--') 24 | return computed(() => [ 25 | `https://img.shields.io/npm/v/%40aoi-js/server?label=server%20latest`, 26 | `https://img.shields.io/badge/server%20current-v${escape(info.value.serverVersion)}-red`, 27 | `https://img.shields.io/npm/v/%40aoi-js/frontend?label=frontend%20latest`, 28 | `https://img.shields.io/badge/frontend%20current-v${escape(appBuildInfo.version)}-red` 29 | ]) 30 | } 31 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/avatar.ts: -------------------------------------------------------------------------------- 1 | import md5 from 'blueimp-md5' 2 | 3 | import { gravatarBase } from './flags' 4 | 5 | export function getAvatarUrl(mailOrHash: string) { 6 | mailOrHash = mailOrHash.includes('@') ? md5(mailOrHash) : mailOrHash 7 | return `${gravatarBase}/${mailOrHash}?d=mp` 8 | } 9 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/build.ts: -------------------------------------------------------------------------------- 1 | import { version } from '../../package.json' 2 | 3 | export const appBuildInfo = { 4 | version, 5 | hash: __GIT_HASH__, 6 | branch: __GIT_BRANCH__, 7 | time: __BUILD_TIME__ 8 | } 9 | 10 | console.log( 11 | `%cAOI-UI%c${version}%c${__GIT_HASH__}@${__GIT_BRANCH__} ${__BUILD_TIME__}`, 12 | 'background: #35495e; color: #fff; padding: 2px 4px; border-radius: 4px 0 0 4px', 13 | 'background: #1966c0; color: #fff; padding: 2px 4px', 14 | 'background: #afddff; color: #000; padding: 2px 4px; border-radius: 0 4px 4px 0' 15 | ) 16 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/colors.ts: -------------------------------------------------------------------------------- 1 | export function palette(score: number) { 2 | // interpolation between red and green 3 | const start = '#2ecc71' 4 | const end = '#e74c3c' 5 | const t = score / 100 6 | const r = Math.floor( 7 | t * parseInt(start.slice(1, 3), 16) + (1 - t) * parseInt(end.slice(1, 3), 16) 8 | ) 9 | const g = Math.floor( 10 | t * parseInt(start.slice(3, 5), 16) + (1 - t) * parseInt(end.slice(3, 5), 16) 11 | ) 12 | const b = Math.floor( 13 | t * parseInt(start.slice(5, 7), 16) + (1 - t) * parseInt(end.slice(5, 7), 16) 14 | ) 15 | return `rgb(${r}, ${g}, ${b})` 16 | } 17 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/contest/action.ts: -------------------------------------------------------------------------------- 1 | import { useToast } from 'vue-toastification' 2 | 3 | import type { IContestAction } from '@/types' 4 | 5 | export const useContestAction = () => { 6 | const toast = useToast() 7 | const execute = (action: IContestAction) => { 8 | try { 9 | switch (action.type) { 10 | case 'link': 11 | window.open(action.target, '_blank') 12 | break 13 | case 'toast': 14 | toast.info(action.target) 15 | break 16 | } 17 | } catch (err) { 18 | toast.error(`${err}`) 19 | } 20 | } 21 | return { execute } 22 | } 23 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/contest/participant.ts: -------------------------------------------------------------------------------- 1 | import { Type, type Static } from '@sinclair/typebox' 2 | import { TypeCompiler } from '@sinclair/typebox/compiler' 3 | import * as xlsx from 'xlsx' 4 | 5 | import { withMessage } from '../async' 6 | import { http } from '../http' 7 | 8 | const batchUpdateSchema = Type.Object({ 9 | userId: Type.String(), 10 | tags: Type.String() 11 | }) 12 | const batchUpdateSchemaChecker = TypeCompiler.Compile(batchUpdateSchema) 13 | 14 | export async function participantBatchUpdate(contestId: string, xlsxFile: File) { 15 | const workbook = xlsx.read(await xlsxFile.arrayBuffer()) 16 | const sheet = workbook.Sheets[workbook.SheetNames[0]] 17 | const data: Static[] = xlsx.utils.sheet_to_json(sheet) 18 | for (const row of data) { 19 | if (!batchUpdateSchemaChecker.Check(row)) { 20 | throw new Error('Invalid data') 21 | } 22 | } 23 | let success = 0 24 | let failed = 0 25 | for (const row of data) { 26 | try { 27 | await http.patch(`contest/${contestId}/participant/admin/${row.userId}`, { 28 | json: { 29 | tags: row.tags.split(',').map((tag) => tag.trim()) 30 | } 31 | }) 32 | success++ 33 | } catch (err) { 34 | console.log(err) 35 | failed++ 36 | } 37 | } 38 | return withMessage(`Success: ${success}, Failed: ${failed}`) 39 | } 40 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/contest/problem/inject.ts: -------------------------------------------------------------------------------- 1 | import { useAsyncState } from '@vueuse/core' 2 | import { toRef, type MaybeRef, type InjectionKey, provide, type Ref, inject, computed } from 'vue' 3 | 4 | import type { IContestProblemListDTO } from '@/components/contest/types' 5 | import { http } from '@/utils/http' 6 | 7 | export const kContestProblemList: InjectionKey> = 8 | Symbol('contest-problem-list') 9 | 10 | export function useContestProblemList(contestId: MaybeRef) { 11 | const contestIdRef = toRef(contestId) 12 | const problems = useAsyncState(async () => { 13 | const resp = await http.get(`contest/${contestIdRef.value}/problem`) 14 | const data = await resp.json() 15 | // sort by slug lexically 16 | data.sort((a, b) => a.settings.slug.localeCompare(b.settings.slug)) 17 | return data 18 | }, null as never) 19 | provide(kContestProblemList, problems.state) 20 | return problems 21 | } 22 | 23 | export function useContestProblemTitle(_id: string) { 24 | const problems = inject(kContestProblemList) 25 | if (!problems) throw new Error('No problems provided') 26 | return computed(() => problems.value?.find((problem) => problem._id === _id)?.title) 27 | } 28 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/editor.ts: -------------------------------------------------------------------------------- 1 | import { ModelOperations } from '@vscode/vscode-languagedetection' 2 | import modelBin from '@vscode/vscode-languagedetection/model/group1-shard1of1.bin?url' 3 | 4 | import monaco from './monaco' 5 | 6 | const languages = monaco.languages.getLanguages() 7 | 8 | const modulOperations = new ModelOperations({ 9 | modelJsonLoaderFunc: () => 10 | import('@vscode/vscode-languagedetection/model/model.json').then((res) => res.default), 11 | weightsLoaderFunc: () => fetch(modelBin).then((res) => res.arrayBuffer()) 12 | }) 13 | 14 | export async function detectLanguage(text: string, filename: string) { 15 | const ext = filename.split('.').pop() 16 | let language = languages.find(({ extensions }) => extensions?.includes('.' + ext)) 17 | if (language) return language.id 18 | 19 | const result = await modulOperations.runModel(text) 20 | const { languageId } = result[0] 21 | language = languages.find( 22 | ({ id, aliases, extensions }) => 23 | id === languageId || aliases?.includes(languageId) || extensions?.includes('.' + languageId) 24 | ) 25 | return language?.id ?? 'plaintext' 26 | } 27 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/files/hash.ts: -------------------------------------------------------------------------------- 1 | import { Sha256 } from '@aws-crypto/sha256-browser' 2 | 3 | function Uint8ArrayToHex(u8a: Uint8Array) { 4 | let result = '' 5 | const h = '0123456789abcdef' 6 | for (let i = 0; i < u8a.length; i++) { 7 | result += h[u8a[i] >> 4] + h[u8a[i] & 15] 8 | } 9 | return result 10 | } 11 | 12 | export async function calculateHashCallback( 13 | stream: ReadableStream, 14 | callback: (data: { read: number; digest?: string }) => void | Promise 15 | ) { 16 | const hash = new Sha256() 17 | const reader = stream.getReader() 18 | let done = false 19 | let read = 0 20 | do { 21 | const { value, done: _done } = await reader.read() 22 | if (value) hash.update(value) 23 | done = _done 24 | read += value?.length || 0 25 | await callback({ read }) 26 | } while (!done) 27 | const digest = Uint8ArrayToHex(await hash.digest()) 28 | await callback({ digest, read }) 29 | return digest 30 | } 31 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/files/hash.worker.ts: -------------------------------------------------------------------------------- 1 | import { calculateHashCallback } from './hash' 2 | 3 | self.addEventListener('message', async (ev) => { 4 | const stream: ReadableStream = ev.data 5 | await calculateHashCallback(stream, (data) => self.postMessage(data)) 6 | }) 7 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/files/index.ts: -------------------------------------------------------------------------------- 1 | import { nextTick } from 'vue' 2 | 3 | import { sleep } from '../async' 4 | 5 | import FileWorker from './hash.worker?worker' 6 | 7 | export type ProgressCallback = (progress: number, digest?: string) => void 8 | 9 | export function computeSHA256Progress(file: File, callback?: ProgressCallback): Promise { 10 | const size = file.size 11 | const stream = file.stream() 12 | try { 13 | const worker = new FileWorker() 14 | worker.postMessage(stream, [stream]) 15 | return new Promise((resolve) => { 16 | worker.addEventListener('message', (ev) => { 17 | const { read, digest } = ev.data 18 | callback?.(read / size, digest) 19 | if (digest) { 20 | resolve(digest) 21 | worker.terminate() 22 | } 23 | }) 24 | }) 25 | } catch { 26 | return import('./hash').then(({ calculateHashCallback }) => 27 | calculateHashCallback(stream, async ({ read, digest }) => { 28 | callback?.(read / size, digest) 29 | await nextTick() 30 | await sleep(0) 31 | }) 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/flags.ts: -------------------------------------------------------------------------------- 1 | export const gravatarBase = import.meta.env.VITE_GRAVATAR_BASE ?? `https://www.gravatar.com/avatar` 2 | export const enableMfa = !import.meta.env.VITE_DISABLE_MFA_BIND 3 | export const appName = import.meta.env.VITE_APP_NAME ?? 'AOI' 4 | export const showCountdown = !!import.meta.env.VITE_APPBAR_SHOW_COUNTDOWN 5 | export const extraFooter = import.meta.env.VITE_EXTRA_FOOTER 6 | export const loginHint = import.meta.env.VITE_LOGIN_HINT 7 | export const verifyHint = import.meta.env.VITE_VERIFY_HINT 8 | export const enableOverview = !!import.meta.env.VITE_ENABLE_OVERVIEW 9 | export const enableSlugFinder = !!import.meta.env.VITE_ENABLE_SLUG_FINDER 10 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/markdown/index.ts: -------------------------------------------------------------------------------- 1 | import { common, createStarryNight } from '@wooorm/starry-night' 2 | import dompurify from 'dompurify' 3 | import { toHtml } from 'hast-util-to-html' 4 | import markdownIt from 'markdown-it' 5 | import onigurumaUrl from 'vscode-oniguruma/release/onig.wasm?url' 6 | 7 | import markdownAlerts from './alerts' 8 | import markdownItKatex from './katex' 9 | 10 | const starryNight = await createStarryNight(common, { 11 | getOnigurumaUrlFetch() { 12 | return new URL(onigurumaUrl, import.meta.url) 13 | } 14 | }) 15 | 16 | const md = markdownIt({ 17 | html: true, 18 | highlight(value, lang) { 19 | const scope = starryNight.flagToScope(lang) 20 | 21 | return toHtml({ 22 | type: 'element', 23 | tagName: 'pre', 24 | properties: { 25 | className: scope 26 | ? ['highlight', 'highlight-' + scope.replace(/^source\./, '').replace(/\./g, '-')] 27 | : undefined 28 | }, 29 | children: scope 30 | ? (starryNight.highlight(value, scope).children as never) 31 | : [{ type: 'text', value }] 32 | }) 33 | } 34 | }) 35 | md.use(markdownAlerts) 36 | md.use(markdownItKatex) 37 | 38 | export function renderMarkdown(source: string) { 39 | return dompurify.sanitize(md.render(source)) 40 | } 41 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/markdown/katex.d.ts: -------------------------------------------------------------------------------- 1 | import type MarkdownIt from 'markdown-it' 2 | 3 | declare const mk: MarkdownIt.PluginSimple 4 | export default mk 5 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/org/runner.ts: -------------------------------------------------------------------------------- 1 | import { denseDateString, prettyMs } from '../time' 2 | 3 | export function runnerLastAccessAttrs(accessedAt: number) { 4 | const now = Date.now() 5 | const offline = now - accessedAt > 1000 * 60 * 5 6 | const color = offline ? 'red' : 'green' 7 | const text = offline ? denseDateString(accessedAt) : prettyMs(now - accessedAt) 8 | return { text, color } 9 | } 10 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/persist.ts: -------------------------------------------------------------------------------- 1 | import { syncRef, useLocalStorage } from '@vueuse/core' 2 | import type { Ref } from 'vue' 3 | 4 | export const attachToLocalStorage = (key: string, ref: Ref) => { 5 | const persisted = useLocalStorage(key, ref.value) 6 | // @ts-expect-error vueuse type is wrong here 7 | syncRef(persisted, ref) 8 | return persisted 9 | } 10 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/plan/inject.ts: -------------------------------------------------------------------------------- 1 | import { useAsyncState } from '@vueuse/core' 2 | import { toRef, type InjectionKey, provide, computed, inject, type MaybeRef, type Ref } from 'vue' 3 | 4 | import { hasCapability, planBits } from '../capability' 5 | import { http } from '../http' 6 | 7 | import type { IPlanDTO } from '@/components/plan/types' 8 | 9 | const kPlan: InjectionKey> = Symbol('plan') 10 | 11 | export function usePlan(orgId: MaybeRef, planId: MaybeRef) { 12 | const orgIdRef = toRef(orgId) 13 | const planIdRef = toRef(planId) 14 | const plan = useAsyncState(async () => { 15 | const resp = await http.get(`plan/${planIdRef.value}`) 16 | const data = await resp.json() 17 | if (data.orgId !== orgIdRef.value) throw new Error('orgId not match') 18 | return data 19 | }, null as never) 20 | provide(kPlan, plan.state) 21 | const hasCapabilityRef = (type: keyof typeof planBits) => 22 | computed(() => hasCapability(plan.state.value?.capability ?? '0', planBits[type])) 23 | return { 24 | plan, 25 | showAdminTab: hasCapabilityRef('admin') 26 | } 27 | } 28 | 29 | export function usePlanCapability(type: keyof typeof planBits) { 30 | const plan = inject(kPlan) 31 | if (!plan) throw new Error('No plan provided') 32 | return computed(() => hasCapability(plan?.value.capability, planBits[type])) 33 | } 34 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/platform/stage1.ts: -------------------------------------------------------------------------------- 1 | export async function detectPlatformIssues(): Promise { 2 | const issues: string[] = [] 3 | let worker, file, stream 4 | try { 5 | worker = new Worker('data:application/javascript,') 6 | } catch { 7 | issues.push('web-worker') 8 | } 9 | try { 10 | file = new File([''], 'file') 11 | } catch { 12 | issues.push('file') 13 | } 14 | try { 15 | stream = file!.stream() 16 | } catch { 17 | issues.push('file-stream') 18 | } 19 | // Disabled due to Safari shamefully not supporting transferable streams 20 | // try { 21 | // worker!.postMessage(stream!, [stream!]) 22 | // } catch { 23 | // issues.push('transferable-stream') 24 | // } 25 | return issues 26 | } 27 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/slug.ts: -------------------------------------------------------------------------------- 1 | import { HTTPError } from 'ky' 2 | 3 | import { http } from './http' 4 | 5 | async function findNext(current: number, check: (n: number) => Promise) { 6 | let step = 1 7 | while (await check(current + step)) { 8 | step *= 2 9 | } 10 | let left = current + step / 2 11 | let right = current + step 12 | let result = right 13 | while (left <= right) { 14 | const mid = Math.floor((left + right) / 2) 15 | if (await check(mid)) { 16 | left = mid + 1 17 | } else { 18 | result = mid 19 | right = mid - 1 20 | } 21 | } 22 | return result 23 | } 24 | 25 | export async function findNextSlug(type: string, orgId: string, formatter: (n: number) => string) { 26 | const key = `aoi-next-slug-${type}-${orgId}` 27 | const current = parseInt(localStorage.getItem(key) || '0', 10) 28 | const next = await findNext(current, async (n) => { 29 | try { 30 | await http.post(`public/resolve-slug`, { 31 | json: { orgId, type, slug: formatter(n) } 32 | }) 33 | return true 34 | } catch (err) { 35 | if (err instanceof HTTPError && err.response.status === 404) { 36 | return false 37 | } 38 | throw err 39 | } 40 | }) 41 | localStorage.setItem(key, next.toString()) 42 | return formatter(next) 43 | } 44 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/user/email.ts: -------------------------------------------------------------------------------- 1 | import { ref, type MaybeRef, toRef } from 'vue' 2 | 3 | import { useAsyncTask } from '../async' 4 | import { http } from '../http' 5 | 6 | import { useAppState } from '@/stores/app' 7 | 8 | export function useChangeEmail(userId: MaybeRef) { 9 | const newEmail = ref('') 10 | const emailCode = ref('') 11 | const userIdRef = toRef(userId) 12 | const app = useAppState() 13 | const sendEmailTask = useAsyncTask(async () => { 14 | await http.post(`user/${userIdRef.value}/preBind`, { 15 | json: { 16 | provider: 'mail', 17 | payload: { 18 | email: newEmail.value 19 | }, 20 | mfaToken: app.mfaToken 21 | } 22 | }) 23 | }) 24 | const updateEmailTask = useAsyncTask(async () => { 25 | await http.post(`user/${userIdRef.value}/bind`, { 26 | json: { 27 | provider: 'mail', 28 | payload: { 29 | code: emailCode.value 30 | }, 31 | mfaToken: app.mfaToken 32 | } 33 | }) 34 | }) 35 | return { newEmail, emailCode, sendEmailTask, updateEmailTask } 36 | } 37 | -------------------------------------------------------------------------------- /apps/frontend/src/utils/user/password.ts: -------------------------------------------------------------------------------- 1 | import { ref, type MaybeRef, toRef } from 'vue' 2 | 3 | import { useAsyncTask } from '../async' 4 | import { http } from '../http' 5 | 6 | import { useAppState } from '@/stores/app' 7 | 8 | export function useChangePassword(userId: MaybeRef) { 9 | const oldPassword = ref('') 10 | const newPassword = ref('') 11 | const userIdRef = toRef(userId) 12 | const app = useAppState() 13 | const updateTask = useAsyncTask(async () => { 14 | await http.post(`user/${userIdRef.value}/bind`, { 15 | json: { 16 | provider: 'password', 17 | payload: { 18 | oldPassword: oldPassword.value, 19 | password: newPassword.value 20 | }, 21 | mfaToken: app.mfaToken 22 | } 23 | }) 24 | }) 25 | return { oldPassword, newPassword, updateTask } 26 | } 27 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue", "package.json"], 4 | "exclude": ["src/**/__tests__/*"], 5 | "compilerOptions": { 6 | "composite": true, 7 | "baseUrl": ".", 8 | "paths": { 9 | "@/*": ["./src/*"] 10 | }, 11 | "skipLibCheck": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.node.json" 6 | }, 7 | { 8 | "path": "./tsconfig.app.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/frontend/uno.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, presetWind } from 'unocss' 2 | 3 | export default defineConfig({ 4 | presets: [ 5 | presetWind({ 6 | prefix: 'u-' 7 | }) 8 | ] 9 | }) 10 | -------------------------------------------------------------------------------- /apps/server/.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | .env 3 | -------------------------------------------------------------------------------- /apps/server/src/auth/base.ts: -------------------------------------------------------------------------------- 1 | import { FastifyReply, FastifyRequest } from 'fastify' 2 | import { BSON } from 'mongodb' 3 | 4 | import { loadEnv, parseBoolean } from '../utils/index.js' 5 | 6 | export abstract class BaseAuthProvider { 7 | enableMfaBind 8 | 9 | constructor() { 10 | this.enableMfaBind = loadEnv('AUTH_ENABLE_MFA_BIND', parseBoolean, true) 11 | } 12 | 13 | abstract readonly name: string 14 | 15 | init?(): Promise 16 | 17 | preBind?( 18 | userId: BSON.UUID, 19 | payload: unknown, 20 | req: FastifyRequest, 21 | rep: FastifyReply 22 | ): Promise 23 | abstract bind( 24 | userId: BSON.UUID, 25 | payload: unknown, 26 | req: FastifyRequest, 27 | rep: FastifyReply 28 | ): Promise 29 | 30 | preVerify?( 31 | userId: BSON.UUID, 32 | payload: unknown, 33 | req: FastifyRequest, 34 | rep: FastifyReply 35 | ): Promise 36 | abstract verify( 37 | userId: BSON.UUID, 38 | payload: unknown, 39 | req: FastifyRequest, 40 | rep: FastifyReply 41 | ): Promise 42 | 43 | preLogin?(payload: unknown, req: FastifyRequest, rep: FastifyReply): Promise 44 | login?( 45 | payload: unknown, 46 | req: FastifyRequest, 47 | rep: FastifyReply 48 | ): Promise<[userId: BSON.UUID, tags?: string[]]> 49 | } 50 | -------------------------------------------------------------------------------- /apps/server/src/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { fastifyPlugin } from 'fastify-plugin' 2 | 3 | import { loadEnv } from '../index.js' 4 | 5 | import { BaseAuthProvider } from './base.js' 6 | import { IaaaAuthProvider } from './iaaa.js' 7 | import { MailAuthProvider } from './mail.js' 8 | import { PasswordAuthProvider } from './password.js' 9 | import { SMSAuthProvider } from './sms.js' 10 | import { UaaaAuthProvider } from './uaaa.js' 11 | 12 | declare module 'fastify' { 13 | interface FastifyInstance { 14 | authProviders: Record 15 | } 16 | } 17 | 18 | export const authProviderPlugin = fastifyPlugin(async (s) => { 19 | const enabledAuthProviders = loadEnv('AUTH_PROVIDERS', String, 'password').split(',') 20 | 21 | const authProviderList: Array = [ 22 | new PasswordAuthProvider(s.db.users), 23 | new MailAuthProvider(s.db.users, s.cache), 24 | new IaaaAuthProvider(s.db.users), 25 | new SMSAuthProvider(s.db.users, s.cache), 26 | new UaaaAuthProvider(s.db.users) 27 | ].filter((p) => enabledAuthProviders.includes(p.name)) 28 | 29 | await Promise.all(authProviderList.map((p) => p.init?.())) 30 | 31 | s.decorate('authProviders', Object.fromEntries(authProviderList.map((p) => [p.name, p]))) 32 | }) 33 | -------------------------------------------------------------------------------- /apps/server/src/cache/base.ts: -------------------------------------------------------------------------------- 1 | export abstract class BaseCache { 2 | constructor() {} 3 | 4 | init?(): Promise 5 | 6 | abstract set(key: string, value: string, expiresIn: number): Promise 7 | abstract get(key: string): Promise 8 | abstract del(key: string): Promise 9 | abstract ttl(key: string): Promise 10 | abstract clear(): Promise 11 | 12 | async gete(key: string): Promise { 13 | const value = await this.get(key) 14 | if (value === null) throw new Error('not found') 15 | return value 16 | } 17 | 18 | setx(key: string, value: T, expiresIn: number): Promise { 19 | return this.set(key, JSON.stringify(value), expiresIn) 20 | } 21 | 22 | async getx(key: string): Promise { 23 | const value = await this.get(key) 24 | if (value === null) return null 25 | return JSON.parse(value) as T 26 | } 27 | 28 | async getex(key: string): Promise { 29 | const value = await this.getx(key) 30 | if (value === null) throw new Error('not found') 31 | return value 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/server/src/cache/index.ts: -------------------------------------------------------------------------------- 1 | import { fastifyPlugin } from 'fastify-plugin' 2 | 3 | import { loadEnv, logger } from '../utils/index.js' 4 | 5 | import { BaseCache } from './base.js' 6 | import { MongoCache } from './mongo.js' 7 | import { RedisCache } from './redis.js' 8 | 9 | export * from './base.js' 10 | 11 | declare module 'fastify' { 12 | interface FastifyInstance { 13 | cache: BaseCache 14 | } 15 | } 16 | 17 | export const cachePlugin = fastifyPlugin(async (s) => { 18 | const url = loadEnv('REDIS_URL', String, '') 19 | let cache: BaseCache 20 | if (url) { 21 | cache = new RedisCache(url) 22 | } else { 23 | cache = new MongoCache(s.db.db) 24 | logger.warn('Using MongoDB cache') 25 | } 26 | await cache.init?.() 27 | s.decorate('cache', cache) 28 | logger.info('Cache ready') 29 | }) 30 | -------------------------------------------------------------------------------- /apps/server/src/cache/redis.ts: -------------------------------------------------------------------------------- 1 | import { Redis } from 'ioredis' 2 | 3 | import { BaseCache } from './base.js' 4 | 5 | export class RedisCache extends BaseCache { 6 | redis!: Redis 7 | 8 | constructor(public redisUrl: string) { 9 | super() 10 | } 11 | 12 | async init(): Promise { 13 | this.redis = new Redis(this.redisUrl) 14 | } 15 | 16 | async set(key: string, value: string, expiresIn: number): Promise { 17 | await this.redis.set(key, value, 'PX', expiresIn) 18 | } 19 | 20 | async get(key: string): Promise { 21 | return this.redis.get(key) 22 | } 23 | 24 | async del(key: string): Promise { 25 | await this.redis.del(key) 26 | } 27 | 28 | async ttl(key: string): Promise { 29 | return this.redis.pttl(key) 30 | } 31 | 32 | async clear(): Promise { 33 | await this.redis.flushdb() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /apps/server/src/cli/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { logger, server } from '../index.js' 3 | 4 | const address = await server.listen({ 5 | port: 1926, 6 | host: '0.0.0.0' 7 | }) 8 | 9 | logger.fatal(`Server listening at ${address}`) 10 | -------------------------------------------------------------------------------- /apps/server/src/db/announcement.ts: -------------------------------------------------------------------------------- 1 | import { fastifyPlugin } from 'fastify-plugin' 2 | import { BSON, Collection } from 'mongodb' 3 | 4 | export interface IAnnouncement { 5 | _id: BSON.UUID 6 | 7 | title: string 8 | description: string 9 | date: string 10 | public: boolean 11 | } 12 | 13 | declare module './index.js' { 14 | interface IDbContainer { 15 | announcements: Collection 16 | } 17 | } 18 | 19 | export const dbAnnouncementPlugin = fastifyPlugin(async (s) => { 20 | const col = s.db.db.collection('announcements') 21 | await col.createIndex({ date: -1 }) 22 | await col.createIndex({ title: 1 }) 23 | s.db.announcements = col 24 | }) 25 | -------------------------------------------------------------------------------- /apps/server/src/db/app.ts: -------------------------------------------------------------------------------- 1 | import { fastifyPlugin } from 'fastify-plugin' 2 | import { Collection, UUID } from 'mongodb' 3 | 4 | import { IAppSettings } from '../schemas/app.js' 5 | import { capabilityMask } from '../utils/index.js' 6 | 7 | import { IPrincipalControlable, IWithAccessLevel, IWithContent } from './common.js' 8 | 9 | export const APP_CAPS = { 10 | CAP_ACCESS: capabilityMask(0), 11 | CAP_ADMIN: capabilityMask(1), 12 | CAP_CONTENT: capabilityMask(2), 13 | CAP_LOGIN: capabilityMask(3) 14 | } 15 | 16 | export interface IApp extends IPrincipalControlable, IWithAccessLevel, IWithContent { 17 | _id: UUID 18 | orgId: UUID 19 | 20 | settings: IAppSettings 21 | 22 | secret: string 23 | 24 | createdAt: number 25 | } 26 | 27 | declare module './index.js' { 28 | interface IDbContainer { 29 | apps: Collection 30 | } 31 | } 32 | 33 | export const dbAppPlugin = fastifyPlugin(async (s) => { 34 | const col = s.db.db.collection('apps') 35 | await col.createIndex({ orgId: 1, slug: 1 }, { unique: true }) 36 | await col.createIndex({ [`associations.principalId`]: 1 }) 37 | s.db.apps = col 38 | }) 39 | -------------------------------------------------------------------------------- /apps/server/src/db/group.ts: -------------------------------------------------------------------------------- 1 | import { fastifyPlugin } from 'fastify-plugin' 2 | import { BSON, Collection } from 'mongodb' 3 | 4 | import { IGroupProfile } from '../schemas/index.js' 5 | import { capabilityMask } from '../utils/capability.js' 6 | 7 | export const GROUP_CAPS = { 8 | CAP_ACCESS: capabilityMask(0) 9 | } 10 | 11 | export interface IGroup { 12 | _id: BSON.UUID 13 | 14 | orgId: BSON.UUID 15 | profile: IGroupProfile 16 | } 17 | 18 | declare module './index.js' { 19 | interface IDbContainer { 20 | groups: Collection 21 | } 22 | } 23 | 24 | export const dbGroupPlugin = fastifyPlugin(async (s) => { 25 | const col = s.db.db.collection('groups') 26 | await col.createIndex({ orgId: 1 }) 27 | await col.createIndex({ orgId: 1, [`profile.name`]: 1 }, { unique: true }) 28 | s.db.groups = col 29 | }) 30 | -------------------------------------------------------------------------------- /apps/server/src/db/info.ts: -------------------------------------------------------------------------------- 1 | import { fastifyPlugin } from 'fastify-plugin' 2 | import { BSON, Collection } from 'mongodb' 3 | 4 | export interface IInfoMilestone { 5 | csp: string 6 | noip: string 7 | } 8 | 9 | export interface IInfoFriend { 10 | title: string 11 | url: string 12 | } 13 | 14 | export interface IInfoPoster { 15 | title: string 16 | url: string 17 | } 18 | 19 | export interface IInfo { 20 | _id: BSON.UUID 21 | 22 | milestone: IInfoMilestone 23 | friends: IInfoFriend[] 24 | posters: IInfoPoster[] 25 | 26 | regDefaultOrg?: string 27 | } 28 | 29 | declare module './index.js' { 30 | interface IDbContainer { 31 | infos: Collection 32 | } 33 | } 34 | 35 | export const dbInfoPlugin = fastifyPlugin(async (s) => { 36 | const infos = s.db.db.collection('info') 37 | // initialize 38 | const count = await infos.countDocuments() 39 | if (count === 0) { 40 | // insert one if none exists 41 | await infos.insertOne({ 42 | _id: new BSON.UUID(), 43 | milestone: { 44 | csp: '2024-12-02', 45 | noip: '2024-12-02' 46 | }, 47 | friends: [], 48 | posters: [] 49 | }) 50 | } 51 | s.db.infos = infos 52 | }) 53 | -------------------------------------------------------------------------------- /apps/server/src/db/publicRanklist.ts: -------------------------------------------------------------------------------- 1 | import { fastifyPlugin } from 'fastify-plugin' 2 | import { BSON, Collection } from 'mongodb' 3 | 4 | export interface IPublicRanklist { 5 | ranklistId: number 6 | orgId: BSON.UUID 7 | contestId: BSON.UUID 8 | ranklistKey: string 9 | visible: boolean 10 | password?: string 11 | } 12 | 13 | declare module './index.js' { 14 | interface IDbContainer { 15 | pubrk: Collection 16 | } 17 | } 18 | 19 | export const dbPublicRanklistPlugin = fastifyPlugin(async (s) => { 20 | const col = s.db.db.collection('pubrk') 21 | await col.createIndex({ ranklistId: -1 }, { unique: true }) 22 | s.db.pubrk = col 23 | }) 24 | -------------------------------------------------------------------------------- /apps/server/src/db/runner.ts: -------------------------------------------------------------------------------- 1 | import { fastifyPlugin } from 'fastify-plugin' 2 | import { BSON, Collection } from 'mongodb' 3 | 4 | export interface IRunner { 5 | _id: BSON.UUID 6 | orgId: BSON.UUID 7 | labels: string[] 8 | name: string 9 | key: string 10 | version: string 11 | message: string 12 | ip: string 13 | 14 | createdAt: number 15 | accessedAt: number 16 | } 17 | 18 | declare module './index.js' { 19 | interface IDbContainer { 20 | runners: Collection 21 | } 22 | } 23 | 24 | export const dbRunnerPlugin = fastifyPlugin(async (s) => { 25 | const col = s.db.db.collection('runners') 26 | await col.createIndex({ orgId: 1, key: 1 }, { unique: true }) 27 | s.db.runners = col 28 | }) 29 | -------------------------------------------------------------------------------- /apps/server/src/db/user.ts: -------------------------------------------------------------------------------- 1 | import { IAAAUserInfo } from '@lcpu/iaaa' 2 | import { fastifyPlugin } from 'fastify-plugin' 3 | import { BSON, Collection } from 'mongodb' 4 | 5 | import { IUserProfile } from '../schemas/index.js' 6 | import { capabilityMask } from '../utils/index.js' 7 | 8 | export const USER_CAPS = { 9 | CAP_ADMIN: capabilityMask(0), 10 | CAP_CREATE_ORG: capabilityMask(1) 11 | } 12 | 13 | export interface IUserAuthSources { 14 | password?: string 15 | passwordResetDue?: boolean 16 | mail?: string 17 | sms?: string 18 | iaaaId?: string 19 | iaaaInfo?: IAAAUserInfo 20 | uaaa?: string 21 | } 22 | 23 | export interface IUser { 24 | _id: BSON.UUID 25 | 26 | profile: IUserProfile 27 | authSources: IUserAuthSources 28 | capability?: BSON.Long 29 | namespace?: string 30 | tags?: string[] 31 | } 32 | 33 | declare module './index.js' { 34 | interface IDbContainer { 35 | users: Collection 36 | } 37 | } 38 | 39 | export const dbUserPlugin = fastifyPlugin(async (s) => { 40 | const col = s.db.db.collection('users') 41 | await col.createIndex({ namespace: 1, 'profile.name': 1 }, { unique: true }) 42 | s.db.users = col 43 | }) 44 | -------------------------------------------------------------------------------- /apps/server/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './db/index.js' 2 | export * from './oss/index.js' 3 | export * from './routes/index.js' 4 | export * from './schemas/index.js' 5 | export * from './server/index.js' 6 | export * from './utils/index.js' 7 | -------------------------------------------------------------------------------- /apps/server/src/oss/key.ts: -------------------------------------------------------------------------------- 1 | import { BSON } from 'mongodb' 2 | 3 | export function problemDataKey(problemId: BSON.UUID, dataHash: string) { 4 | return `problem/${problemId}/data/${dataHash}` 5 | } 6 | 7 | export function problemAttachmentKey(problemId: BSON.UUID, attachmentKey: string) { 8 | return `problem/${problemId}/attachment/${attachmentKey}` 9 | } 10 | 11 | export function solutionDataKey(solutionId: BSON.UUID) { 12 | return `solution/${solutionId}/data` 13 | } 14 | 15 | export function solutionDetailsKey(solutionId: BSON.UUID) { 16 | return `solution/${solutionId}/result.json` 17 | } 18 | 19 | export function contestAttachmentKey(contestId: BSON.UUID, attachmentKey: string) { 20 | return `contest/${contestId}/attachment/${attachmentKey}` 21 | } 22 | 23 | export function contestRanklistKey(contestId: BSON.UUID, ranklistKey: string) { 24 | return `contest/${contestId}/ranklist/${ranklistKey}.json` 25 | } 26 | -------------------------------------------------------------------------------- /apps/server/src/routes/admin/index.ts: -------------------------------------------------------------------------------- 1 | import { USER_CAPS, hasCapability } from '../../index.js' 2 | import { packageJson } from '../../utils/package.js' 3 | import { loadUserCapability } from '../common/access.js' 4 | import { defineRoutes, swaggerTagMerger } from '../common/index.js' 5 | 6 | import { adminUserRoutes } from './user.js' 7 | 8 | export const adminRoutes = defineRoutes(async (s) => { 9 | s.addHook('onRoute', swaggerTagMerger('admin')) 10 | 11 | s.addHook('onRequest', async (req, rep) => { 12 | const capability = await loadUserCapability(req) 13 | if (!hasCapability(capability, USER_CAPS.CAP_ADMIN)) return rep.forbidden() 14 | }) 15 | 16 | s.get('/', async () => { 17 | return { 18 | serverVersion: packageJson.version 19 | } 20 | }) 21 | 22 | s.register(adminUserRoutes, { prefix: '/user' }) 23 | }) 24 | -------------------------------------------------------------------------------- /apps/server/src/routes/app/inject.ts: -------------------------------------------------------------------------------- 1 | import { BSON } from 'mongodb' 2 | 3 | import { IApp, IOrgMembership } from '../../db/index.js' 4 | import { defineInjectionPoint } from '../../utils/inject.js' 5 | 6 | export const kAppContext = defineInjectionPoint<{ 7 | app: IApp 8 | capability: BSON.Long 9 | membership: IOrgMembership 10 | }>('app') 11 | -------------------------------------------------------------------------------- /apps/server/src/routes/common/content.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox' 2 | import { FastifyRequest } from 'fastify' 3 | import { BSON, Collection } from 'mongodb' 4 | 5 | import { IWithContent } from '../../db/index.js' 6 | import { T } from '../../schemas/index.js' 7 | 8 | export const manageContent: FastifyPluginAsyncTypebox<{ 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | collection: Collection 11 | resolve: (req: FastifyRequest) => Promise 12 | }> = async (s, opts) => { 13 | const collection = opts.collection as Collection 14 | s.patch( 15 | '/', 16 | { 17 | schema: { 18 | description: 'Update problem content', 19 | body: T.Partial( 20 | T.StrictObject({ 21 | title: T.String(), 22 | slug: T.String(), 23 | description: T.String(), 24 | tags: T.Array(T.String()) 25 | }) 26 | ), 27 | response: { 28 | 200: T.Object({}) 29 | } 30 | } 31 | }, 32 | async (req, rep) => { 33 | const _id = await opts.resolve(req) 34 | if (!_id) return rep.notFound() 35 | await collection.updateOne({ _id }, { $set: req.body }) 36 | return {} 37 | } 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /apps/server/src/routes/common/rule.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginAsyncTypebox, TSchema } from '@fastify/type-provider-typebox' 2 | import { FastifyRequest } from 'fastify' 3 | import { Collection, UUID } from 'mongodb' 4 | 5 | import { T } from '../../schemas/index.js' 6 | 7 | import { manageSettings } from './settings.js' 8 | 9 | export const manageRules: FastifyPluginAsyncTypebox<{ 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | collection: Collection 12 | resolve: (req: FastifyRequest) => Promise 13 | schemas: Record 14 | }> = async (s, { collection, resolve, schemas }) => { 15 | const ruleSetSpec = JSON.stringify( 16 | Object.fromEntries(Object.entries(schemas).map(([k, v]) => [k, T.RuleSet(v)])) 17 | ) 18 | s.get('/', async (req, rep) => { 19 | rep.header('Content-Type', 'application/json') 20 | return ruleSetSpec 21 | }) 22 | 23 | for (const [key, schema] of Object.entries(schemas)) { 24 | s.register(manageSettings, { 25 | collection, 26 | resolve, 27 | schema: T.RuleSet(schema), 28 | key: `rules.${key}`, 29 | allowDelete: true, 30 | prefix: `/${key}`, 31 | extractor: (item) => item?.rules?.[key] 32 | }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /apps/server/src/routes/contest/inject.ts: -------------------------------------------------------------------------------- 1 | import { BSON } from 'mongodb' 2 | 3 | import { IContest, IContestStage, IContestParticipant } from '../../index.js' 4 | import { defineInjectionPoint } from '../../utils/inject.js' 5 | 6 | export const kContestContext = defineInjectionPoint<{ 7 | _contestId: BSON.UUID 8 | _contest: IContest 9 | _contestCapability: BSON.Long 10 | _contestStage: IContestStage 11 | _contestParticipant: IContestParticipant | null 12 | }>('contest') 13 | -------------------------------------------------------------------------------- /apps/server/src/routes/contest/problem/common.ts: -------------------------------------------------------------------------------- 1 | import { FastifyRequest } from 'fastify' 2 | 3 | import { tryLoadUUID } from '../../common/index.js' 4 | import { kContestContext } from '../inject.js' 5 | 6 | export function loadProblemSettings(req: FastifyRequest) { 7 | const problemId = tryLoadUUID(req.params, 'problemId') 8 | if (!problemId) return [null, undefined] as const 9 | const ctx = req.inject(kContestContext) 10 | const settings = ctx._contest.problems.find((problem) => 11 | problemId.equals(problem.problemId) 12 | )?.settings 13 | return [problemId, settings] as const 14 | } 15 | -------------------------------------------------------------------------------- /apps/server/src/routes/org/inject.ts: -------------------------------------------------------------------------------- 1 | import { BSON } from 'mongodb' 2 | 3 | import { IOrgMembership } from '../../db/index.js' 4 | import { defineInjectionPoint } from '../../utils/inject.js' 5 | 6 | export const kOrgContext = defineInjectionPoint<{ 7 | _orgId: BSON.UUID 8 | // Guest users will have null membership 9 | _orgMembership: IOrgMembership | null 10 | }>('org') 11 | -------------------------------------------------------------------------------- /apps/server/src/routes/plan/inject.ts: -------------------------------------------------------------------------------- 1 | import { BSON } from 'mongodb' 2 | 3 | import { IPlan, IPlanParticipant } from '../../index.js' 4 | import { defineInjectionPoint } from '../../utils/inject.js' 5 | 6 | export const kPlanContext = defineInjectionPoint<{ 7 | _plan: IPlan 8 | _planCapability: BSON.Long 9 | _planParticipant: IPlanParticipant | null 10 | }>('plan') 11 | -------------------------------------------------------------------------------- /apps/server/src/routes/plugins/health.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox' 2 | 3 | export const apiHealthPlugin: FastifyPluginAsyncTypebox = async (s) => { 4 | s.get( 5 | '/ping', 6 | { 7 | schema: { 8 | description: 'Server health check', 9 | security: [] 10 | } 11 | }, 12 | async () => ({ ping: 'pong' }) 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /apps/server/src/routes/plugins/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.js' 2 | export * from './health.js' 3 | export * from './inject.js' 4 | export * from './ratelimit.js' 5 | -------------------------------------------------------------------------------- /apps/server/src/routes/plugins/inject.ts: -------------------------------------------------------------------------------- 1 | import { fastifyPlugin } from 'fastify-plugin' 2 | 3 | import { IContainer, createInjectionContainer } from '../../utils/index.js' 4 | 5 | declare module 'fastify' { 6 | interface FastifyRequest { 7 | _now: number 8 | _container: IContainer 9 | } 10 | } 11 | 12 | export const apiInjectPlugin = fastifyPlugin(async (s) => { 13 | s.addHook('onRequest', async (req) => { 14 | req._now = Date.now() 15 | req._container = createInjectionContainer() 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /apps/server/src/routes/plugins/ratelimit.ts: -------------------------------------------------------------------------------- 1 | import { RateLimitPluginOptions, fastifyRateLimit } from '@fastify/rate-limit' 2 | import { fastifyPlugin } from 'fastify-plugin' 3 | 4 | import { RedisCache } from '../../cache/redis.js' 5 | 6 | export interface IApiRateLimitOptions extends RateLimitPluginOptions {} 7 | 8 | export const apiRatelimitPlugin = fastifyPlugin(async (s, options) => { 9 | await s.register(fastifyRateLimit, { 10 | max: 200, 11 | timeWindow: '1 minute', 12 | redis: s.cache instanceof RedisCache ? s.cache.redis : undefined, 13 | ...options 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /apps/server/src/routes/problem/inject.ts: -------------------------------------------------------------------------------- 1 | import { BSON } from 'mongodb' 2 | 3 | import { IProblem } from '../../index.js' 4 | import { defineInjectionPoint } from '../../utils/inject.js' 5 | 6 | export const kProblemContext = defineInjectionPoint<{ 7 | _problemId: BSON.UUID 8 | _problemCapability: BSON.Long 9 | _problem: IProblem 10 | }>('problem') 11 | -------------------------------------------------------------------------------- /apps/server/src/routes/runner/inject.ts: -------------------------------------------------------------------------------- 1 | import { IRunner } from '../../index.js' 2 | import { defineInjectionPoint } from '../../utils/inject.js' 3 | 4 | export const kRunnerContext = defineInjectionPoint<{ 5 | _runner: IRunner 6 | }>('runner') 7 | -------------------------------------------------------------------------------- /apps/server/src/routes/solution/scoped.ts: -------------------------------------------------------------------------------- 1 | import { BSON } from 'mongodb' 2 | 3 | import { T } from '../../schemas/index.js' 4 | import { defineInjectionPoint } from '../../utils/inject.js' 5 | import { defineRoutes, loadUUID, paramSchemaMerger } from '../common/index.js' 6 | 7 | const solutionIdSchema = T.Object({ 8 | solutionId: T.String() 9 | }) 10 | 11 | const kSolutionContext = defineInjectionPoint<{ 12 | _solutionId: BSON.UUID 13 | }>('solution') 14 | 15 | export const solutionScopedRoute = defineRoutes(async (s) => { 16 | s.addHook('onRoute', paramSchemaMerger(solutionIdSchema)) 17 | 18 | // TODO: Access Control 19 | s.addHook('onRequest', async (req) => { 20 | req.provide(kSolutionContext, { 21 | _solutionId: loadUUID(req.params, 'solutionId', s.httpErrors.notFound()) 22 | }) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /apps/server/src/schemas/api.ts: -------------------------------------------------------------------------------- 1 | import { T } from './common.js' 2 | 3 | export const SAPIResponseVoid = T.Object({}) 4 | -------------------------------------------------------------------------------- /apps/server/src/schemas/app.ts: -------------------------------------------------------------------------------- 1 | import { T, Static } from './common.js' 2 | 3 | export const SAppSettings = T.Partial( 4 | T.Object({ 5 | allowPublicLogin: T.Boolean(), 6 | requireMfa: T.Boolean(), 7 | scopes: T.Array( 8 | T.String({ 9 | pattern: '^[^.].?$' 10 | }) 11 | ), 12 | redirectUris: T.Array( 13 | T.Object({ 14 | uri: T.String(), 15 | label: T.String() 16 | }) 17 | ), 18 | attachUser: T.Boolean(), 19 | attachMembership: T.Boolean(), 20 | allowDeviceFlow: T.Boolean(), 21 | enableIaaa: T.Boolean() 22 | }) 23 | ) 24 | 25 | export interface IAppSettings extends Static {} 26 | -------------------------------------------------------------------------------- /apps/server/src/schemas/group.ts: -------------------------------------------------------------------------------- 1 | import { T, Static, SBaseProfile } from './common.js' 2 | 3 | export const SGroupProfile = T.NoAdditionalProperties(SBaseProfile) 4 | export interface IGroupProfile extends Static {} 5 | -------------------------------------------------------------------------------- /apps/server/src/schemas/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api.js' 2 | export * from './app.js' 3 | export * from './common.js' 4 | export * from './contest.js' 5 | export * from './group.js' 6 | export * from './org.js' 7 | export * from './plan.js' 8 | export * from './problem.js' 9 | export * from './user.js' 10 | -------------------------------------------------------------------------------- /apps/server/src/schemas/org.ts: -------------------------------------------------------------------------------- 1 | import { T, Static, SBaseProfile } from './common.js' 2 | 3 | export const SOrgProfile = T.NoAdditionalProperties(SBaseProfile) 4 | export interface IOrgProfile extends Static {} 5 | 6 | export const SOrgOssSettings = T.StrictObject({ 7 | bucket: T.String(), 8 | accessKey: T.String(), 9 | secretKey: T.String(), 10 | region: T.Optional(T.String()), 11 | endpoint: T.Optional(T.String()), // The default is AWS S3 12 | pathStyle: T.Optional(T.Boolean()) 13 | }) 14 | export interface IOrgOssSettings extends Static {} 15 | 16 | export const SOrgSettings = T.StrictObject({ 17 | oss: T.Optional(SOrgOssSettings), 18 | problemInstanceLimit: T.Optional(T.Number()) 19 | }) 20 | export interface IOrgSettings extends Static {} 21 | -------------------------------------------------------------------------------- /apps/server/src/schemas/plan.ts: -------------------------------------------------------------------------------- 1 | import { T, Static } from './common.js' 2 | 3 | export const SPlanContestPrecondition = T.Partial( 4 | T.StrictObject({ 5 | minTotalScore: T.Number(), 6 | problems: T.Array( 7 | T.Object({ 8 | problemId: T.String(), 9 | minScore: T.Number() 10 | }) 11 | ) 12 | }) 13 | ) 14 | 15 | export interface IPlanContestPrecondition extends Static {} 16 | 17 | export const SPlanContestSettings = T.StrictObject({ 18 | slug: T.String(), 19 | preConditionContests: T.Optional( 20 | T.Array( 21 | T.Object({ 22 | contestId: T.String(), 23 | conditions: SPlanContestPrecondition 24 | }) 25 | ) 26 | ) 27 | }) 28 | 29 | export interface IPlanContestSettings extends Static {} 30 | 31 | export const SPlanSettings = T.Partial( 32 | T.StrictObject({ 33 | registrationEnabled: T.Boolean(), 34 | registrationAllowPublic: T.Boolean(), 35 | promotion: T.Optional(T.Boolean()) 36 | }) 37 | ) 38 | 39 | export interface IPlanSettings extends Static {} 40 | -------------------------------------------------------------------------------- /apps/server/src/schemas/problem.ts: -------------------------------------------------------------------------------- 1 | import { Static, T } from './common.js' 2 | 3 | export const SProblemSettings = T.Partial( 4 | T.Object({ 5 | allowPublicSubmit: T.Boolean(), 6 | maxSolutionCount: T.Number(), 7 | // Allow participant see other's solution's status 8 | solutionShowOther: T.Boolean(), 9 | // Allow participant see self solution's details (control OSS result json file) 10 | solutionShowDetails: T.Boolean(), 11 | // Allow participant see other's solution's details (control OSS result json file) 12 | solutionShowOtherDetails: T.Boolean(), 13 | // Allow participant see other's solution's data (control OSS data file) 14 | solutionShowOtherData: T.Boolean(), 15 | 16 | allowPublicInstance: T.Boolean(), 17 | maxInstanceCount: T.Number() 18 | }) 19 | ) 20 | 21 | export interface IProblemSettings extends Static {} 22 | 23 | export const SProblemSolutionRuleResult = T.StrictObject({ 24 | showData: T.BooleanOrString() 25 | }) 26 | 27 | export interface IProblemSolutionRuleResult extends Static {} 28 | -------------------------------------------------------------------------------- /apps/server/src/schemas/user.ts: -------------------------------------------------------------------------------- 1 | import { T, Static } from './common.js' 2 | 3 | export const SUserProfile = T.StrictObject({ 4 | name: T.String({ maxLength: 16 }), 5 | email: T.String({ maxLength: 128, pattern: '^\\S+@\\S+$' }), 6 | realname: T.String({ maxLength: 16 }), 7 | telephone: T.Optional(T.String({ pattern: '^\\d{11}$' })), 8 | school: T.Optional(T.String({ maxLength: 32 })), 9 | studentGrade: T.Optional(T.String({ maxLength: 32 })), 10 | verified: T.Optional(T.Array(T.String())) 11 | }) 12 | 13 | export interface IUserProfile extends Static {} 14 | -------------------------------------------------------------------------------- /apps/server/src/server/schemas.ts: -------------------------------------------------------------------------------- 1 | import { SProblemConfigSchema } from '@aoi-js/common' 2 | import { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox' 3 | 4 | import { T } from '../index.js' 5 | 6 | const schemas = Object.fromEntries( 7 | Object.entries({ 8 | 'problem-config.json': SProblemConfigSchema 9 | }).map(([name, schema]) => [name, JSON.stringify(schema)]) 10 | ) 11 | 12 | export const schemaRoutes: FastifyPluginAsyncTypebox = async (s) => { 13 | s.get( 14 | '/:name', 15 | { 16 | schema: { 17 | params: T.Object({ name: T.String() }) 18 | } 19 | }, 20 | (req, rep) => { 21 | if (!Object.hasOwn(schemas, req.params.name)) return rep.notFound() 22 | return rep.header('content-type', 'application/json').send(schemas[req.params.name]) 23 | } 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /apps/server/src/utils/capability.ts: -------------------------------------------------------------------------------- 1 | import { BSON } from 'mongodb' 2 | 3 | import { IPrincipalControlable, IOrgMembership } from '../db/index.js' 4 | 5 | /** 6 | * See: 7 | * https://stackoverflow.com/questions/75000363/how-to-prevent-mongodb-nodejs-driver-converts-long-into-number 8 | */ 9 | 10 | export const CAP_ALL = BSON.Long.MAX_UNSIGNED_VALUE 11 | export const CAP_NONE = BSON.Long.UZERO 12 | 13 | /** 14 | * return a mask that is `1 << n` in Long 15 | * 16 | * @param n 17 | */ 18 | export function capabilityMask(n: number) { 19 | return BSON.Long.fromInt(1, true).shl(n) 20 | } 21 | 22 | export function computeCapability( 23 | object: IPrincipalControlable, 24 | membership: IOrgMembership | null, 25 | defaultCapability = CAP_NONE 26 | ) { 27 | if (!membership) return defaultCapability 28 | return object.associations.reduce( 29 | (acc, { principalId, capability }) => 30 | principalId.equals(membership.userId) || 31 | membership.groups.some((groupId) => groupId.equals(principalId)) 32 | ? acc.or(capability) 33 | : acc, 34 | defaultCapability 35 | ) 36 | } 37 | 38 | export function hasCapability(capability: BSON.Long, mask: BSON.Long) { 39 | return capability.and(mask).equals(mask) 40 | } 41 | 42 | export function ensureCapability(capability: BSON.Long, mask: BSON.Long, err: E) { 43 | if (!hasCapability(capability, mask)) { 44 | throw err 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /apps/server/src/utils/config.ts: -------------------------------------------------------------------------------- 1 | export const ENV_PREFIX = process.env.AOI_ENV_PREFIX ?? 'AOI_' 2 | 3 | export function loadEnv( 4 | key: string, 5 | transform: (value: string) => T, 6 | ...defaultValue: S 7 | ): T { 8 | key = ENV_PREFIX + key 9 | if (!(key in process.env)) { 10 | if (defaultValue.length > 0) { 11 | return defaultValue[0] as T 12 | } 13 | throw new Error(`Missing env ${key}`) 14 | } 15 | const value = process.env[key] 16 | return transform(value ?? '') 17 | } 18 | 19 | export const parseBoolean = (value: string) => !!JSON.parse(value) 20 | -------------------------------------------------------------------------------- /apps/server/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './capability.js' 2 | export * from './config.js' 3 | export * from './inject.js' 4 | export * from './logger.js' 5 | export * from './module.js' 6 | export * from './package.js' 7 | export * from './pagination.js' 8 | export * from './rule.js' 9 | export * from './search.js' 10 | export * from './types.js' 11 | -------------------------------------------------------------------------------- /apps/server/src/utils/inject.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-unused-vars 2 | export interface InjectionPoint<_T> extends Symbol {} 3 | export type InjectionPayload = T extends InjectionPoint ? U : never 4 | export interface IContainer { 5 | [key: symbol]: unknown 6 | } 7 | 8 | export function createInjectionContainer(): IContainer { 9 | return Object.create(null) 10 | } 11 | 12 | export function defineInjectionPoint(name: string): InjectionPoint { 13 | return Symbol(name) 14 | } 15 | 16 | export function provide(container: IContainer, point: InjectionPoint, value: T): void { 17 | if ((point as symbol) in container) 18 | throw new Error(`Duplicate injection point ${point.toString()}`) 19 | container[point as symbol] = value 20 | } 21 | 22 | export function inject(container: IContainer, point: InjectionPoint): T { 23 | const value = container[point as symbol] 24 | if (value === undefined) throw new Error(`Missing injection point ${point.toString()}`) 25 | return value as T 26 | } 27 | -------------------------------------------------------------------------------- /apps/server/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { pino } from 'pino' 2 | 3 | import { loadEnv } from './config.js' 4 | 5 | export const logger = pino({ 6 | level: loadEnv('LOG_LEVEL', String, 'info') 7 | }) 8 | -------------------------------------------------------------------------------- /apps/server/src/utils/module.ts: -------------------------------------------------------------------------------- 1 | import { createRequire } from 'node:module' 2 | 3 | const require = createRequire(import.meta.url) 4 | 5 | export function tryResolve(mod: string) { 6 | try { 7 | return require.resolve(mod) 8 | } catch { 9 | return null 10 | } 11 | } 12 | 13 | export function hasModule(mod: string) { 14 | return tryResolve(mod) !== null 15 | } 16 | -------------------------------------------------------------------------------- /apps/server/src/utils/package.ts: -------------------------------------------------------------------------------- 1 | import packageJson from '../../package.json' with { type: 'json' } 2 | 3 | export { packageJson } 4 | -------------------------------------------------------------------------------- /apps/server/src/utils/pagination.ts: -------------------------------------------------------------------------------- 1 | import { Collection, Document, Filter, FindOptions, Sort, WithId } from 'mongodb' 2 | 3 | export function paginationSkip(page: number, perPage: number) { 4 | return (page - 1) * perPage 5 | } 6 | 7 | export async function findPaginated( 8 | collection: Collection, 9 | page: number, 10 | perPage: number, 11 | count: boolean, 12 | filter: Filter, 13 | options?: FindOptions, 14 | sort?: Sort 15 | ): Promise<{ 16 | items: WithId[] 17 | total?: number 18 | }> { 19 | const skip = paginationSkip(page, perPage) 20 | let cursor = collection.find(filter, options) 21 | if (sort) cursor = cursor.sort(sort) 22 | cursor = cursor.skip(skip).limit(perPage) 23 | const items = await cursor.toArray() 24 | const total = count ? await collection.countDocuments(filter, options) : undefined 25 | return { items, total } 26 | } 27 | -------------------------------------------------------------------------------- /apps/server/src/utils/rule.ts: -------------------------------------------------------------------------------- 1 | import { IRuleSet, IRuleSetEvaluateOptions, Projector, evaluateRuleSet } from '@aoi-js/rule' 2 | import { httpErrors } from '@fastify/sensible' 3 | import { Static, TSchema } from '@sinclair/typebox' 4 | import { TypeCompiler } from '@sinclair/typebox/compiler' 5 | 6 | import { T } from '../schemas/common.js' 7 | 8 | export function createEvaluator(schema: T) { 9 | const checker = TypeCompiler.Compile(T.Partial(schema)) 10 | type Result = Static 11 | return ( 12 | context: Context, 13 | ruleSet: IRuleSet, 14 | options: IRuleSetEvaluateOptions = {}, 15 | fallback?: Projector 16 | ) => { 17 | try { 18 | const result = evaluateRuleSet(context, ruleSet, options, fallback) 19 | if (!checker.Check(result)) throw new Error('Invalid rule result') 20 | return result 21 | } catch (err) { 22 | throw httpErrors.preconditionFailed(`${err}`) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /apps/server/src/utils/search.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'mongodb' 2 | 3 | export function escapeSearch(search: string) { 4 | return search.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') 5 | } 6 | 7 | export function searchToFilter( 8 | query: { 9 | search?: string 10 | tag?: string 11 | tags?: string[] 12 | }, 13 | { maxConditions } = { maxConditions: 1 } 14 | ): Document | null { 15 | if (Object.keys(query).length > maxConditions) return null 16 | const filter: Document = {} 17 | if (query.search) { 18 | const escapedRegex = escapeSearch(query.search) 19 | // Should satisfy: title match Regex or slug match Regex 20 | filter.$or = [{ title: { $regex: escapedRegex } }, { slug: { $regex: escapedRegex } }] 21 | } 22 | if (query.tag) { 23 | filter.tags = query.tag 24 | } 25 | if (query.tags) { 26 | filter.tags = { $all: query.tags } 27 | } 28 | return filter 29 | } 30 | 31 | export function filterMerge(base: Document, extra: Document): Document { 32 | return { $and: [base, extra] } 33 | } 34 | -------------------------------------------------------------------------------- /apps/server/src/utils/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | export type Shift = T extends [any, ...infer U] ? U : never 4 | -------------------------------------------------------------------------------- /apps/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "composite": true, 5 | "rootDir": "src", 6 | "target": "ESNext", 7 | "lib": ["ESNext"], 8 | "module": "NodeNext", 9 | "moduleResolution": "NodeNext", 10 | "declaration": true, 11 | "sourceMap": true, 12 | "outDir": "lib", 13 | "stripInternal": true, 14 | "esModuleInterop": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "strict": true, 17 | "skipLibCheck": true, 18 | "resolveJsonModule": true 19 | }, 20 | "include": ["src/**/*.ts"], 21 | "references": [ 22 | { 23 | "path": "./tsconfig.package.json" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /apps/server/tsconfig.package.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "resolveJsonModule": true, 4 | "composite": true 5 | }, 6 | "include": ["package.json"] 7 | } 8 | -------------------------------------------------------------------------------- /docker/compose/server/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | server: 5 | build: ../../images/server 6 | expose: 7 | - 1926 8 | environment: 9 | - AOI_MONGO_URL=mongodb://mongo:27017/aoi 10 | - AOI_JWT_SECRET=${AOI_JWT_SECRET} 11 | 12 | mongo: 13 | image: mongo:latest 14 | volumes: 15 | - ./mongo:/data/db 16 | expose: 17 | - 27017 18 | -------------------------------------------------------------------------------- /docker/dockerfiles/server.dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine 2 | 3 | ARG NODE_ENV=production 4 | ENV NODE_ENV $NODE_ENV 5 | WORKDIR /opt 6 | COPY apps/server/package.tgz /opt/package.tgz 7 | RUN tar -xzf package.tgz && rm package.tgz && mv package aoi-server 8 | WORKDIR /opt/aoi-server 9 | RUN npm install --omit=dev --omit=optional && npm cache clean --force 10 | 11 | USER node 12 | 13 | CMD [ "node", "lib/cli/index.js" ] 14 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import DefaultTheme from 'vitepress/theme' 2 | import './custom.css' 3 | 4 | export default DefaultTheme 5 | -------------------------------------------------------------------------------- /docs/basic-concepts.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # 基本概念 6 | 7 | 本章中,笔者将介绍AOI评测系统的几个基本概念。 8 | 9 | 跟随本章的指引,你将知晓AOI系统的设计思路,并为使用AOI系统做好准备。 10 | 11 | ## AOI基本概念 12 | 13 | AOI系统中有如下基本概念: 14 | 15 | | 名称 | 代码名称 | 描述 | 16 | | ------ | ---------- | ---------------------------------------------------------------------- | 17 | | 用户 | `user` | 系统中的用户,对应唯一的自然人 | 18 | | 组织 | `org` | 一个组织。所有的小组、题目、比赛、计划、解答等实体都属于特定的组织 | 19 | | 小组 | `group` | 组织中的一些成员构成的集合 | 20 | | 题目 | `problem` | 一道题目,是评测数据的基本单元 | 21 | | 比赛 | `contest` | 题目的集合,需要报名后方可参赛。参赛后可以在比赛内提交比赛的题目 | 22 | | 计划 | `plan` | 比赛的集合,需要报名后方可参加。参加后可以按照一定的规则报名其中的比赛 | 23 | | 解答 | `solution` | 评测的基本单元,是对一道题的提交,可能在也可能不在一场比赛内 | 24 | | 执行器 | `runner` | 执行具体的评测和排行榜计算的,注册在特定组织下的执行程序 | 25 | 26 | 其中,组织可以认为是权限/资源控制的隔离单位。每个组织需要配置自己的OSS和Runner。当然,这意味着不同组织之间的资源完全隔离。 27 | 28 | 目前,比赛/计划只能添加同组织下的题目/比赛。该限制可能在未来的版本中放开。 29 | 30 | ## AOI权限模型 31 | 32 | AOI的权限模型是基于控制实体(Principal)的。控制实体是用户或小组,每个可控对象均具有自己的ACL列表,记录了每个控制实体的权限。 33 | 用户所具备的权限为其所属所有控制实体的权限的并集。 34 | 35 | 具备基于控制实体的权限控制能力的对象有: 36 | 37 | - 题目 38 | - 比赛 39 | - 计划 40 | 41 | ## AOI系统架构 42 | 43 | ![Arch](https://pub-88de94d1076a46e2a317ff578c7fabb1.r2.dev/docs/arch.svg) 44 | -------------------------------------------------------------------------------- /docs/dev-guide.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # 开发指南 6 | 7 | 本文将从开发者的角度介绍AOI仓库,并为希望参与AOI项目开发的开发者提供帮助。 8 | 9 | ## 仓库结构 10 | 11 | - `apps` 独立部署的App包 12 | - `server` 服务器 13 | - `frontend` 前端 14 | - `docker` Docker相关文件 15 | - `compose` Docker Compose文件 16 | - `dockerfiles` Dockerfile文件 17 | - `docs` 文档源码,参见[VitePress](https://vitepress.dev) 18 | - `libs` 通用的库包 19 | - `common` 共享的一些工具代码与模式 20 | - `scripts` 一些辅助脚本 21 | - `local` 本地开发脚本,不会被提交 22 | 23 | ## 代码规范 24 | 25 | 仓库使用Husky在提交代码时自动执行代码规范检查(代码样式、风格与类型检查)。同时,亦有Github Actions执行CI检查。 26 | 27 | 参见[Prettier](https://prettier.io)、[ESLint](https://eslint.org)与[TypeScript](https://www.typescriptlang.org)文档了解。 28 | 29 | ## 设计准则 30 | 31 | 1. 在基于语义与RESTFul的前提下保持API的简明,需求尽量隔离在前端; 32 | 2. 保持代码可读性与自解释性,避免使用非公认的缩写; 33 | 3. 代理功能,避免在API服务中实现非通用的业务逻辑。 34 | 35 | ## 兼容性 36 | 37 | ::: warning 38 | :warning: 在AOI Sekai (v1.x)版本发布前,不保证API的稳定性和兼容性。 39 | ::: 40 | 41 | 提供基于[Semantic Versioning](https://semver.org)的版本兼容性控制。 42 | 43 | ## 安全性 44 | 45 | 团队将会审查所有外部PR,并定期审查代码,确保代码的安全性。同时,我们也欢迎社区的开发者提交安全报告。 46 | 47 | 漏洞可以通过发送邮件至[`security@fedstack.org`](mailto:security@fedstack.org)与我们联系。 48 | 49 | ## 贡献指南 50 | 51 | 若您希望参与AOI项目的开发,您可以: 52 | 53 | - 若您发现了问题,但不知道如何解决,您可以在Discussion中询问; 54 | - 若您具备开发能力,能提供完整的错误发生的环境并可以复现,您可以提交Issue; 55 | - 若您还具备修复问题的能力,您可以提交PR解决之; 56 | - 除此之外,我们亦欢迎文档的维护者。 57 | -------------------------------------------------------------------------------- /docs/en/admin-guide.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # AOI Admin Guide 6 | -------------------------------------------------------------------------------- /docs/en/dev-guide.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # AOI Dev Guide 6 | -------------------------------------------------------------------------------- /docs/en/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Getting Started 6 | -------------------------------------------------------------------------------- /docs/en/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: 'The AOI Project' 7 | text: 'All in One Solution for Online Judge' 8 | tagline: 'The ultimate, scalable, performant online judge infrastructure' 9 | image: /logo.svg 10 | actions: 11 | - theme: brand 12 | text: Getting Started 13 | link: /getting-started 14 | - theme: alt 15 | text: Admin Guide 16 | link: /admin-guide 17 | - theme: alt 18 | text: Dev Guide 19 | link: /dev-guide 20 | 21 | features: 22 | - title: Out of the Box 23 | details: Containerized, cloud-native deployment with Docker and Kubernetes 24 | - title: Builtin Scalability 25 | details: Horizontal scalability with fully stateless design 26 | - title: Full Customization 27 | details: Customized UI&UX with fully decoupled frontend, judger and ranker 28 | --- 29 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: '苍穹(AOI)评测系统' 7 | text: '一体化在线评测方案' 8 | tagline: '全功能,可扩展,高性能的评测基础设施' 9 | image: /logo.svg 10 | actions: 11 | - theme: brand 12 | text: 开始使用 13 | link: /getting-started 14 | - theme: alt 15 | text: 使用指南 16 | link: /user-guide 17 | - theme: alt 18 | text: 管理指南 19 | link: /admin-guide 20 | - theme: alt 21 | text: 开发指南 22 | link: /dev-guide 23 | 24 | features: 25 | - icon: 📦 26 | title: 开箱即用 27 | details: 使用Docker与K8s快速容器化部署 28 | - icon: 📈 29 | title: 无限扩展 30 | details: 纯无状态设计,可无限水平扩展 31 | - icon: 🎨 32 | title: 完全定制 33 | details: 完全可定制的前端、评测机、排名系统 34 | --- 35 | -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedstack-org/aoi/d84e85ed799eebd905ccd93f028657f799971021/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /libs/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedstack-org/aoi/d84e85ed799eebd905ccd93f028657f799971021/libs/README.md -------------------------------------------------------------------------------- /libs/common/.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | -------------------------------------------------------------------------------- /libs/common/README.md: -------------------------------------------------------------------------------- 1 | # `common` 2 | -------------------------------------------------------------------------------- /libs/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@aoi-js/common", 3 | "version": "1.2.0", 4 | "type": "module", 5 | "publishConfig": { 6 | "access": "public" 7 | }, 8 | "license": "AGPL-3.0-only", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/fedstack-org/aoi.git", 12 | "directory": "libs/common" 13 | }, 14 | "main": "lib/index.js", 15 | "files": [ 16 | "lib" 17 | ], 18 | "peerDependencies": { 19 | "@sinclair/typebox": "*" 20 | }, 21 | "scripts": { 22 | "build": "run -T tsc", 23 | "type-check": "run -T tsc --noEmit", 24 | "lint": "run -T eslint --ignore-path .gitignore .", 25 | "format": "run -T prettier --ignore-path .gitignore --check ." 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /libs/common/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './schemas/index.js' 2 | -------------------------------------------------------------------------------- /libs/common/src/schemas/index.ts: -------------------------------------------------------------------------------- 1 | export * from './problem.js' 2 | export * from './ranklist.js' 3 | export * from './solution.js' 4 | -------------------------------------------------------------------------------- /libs/common/src/schemas/solution.ts: -------------------------------------------------------------------------------- 1 | import { type Static, Type } from '@sinclair/typebox' 2 | 3 | export const SSolutionDetailsTestSchema = Type.Object({ 4 | name: Type.String(), 5 | score: Type.Number(), 6 | scoreScale: Type.Optional(Type.Number()), 7 | status: Type.String(), 8 | summary: Type.Optional(Type.String()) 9 | }) 10 | 11 | export type SolutionDetailsTest = Static 12 | 13 | export const SSolutionDetailsJobSchema = Type.Object({ 14 | name: Type.String(), 15 | score: Type.Number(), 16 | scoreScale: Type.Optional(Type.Number()), 17 | status: Type.String(), 18 | tests: Type.Optional(Type.Array(SSolutionDetailsTestSchema)), 19 | summary: Type.Optional(Type.String()) 20 | }) 21 | 22 | export type SolutionDetailsJob = Static 23 | 24 | export const SSolutionDetailsSchema = Type.Object({ 25 | version: Type.Integer({ minimum: 1 }), 26 | jobs: Type.Optional(Type.Array(SSolutionDetailsJobSchema)), 27 | summary: Type.Optional(Type.String()) 28 | }) 29 | 30 | export type SolutionDetails = Static 31 | -------------------------------------------------------------------------------- /libs/common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "composite": true, 5 | "rootDir": "src", 6 | "target": "ESNext", 7 | "lib": [], 8 | "module": "NodeNext", 9 | "moduleResolution": "NodeNext", 10 | "declaration": true, 11 | "sourceMap": true, 12 | "outDir": "lib", 13 | "stripInternal": true, 14 | "esModuleInterop": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "strict": true, 17 | "skipLibCheck": true, 18 | "resolveJsonModule": true 19 | }, 20 | "include": ["src/**/*.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /libs/rule/.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | -------------------------------------------------------------------------------- /libs/rule/README.md: -------------------------------------------------------------------------------- 1 | # `common` 2 | -------------------------------------------------------------------------------- /libs/rule/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@aoi-js/rule", 3 | "version": "1.1.0", 4 | "type": "module", 5 | "publishConfig": { 6 | "access": "public" 7 | }, 8 | "license": "AGPL-3.0-only", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/fedstack-org/aoi.git", 12 | "directory": "libs/rule" 13 | }, 14 | "main": "lib/index.js", 15 | "files": [ 16 | "lib" 17 | ], 18 | "scripts": { 19 | "build": "run -T tsc", 20 | "type-check": "run -T tsc --noEmit", 21 | "lint": "run -T eslint --ignore-path .gitignore .", 22 | "format": "run -T prettier --ignore-path .gitignore --check .", 23 | "test": "run -T tsx --test src/**/*.spec.ts" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /libs/rule/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "composite": true, 5 | "rootDir": "src", 6 | "target": "ESNext", 7 | "lib": ["ESNext"], 8 | "module": "NodeNext", 9 | "moduleResolution": "NodeNext", 10 | "declaration": true, 11 | "sourceMap": true, 12 | "outDir": "lib", 13 | "stripInternal": true, 14 | "esModuleInterop": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "strict": true, 17 | "skipLibCheck": true, 18 | "resolveJsonModule": true 19 | }, 20 | "include": ["src/**/*.ts"], 21 | "exclude": ["src/**/*.spec.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /scripts/local/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedstack-org/aoi/d84e85ed799eebd905ccd93f028657f799971021/scripts/local/.gitkeep --------------------------------------------------------------------------------