Compare commits

..

123 Commits

Author SHA1 Message Date
92c3c8eefe
update: dependency version 2025-06-10 15:08:08 +08:00
497ea031d8
fix: missing field in db schema 2025-06-10 15:03:39 +08:00
39ca394a56
fix: several bugs about type 2025-06-10 14:46:28 +08:00
0bd1771f35
improve: date formatting in frontend 2025-06-10 14:38:55 +08:00
328c73c209
fix: 'close' is missing in DialogButtonGroup in ErrorDialog 2025-06-08 18:24:15 +08:00
5ac952ec13
merge: branch 'feat/frontend' into main 2025-06-08 18:09:49 +08:00
2cf5923b28
add: logout in frontend 2025-06-08 18:06:46 +08:00
75973c72ee
add: login, user profile page & license 2025-06-07 19:42:36 +08:00
b40d24721c
add: milestone monitoring for videos close to N million views 2025-06-06 21:37:55 +08:00
0a6ecc6314
add: route for getting videos 2025-06-06 21:03:52 +08:00
b4a0320e3e
version: cvsa/3.15.34 2025-06-06 17:40:32 +08:00
8cf9395354
fix: don't adjust start time for schedule when no proxy is available 2025-06-06 17:11:44 +08:00
1e8d28e194
fix: incorrectly ignored type when collecting videos for archive snapshots 2025-06-06 16:52:27 +08:00
c0340677a1
merge: pull request #8 from ref/next 2025-06-05 22:31:06 +08:00
54a2de0a11
Merge branch 'main' into ref/next 2025-06-05 22:30:19 +08:00
3abd6666c0
add: missing dependency in backend 2025-06-02 05:33:50 +08:00
44e13724fc
fix: missing dependency in backend 2025-06-02 05:17:46 +08:00
dd7e2242a0
add: docker support for nextjs frontend 2025-06-02 05:04:26 +08:00
503a93a09f
update: .tokeignore 2025-06-02 04:06:56 +08:00
507f2c331e
add: several pages, synced with old frontend 2025-06-02 04:04:17 +08:00
a1a4abff46
fix: missing import in @cvsa/core 2025-06-01 21:31:37 +08:00
c6b7736dac
fix: import from the core package in next frontend 2025-06-01 18:48:50 +08:00
fa5ab258da
add: login status detection in frontend 2025-06-01 17:21:09 +08:00
9dd06fa7bc
add: set cookie after signing up 2025-06-01 16:18:01 +08:00
bb7f846305
improve: the structure of the error handling in sign up page 2025-06-01 14:36:55 +08:00
7f9563a2a6
ref: use axios for API requests
add: a useCaptcha hook
2025-06-01 01:53:06 +08:00
d0d9c21aba
update: as-nned URL prefix for i18n routing 2025-05-31 21:56:15 +08:00
44bc99dd9d
add: i18n 2025-05-31 21:29:47 +08:00
2c83b79881
update: termination condition to time-based in classifyVideosWorker 2025-05-31 12:23:01 +08:00
1a20d5afe0
update: schedule archive snapshots to next Saturday midnight
fix: no expire when acquiring lock for classifyVideos
ref: format
2025-05-31 12:13:56 +08:00
ae338f88ee
update: the error dialog 2025-05-31 11:47:45 +08:00
96903dec2b
fix: prevent scrolling of body when model dialog is shown 2025-05-30 03:24:37 +08:00
58b4e2613c
add: button components in dialog 2025-05-28 23:01:43 +08:00
2b0497c83a
fix: incorrect timezone when adding to bilibili metadata 2025-05-27 22:40:53 +08:00
3bc72720d1
fix: remove bull-board from all scripts to prevent crashing 2025-05-27 22:35:44 +08:00
557a013b42
add: some components, fonts 2025-05-27 22:33:28 +08:00
16cfae8bad
fix: unexpectedly set prop variant of TextField to required 2025-05-25 03:15:51 +08:00
cbd46d4030
improve: code style and some subtle changes 2025-05-25 03:15:10 +08:00
6b93a781b7
add: sign up page 2025-05-24 17:13:26 +08:00
fe2fd4fe36
feat: home page with header 2025-05-24 03:08:32 +08:00
dd70543594
init: next for frontend 2025-05-22 03:15:07 +08:00
1ff71ab241
improve: code quality 2025-05-19 00:13:01 +08:00
cf7a285f57
fix: incorrect import for psql.d.ts, remove unnecessary benchmark files 2025-05-19 00:11:53 +08:00
79a37d927a
fix: forget to specify type when collecting videos without active schedules;
missing return when eta too long for milestone snapshot
2025-05-19 00:10:33 +08:00
f003e77d52
add: text fields and button in signup page 2025-05-18 20:55:51 +08:00
4addadb035
update: the style in content.css 2025-05-18 15:34:12 +08:00
23917b2976
merge: branch 'feat/backend' into main 2025-05-18 03:53:22 +08:00
6d946f74df
update: rate limiter 2025-05-18 03:52:42 +08:00
c5ba673069
fix: several bugs in captcha rate limiting 2025-05-18 03:28:27 +08:00
fa5ccce83f
fix: incorrect import of type Psql 2025-05-18 00:36:39 +08:00
7786d66dbb
add: complete guarding under uCaptcha 2025-05-18 00:33:43 +08:00
b18b45078f
add: tokenID added to JWT in endpoint GET /captcha/:id/result 2025-05-17 02:15:05 +08:00
1633e56b1e
update: returns JWT in the /captcha/:id/result endpoint 2025-05-11 19:40:24 +08:00
a063f2401b
update: rename the captcha endpoint 2025-05-11 19:14:19 +08:00
44f68993a0
fix: missing env during building in frontend 2025-05-11 16:20:22 +08:00
980dd542ee
add: video info page in frontend 2025-05-11 03:57:26 +08:00
c82a95d0bc
fix: missing aliyun CLI installation process in backend Dockerfile 2025-05-11 02:38:46 +08:00
137c19d74e
ref: move from sliding window to token bucket in rate limiter 2025-05-11 01:50:02 +08:00
5fb1355346
ref: rename endpoint to POST /validation/session 2025-05-10 22:41:10 +08:00
8456bb7485
add: route for validation 2025-05-10 21:48:02 +08:00
01f5e57864
ref: dir structure of backend package 2025-05-10 00:20:20 +08:00
bf00918c00
merge: branch 'main' into feat/backend 2025-05-10 00:19:55 +08:00
44f4ee5b01
fix: incorrect path in build.ts in crawler 2025-05-06 03:42:42 +08:00
cd8fe502ba
update: Dockerfile for crawler
fix: incorrect condition in logger
2025-05-06 03:24:08 +08:00
95681dcbf3
add: Dockerfile for crawler 2025-05-06 03:10:20 +08:00
59f09ca5eb
ref: switch to bun 2025-05-05 01:53:33 +08:00
d686b6a369
add: example docker compose file 2025-05-04 02:59:04 +08:00
d8c74a609a
merge: branch 'main' into ref/deno 2025-05-03 05:02:21 +08:00
9d5c7cc47d
update: new delegate that fits to new infra 2025-05-02 20:14:05 +08:00
7528dcdf81
debug: add debug logging in alicloudFcRequest 2025-05-02 20:01:21 +08:00
811a8261b3
fix: BullMQ crashes when not setting Redis config maxRetriesPerRequest to null 2025-05-02 19:48:17 +08:00
d0aa27b2ad
ref: backend structure, make backend & frontend containerize
fix: some errors in dockerfile
2025-04-29 21:45:38 +08:00
280825cf67
fix: missing dependency in frontend 2025-04-29 06:42:24 +08:00
e658135a81
fix: missing name in packages/core/package.json 2025-04-29 06:23:11 +08:00
4632bd5906
fix: missing dependency that causes failure of resolving import 2025-04-29 06:20:22 +08:00
43b52dee0b
add: complete docker config 2025-04-29 06:12:44 +08:00
8900ac7ec7
update: .gitignore 2025-04-29 04:53:26 +08:00
2772849933
update: .gitignore 2025-04-29 04:52:58 +08:00
784939074a
merge: branch 'ref/deno' into ref/docker 2025-04-29 04:00:57 +08:00
94dd662c40
fix: module not found "net/getVideoInfo.ts" 2025-04-29 04:00:15 +08:00
1ebc0d0c1b
update: .gitignore 2025-04-29 03:52:20 +08:00
728a74f4d3
merge: branch 'feat/backend' into ref/docker 2025-04-29 03:49:27 +08:00
0c8a459d92
update: .gitignore 2025-04-29 03:49:17 +08:00
d7b6792d05
add: basic docker config 2025-04-29 03:45:43 +08:00
50bcb48bd6
fix: incorrect key causing RangeError in date-fns 2025-04-29 00:33:12 +08:00
fe386d2b02
debug: add logs for info.astro 2025-04-29 00:24:05 +08:00
e72b3008d1
ref: switch to bun & postgres.js for backend 2025-04-29 00:07:12 +08:00
a31f702499
fix: missing navigator object in server-side 2025-04-28 06:15:06 +08:00
5a112aeaee
fix: missing navigator object in server-side 2025-04-28 06:05:37 +08:00
d675187f68
add: showing browser version in the test result of VDF benchmark 2025-04-28 06:01:07 +08:00
10de53b773
add: title for test-vdf page 2025-04-28 05:51:07 +08:00
f97e42e7d0
fix: incorrect speedSample 2025-04-28 05:46:24 +08:00
be0ff294be
update: UI of VDF test page 2025-04-28 05:40:02 +08:00
d74ff02a3f
chores: .idea config & gitignore 2025-04-28 02:19:41 +08:00
92e00d033d
improve: UI/UX for info page and search box 2025-04-28 02:15:50 +08:00
6d1698fcb6
update: color system with tailwind theme 2025-04-28 02:02:52 +08:00
7d8361589c
add: link to homepage in login page 2025-04-27 01:07:35 +08:00
66b89eb562
fix: incorrectly hard-coded height for a p tag, resulting in overflow on small screens 2025-04-27 01:06:13 +08:00
d994e67036
fix: incorrect method of importing tailwindcss in content.css 2025-04-27 00:57:32 +08:00
ee40764fdc
merge: branch 'feat/frontend' into main 2025-04-27 00:53:15 +08:00
175ec047cf
update: login & register page 2025-04-27 00:51:25 +08:00
358dd1ee5e
improve: style for search bar & song info page 2025-04-26 23:51:40 +08:00
d9c8253019
add: custom fonts, register page
improve: style
2025-04-26 20:50:30 +08:00
1a86831e90
fix: handle ALICLOUD_PROXY_ERR error in snapshotVideoWorker 2025-04-24 03:42:25 +08:00
a67d896d86
update: WebStorm config 2025-04-19 03:20:15 +08:00
244298913a
fix: comparison condition error in scheduleSnapshot 2025-04-18 22:38:02 +08:00
2c47105913
fix: incorrectly delayed milestone schedule due to UPDATE 2025-04-17 02:18:25 +08:00
6eaaf921d6
fix: ignored the case of inserting schedule when type = 'milestone' in scheduleSnapshot 2025-04-15 22:06:14 +08:00
288e4f9571
fix: incorrect SQL in scheduleSnapshot 2025-04-15 22:00:02 +08:00
907c0a6976
fix: update started_at instead of returning for milestone snapshots in scheduleSnapshot 2025-04-15 21:42:02 +08:00
7689e687ff
ref: extract scheduleCleanup into individual file
improve: logic of scheduleCleanup
2025-04-15 03:50:03 +08:00
651eef0b9e
fix: incorrectly set snapshot status to 'completed' when bilibili returned other status code 2025-04-15 03:34:45 +08:00
68bd46fd8a
improve: logging text in snapshotVideo.ts 2025-04-14 05:39:27 +08:00
13ea8fec8b
improve: remove unused import in snapshotTick.ts 2025-04-14 05:37:06 +08:00
3d9e98c949
ref: extract snapshotVideoWorker into individual file 2025-04-14 05:36:07 +08:00
c7dd1cfc2e
improve: unused imports in snapshotTick.ts 2025-04-14 05:21:06 +08:00
e0a19499e1
ref: move some worker functions into individual files 2025-04-14 05:20:35 +08:00
0930bbe6f4
improve: remove unused import 2025-04-14 05:11:35 +08:00
054d28e796
merge: branch 'main' into ref/structure 2025-04-14 05:10:44 +08:00
b080c51c3e
merge: branch 'main' into ref/structure 2025-04-13 18:38:34 +08:00
a9582722f4
ref: wrap some worker functions with withDbConnection 2025-04-13 05:06:52 +08:00
4ee4d2ede9
ref: move some functions into separate files 2025-04-12 02:08:04 +08:00
f21ff45dd3
version: crawler/1.0.26 2025-04-08 02:12:04 +08:00
420 changed files with 22470 additions and 3684 deletions

96
.dockerignore Normal file
View File

@ -0,0 +1,96 @@
built/*
tests/cases/rwc/*
tests/cases/perf/*
!tests/cases/webharness/compilerToString.js
test-args.txt
~*.docx
\#*\#
.\#*
tests/baselines/local/*
tests/baselines/local.old/*
tests/services/baselines/local/*
tests/baselines/prototyping/local/*
tests/baselines/rwc/*
tests/baselines/reference/projectOutput/*
tests/baselines/local/projectOutput/*
tests/baselines/reference/testresults.tap
tests/baselines/symlinks/*
tests/services/baselines/prototyping/local/*
tests/services/browser/typescriptServices.js
src/harness/*.js
src/compiler/diagnosticInformationMap.generated.ts
src/compiler/diagnosticMessages.generated.json
src/parser/diagnosticInformationMap.generated.ts
src/parser/diagnosticMessages.generated.json
rwc-report.html
*.swp
build.json
*.actual
tests/webTestServer.js
tests/webTestServer.js.map
tests/webhost/*.d.ts
tests/webhost/webtsc.js
tests/cases/**/*.js
tests/cases/**/*.js.map
*.config
scripts/eslint/built/
scripts/debug.bat
scripts/run.bat
scripts/**/*.js
scripts/**/*.js.map
coverage/
internal/
**/.DS_Store
.settings
**/.vs
**/.vscode/*
!**/.vscode/tasks.json
!**/.vscode/settings.template.json
!**/.vscode/launch.template.json
!**/.vscode/extensions.json
!tests/cases/projects/projectOption/**/node_modules
!tests/cases/projects/NodeModulesSearch/**/*
!tests/baselines/reference/project/nodeModules*/**/*
yarn.lock
yarn-error.log
.parallelperf.*
tests/baselines/reference/dt
.failed-tests
TEST-results.xml
package-lock.json
.eslintcache
*v8.log
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# npm dependencies
node_modules/
# project specific
logs/
__pycache__
ml/filter/runs
ml/pred/runs
ml/pred/checkpoints
ml/pred/observed
ml/data/
ml/filter/checkpoints
scripts
model/
.astro
# Database
*.dump
*.db
*.sqlite
*.sqlite3
data/
docker-compose.yml

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
*.woff2 filter=lfs diff=lfs merge=lfs -text

83
.gitignore vendored
View File

@ -1,79 +1,18 @@
built/*
tests/cases/rwc/*
tests/cases/perf/*
!tests/cases/webharness/compilerToString.js
test-args.txt
~*.docx
\#*\#
.\#*
tests/baselines/local/*
tests/baselines/local.old/*
tests/services/baselines/local/*
tests/baselines/prototyping/local/*
tests/baselines/rwc/*
tests/baselines/reference/projectOutput/*
tests/baselines/local/projectOutput/*
tests/baselines/reference/testresults.tap
tests/baselines/symlinks/*
tests/services/baselines/prototyping/local/*
tests/services/browser/typescriptServices.js
src/harness/*.js
src/compiler/diagnosticInformationMap.generated.ts
src/compiler/diagnosticMessages.generated.json
src/parser/diagnosticInformationMap.generated.ts
src/parser/diagnosticMessages.generated.json
rwc-report.html
*.swp
build.json
*.actual
tests/webTestServer.js
tests/webTestServer.js.map
tests/webhost/*.d.ts
tests/webhost/webtsc.js
tests/cases/**/*.js
tests/cases/**/*.js.map
*.config
scripts/eslint/built/
scripts/debug.bat
scripts/run.bat
scripts/**/*.js
scripts/**/*.js.map
coverage/
internal/
**/.DS_Store
.settings
**/.vs
**/.vscode/*
!**/.vscode/tasks.json
!**/.vscode/settings.template.json
!**/.vscode/launch.template.json
!**/.vscode/extensions.json
!tests/cases/projects/projectOption/**/node_modules
!tests/cases/projects/NodeModulesSearch/**/*
!tests/baselines/reference/project/nodeModules*/**/*
yarn.lock
yarn-error.log
.parallelperf.*
tests/baselines/reference/dt
.failed-tests
TEST-results.xml
package-lock.json
.eslintcache
*v8.log
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.env.*
# Fresh build directory
_fresh/
# npm dependencies
node_modules/
# project specific
logs/
__pycache__
@ -85,3 +24,21 @@ ml/data/
ml/filter/checkpoints
scripts
model/
.astro
# Database
*.dump
*.db
*.sqlite
*.sqlite3
data/
redis/
# Build
dist/
build/
docker-compose.yml
ucaptcha-config.yaml

3
.idea/.gitignore vendored
View File

@ -6,4 +6,5 @@
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
dataSources.xml
dataSources.xml
MarsCodeWorkspaceAppSettings.xml

6
.idea/bun.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="BunSettings">
<option name="bunPath" value="$USER_HOME$/.bun/bin/bun" />
</component>
</project>

View File

@ -0,0 +1,55 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="LINE_SEPARATOR" value="&#10;" />
<HTMLCodeStyleSettings>
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="120" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="120" />
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="120" />
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="120" />
<indentOptions>
<option name="INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="4" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View File

@ -19,6 +19,17 @@
<excludeFolder url="file://$MODULE_DIR$/.zed" />
<excludeFolder url="file://$MODULE_DIR$/packages/frontend/.astro" />
<excludeFolder url="file://$MODULE_DIR$/scripts" />
<excludeFolder url="file://$MODULE_DIR$/.astro" />
<excludeFolder url="file://$MODULE_DIR$/ml/pred/checkpoints" />
<excludeFolder url="file://$MODULE_DIR$/ml/pred/observed" />
<excludeFolder url="file://$MODULE_DIR$/ml/pred/runs" />
<excludeFolder url="file://$MODULE_DIR$/packages/backend/logs" />
<excludeFolder url="file://$MODULE_DIR$/packages/core/net/logs" />
<excludeFolder url="file://$MODULE_DIR$/packages/crawler/logs" />
<excludeFolder url="file://$MODULE_DIR$/data" />
<excludeFolder url="file://$MODULE_DIR$/redis" />
<excludeFolder url="file://$MODULE_DIR$/ml" />
<excludeFolder url="file://$MODULE_DIR$/src" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />

View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DenoSettings">
<option name="useDenoValue" value="ENABLE" />
<option name="denoInit" value="{&#10; &quot;enable&quot;: true,&#10; &quot;lint&quot;: true,&#10; &quot;unstable&quot;: true,&#10; &quot;importMap&quot;: &quot;import_map.json&quot;,&#10; &quot;config&quot;: &quot;deno.json&quot;,&#10; &quot;fmt&quot;: {&#10; &quot;useTabs&quot;: true,&#10; &quot;lineWidth&quot;: 120,&#10; &quot;indentWidth&quot;: 4,&#10; &quot;semiColons&quot;: true,&#10; &quot;proseWrap&quot;: &quot;always&quot;&#10; }&#10;}" />
<option name="useDenoValue" value="DISABLE" />
</component>
</project>

View File

@ -1,12 +1,36 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<option name="scopesOrder">
<list>
<option value="Astro" />
<option value="All Changed Files" />
<option value="Open Files" />
<option value="Project Files" />
<option value="Scratches and Consoles" />
<option value="Tests" />
</list>
</option>
<inspection_tool class="ES6UnusedImports" enabled="true" level="WARNING" enabled_by_default="true">
<scope name="Astro" level="INFORMATION" enabled="false" editorAttributes="INFORMATION_ATTRIBUTES" />
</inspection_tool>
<inspection_tool class="GrazieInspection" enabled="false" level="GRAMMAR_ERROR" enabled_by_default="false" />
<inspection_tool class="HtmlUnknownAttribute" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myValues">
<value>
<list size="1">
<item index="0" class="java.lang.String" itemvalue="autocorrect" />
</list>
</value>
</option>
<option name="myCustomValuesEnabled" value="true" />
</inspection_tool>
<inspection_tool class="LanguageDetectionInspection" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
<option name="processCode" value="true" />
<option name="processLiterals" value="true" />
<option name="processComments" value="true" />
</inspection_tool>
<inspection_tool class="TsLint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

3
.idea/scopes/Astro.xml Normal file
View File

@ -0,0 +1,3 @@
<component name="DependencyValidationManager">
<scope name="Astro" pattern="file:*.astro" />
</component>

8
.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"useTabs": true,
"tabWidth": 4,
"trailingComma": "none",
"singleQuote": false,
"printWidth": 120,
"endOfLine": "lf"
}

View File

@ -3,4 +3,9 @@ data
*.svg
*.txt
*.md
*config*
*config*
Inter.css
MiSans.css
*.yaml
*.yml
*.mdx

View File

@ -1,6 +0,0 @@
{
"recommendations": [
"denoland.vscode-deno",
"bradlc.vscode-tailwindcss"
]
}

View File

@ -1,35 +0,0 @@
// Folder-specific settings
//
// For a full list of overridable settings, and general information on folder-specific settings,
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
{
"lsp": {
"deno": {
"settings": {
"deno": {
"enable": true
}
}
}
},
"languages": {
"TypeScript": {
"language_servers": [
"deno",
"!typescript-language-server",
"!vtsls",
"!eslint"
],
"formatter": "language_server"
},
"TSX": {
"language_servers": [
"deno",
"!typescript-language-server",
"!vtsls",
"!eslint"
],
"formatter": "language_server"
}
}
}

23
Dockerfile.backend Normal file
View File

@ -0,0 +1,23 @@
FROM oven/bun:1.2.8-debian
WORKDIR /app
COPY ./packages/core ./core
COPY ./packages/backend/package.json ./packages/backend/bun.lock ./backend/
RUN apt update && apt install -y curl
RUN ln -s /bin/uname /usr/bin/uname
RUN /bin/bash -c "$(curl -fsSL https://aliyuncli.alicdn.com/install.sh)"
WORKDIR backend
RUN bun install
COPY ./packages/backend/ .
RUN mkdir -p /app/logs
CMD ["bun", "start"]

19
Dockerfile.crawler Normal file
View File

@ -0,0 +1,19 @@
FROM oven/bun:1.2.8-debian
WORKDIR /app
COPY . .
RUN bun i
RUN mkdir -p /app/logs
RUN apt update && apt install -y curl
RUN ln -s /bin/uname /usr/bin/uname
RUN /bin/bash -c "$(curl -fsSL https://aliyuncli.alicdn.com/install.sh)"
WORKDIR packages/crawler
CMD ["bun", "all"]

23
Dockerfile.frontend Normal file
View File

@ -0,0 +1,23 @@
FROM oven/bun
ARG BACKEND_URL
WORKDIR /app
COPY . .
RUN bun install
WORKDIR packages/frontend
RUN bun run build
ENV HOST=0.0.0.0
ENV PORT=4321
ENV BACKEND_URL=${BACKEND_URL}
EXPOSE 4321
RUN mkdir -p /app/logs
CMD ["bun", "/app/packages/frontend/dist/server/entry.mjs"]

14
Dockerfile.next Normal file
View File

@ -0,0 +1,14 @@
FROM node:lts-slim AS production
WORKDIR /app
COPY ./packages/next/.next ./.next
COPY ./packages/next/public ./public
COPY ./packages/next/package.json ./package.json
COPY ./packages/next/node_modules ./node_modules
ENV NODE_ENV production
EXPOSE 7400
CMD ["npm", "start"]

1732
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +0,0 @@
{
"lock": false,
"workspace": ["./packages/crawler", "./packages/frontend", "./packages/backend", "./packages/core"],
"nodeModulesDir": "auto",
"tasks": {
"crawler": "deno task --filter 'crawler' all",
"backend": "deno task --filter 'backend' start"
},
"fmt": {
"useTabs": true,
"lineWidth": 120,
"indentWidth": 4,
"semiColons": true,
"proseWrap": "always"
},
"imports": {
"@astrojs/node": "npm:@astrojs/node@^9.1.3",
"@astrojs/svelte": "npm:@astrojs/svelte@^7.0.8",
"date-fns": "npm:date-fns@^4.1.0"
}
}

View File

@ -1,107 +0,0 @@
openapi: 3.1.0
info:
title: CVSA API
version: v1
servers:
- url: https://api.projectcvsa.com
paths:
/video/{id}/snapshots:
get:
summary: Get list of video snapshots
description: Get a list of video snapshots by the ID. The ID can be "av" + number, or "BV" + a 12-digit alphanumeric string, or an integer as the av number in bilibili.
parameters:
- in: path
name: id
required: true
schema:
type: string
description: "The ID of the video (e.g. av78977256, BV1KJ411C7CW, 78977256)"
- in: query
name: ps
schema:
type: integer
minimum: 1
default: 1000
description: The number of snapshots returned per page (pageSize), the default is 1000.
- in: query
name: pn
schema:
type: integer
minimum: 1
description: The page number, used for pagination. Only one of offset and pn can be specified.
- in: query
name: offset
schema:
type: integer
minimum: 1
description: The offset for offset-based queries. Only one of offset and pn can be specified.
- in: query
name: reverse
schema:
type: boolean
description: Reverse snapshots from old to new if set to true. Default is false.
responses:
"200":
description: Successfuly retrieved snapshots
content:
application/json:
schema:
type: array
items:
type: object
properties:
id:
type: integer
description: Snapshot ID (Not the same as the video ID)
aid:
type: integer
description: The av number of the video
views:
type: integer
description: The number of views the video has
coins:
type: integer
description: The number of coins the video has
likes:
type: integer
description: The number of likes the video has
favorites:
type: integer
description: The number of favorites the video has
shares:
type: integer
description: The number of shares the video has
danmakus:
type: integer
description: The number of danmakus the video has
replies:
type: integer
description: The number of replies the video has
"400":
description: Invalid query parameters
content:
application/json:
schema:
type: object
properties:
message:
type: string
description: Error message
errors:
type: object
description: Detailed error information
"500":
description: Internal server error
content:
application/json:
schema:
type: object
properties:
message:
type: string
description: Error message
error:
type: object
description: Detailed error information

View File

@ -17,8 +17,9 @@ layout:
Welcome to the CVSA Documentation!
This doc contains various information about the CVSA project, including technical architecture, tutorials for visitors, etc.
This doc contains various information about the CVSA project, including technical architecture, tutorials for visitors,
etc.
### Jump right in
<table data-view="cards"><thead><tr><th></th><th></th><th data-hidden data-card-cover data-type="files"></th><th data-hidden></th><th data-hidden data-card-target data-type="content-ref"></th></tr></thead><tbody><tr><td><strong>About this project</strong></td><td>Some information you might want to know about.</td><td></td><td></td><td><a href="about/this-project.md">this-project.md</a></td></tr><tr><td><strong>Architecture</strong></td><td>The technical details about how CVSA was built.</td><td></td><td></td><td><a href="broken-reference">Broken link</a></td></tr><tr><td><strong>API Doc</strong></td><td>Documentation about APIs provided by CVSA.</td><td></td><td></td><td><a href="broken-reference/">broken-reference</a></td></tr><tr><td><strong>Source Code</strong></td><td>View this project on GitHub</td><td></td><td></td><td><a href="https://github.com/alikia2x/cvsa">https://github.com/alikia2x/cvsa</a></td></tr><tr><td>🇨🇳 中文版本</td><td>浏览本文档的中文版本</td><td></td><td></td><td><a href="https://app.gitbook.com/s/pv6AFgCPzXeRmP9slTBR/">欢迎</a></td></tr></tbody></table>
<table data-view="cards"><thead><tr><th></th><th></th><th data-hidden data-card-cover data-type="files"></th><th data-hidden></th><th data-hidden data-card-target data-type="content-ref"></th></tr></thead><tbody><tr><td><strong>About CVSA</strong></td><td>Some information you might want to know about.</td><td></td><td></td><td><a href="about/this-project.md">this-project.md</a></td></tr><tr><td><strong>Architecture</strong></td><td>The technical details about how CVSA was built.</td><td></td><td></td><td><a href="broken-reference">Broken link</a></td></tr><tr><td><strong>API Doc</strong></td><td>Documentation about APIs provided by CVSA.</td><td></td><td></td><td><a href="broken-reference">Broken link</a></td></tr></tbody></table>

View File

@ -4,16 +4,16 @@
## About
* [About the CVSA Project](about/this-project.md)
* [About CVSA Project](about/this-project.md)
* [Scope of Inclusion](about/scope-of-inclusion.md)
## Architecture
## Architecure
* [Overview](architecture/overview.md)
* [Crawler](architecture/crawler.md)
* [Database Structure](architecture/database-structure/README.md)
* [Type of a Song](architecture/database-structure/type-of-song.md)
* [Artificial Intelligence](architecture/artificial-intelligence.md)
* [Overview](architecure/overview.md)
* [Crawler](architecure/crawler.md)
* [Database Structure](architecure/database-structure/README.md)
* [Type of Song](architecure/database-structure/type-of-song.md)
* [Artificial Intelligence](architecure/artificial-intelligence.md)
## API Doc

View File

@ -1,34 +1,48 @@
# Scope of Inclusion
CVSA contains many aspects of Chinese Vocal Synthesis, including songs, albums, artists (publisher, manipulators, arranger, etc), singers and voice engines / voicebanks.
CVSA contains many aspects of Chinese Vocal Synthesis, including songs, albums, artists (publisher, manipulators,
arranger, etc), singers and voice engines / voicebanks.&#x20;
For a **song**, it must meet the following two conditions to be included in CVSA:
For a **song**, it must meet the following conditions to be included in CVSA:
### Category 30
In principle, the songs must be featured in a video that is categorized under the VOCALOID·UTAU (ID 30) category in
[Bilibili](https://en.wikipedia.org/wiki/Bilibili) in order to be observed by our
[automation program](../architecure/overview.md#crawler). We welcome editors to manually add songs that have not been
uploaded to bilibili / categorized under this category.
#### NEWS
Recently, Bilibili seems to be offlining the sub-category. This means the VOCALOID·UTAU category can no longer be
entered from the frontend, and producers can no longer upload videos to this category (instead, they can only choose the
parent category "Music").&#x20;
According to our experiments, Bilibili still retains the code logic of sub-categories in the backend, and newly
published songs may still be in the VOCALOID·UTAU sub-category, and the related APIs can still work normally. However,
there are [reports](https://www.bilibili.com/opus/1041223385394184199) that some of the new songs have been placed under
the "Music General" sub-category.\
We are still waiting for Bilibili's follow-up actions, and in the future, we may adjust the scope of our automated
program's crawling.
### At Leats One Line of Chinese / Chinese Virtual Singer
The lyrics of the song must contain at least one line in Chinese. Otherwise, if the lyrics of the song do not contain Chinese, it will only be included in the CVSA only if a Chinese virtual singer has been used.
The lyrics of the song must contain at least one line in Chinese. Otherwise, if the lyrics of the song do not contain
Chinese, it will only be included in the CVSA only if a Chinese virtual singer has been used.
We define a **Chinese virtual singer** as follows:
1. The singer primarily uses Chinese voicebank (i.e. the most widely used voickbank for the singer is Chinese)
2. The singer is operated by a company, organization, individual or group located in Mainland China, Hong Kong, Macau or\
2. The singer is operated by a company, organization, individual or group located in Mainland China, Hong Kong, Macau or
Taiwan.
### Using Vocal Synthesizer
To be included in CVSA, at least one line of the song must be produced by a Vocal Synthesizer (including harmony vocals).
To be included in CVSA, at least one line of the song must be produced by a Vocal Synthesizer (including harmony
vocals).
We define a vocal synthesizer as a software or system that generates synthesized singing voices by algorithmically modeling vocal characteristics and producing audio from input parameters such as lyrics, pitch, and dynamics, encompassing both waveform-concatenation-based (e.g., VOCALOID 1\~5, UTAU) and AI-based (e.g., Synthesizer V, ACE Studio) approaches, **but excluding voice conversion tools that solely alter the timbre of pre-existing recordings** (e.g.,[so-vits svc](https://github.com/svc-develop-team/so-vits-svc)).
In addition, the songs must be featured in a video that is categorized under the VOCALOID·UTAU (ID 30) category in [Bilibili](https://en.wikipedia.org/wiki/Bilibili) in order to be observed by our [automation program](../architecture/overview.md#crawler). We welcome editors to manually add songs that have not been uploaded to bilibili / categorized under this category.
#### NEWS
Recently, Bilibili seems to be offlining the sub-category. This means the VOCALOID·UTAU category can no longer be entered from the frontend, and producers can no longer upload videos to this category (instead, they can only choose the parent category "Music").
According to our experiments, Bilibili still retains the code logic of sub-categories in the backend, and newly published songs may still be in the VOCALOID·UTAU sub-category, and the related APIs can still work normally. However, there are [reports](https://www.bilibili.com/opus/1041223385394184199) that some of the new songs have been placed under\
the "Music General" sub-category.
We are still waiting for Bilibili's follow-up actions, and in the future, we may adjust the scope of our automated program's crawling.
We define a vocal synthesizer as a software or system that generates synthesized singing voices by algorithmically
modeling vocal characteristics and producing audio from input parameters such as lyrics, pitch, and dynamics,
encompassing both waveform-concatenation-based (e.g., VOCALOID, UTAU) and AI-based (e.g., Synthesizer V, ACE Studio)
approaches, **but excluding voice conversion tools that solely alter the timbre of pre-existing recordings** (e.g.,
[so-vits svc](https://github.com/svc-develop-team/so-vits-svc)).

View File

@ -1,13 +1,13 @@
# About the CVSA Project
# About CVSA Project
CVSA (Chinese Vocal Synthesis Archive) aims to collect as much content as possible about the Chinese Vocal Synthesis\
community in a highly automation-assisted way.
CVSA (Chinese Vocal Synthesis Archive) aims to collect as much content as possible about the Chinese Vocal Synthesis
community in a highly automation-assisted way.&#x20;
Unlike existing projects such as [VocaDB](https://vocadb.net), CVSA collects and displays the following content in an\
Unlike existing projects such as [VocaDB](https://vocadb.net), CVSA collects and displays the following content in an
automated and manually edited way:
* Metadata of songs (name, duration, publisher, singer, etc.)
* Descriptive information of songs (content introduction, creation background, lyrics, etc.)
* Statistical data snapshots of songs, i.e. historical snapshots of their statistical data (including number views, favorites, likes, etc.) on the [bilibili](https://en.wikipedia.org/wiki/Bilibili) website.
* Information about artists, albums, vocal synthesizers, and voicebanks.
- Metadata of songs (name, duration, publisher, singer, etc.)
- Descriptive information of songs (content introduction, creation background, lyrics, etc.)
- Engagement data snapshots of songs, i.e. historical snapshots of their engagement data (including views, favorites,
likes, etc.) on the [Bilibili](https://en.wikipedia.org/wiki/Bilibili) website.
- Information about artists, albums, vocal synthesizers, and voicebanks.

View File

@ -1,6 +1,3 @@
# Songs
{% openapi src="../.gitbook/assets/api-doc.yaml" path="/video/{id}/snapshots" method="get" %}
[api-doc.yaml](../.gitbook/assets/api-doc.yaml)
{% endopenapi %}
Not implemented yet.

View File

@ -1,23 +0,0 @@
# Artificial Intelligence
CVSA's automated workflow relies heavily on artificial intelligence for information extraction and classification.
The AI systems we currently use are:
### The Filter (codename Akari)
Located at `/ml/filter/` under project root dir, it classifies a video in the [category 30](../about/scope-of-inclusion.md#category-30) into the following categories:
* 0: Not related to Chinese vocal synthesis
* 1: A original song with Chinese vocal synthesis
* 2: A cover/remix song with Chinese vocal synthesis
We also have some experimental work that is not yet in production:
### The Predictor
Located at `/ml/pred/`under the project root dir, it predicts the future views of a video. This is a regression model that takes historical view trends of a video, other contextual information (such as the current time), and future time points to be predicted as feature inputs, and outputs the increment in the video's view count from "now" to the specified future time point.
### Lyrics Alignment
Located at `/ml/lab/`under the project root dir, it uses [MMS wav2vec](https://huggingface.co/docs/transformers/en/model_doc/mms) and [Whisper](https://github.com/openai/whisper) models for phoneme-level and line-level alignment, respectively. The original purpose of this work is to drive the live lyrics feature in our other project: [AquaVox](https://github.com/alikia2x/aquavox).

View File

@ -1,66 +0,0 @@
# Crawler
Automation is at the core of CVSAs technical architecture. The `crawler` is built to efficiently orchestrate data collection tasks using a message queue system powered by [BullMQ](https://bullmq.io/). This design enables concurrent processing across multiple stages of the data collection lifecycle.
State management and data persistence are handled using a combination of Redis (for caching and real-time data) and PostgreSQL (as the primary database).
## `crawler/db`
This module handles all database interactions for the crawler, including creation, updates, and data retrieval.
- `init.ts`: Initializes the PostgreSQL connection pool.
- `redis.ts`: Sets up the Redis client.
- `withConnection.ts`: Exports `withDatabaseConnection`, a helper that provides a database context to any function.
- Other files: Contain table-specific functions, with each file corresponding to a database table.
## `crawler/ml`
This module handles machine learning tasks, such as content classification.
- `manager.ts`: Defines a base class `AIManager` for managing ML models.
- `akari.ts`: Implements our primary classification model, `AkariProto`, which extends `AIManager`. It filters videos to determine if they should be included as songs.
## `crawler/mq`
This module manages task queuing and processing through BullMQ.
## `crawler/mq/exec`
Contains the functions executed by BullMQ workers. Examples include `getVideoInfoWorker` and `takeBulkSnapshotForVideosWorker`.
> **Terminology note:**
> In this documentation:
> - Functions in `crawler/mq/exec` are called **workers**.
> - Functions in `crawler/mq/workers` are called **BullMQ workers**.
**Design detail:**
Since BullMQ requires one handler per queue, we use a `switch` statement inside each BullMQ worker to route jobs based on their name to the correct function in `crawler/mq/exec`.
## `crawler/mq/workers`
Houses the BullMQ worker functions. Each function handles jobs for a specific queue.
## `crawler/mq/task`
To keep worker functions clean and focused, reusable logic is extracted into this directory as **tasks**. These tasks are then imported and used by the worker functions.
## `crawler/net`
This module handles all data fetching operations. Its core component is the `NetworkDelegate`, defined in `net/delegate.ts`.
## `crawler/net/delegate.ts`
Implements robust network request handling, including:
- Rate limiting by task type and proxy
- Support for serverless functions to dynamically rotate requesting IPs
## `crawler/utils`
A collection of utility functions shared across the crawler modules.
## `crawler/src`
Contains the main entry point of the crawler.
We use [concurrently](https://www.npmjs.com/package/concurrently) to run multiple scripts in parallel, enabling efficient execution of various processes.

View File

@ -1,14 +0,0 @@
# Database Structure
CVSA uses [PostgreSQL](https://www.postgresql.org/) as our database.
All public data of CVSA (excluding users' personal data) is stored in a database named `cvsa_main`, which contains the\
following tables:
* songs: stores the main information of songs
* bilibili\_user: stores snapshots of Bilibili user information
* bilibili\_metadata: metadata of all videos we collected from bilibili.
* labelling\_result: Contains label of videos in `bilibili_metadata`tagged by our [AI system](../artificial-intelligence.md#the-filter).
* video\_snapshot: Statistical data of videos that are fetched regularly (e.g., number of views, etc.), we call this fetch process as "snapshot".
* snapshot\_schedule: The scheduling information for video snapshots.

View File

@ -0,0 +1,21 @@
# Artificial Intelligence
CVSA's automated workflow relies heavily on artificial intelligence for information extraction and classification.
The AI systems we currently use are:
### The Filter
Located at `/filter/` under project root dir, it classifies a video in the
[category 30](../about/scope-of-inclusion.md#category-30) into the following categories:
- 0: Not related to Chinese vocal synthesis
- 1: A original song with Chinese vocal synthesis
- 2: A cover/remix song with Chinese vocal synthesis
### The Predictor
Located at `/pred/`under the project root dir, it predicts the future views of a video. This is a regression model that
takes historical view trends of a video, other contextual information (such as the current time), and future time points
to be predicted as feature inputs, and outputs the increment in the video's view count from "now" to the specified
future time point.

View File

@ -0,0 +1,4 @@
# Crawler
A central aspect of CVSA's technical design is its emphasis on automation. The data collection process within the `crawler` is orchestrated using a message queue powered by [BullMQ](https://bullmq.io/). This enables concurrent processing of various tasks involved in the data lifecycle. State management and data persistence are handled by a combination of Redis for caching and real-time data, and PostgreSQL as the primary database.

View File

@ -0,0 +1,15 @@
# Database Structure
CVSA uses [PostgreSQL](https://www.postgresql.org/) as our database.
All public data of CVSA (excluding users' personal data) is stored in a database named `cvsa_main`, which contains the
following tables:
- songs: stores the main information of songs
- bili\_user: stores snapshots of Bilibili user information
- all\_data: metadata of all videos in [category 30](../../about/scope-of-inclusion.md#category-30).
- labelling\_result: Contains label of videos in `all_data`tagged by our
[AI system](../artificial-intelligence.md#the-filter).
- video\_snapshot: Statistical data of videos that are fetched regularly (e.g., number of views, etc.), we call this
fetch process as "snapshot".
- snapshot\_schedule: The scheduling information for video snapshots.

View File

@ -31,7 +31,12 @@ cvsa
**Package Breakdown:**
- **`backend`**: This package houses the server-side logic, built with the [Hono](https://hono.dev/) web framework. It's responsible for interacting with the database and exposing data through REST and GraphQL APIs for consumption by the frontend, internal applications, and third-party developers.
- **`frontend`**: The user-facing web interface of CVSA is developed using [Astro](https://astro.build/). This package handles the presentation layer, displaying information fetched from the database.
- **`crawler`**: This automated data collection system is a key component of CVSA. It's designed to automatically discover and gather new song data from bilibili, as well as track relevant statistics over time.
- **`core`**: This package contains reusable and generic code that is utilized across multiple workspaces within the CVSA monorepo.
* **`backend`**: This package houses the server-side logic, built with the [Hono](https://hono.dev/) web framework. It's responsible for interacting with the database and exposing data through REST and GraphQL APIs for consumption by the frontend, internal applications, and third-party developers.
* **`frontend`**: The user-facing web interface of CVSA is developed using [Astro](https://astro.build/). This package handles the presentation layer, displaying information fetched from the database.
* **`crawler`**: This automated data collection system is a key component of CVSA. It's designed to automatically discover and gather new song data from bilibili, as well as track relevant statistics over time.
* **`core`**: This package contains reusable and generic code that is utilized across multiple workspaces within the CVSA monorepo.
### Crawler
Automation is the biggest highlight of CVSA's technical design. The data collection process within the `crawler` is orchestrated using a message queue powered by [BullMQ](https://bullmq.io/). This enables concurrent processing of various tasks involved in the data collection lifecycle. State management and data persistence are handled by a combination of Redis for caching and real-time data, and PostgreSQL as the primary database.

View File

@ -1,6 +1,6 @@
---
description: 「中V档案馆」 (CVSA) 是一个收录中文歌声合成文化圈有关信息的网站。
icon: hand-wave
description: 「中V档案馆」 (CVSA) 是一个收录中文歌声合成文化圈有关信息的网站。
layout:
title:
visible: true
@ -16,10 +16,10 @@ layout:
# 欢迎
欢迎阅读中V档案馆文档!
欢迎阅读CVSA文档!
该文档包含有关中V档案馆项目的各种信息包括本项目的有关信息、技术架构、访客指南、API文档等。
### 导航
<table data-view="cards"><thead><tr><th></th><th></th><th data-hidden data-card-target data-type="content-ref"></th></tr></thead><tbody><tr><td><strong>关于本项目</strong></td><td>一些你可能想知道的…</td><td><a href="about/this-project.md">this-project.md</a></td></tr><tr><td><strong>技术架构</strong></td><td>关于本项目的技术细节</td><td><a href="broken-reference">Broken link</a></td></tr><tr><td><strong>API 文档</strong> </td><td>中V档案馆公开 API 的文档</td><td><a href="broken-reference">Broken link</a></td></tr><tr><td>🇺🇸 English Version</td><td>Tip: There is a language selector in the header.</td><td><a href="https://app.gitbook.com/o/ZRcyqFK0ovlJduZb50X0/s/89Gi0XfqMigoQkEYJZZl/">CVSA Doc English</a></td></tr><tr><td><strong>项目地址</strong></td><td><a href="https://github.com/alikia2x/cvsa">GitHub</a><a href="https://gitee.com/alikia/cvsa">Gitee</a> 上查看本项目</td><td><a href="https://gitee.com/alikia/cvsa">https://gitee.com/alikia/cvsa</a></td></tr><tr><td><strong>网站</strong></td><td>我们新上线的测试网站,查看目前数据库中的信息</td><td><a href="https://projectcvsa.com">https://projectcvsa.com</a></td></tr></tbody></table>
<table data-view="cards"><thead><tr><th></th><th></th><th data-hidden data-card-cover data-type="files"></th><th data-hidden></th><th data-hidden data-card-target data-type="content-ref"></th></tr></thead><tbody><tr><td><strong>关于本项目</strong></td><td>一些你可能想知道的…</td><td></td><td></td><td><a href="about/this-project.md">this-project.md</a></td></tr><tr><td><strong>技术架构</strong></td><td>关于本项目的技术细节</td><td></td><td></td><td><a href="broken-reference">Broken link</a></td></tr><tr><td><strong>API 文档</strong> </td><td>中V档案馆公开 API 的文档</td><td></td><td></td><td><a href="broken-reference">Broken link</a></td></tr><tr><td><strong>项目地址</strong></td><td><a href="https://github.com/alikia2x/cvsa">GitHub</a><a href="https://gitee.com/alikia/cvsa">Gitee</a> 上查看本项目</td><td></td><td></td><td><a href="https://gitee.com/alikia/cvsa">https://gitee.com/alikia/cvsa</a></td></tr><tr><td>🇺🇸 English Version</td><td>Hint: There's a language switcher on the top-left corner, just to the right of the logo.</td><td></td><td></td><td><a href="https://app.gitbook.com/o/ZRcyqFK0ovlJduZb50X0/s/89Gi0XfqMigoQkEYJZZl/">CVSA Doc English</a></td></tr></tbody></table>

View File

@ -9,12 +9,12 @@
## 技术架构 <a href="#architecture" id="architecture"></a>
* [概览](architecture/overview.md)
* [Crawler 模块介绍](architecture/crawler.md)
* [数据库结构](architecture/database-structure/README.md)
* [歌曲类型](architecture/database-structure/type-of-song.md)
* [snapshot\_schedule 表](architecture/database-structure/table-snapshot_schedule.md)
* [机器学习](architecture/machine-learning.md)
- [概览](architecture/overview.md)
- [数据库结构](architecture/database-structure/README.md)
- [歌曲类型](architecture/database-structure/type-of-song.md)
- [人工智能](architecture/artificial-intelligence.md)
- [消息队列](architecture/message-queue/README.md)
- [LatestVideosQueue 队列](architecture/message-queue/latestvideosqueue-dui-lie.md)
## API 文档 <a href="#api-doc" id="api-doc"></a>

View File

@ -1,32 +1,22 @@
# 收录范围
中V档案馆收录许多有关中文歌声合成的内容包括歌曲、专辑、艺术家发布者、调校师、编曲者等、歌手以及引擎/声库。
中V档案馆收录许多有关中文歌声合成的内容包括歌曲、专辑、艺术家发布者、调校师、编曲者等、歌手以及引擎/声库。&#x20;
对于一首**歌曲**,必须满足以下两个条件才能被收录到中V档案馆中
对于一首**歌曲**必须满足以下条件才能被收录到中V档案馆中
### 至少一行中文/中文虚拟歌手
#### VOCALOID·UATU 分区
歌曲歌词必须至少包含一行中文。否则如果歌曲歌词不包含中文则只有在使用中文虚拟歌手的情况下才会将其包含在中V档案馆中。
原则上中V档案馆中收录的歌曲必须包含在哔哩哔哩 VOCALOID·UTAU
分区分区ID为30下的视频中。在某些特殊情况下此规则可能不是强制的。
我们对**中文虚拟歌手**的定义如下:
#### 至少一行中文
1. 歌手主要使用中文声库(即歌手最广泛使用的声库是中文)。
2. 歌手由位于中国大陆、香港、澳门或台湾的公司、组织、个人或团体运营。
歌曲的歌词必须包含至少一行中文。这意味着即使使用了仅支持中文的声库如果歌曲的歌词中没有中文也不会被收录到中V档案馆中例如跨语种调校
### 使用歌声合成器
#### 使用歌声合成器
歌曲的至少一行必须由歌声合成器合成(包括和声才能被收录到中V档案馆中。
歌曲的至少一行必须由歌声合成器生成(包括和声部分才能被收录到中V档案馆中。
我们将歌声合成器定义为通过算法建模声音特征并根据输入的歌词、音高等参数生成音频的软件或系统包括基于波形拼接的如VOCALOID 1\~5、UTAU和基于 AI 的(如 Synthesizer V、ACE Studio方法**但不包括仅改变现有歌声音色的AI声音转换器**(例如[so-vits svc](https://github.com/svc-develop-team/so-vits-svc))。
&#x20;
此外,歌曲必须出现在发布到哔哩哔哩中 VOCALOID·UTAU 分区下视频中,才能被我们的自动化程序观察到。我们欢迎编辑手动添加尚未上传到 bilibili或未归类到此类别的歌曲。
**新闻**
最近哔哩哔哩似乎正在下线二级分区。这意味着VOCALOID·UTAU分区将无法从前端进入创作者们也无法再将视频上传到该分区只能选择“音乐区”
根据我们的实验,哔哩哔哩在后端仍然保留了二级分区的代码逻辑,新发布的歌曲可能仍在 VOCALOID·UTAU 分区中相关API仍可正常工作。目前有[报告](https://www.bilibili.com/opus/1041223385394184199)称部分新歌曲被归入了“音乐综合”子分区。。此外,我们观察到哔哩哔哩实际上并没有尊重创作者投稿时选择的分区,而是使用某种方法自动为视频分配分区。我们已经观察到有[稿件](https://www.bilibili.com/video/av114163368068672/)出现了被归类到非音乐区的问题。
我们仍在等待哔哩哔哩的后续行动,未来我们可能会调整自动化程序的抓取范围。
我们将歌声合成器定义为通过算法建模声音特征并根据输入的歌词、音高等参数生成音频的软件或系统,包括基于波形拼接的(如
VOCALOID、UTAU和基于 AI 的(如 Synthesizer V、ACE Studio方法**但不包括仅改变现有歌声音色的AI声音转换器**(例如
[so-vits svc](https://github.com/svc-develop-team/so-vits-svc))。

View File

@ -6,28 +6,33 @@
纵观整个互联网对于「中文歌声合成」或「中文虚拟歌手」常简称为中V或VC相关信息进行较为系统、全面地整理收集的主要有以下几个网站
* [萌娘百科](https://zh.moegirl.org.cn/): 收录了大量中V歌曲及歌姬的信息呈现形式为传统维基基于[MediaWiki](https://www.mediawiki.org/))。
* [VCPedia](https://vcpedia.cn/): 由原萌娘百科中文歌声合成编辑团队的部分成员搭建,专属于中文歌声合成相关内容的信息集成站点,呈现形式为传统维基(基于[MediaWiki](https://www.mediawiki.org/))。
* [VocaDB](https://vocadb.net/): [一个围绕 Vocaloid、UTAU 和其他歌声合成器的协作数据库其中包含艺术家、唱片、PV 等](#user-content-fn-1)[^1],其中包含大量中文歌声合成作品。
* [天钿Daily](https://tdd.bunnyxt.com/)一个VC相关数据交流与分享的网站。致力于VC相关数据交流定期抓取VC相关数据选取有意义的纬度展示。
- [萌娘百科](https://zh.moegirl.org.cn/):
收录了大量中V歌曲及歌姬的信息呈现形式为传统维基基于[MediaWiki](https://www.mediawiki.org/))。
- [VCPedia](https://vcpedia.cn/):
由原萌娘百科中文歌声合成编辑团队的部分成员搭建,专属于中文歌声合成相关内容的信息集成站点[^1],呈现形式为传统维基(基于[MediaWiki](https://www.mediawiki.org/))。
- [VocaDB](https://vocadb.net/):
[一个围绕 Vocaloid、UTAU 和其他歌声合成器的协作数据库其中包含艺术家、唱片、PV 等](#user-content-fn-2)[^2],其中包含大量中文歌声合成作品。
- [天钿Daily](https://tdd.bunnyxt.com/)一个VC相关数据交流与分享的网站。致力于VC相关数据交流定期抓取VC相关数据选取有意义的纬度展示。
上述网站中,或多或少存在一些不足,例如:
* 萌娘百科、VCPedia受限于传统维基绝大多数内容依赖人工编辑。
* VocaDB基于结构化数据库构建由此可以依赖程序生成一些信息但**条目收录**仍然完全依赖人工完成。
* VocaDB主要专注于元数据展示少有关于歌曲、作者等的描述性的文字也缺乏描述性的背景信息。
* 天钿Daily只展示歌曲的统计数据及历史趋势没有关于歌曲其它信息的收集。
- 萌娘百科、VCPedia受限于传统维基绝大多数内容依赖人工编辑。
- VocaDB基于结构化数据库构建由此可以依赖程序生成一些信息但**条目收录**仍然完全依赖人工完成。
- VocaDB主要专注于元数据展示少有关于歌曲、作者等的描述性的文字也缺乏描述性的背景信息。
- 天钿Daily只展示歌曲的统计数据及历史趋势没有关于歌曲其它信息的收集。
因此,**中V档案馆**吸取前人经验,克服上述网站的不足,希望做到:
* 歌曲收录(指发现歌曲并创建条目)的完全自动化
* 歌曲元信息提取的高度自动化
* 歌曲统计数据收集的完全自动化
* 在程序辅助的同时欢迎并鼓励贡献者参与编辑(主要为描述性内容)或纠错
* 在适当的许可声明下,引用来自上述源的数据,使内容更加全面、丰富。
- 歌曲收录(指发现歌曲并创建条目)的完全自动化
- 歌曲元信息提取的高度自动化
- 歌曲统计数据收集的完全自动化
- 在程序辅助的同时欢迎并鼓励贡献者参与编辑(主要为描述性内容)或纠错
- 在适当的许可声明下,引用来自上述源的数据,使内容更加全面、丰富。
***
---
本文在[CC BY-NC-SA 4.0协议](https://creativecommons.org/licenses/by-nc-sa/4.0/)提供。
[^1]: 翻译自[VocaDB](https://vocadb.net/),于[CC BY 4.0协议](https://creativecommons.org/licenses/by/4.0/)下提供。
[^1]: 引用自[VCPedia](https://vcpedia.cn/%E9%A6%96%E9%A1%B5),于[知识共享 署名-非商业性使用-相同方式共享 3.0中国大陆 (CC BY-NC-SA 3.0 CN) 许可协议](https://creativecommons.org/licenses/by-nc-sa/3.0/cn/)下提供。
[^2]: 翻译自[VocaDB](https://vocadb.net/),于[CC BY 4.0协议](https://creativecommons.org/licenses/by/4.0/)下提供。

View File

@ -1,6 +1,6 @@
# 视频快照
{% openapi src="../.gitbook/assets/API-doc.yaml" path="/video/{id}/snapshots" method="get" %}
[API-doc.yaml](../.gitbook/assets/API-doc.yaml)
{% openapi src="../.gitbook/assets/1.yaml" path="/video/{id}/snapshots" method="get" %}
[1.yaml](../.gitbook/assets/1.yaml)
{% endopenapi %}

View File

@ -0,0 +1,13 @@
# 人工智能
CVSA 的自动化工作流高度依赖人工智能进行信息提取和分类。
我们目前使用的 AI 系统有:
#### Filter
位于项目根目录下的 `/filter/`,它将 [30 分区](../about/scope-of-inclusion.md#vocaloiduatu-fen-qu) 中的视频分为以下类别:
- 0与中文人声合成无关
- 1中文人声合成原创曲
- 2中文人声合成的翻唱/混音歌曲

View File

@ -1,68 +0,0 @@
# Crawler 模块介绍
在中V档案馆的技术架构中自动化是核心设计理念。`crawler` 模块负责整个数据采集流程,通过 [BullMQ](https://bullmq.io/) 实现任务的消息队列管理,支持高并发地处理多个采集任务。
系统的数据存储与状态管理采用了 Redis用于缓存和实时数据与 PostgreSQL作为主数据库的组合方式确保了稳定性与高效性。
***
### 模块结构概览
#### `crawler/db` —— 数据库操作模块
负责与数据库的交互,提供创建、更新、查询等功能。
* `init.ts`:初始化 PostgreSQL 连接池。
* `redis.ts`:配置 Redis 客户端。
* `withConnection.ts`:导出 `withDatabaseConnection` 函数,用于包装数据库操作函数,提供数据库上下文。
* 其他文件:每个文件对应数据库中的一张表,封装了该表的操作逻辑。
#### `crawler/ml` —— 机器学习模块
负责与机器学习模型相关的处理逻辑,主要用于视频内容的文本分类。
* `manager.ts`:定义了一个模型管理基类 `AIManager`
* `akari.ts`:实现了用于筛选歌曲视频的分类模型 `AkariProto`,继承自 `AIManager`
#### `crawler/mq` —— 消息队列模块
整合 BullMQ实现任务调度和异步处理。
**`crawler/mq/exec`**
该目录下包含了各类任务的处理函数。虽然这些函数并非 BullMQ 所直接定义的“worker”但在文档中我们仍将其统一称为 **worker**(例如 `getVideoInfoWorker`、`takeBulkSnapshotForVideosWorker`)。
> **说明:**
>
> * `crawler/mq/exec` 中的函数称为 **worker**。
> * `crawler/mq/workers` 中的函数我们称为 **BullMQ worker**。
**架构设计说明:**\
由于 BullMQ 设计上每个队列只能有一个处理函数,我们通过 `switch` 语句在一个 worker 中区分并路由不同的任务类型,将其分发给相应的执行函数。
**`crawler/mq/workers`**
这个目录定义了真正的 BullMQ worker用于消费对应队列中的任务并调用具体的执行逻辑。
**`crawler/mq/task`**
为了保持 worker 函数的简洁与可维护性部分复杂逻辑被抽离成独立的“任务task”函数集中放在这个目录中。
#### `crawler/net` —— 网络请求模块
该模块用于与外部系统通信,负责所有网络请求的封装和管理。核心是 `net/delegate.ts` 中定义的 `NetworkDelegate` 类。
**`crawler/net/delegate.ts`**
这是我们进行大规模请求的主要实现,支持以下功能:
* 基于任务类型和代理的限速策略
* 结合 serverless 架构,根据策略动态切换请求来源 IP
#### `crawler/utils` —— 工具函数模块
存放项目中通用的工具函数,供各模块调用。
#### `crawler/src` —— 主程序入口
该目录包含 crawler 的启动脚本。我们使用 [concurrently](https://www.npmjs.com/package/concurrently) 同时运行多个任务文件,实现并行处理。

View File

@ -2,21 +2,14 @@
CVSA 使用 [PostgreSQL](https://www.postgresql.org/) 作为数据库。
CVSA 设计了两个数据库,`cvsa_main` 和 `cvsa_cred`。前者用于存储可公开的数据,而后者则存储用户相关的个人信息(如登录凭据、账户管理信息等)。
CVSA 设计了两个
CVSA 的所有公开数据(不包括用户的个人数据)都存储在名为 `cvsa_main` 的数据库中,该数据库包含以下表:
* songs存储歌曲的主要信息。
* bilibili\_user存储哔哩哔哩 UP主 的元信息。
* bilibili\_metadata我们收录的哔哩哔哩所有视频的元数据。
* labelling\_result包含由我们的机器学习模型标记的 `bilibili_metadata` 中视频的标签。
* latest\_video\_snapshot存储视频最新的快照。
* video\_snapshot存储视频的快照包括特定时间下视频的统计信息播放量、点赞数等
* snapshot\_schedule视频快照的规划信息为辅助表。
> **快照:**
>
> 我们定期采集哔哩哔哩视频的播放量、点赞收藏数等统计信息,在一个给定时间点下某支视频的统计数据即为该视频的一个快照。
- songs存储歌曲的主要信息
- bilibili\_user存储 Bilibili 用户信息快照
- bilibili\_metadata[分区 30](../../about/scope-of-inclusion.md#vocaloiduatu-fen-qu) 中所有视频的元数据
- labelling\_result包含由我们的 AI 系统 标记的 `all_data` 中视频的标签。
- latest\_video\_snapshot存储视频最新的快照
- video\_snapshot存储视频的快照包括特定时间下视频的统计信息播放量、点赞数等
- snapshot\_schedule视频快照的规划信息为辅助表

View File

@ -1,43 +0,0 @@
# snapshot\_schedule 表
该表用于记录视频快照任务的调度信息。
### 字段说明
| 字段名 | 类型 | 是否为空 | 默认值 | 描述 |
| ------------- | -------------------------- | ---- | ------------------------------------- | ------------ |
| `id` | `bigint` | 否 | `nextval('snapshot_schedule_id_seq')` | 主键自增ID |
| `aid` | `bigint` | 否 | 无 | 哔哩哔哩视频的 AV 号 |
| `type` | `text` | 是 | 无 | 快照类型。 |
| `created_at` | `timestamp with time zone` | 否 | `CURRENT_TIMESTAMP` | 记录创建时间 |
| `started_at` | `timestamp with time zone` | 是 | 无 | 计划开始拍摄快照的时间 |
| `finished_at` | `timestamp with time zone` | 是 | 无 | 快照任务完成的时间 |
| `status` | `text` | 否 | `'pending'` | 快照任务状态。 |
### 字段取值说明(待补充)
#### `type` 字段
用于标识快照的类型,例如是定期存档、成就节点、首次收录等。
* `archive`:每隔一段时间内,对`bilibili_metadata`表中所有视频的定期快照。
* `milestone`:监测到曲目即将达成成就(殿堂/传说/神话)时,将会调度该类型的快照任务。
* `new`新观测到歌曲时会在最长48小时内持续追踪其初始播放量增长趋势。
* `normal`:对于所有`songs`表内的曲目根据播放量增长速度以动态间隔6-72小时定期进行的快照。
#### `status` 字段
用于标识快照任务的当前状态。
* `completed`:快照任务已经完成
* `failed`:快照任务因不明原因失败
* `no_proxy`:快照任务被执行,但当前没有代理可用于拍摄快照
* `pending`:快照任务已经被调度,但尚未开始执行
* `processing`:正在获取快照
* `timeout`:快照任务在一定时间内没有被响应,因此被丢弃
* `bili_error`: 哔哩哔哩返回了一个表示请求失败的状态码
### 备注
* 此表中的 `started_at` 字段为计划中的快照开始时间,实际执行时间可能与其略有偏差,具体执行记录可结合其他日志或任务表查看。
* 每个 av 号在可以同时存在多个不同类型的快照任务处于 pending 状态但对于同一种类型只允许一个pending任务同时存在。

View File

@ -1,27 +0,0 @@
# 机器学习
中V档案馆的自动化工作流高度依赖机器学习进行信息提取和分类。
我们目前使用的机器学习系统有:
#### Filter (代号 Akari
位于项目根目录下的 `/ml/filter/`,它是一个分类模型,将来自哔哩哔哩的视频分为以下类别:
* 0与中文歌声合成无关
* 1中文歌声合成原创曲
* 2中文歌声合成的翻唱/Remix歌曲
它接收三个通道的纯文本:视频的标题、简介和标签,使用一个修改后的[model2vec](https://github.com/MinishLab/model2vec)模型(从[jina-embedding-v3](https://huggingface.co/jinaai/jina-embeddings-v3)从三个通道的文本分别产生1024维的嵌入向量作为表征通过可学习的通道权重进行调整后送入一个隐藏层维度1296的单层全连接网络最终连接到一个三分类器作为输出。我们使用了一个自定义的损失函数`AdaptiveRecallLoss`,以优化歌声合成作品的 recall即使得第 0 类的 precision 尽可能高)。
此外,我们还有一些尚未投入生产的实验性工作:
#### Predictor
位于项目根目录下的 `/ml/pred/`,它预测视频的未来播放量。这是一个回归模型,它将视频的历史播放量趋势、其他上下文信息(例如当前时间)和要预测的未来时间增量作为特征输入,并输出视频播放量从“现在”到指定未来时间点的增量。
#### 歌词对齐
位于项目根目录下的 `/ml/lab/`,它分别使用 [MMS wav2vec](https://huggingface.co/docs/transformers/en/model_doc/mms) 和 [Whisper](https://github.com/openai/whisper) 模型进行音素级和行级对齐。这项工作的最初目的是驱动我们另一个项目 [AquaVox](https://github.com/alikia2x/aquavox) 中的实时歌词功能。

View File

@ -0,0 +1 @@
# 消息队列

View File

@ -0,0 +1 @@
# LatestVideosQueue 队列

View File

@ -14,30 +14,13 @@ layout:
# 概览
CVSA 是一个 [monorepo](https://en.wikipedia.org/wiki/Monorepo) 代码库,使用 [Deno workspace](https://docs.deno.com/runtime/fundamentals/workspaces/) 作为monorepo管理工具TypeScript 是主要的开发语言。
整个CVSA项目分为三个组件**crawler**, **frontend** 和 **backend。**
**项目结构:**
### **crawler**
```
cvsa
├── deno.json
├── ml
│ ├── filter
│ ├── lab
│ └── pred
├── packages
│ ├── backend
│ ├── core
│ ├── crawler
│ └── frontend
└── README.md
```
位于项目目录`packages/crawler` 下,它负责以下工作:
**其中, `packages` 为 monorepo 主要的根目录,包含 CVSA 主要的程序逻辑**
- 抓取新的视频并收录作品
- 持续监控视频的播放量等统计信息
* **`backend`**:这个模块包含使用 [Hono](https://hono.dev/) 框架构建的服务器端逻辑。它负责与数据库交互并通过 REST 和 GraphQL API 公开数据,供前端网站、应用和第三方使用。
* **`frontend`**中V档案馆的网站是 [Astro](https://astro.build/) 驱动的。这个模块包含完整的 Astro 前端项目。
* **`crawler`**这个模块包含中V档案馆的自动数据收集系统。它旨在自动发现和收集来自哔哩哔哩的新歌曲数据以及跟踪相关统计数据如播放量信息
* **`core`**:这个模块内包含可重用和通用的代码。
`ml` 为机器学习相关包,参见
整个 crawler 由 BullMQ 消息队列驱动,使用 Redis 和 PostgreSQL 管理状态。

View File

@ -0,0 +1,71 @@
version: '3.8'
services:
db:
image: postgres:17
ports:
- "5431:5432"
environment:
POSTGRES_USER: cvsa
POSTGRES_PASSWORD: ""
POSTGRES_DB: cvsa_main
volumes:
- ./data:/var/lib/postgresql/data
redis:
image: redis:latest
ports:
- "6378:6379"
volumes:
- ./redis/data:/data
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf
- ./redis/logs:/logs
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
ports:
- "4321:4321"
environment:
- HOST=0.0.0.0
- PORT=4321
- DB_HOST=db
- DB_NAME=cvsa_main
- DB_NAME_CRED=cvsa_cred
- DB_USER=cvsa
- DB_PORT=5432
- DB_PASSWORD=""
- LOG_VERBOSE=/app/logs/verbose.log
- LOG_WARN=/app/logs/warn.log
- LOG_ERR=/app/logs/error.log
depends_on:
- db
volumes:
- /path/to/your/logs:/app/logs
backend:
build:
context: .
dockerfile: Dockerfile.backend
ports:
- "8000:8000"
environment:
- HOST=0.0.0.0
- DB_HOST=db
- DB_NAME=cvsa_main
- DB_NAME_CRED=cvsa_cred
- DB_USER=cvsa
- DB_PORT=5432
- DB_PASSWORD=""
- LOG_VERBOSE=/app/logs/verbose.log
- LOG_WARN=/app/logs/warn.log
- LOG_ERR=/app/logs/error.log
- REDIS_HOST=redis
- REDIS_PORT=6379
depends_on:
- db
volumes:
- /path/to/your/logs:/app/logs
volumes:
db_data:

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "cvsa",
"version": "3.15.34",
"private": false,
"type": "module",
"workspaces": [
"packages/frontend",
"packages/core",
"packages/backend",
"packages/crawler"
],
"dependencies": {
"arg": "^5.0.2",
"postgres": "^3.4.5"
},
"devDependencies": {
"@types/bun": "^1.2.15",
"prettier": "^3.5.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.2",
"vitest-tsconfig-paths": "^3.4.1"
}
}

View File

@ -0,0 +1,8 @@
{
"useTabs": true,
"tabWidth": 4,
"trailingComma": "none",
"singleQuote": false,
"printWidth": 120,
"endOfLine": "lf"
}

208
packages/backend/bun.lock Normal file
View File

@ -0,0 +1,208 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"dependencies": {
"@rabbit-company/argon2id": "^2.1.0",
"hono": "^4.7.8",
"hono-rate-limiter": "^0.4.2",
"ioredis": "^5.6.1",
"postgres": "^3.4.5",
"rate-limit-redis": "^4.2.0",
"yup": "^1.6.1",
"zod": "^3.24.3",
},
"devDependencies": {
"@types/bun": "^1.2.11",
"prettier": "^3.5.3",
},
},
},
"packages": {
"@ioredis/commands": ["@ioredis/commands@1.2.0", "", {}, "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg=="],
"@rabbit-company/argon2id": ["@rabbit-company/argon2id@2.1.0", "", { "peerDependencies": { "typescript": "^5.6.2" } }, "sha512-X/kt89qjmS9+Zh+DYCGcWeTwHa4C8vY8T3EnSma+vWj7spMzAYX4F8vmGUkny9hygpTOeC/yXwAUdJAfJ52H+w=="],
"@types/bun": ["@types/bun@1.2.11", "", { "dependencies": { "bun-types": "1.2.11" } }, "sha512-ZLbbI91EmmGwlWTRWuV6J19IUiUC5YQ3TCEuSHI3usIP75kuoA8/0PVF+LTrbEnVc8JIhpElWOxv1ocI1fJBbw=="],
"@types/node": ["@types/node@22.15.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="],
"bun-types": ["bun-types@1.2.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-dbkp5Lo8HDrXkLrONm6bk+yiiYQSntvFUzQp0v3pzTAsXk6FtgVMjdQ+lzFNVAmQFUkPQZ3WMZqH5tTo+Dp/IA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
"content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="],
"express-rate-limit": ["express-rate-limit@7.5.0", "", { "peerDependencies": { "express": "^4.11 || 5 || ^5.0.0-beta.1" } }, "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg=="],
"finalhandler": ["finalhandler@2.1.0", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hono": ["hono@4.7.8", "", {}, "sha512-PCibtFdxa7/Ldud9yddl1G81GjYaeMYYTq4ywSaNsYbB1Lug4mwtOMJf2WXykL0pntYwmpRJeOI3NmoDgD+Jxw=="],
"hono-rate-limiter": ["hono-rate-limiter@0.4.2", "", { "peerDependencies": { "hono": "^4.1.1" } }, "sha512-AAtFqgADyrmbDijcRTT/HJfwqfvhalya2Zo+MgfdrMPas3zSMD8SU03cv+ZsYwRU1swv7zgVt0shwN059yzhjw=="],
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ioredis": ["ioredis@5.6.1", "", { "dependencies": { "@ioredis/commands": "^1.1.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-to-regexp": ["path-to-regexp@8.2.0", "", {}, "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ=="],
"postgres": ["postgres@3.4.5", "", {}, "sha512-cDWgoah1Gez9rN3H4165peY9qfpEo+SA61oQv65O3cRUE1pOEoJWwddwcqKE8XZYjbblOJlYDlLV4h67HrEVDg=="],
"prettier": ["prettier@3.5.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="],
"property-expr": ["property-expr@2.0.6", "", {}, "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"rate-limit-redis": ["rate-limit-redis@4.2.0", "", { "peerDependencies": { "express-rate-limit": ">= 6" } }, "sha512-wV450NQyKC24NmPosJb2131RoczLdfIJdKCReNwtVpm5998U8SgKrAZrIHaN/NfQgqOHaan8Uq++B4sa5REwjA=="],
"raw-body": ["raw-body@3.0.0", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.6.3", "unpipe": "1.0.0" } }, "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g=="],
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="],
"serve-static": ["serve-static@2.2.0", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
"statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
"tiny-case": ["tiny-case@1.0.3", "", {}, "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"toposort": ["toposort@2.0.2", "", {}, "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg=="],
"type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"yup": ["yup@1.6.1", "", { "dependencies": { "property-expr": "^2.0.5", "tiny-case": "^1.0.3", "toposort": "^2.0.2", "type-fest": "^2.19.0" } }, "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA=="],
"zod": ["zod@3.24.3", "", {}, "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg=="],
}
}

View File

@ -1,30 +0,0 @@
import { type Client, Pool } from "https://deno.land/x/postgres@v0.19.3/mod.ts";
import { postgresConfig, postgresConfigCred } from "@core/db/pgConfig.ts";
import { createMiddleware } from "hono/factory";
const pool = new Pool(postgresConfig, 4);
const poolCred = new Pool(postgresConfigCred, 2);
export const db = pool;
export const dbCred = poolCred;
export const dbMiddleware = createMiddleware(async (c, next) => {
const connection = await pool.connect();
c.set("db", connection);
await next();
connection.release();
});
export const dbCredMiddleware = createMiddleware(async (c, next) => {
const connection = await poolCred.connect();
c.set("dbCred", connection);
await next();
connection.release();
});
declare module "hono" {
interface ContextVariableMap {
db: Client;
dbCred: Client;
}
}

View File

@ -0,0 +1,13 @@
import { sql } from "@core/db/dbNew";
import type { LatestSnapshotType } from "@core/db/schema.d.ts";
export async function getVideosInViewsRange(minViews: number, maxViews: number) {
return sql<LatestSnapshotType[]>`
SELECT *
FROM latest_video_snapshot
WHERE views >= ${minViews}
AND views <= ${maxViews}
ORDER BY views DESC
LIMIT 5000
`;
}

View File

@ -0,0 +1,58 @@
import { sql } from "@core/db/dbNew";
import type { VideoSnapshotType } from "@core/db/schema.d.ts";
export async function getVideoSnapshots(
aid: number,
limit: number,
pageOrOffset: number,
reverse: boolean,
mode: "page" | "offset" = "page"
) {
const offset = mode === "page" ? (pageOrOffset - 1) * limit : pageOrOffset;
if (reverse) {
return sql<VideoSnapshotType[]>`
SELECT *
FROM video_snapshot
WHERE aid = ${aid}
ORDER BY created_at
LIMIT ${limit} OFFSET ${offset}
`;
} else {
return sql<VideoSnapshotType[]>`
SELECT *
FROM video_snapshot
WHERE aid = ${aid}
ORDER BY created_at DESC
LIMIT ${limit} OFFSET ${offset}
`;
}
}
export async function getVideoSnapshotsByBV(
bv: string,
limit: number,
pageOrOffset: number,
reverse: boolean,
mode: "page" | "offset" = "page"
) {
const offset = mode === "page" ? (pageOrOffset - 1) * limit : pageOrOffset;
if (reverse) {
return sql<VideoSnapshotType[]>`
SELECT vs.*
FROM video_snapshot vs
JOIN bilibili_metadata bm ON vs.aid = bm.aid
WHERE bm.bvid = ${bv}
ORDER BY vs.created_at
LIMIT ${limit} OFFSET ${offset}
`;
} else {
return sql<VideoSnapshotType[]>`
SELECT vs.*
FROM video_snapshot vs
JOIN bilibili_metadata bm ON vs.aid = bm.aid
WHERE bm.bvid = ${bv}
ORDER BY vs.created_at DESC
LIMIT ${limit} OFFSET ${offset}
`;
}
}

View File

@ -1,22 +0,0 @@
{
"name": "@cvsa/backend",
"imports": {
"@rabbit-company/argon2id": "jsr:@rabbit-company/argon2id@^2.1.0",
"hono": "jsr:@hono/hono@^4.7.5",
"zod": "npm:zod",
"yup": "npm:yup",
"@core/": "../core/",
"log/": "../core/log/",
"@crawler/net/videoInfo": "../crawler/net/getVideoInfo.ts",
"ioredis": "npm:ioredis"
},
"tasks": {
"dev": "deno serve --env-file=.env --allow-env --allow-net --allow-read --allow-write --allow-run --watch main.ts",
"start": "deno serve --env-file=.env --allow-env --allow-net --allow-read --allow-write --allow-run --host 127.0.0.1 main.ts"
},
"compilerOptions": {
"jsx": "precompile",
"jsxImportSource": "hono/jsx"
},
"exports": "./main.ts"
}

View File

@ -0,0 +1,62 @@
import { Psql } from "@core/db/psql";
import { SlidingWindow } from "@core/mq/slidingWindow.ts";
import { redis } from "@core/db/redis.ts";
import { getIdentifier } from "@/middleware/rateLimiters.ts";
import { Context } from "hono";
type seconds = number;
export interface CaptchaDifficultyConfig {
global: boolean;
duration: seconds;
threshold: number;
difficulty: number;
}
export const getCaptchaDifficultyConfigByRoute = async (sql: Psql, route: string): Promise<CaptchaDifficultyConfig[]> => {
return sql<CaptchaDifficultyConfig[]>`
SELECT duration, threshold, difficulty, global
FROM captcha_difficulty_settings
WHERE CONCAT(method, '-', path) = ${route}
ORDER BY duration
`;
};
export const getCaptchaConfigMaxDuration = async (sql: Psql, route: string): Promise<seconds> => {
const rows = await sql<{max: number}[]>`
SELECT MAX(duration)
FROM captcha_difficulty_settings
WHERE CONCAT(method, '-', path) = ${route}
`;
if (rows.length < 1){
return Number.MAX_SAFE_INTEGER;
}
return rows[0].max;
}
export const getCurrentCaptchaDifficulty = async (sql: Psql, c: Context | string): Promise<number | null> => {
const isRoute = typeof c === "string";
const route = isRoute ? c : `${c.req.method}-${c.req.path}`
const configs = await getCaptchaDifficultyConfigByRoute(sql, route);
if (configs.length < 1) {
return null
}
else if (configs.length == 1) {
return configs[0].difficulty
}
const maxDuration = configs.reduce((max, config) =>
Math.max(max, config.duration), 0);
const slidingWindow = new SlidingWindow(redis, maxDuration);
for (let i = 1; i < configs.length; i++) {
const config = configs[i];
const lastConfig = configs[i - 1];
const identifier = isRoute ? c : getIdentifier(c, config.global);
const count = await slidingWindow.count(`captcha-${identifier}`, config.duration);
if (count >= config.threshold) {
continue;
}
return lastConfig.difficulty
}
return configs[configs.length-1].difficulty;
}

View File

@ -0,0 +1,14 @@
import { ErrorResponse } from "src/schema";
export const getJWTsecret = () => {
const secret = process.env["JWT_SECRET"];
if (!secret) {
const response: ErrorResponse = {
message: "JWT_SECRET is not set",
code: "SERVER_ERROR",
errors: []
};
return [response, true];
}
return [secret, null];
};

View File

@ -0,0 +1,103 @@
export const singers = [
{
name: "洛天依",
color: "#66CCFF",
birthday: "0712"
},
{
name: "言和",
color: "#00FFCC",
birthday: "0711"
},
{
name: "乐正绫",
color: "#EE0000",
birthday: "0412"
},
{
name: "乐正龙牙",
color: "#006666",
birthday: "1002"
},
{
name: "徵羽摩柯",
color: "#0080FF",
birthday: "1210"
},
{
name: "墨清弦",
color: "#FFFF00",
birthday: "0520"
},
{
name: "星尘",
color: "#9999FF",
birthday: "0812"
},
{
name: "心华",
color: "#EE82EE",
birthday: "0210"
},
{
name: "海伊",
color: "#3399FF",
birthday: "0722"
},
{
name: "苍穹",
color: "#8BC0B5",
birthday: "0520"
},
{
name: "赤羽",
color: "#FF4004",
birthday: "1126"
},
{
name: "诗岸",
color: "#F6BE72",
birthday: "0119"
},
{
name: "牧心",
color: "#2A2859",
birthday: "0807"
}
];
export interface Singer {
name: string;
color?: string;
birthday?: string;
message?: string;
}
export const specialSingers = [
{
name: "雅音宫羽",
message: "你是我最真模样,从来不曾遗忘。"
},
{
name: "初音未来",
message: "初始之音,响彻未来!"
}
];
export const pickSinger = () => {
const index = Math.floor(Math.random() * singers.length);
return singers[index];
};
export const pickSpecialSinger = () => {
const index = Math.floor(Math.random() * specialSingers.length);
return specialSingers[index];
};
export const getSingerForBirthday = (): Singer[] => {
const today = new Date();
const month = String(today.getMonth() + 1).padStart(2, "0");
const day = String(today.getDate()).padStart(2, "0");
const datestring = `${month}${day}`;
return singers.filter((singer) => singer.birthday === datestring);
};

View File

@ -1,26 +0,0 @@
import { Hono } from "hono";
import { dbCredMiddleware, dbMiddleware } from "./database.ts";
import { rootHandler } from "./root.ts";
import { getSnapshotsHanlder } from "./snapshots.ts";
import { registerHandler } from "./register.ts";
import { videoInfoHandler } from "./videoInfo.ts";
export const app = new Hono();
app.use("/video/*", dbMiddleware);
app.use("/user", dbCredMiddleware);
app.get("/", ...rootHandler);
app.get("/video/:id/snapshots", ...getSnapshotsHanlder);
app.post("/user", ...registerHandler);
app.get("/video/:id/info", ...videoInfoHandler);
const fetch = app.fetch;
export default {
fetch,
} satisfies Deno.ServeDefaultExport;
export const VERSION = "0.4.2";

View File

@ -0,0 +1,14 @@
import { bodyLimit } from "hono/body-limit";
import { ErrorResponse } from "../src/schema";
export const bodyLimitForPing = bodyLimit({
maxSize: 14000,
onError: (c) => {
const res: ErrorResponse<string> = {
message: "Body too large",
errors: ["Body should not be larger than 14kB."],
code: "BODY_TOO_LARGE"
};
return c.json(res, 413);
}
});

View File

@ -0,0 +1,120 @@
import { Context, Next } from "hono";
import { ErrorResponse } from "src/schema";
import { SlidingWindow } from "@core/mq/slidingWindow.ts";
import { getCaptchaConfigMaxDuration, getCurrentCaptchaDifficulty } from "@/lib/auth/captchaDifficulty.ts";
import { sqlCred } from "@core/db/dbNew.ts";
import { redis } from "@core/db/redis.ts";
import { verify } from "hono/jwt";
import { JwtTokenInvalid, JwtTokenExpired } from "hono/utils/jwt/types";
import { getJWTsecret } from "@/lib/auth/getJWTsecret.ts";
import { lockManager } from "@core/mq/lockManager.ts";
import { object, string, number, ValidationError } from "yup";
import { getIdentifier } from "@/middleware/rateLimiters.ts";
const tokenSchema = object({
exp: number().integer(),
id: string().length(6),
difficulty: number().integer().moreThan(0)
});
export const captchaMiddleware = async (c: Context, next: Next) => {
const authHeader = c.req.header("Authorization");
if (!authHeader) {
const response: ErrorResponse = {
message: "'Authorization' header is missing.",
code: "UNAUTHORIZED",
errors: []
};
return c.json<ErrorResponse>(response, 401);
}
const authIsBearer = authHeader.startsWith("Bearer ");
if (!authIsBearer || authHeader.length < 8) {
const response: ErrorResponse = {
message: "'Authorization' header is invalid.",
code: "INVALID_HEADER",
errors: []
};
return c.json<ErrorResponse>(response, 400);
}
const [r, err] = getJWTsecret();
if (err) {
return c.json<ErrorResponse>(r as ErrorResponse, 500);
}
const jwtSecret = r as string;
const token = authHeader.substring(7);
const path = c.req.path;
const method = c.req.method;
const route = `${method}-${path}`;
const requiredDifficulty = await getCurrentCaptchaDifficulty(sqlCred, c);
try {
const decodedPayload = await verify(token, jwtSecret);
const payload = await tokenSchema.validate(decodedPayload);
const difficulty = payload.difficulty;
const tokenID = payload.id;
const consumed = await lockManager.isLocked(tokenID);
if (consumed) {
const response: ErrorResponse = {
message: "Token has already been used.",
code: "INVALID_CREDENTIALS",
errors: []
};
return c.json<ErrorResponse>(response, 401);
}
if (difficulty < requiredDifficulty) {
const response: ErrorResponse = {
message: "Token too weak.",
code: "UNAUTHORIZED",
errors: []
};
return c.json<ErrorResponse>(response, 401);
}
const EXPIRE_FIVE_MINUTES = 300;
await lockManager.acquireLock(tokenID, EXPIRE_FIVE_MINUTES);
} catch (e) {
if (e instanceof JwtTokenInvalid) {
const response: ErrorResponse = {
message: "Failed to verify the token.",
code: "INVALID_CREDENTIALS",
errors: []
};
return c.json<ErrorResponse>(response, 400);
} else if (e instanceof JwtTokenExpired) {
const response: ErrorResponse = {
message: "Token expired.",
code: "INVALID_CREDENTIALS",
errors: []
};
return c.json<ErrorResponse>(response, 400);
} else if (e instanceof ValidationError) {
const response: ErrorResponse = {
code: "INVALID_QUERY_PARAMS",
message: "Invalid query parameters",
errors: e.errors
};
return c.json<ErrorResponse>(response, 400);
} else {
const response: ErrorResponse = {
message: "Unknown error.",
code: "UNKNOWN_ERROR",
errors: []
};
return c.json<ErrorResponse>(response, 500);
}
}
const duration = await getCaptchaConfigMaxDuration(sqlCred, route);
const window = new SlidingWindow(redis, duration);
const identifierWithIP = getIdentifier(c, true);
const identifier = getIdentifier(c, false);
await window.event(`captcha-${identifier}`);
await window.event(`captcha-${identifierWithIP}`);
await next();
};

View File

@ -0,0 +1,6 @@
import { Context, Next } from "hono";
export const contentType = async (c: Context, next: Next) => {
await next();
c.header("Content-Type", "application/json; charset=utf-8");
};

View File

@ -0,0 +1,14 @@
import { cors } from "hono/cors";
import { Context, Next } from "hono";
export const corsMiddleware = async (c: Context, next: Next) => {
if (c.req.path.startsWith("/user") || c.req.path.startsWith("/login")) {
const corsMiddlewareHandler = cors({
origin: c.req.header("Origin"),
credentials: true
});
return corsMiddlewareHandler(c, next);
}
const corsMiddlewareHandler = cors();
return corsMiddlewareHandler(c, next);
};

View File

@ -0,0 +1,160 @@
// Color constants
import { Context, Next } from "hono";
import { TimingVariables } from "hono/timing";
import { getConnInfo } from "hono/bun";
const green = "\x1b[97;42m";
const white = "\x1b[90;47m";
const yellow = "\x1b[90;43m";
const red = "\x1b[97;41m";
const blue = "\x1b[97;44m";
const magenta = "\x1b[97;45m";
const cyan = "\x1b[97;46m";
const reset = "\x1b[0m";
let consoleColorMode = "auto";
function formatCurrentTime() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0"); // Month is 0-indexed
const day = String(now.getDate()).padStart(2, "0");
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
const seconds = String(now.getSeconds()).padStart(2, "0");
const milliseconds = String(now.getMilliseconds()).padStart(3, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
}
export const DisableConsoleColor = () => {
consoleColorMode = "disable";
};
export const ForceConsoleColor = () => {
consoleColorMode = "force";
};
const defaultFormatter = (params) => {
const latency = params.latency > 60000 ? `${Math.round(params.latency / 1000)}s` : `${params.latency}ms`;
let statusColor = white;
if (params.isOutputColor) {
if (params.status >= 100 && params.status < 300) statusColor = green;
else if (params.status >= 300 && params.status < 400) statusColor = white;
else if (params.status >= 400 && params.status < 500) statusColor = yellow;
else statusColor = red;
}
let methodColor = reset;
switch (params.method) {
case "GET":
methodColor = blue;
break;
case "POST":
methodColor = cyan;
break;
case "PUT":
methodColor = yellow;
break;
case "DELETE":
methodColor = red;
break;
case "PATCH":
methodColor = green;
break;
case "HEAD":
methodColor = magenta;
break;
case "OPTIONS":
methodColor = white;
break;
}
return (
`${params.timestamp} |${statusColor} ${params.status} ${reset}| ` +
`${latency.padStart(7)} | ${params.ip.padStart(16)} |` +
`${methodColor} ${params.method.padEnd(6)}${reset} ${params.path}`
);
};
type Ctx = Context;
export const logger = (config) => {
const { formatter = defaultFormatter, output = console, skipPaths = [], skip = null } = config;
// Convert skipPaths to Set for faster lookups
const skipPathsSet = new Set(skipPaths);
return async (c: Ctx, next: Next) => {
const start = Date.now();
const url = new URL(c.req.url);
const path = url.pathname;
// Check if we should skip logging
if (skipPathsSet.has(path) || (typeof skip === "function" && skip(c))) {
return next();
}
try {
await next();
} catch (error) {
// Handle errors
const errorParams = {
timestamp: formatCurrentTime(),
latency: Date.now() - start,
status: 500,
ip: getClientIP(c),
method: c.req.method,
path,
error: error.message,
isOutputColor: shouldColorize(c)
};
output.error(
formatter({
...errorParams,
errorMessage: error.message
})
);
throw error;
}
const status = c.res.status;
const latency = Date.now() - start;
const params = {
timestamp: formatCurrentTime(),
latency,
status,
ip: getClientIP(c),
method: c.req.method,
path,
bodySize: c.res.headers.get("content-length") || 0,
isOutputColor: shouldColorize(c)
};
// Format and output the log
const logMessage = formatter(params);
if (status >= 400 && status < 500) {
output.warn?.(logMessage) || output.log(logMessage);
} else if (status >= 500) {
output.error?.(logMessage) || output.log(logMessage);
} else {
output.log(logMessage);
}
};
};
function shouldColorize(c) {
if (consoleColorMode === "disable") return false;
if (consoleColorMode === "force") return true;
// In development environment with TTY
return process.stdout.isTTY;
}
export function getClientIP(c: Ctx) {
const info = getConnInfo(c);
return info.remote.address;
}

View File

@ -0,0 +1,18 @@
import { startTime, endTime } from "hono/timing";
import { Context, Next } from "hono";
export const preetifyResponse = async (c: Context, next: Next) => {
await next();
const contentType = c.res.headers.get("Content-Type") || "";
if (!contentType.includes("application/json")) return;
const accept = c.req.header("Accept") || "";
const secFetchMode = c.req.header("Sec-Fetch-Mode");
const isBrowser = accept.includes("text/html") || secFetchMode === "navigate";
if (isBrowser) {
const json = await c.res.json();
startTime(c, "seralize", "Prettify the response");
const prettyJson = JSON.stringify(json, null, 2);
endTime(c, "seralize");
c.res = new Response(prettyJson, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
}
};

View File

@ -0,0 +1,53 @@
import type { BlankEnv } from "hono/types";
import { getConnInfo } from "hono/bun";
import { Context, Next } from "hono";
import { generateRandomId } from "@core/lib/randomID.ts";
import { RateLimiter } from "@koshnic/ratelimit";
import { ErrorResponse } from "@/src/schema";
import { redis } from "@core/db/redis.ts";
export const getUserIP = (c: Context) => {
let ipAddr = null;
const info = getConnInfo(c);
if (info.remote && info.remote.address) {
ipAddr = info.remote.address;
}
const forwardedFor = c.req.header("X-Forwarded-For");
if (forwardedFor) {
ipAddr = forwardedFor.split(",")[0];
}
return ipAddr;
};
export const getIdentifier = (c: Context, includeIP: boolean = true) => {
let ipAddr = generateRandomId(6);
if (getUserIP(c)) {
ipAddr = getUserIP(c);
}
const path = c.req.path;
const method = c.req.method;
const ipIdentifier = includeIP ? `@${ipAddr}` : "";
return `${method}-${path}${ipIdentifier}`;
};
export const registerRateLimiter = async (c: Context<BlankEnv, "/user", {}>, next: Next) => {
const limiter = new RateLimiter(redis);
const identifier = getIdentifier(c, true);
const { allowed, retryAfter } = await limiter.allow(identifier, {
burst: 5,
ratePerPeriod: 5,
period: 120,
cost: 1
});
if (!allowed) {
const response: ErrorResponse = {
message: `Too many requests, please retry after ${Math.round(retryAfter)} seconds.`,
code: "RATE_LIMIT_EXCEEDED",
errors: []
};
return c.json<ErrorResponse>(response, 429);
}
await next();
};

View File

@ -0,0 +1,30 @@
{
"name": "@cvsa/backend",
"private": false,
"version": "0.6.0",
"scripts": {
"format": "prettier --write .",
"dev": "NODE_ENV=development bun run --hot src/main.ts",
"start": "NODE_ENV=production bun run src/main.ts",
"build": "bun build ./src/main.ts --target bun --outdir ./dist"
},
"dependencies": {
"@koshnic/ratelimit": "^1.0.3",
"@rabbit-company/argon2id": "^2.1.0",
"chalk": "^5.4.1",
"hono": "^4.7.8",
"hono-rate-limiter": "^0.4.2",
"ioredis": "^5.6.1",
"limiter": "^3.0.0",
"postgres": "^3.4.5",
"rate-limit-redis": "^4.2.0",
"yup": "^1.6.1",
"zod": "^3.24.3"
},
"devDependencies": {
"@types/bun": "^1.2.11",
"prettier": "^3.5.3"
},
"main": "./dist/main.js",
"types": "./src/types.d.ts"
}

View File

@ -1,65 +0,0 @@
import { createHandlers } from "./utils.ts";
import Argon2id from "@rabbit-company/argon2id";
import { object, string, ValidationError } from "yup";
import type { Context } from "hono";
import type { Bindings, BlankEnv, BlankInput } from "hono/types";
import type { Client } from "https://deno.land/x/postgres@v0.19.3/mod.ts";
const RegistrationBodySchema = object({
username: string().trim().required("Username is required").max(50, "Username cannot exceed 50 characters"),
password: string().required("Password is required"),
nickname: string().optional(),
});
type ContextType = Context<BlankEnv & { Bindings: Bindings }, "/user", BlankInput>;
export const userExists = async (username: string, client: Client) => {
const query = `
SELECT * FROM users WHERE username = $1
`;
const result = await client.queryObject(query, [username]);
return result.rows.length > 0;
};
export const registerHandler = createHandlers(async (c: ContextType) => {
const client = c.get("dbCred");
try {
const body = await RegistrationBodySchema.validate(await c.req.json());
const { username, password, nickname } = body;
if (await userExists(username, client)) {
return c.json({
message: `User "${username}" already exists.`,
}, 400);
}
const hash = await Argon2id.hashEncoded(password);
const query = `
INSERT INTO users (username, password, nickname) VALUES ($1, $2, $3)
`;
await client.queryObject(query, [username, hash, nickname || null]);
return c.json({
message: `User "${username}" registered successfully.`,
}, 201);
} catch (e) {
if (e instanceof ValidationError) {
return c.json({
message: "Invalid registration data.",
errors: e.errors,
}, 400);
} else if (e instanceof SyntaxError) {
return c.json({
message: "Invalid JSON in request body.",
}, 400);
} else {
console.error("Registration error:", e);
return c.json({
message: "An unexpected error occurred during registration.",
error: (e as Error).message,
}, 500);
}
}
});

View File

@ -0,0 +1,10 @@
import { Context } from "hono";
export const notFoundRoute = (c: Context) => {
return c.json(
{
message: "Not Found"
},
404
);
};

View File

@ -0,0 +1,99 @@
import { Context } from "hono";
import { Bindings, BlankEnv } from "hono/types";
import { ErrorResponse } from "src/schema";
import { createHandlers } from "src/utils.ts";
import { sign } from "hono/jwt";
import { generateRandomId } from "@core/lib/randomID.ts";
import { getJWTsecret } from "lib/auth/getJWTsecret.ts";
interface CaptchaResponse {
success: boolean;
difficulty?: number;
error?: string;
}
const getChallengeVerificationResult = async (id: string, ans: string) => {
const baseURL = process.env["UCAPTCHA_URL"];
const url = new URL(baseURL);
url.pathname = `/challenge/${id}/validation`;
return await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
y: ans
})
});
};
export const verifyChallengeHandler = createHandlers(
async (c: Context<BlankEnv & { Bindings: Bindings }, "/captcha/:id/result">) => {
const id = c.req.param("id");
const ans = c.req.query("ans");
if (!ans) {
const response: ErrorResponse = {
message: "Missing required query parameter: ans",
code: "INVALID_QUERY_PARAMS",
errors: []
};
return c.json<ErrorResponse>(response, 400);
}
const res = await getChallengeVerificationResult(id, ans);
const data: CaptchaResponse = await res.json();
if (data.error && res.status === 404) {
const response: ErrorResponse = {
message: data.error,
code: "ENTITY_NOT_FOUND",
i18n: {
key: "backend.error.captcha_not_found"
},
errors: []
};
return c.json<ErrorResponse>(response, 401);
} else if (data.error && res.status === 400) {
const response: ErrorResponse = {
message: data.error,
code: "INVALID_QUERY_PARAMS",
errors: []
};
return c.json<ErrorResponse>(response, 400);
} else if (data.error) {
const response: ErrorResponse = {
message: data.error,
code: "UNKNOWN_ERROR",
errors: []
};
return c.json<ErrorResponse>(response, 500);
}
if (!data.success) {
const response: ErrorResponse = {
message: "Incorrect answer",
code: "INVALID_CREDENTIALS",
errors: []
};
return c.json<ErrorResponse>(response, 401);
}
const [r, err] = getJWTsecret();
if (err) {
return c.json<ErrorResponse>(r as ErrorResponse, 500);
}
const jwtSecret = r as string;
const tokenID = generateRandomId(6);
const NOW = Math.floor(Date.now() / 1000);
const FIVE_MINUTES_LATER = NOW + 60 * 5;
const jwt = await sign(
{
difficulty: data.difficulty!,
id: tokenID,
exp: FIVE_MINUTES_LATER
},
jwtSecret
);
return c.json({
token: jwt
});
}
);

View File

@ -0,0 +1,44 @@
import { createHandlers } from "src/utils.ts";
import { object, string, ValidationError } from "yup";
import { ErrorResponse } from "src/schema";
import { getCurrentCaptchaDifficulty } from "@/lib/auth/captchaDifficulty.ts";
import { sqlCred } from "@core/db/dbNew.ts";
const queryParamsSchema = object({
route: string().matches(/(?:GET|POST|PUT|PATCH|DELETE)-\/.*/g)
});
export const getCaptchaDifficultyHandler = createHandlers(async (c) => {
try {
const queryParams = await queryParamsSchema.validate(c.req.query());
const { route } = queryParams;
const difficulty = await getCurrentCaptchaDifficulty(sqlCred, route);
if (!difficulty) {
const response: ErrorResponse<unknown> = {
code: "ENTITY_NOT_FOUND",
message: "No difficulty configs found for this route.",
errors: []
};
return c.json<ErrorResponse<unknown>>(response, 404);
}
return c.json({
difficulty: difficulty
});
} catch (e: unknown) {
if (e instanceof ValidationError) {
const response: ErrorResponse = {
code: "INVALID_QUERY_PARAMS",
message: "Invalid query parameters",
errors: e.errors
};
return c.json<ErrorResponse>(response, 400);
} else {
const response: ErrorResponse<unknown> = {
code: "UNKNOWN_ERROR",
message: "Unknown error",
errors: [e]
};
return c.json<ErrorResponse<unknown>>(response, 500);
}
}
});

View File

@ -0,0 +1,2 @@
export * from "./session/POST.ts";
export * from "./[id]/result/GET.ts";

View File

@ -0,0 +1,51 @@
import { createHandlers } from "src/utils.ts";
import { getCurrentCaptchaDifficulty } from "@/lib/auth/captchaDifficulty.ts";
import { sqlCred } from "@core/db/dbNew.ts";
import { object, string, ValidationError } from "yup";
import { CaptchaSessionResponse, ErrorResponse } from "@/src/schema";
import type { ContentfulStatusCode } from "hono/utils/http-status";
const bodySchema = object({
route: string().matches(/(?:GET|POST|PUT|PATCH|DELETE)-\/.*/g)
});
const createNewChallenge = async (difficulty: number) => {
const baseURL = process.env["UCAPTCHA_URL"];
const url = new URL(baseURL);
url.pathname = "/challenge";
return await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
difficulty: difficulty
})
});
};
export const createCaptchaSessionHandler = createHandlers(async (c) => {
try {
const requestBody = await bodySchema.validate(await c.req.json());
const { route } = requestBody;
const difficuly = await getCurrentCaptchaDifficulty(sqlCred, route);
const res = await createNewChallenge(difficuly);
return c.json<CaptchaSessionResponse | unknown>(await res.json(), res.status as ContentfulStatusCode);
} catch (e: unknown) {
if (e instanceof ValidationError) {
const response: ErrorResponse = {
code: "INVALID_QUERY_PARAMS",
message: "Invalid query parameters",
errors: e.errors
};
return c.json<ErrorResponse>(response, 400);
} else {
const response: ErrorResponse<unknown> = {
code: "UNKNOWN_ERROR",
message: "Unknown error",
errors: [e]
};
return c.json<ErrorResponse<unknown>>(response, 500);
}
}
});

View File

@ -1,6 +1,6 @@
import { getSingerForBirthday, pickSinger, pickSpecialSinger, type Singer } from "./singers.ts";
import { VERSION } from "./main.ts";
import { createHandlers } from "./utils.ts";
import { getSingerForBirthday, pickSinger, pickSpecialSinger, type Singer } from "lib/const/singers.ts";
import { VERSION } from "src/main.ts";
import { createHandlers } from "src/utils.ts";
export const rootHandler = createHandlers((c) => {
let singer: Singer | Singer[];
@ -17,13 +17,13 @@ export const rootHandler = createHandlers((c) => {
singer = pickSinger();
}
return c.json({
"project": {
"name": "中V档案馆",
"motto": "一起唱吧,心中的歌!",
project: {
name: "中V档案馆",
motto: "一起唱吧,心中的歌!"
},
"status": 200,
"version": VERSION,
"time": Date.now(),
"singer": singer,
status: 200,
version: VERSION,
time: Date.now(),
singer: singer
});
});

View File

@ -0,0 +1,105 @@
import { Context } from "hono";
import { Bindings, BlankEnv } from "hono/types";
import { ErrorResponse, LoginResponse } from "src/schema";
import { createHandlers } from "src/utils.ts";
import { sqlCred } from "@core/db/dbNew";
import { object, string, ValidationError } from "yup";
import { setCookie } from "hono/cookie";
import Argon2id from "@rabbit-company/argon2id";
import { createLoginSession } from "routes/user/POST";
import { UserType } from "@core/db/schema";
const LoginBodySchema = object({
username: string().trim().required("Username is required").max(50, "Username cannot exceed 50 characters"),
password: string().required("Password is required")
});
export const loginHandler = createHandlers(
async (c: Context<BlankEnv & { Bindings: Bindings }, "/user/session/:id">) => {
try {
const body = await LoginBodySchema.validate(await c.req.json());
const { username, password: submittedPassword } = body;
const result = await sqlCred<UserType[]>`
SELECT *
FROM users
WHERE username = ${username}
`;
if (result.length === 0) {
const response: ErrorResponse<string> = {
message: `User does not exist.`,
errors: [`User ${username} does not exist.`],
code: "ENTITY_NOT_FOUND"
};
return c.json<ErrorResponse<string>>(response, 400);
}
const storedPassword = result[0].password;
const uid = result[0].id;
const nickname = result[0].nickname;
const role = result[0].role;
const passwordAreSame = await Argon2id.verify(storedPassword, submittedPassword);
if (!passwordAreSame) {
const response: ErrorResponse<string> = {
message: "Incorrect password.",
errors: [],
i18n: {
key: "backend.error.incorrect_password"
},
code: "INVALID_CREDENTIALS"
};
return c.json<ErrorResponse<string>>(response, 401);
}
const sessionID = await createLoginSession(uid, c);
const response: LoginResponse = {
uid: uid,
username: username,
nickname: nickname,
role: role,
token: sessionID
};
const A_YEAR = 365 * 86400;
const isDev = process.env.NODE_ENV === "development";
setCookie(c, "session_id", sessionID, {
path: "/",
maxAge: A_YEAR,
domain: process.env.DOMAIN,
secure: isDev ? true : true,
sameSite: isDev ? "None" : "Lax",
httpOnly: true
});
return c.json<LoginResponse>(response, 200);
} catch (e) {
if (e instanceof ValidationError) {
const response: ErrorResponse<string> = {
message: "Invalid registration data.",
errors: e.errors,
code: "INVALID_PAYLOAD"
};
return c.json<ErrorResponse<string>>(response, 400);
} else if (e instanceof SyntaxError) {
const response: ErrorResponse<string> = {
message: "Invalid JSON payload.",
errors: [e.message],
code: "INVALID_FORMAT"
};
return c.json<ErrorResponse<string>>(response, 400);
} else {
const response: ErrorResponse<string> = {
message: "Unknown error.",
errors: [(e as Error).message],
code: "UNKNOWN_ERROR"
};
return c.json<ErrorResponse<string>>(response, 500);
}
}
}
);

View File

@ -0,0 +1,24 @@
import { getClientIP } from "middleware/logger.ts";
import { createHandlers } from "src/utils.ts";
import { VERSION } from "src/main.ts";
export const pingHandler = createHandlers(async (c) => {
const requestHeaders = c.req.raw.headers;
return c.json({
message: "pong",
request: {
headers: requestHeaders,
ip: getClientIP(c),
mode: c.req.raw.mode,
method: c.req.method,
query: new URL(c.req.url).searchParams,
body: await c.req.text(),
url: c.req.raw.url
},
response: {
time: new Date().getTime(),
status: 200,
version: VERSION
}
});
});

View File

@ -0,0 +1,75 @@
import { Context } from "hono";
import { Bindings, BlankEnv } from "hono/types";
import { ErrorResponse } from "src/schema";
import { createHandlers } from "src/utils.ts";
import { sqlCred } from "@core/db/dbNew";
import { object, string, ValidationError } from "yup";
import { setCookie } from "hono/cookie";
const loginSessionExists = async (sessionID: string) => {
const result = await sqlCred`
SELECT 1
FROM login_sessions
WHERE id = ${sessionID}
`;
return result.length > 0;
};
export const logoutHandler = createHandlers(async (c: Context<BlankEnv & { Bindings: Bindings }, "/session/:id">) => {
try {
const session_id = c.req.param("id");
const exists = loginSessionExists(session_id);
if (!exists) {
const response: ErrorResponse<string> = {
message: "Cannot found given session_id.",
errors: [`Session ${session_id} not found`],
code: "ENTITY_NOT_FOUND"
};
return c.json<ErrorResponse<string>>(response, 404);
}
await sqlCred`
UPDATE login_sessions
SET deactivated_at = CURRENT_TIMESTAMP
WHERE id = ${session_id}
`;
const isDev = process.env.NODE_ENV === "development";
setCookie(c, "session_id", "", {
path: "/",
maxAge: 0,
domain: process.env.DOMAIN,
secure: isDev ? true : true,
sameSite: isDev ? "None" : "Lax",
httpOnly: true
});
return c.body(null, 204);
} catch (e) {
if (e instanceof ValidationError) {
const response: ErrorResponse<string> = {
message: "Invalid registration data.",
errors: e.errors,
code: "INVALID_PAYLOAD"
};
return c.json<ErrorResponse<string>>(response, 400);
} else if (e instanceof SyntaxError) {
const response: ErrorResponse<string> = {
message: "Invalid JSON payload.",
errors: [e.message],
code: "INVALID_FORMAT"
};
return c.json<ErrorResponse<string>>(response, 400);
} else {
const response: ErrorResponse<string> = {
message: "Unknown error.",
errors: [(e as Error).message],
code: "UNKNOWN_ERROR"
};
return c.json<ErrorResponse<string>>(response, 500);
}
}
});

View File

@ -0,0 +1 @@
export * from "./[id]/DELETE";

View File

@ -0,0 +1,140 @@
import { createHandlers } from "src/utils.ts";
import Argon2id from "@rabbit-company/argon2id";
import { object, string, ValidationError } from "yup";
import type { Context } from "hono";
import type { Bindings, BlankEnv, BlankInput } from "hono/types";
import { sqlCred } from "@core/db/dbNew.ts";
import { ErrorResponse, SignUpResponse } from "src/schema";
import { generateRandomId } from "@core/lib/randomID";
import { getUserIP } from "@/middleware/rateLimiters";
import { setCookie } from "hono/cookie";
const RegistrationBodySchema = object({
username: string().trim().required("Username is required").max(50, "Username cannot exceed 50 characters"),
password: string().required("Password is required"),
nickname: string().optional()
});
type ContextType = Context<BlankEnv & { Bindings: Bindings }, "/user", BlankInput>;
export const userExists = async (username: string) => {
const result = await sqlCred`
SELECT 1
FROM users
WHERE username = ${username}
`;
return result.length > 0;
};
export const createLoginSession = async (uid: number, c: Context): Promise<string> => {
const ipAddress = getUserIP(c) || null;
const userAgent = c.req.header("User-Agent") || null;
const id = generateRandomId(24);
await sqlCred`
INSERT INTO login_sessions (id, uid, expire_at, ip_address, user_agent)
VALUES (${id}, ${uid}, CURRENT_TIMESTAMP + INTERVAL '1 year', ${ipAddress}, ${userAgent})
`;
return id;
};
const getUserIDByName = async (username: string) => {
const result = await sqlCred<{ id: number }[]>`
SELECT id
FROM users
WHERE username = ${username}
`;
if (result.length === 0) {
return null;
}
return result[0].id;
};
export const registerHandler = createHandlers(async (c: ContextType) => {
try {
const body = await RegistrationBodySchema.validate(await c.req.json());
const { username, password, nickname } = body;
if (await userExists(username)) {
const response: ErrorResponse = {
message: `User "${username}" already exists.`,
code: "ENTITY_EXISTS",
errors: [],
i18n: {
key: "backend.error.user_exists",
values: {
username: username
}
}
};
return c.json<ErrorResponse>(response, 400);
}
const hash = await Argon2id.hashEncoded(password);
await sqlCred`
INSERT INTO users (username, password, nickname)
VALUES (${username}, ${hash}, ${nickname ? nickname : null})
`;
const uid = await getUserIDByName(username);
if (!uid) {
const response: ErrorResponse<string> = {
message: "Cannot find registered user.",
errors: [`Cannot find user ${username} in table 'users'.`],
code: "ENTITY_NOT_FOUND",
i18n: {
key: "backend.error.user_not_found_after_register",
values: {
username: username
}
}
};
return c.json<ErrorResponse<string>>(response, 500);
}
const sessionID = await createLoginSession(uid, c);
const response: SignUpResponse = {
username: username,
token: sessionID
};
const A_YEAR = 365 * 86400;
const isDev = process.env.NODE_ENV === "development";
setCookie(c, "session_id", sessionID, {
path: "/",
maxAge: A_YEAR,
domain: process.env.DOMAIN,
secure: isDev ? false : true,
sameSite: "Lax",
httpOnly: true
});
return c.json<SignUpResponse>(response, 201);
} catch (e) {
if (e instanceof ValidationError) {
const response: ErrorResponse<string> = {
message: "Invalid registration data.",
errors: e.errors,
code: "INVALID_PAYLOAD"
};
return c.json<ErrorResponse<string>>(response, 400);
} else if (e instanceof SyntaxError) {
const response: ErrorResponse<string> = {
message: "Invalid JSON payload.",
errors: [e.message],
code: "INVALID_FORMAT"
};
return c.json<ErrorResponse<string>>(response, 400);
} else {
const response: ErrorResponse<string> = {
message: "Unknown error.",
errors: [(e as Error).message],
code: "UNKNOWN_ERROR"
};
return c.json<ErrorResponse<string>>(response, 500);
}
}
});

View File

@ -0,0 +1,2 @@
export * from "./POST.ts";
export * from "./session/[id]/GET.ts";

View File

@ -0,0 +1,32 @@
import { Context } from "hono";
import { Bindings, BlankEnv } from "hono/types";
import { ErrorResponse } from "src/schema";
import { createHandlers } from "src/utils.ts";
import { sqlCred } from "@core/db/dbNew";
import { UserType } from "@core/db/schema";
export const getUserByLoginSessionHandler = createHandlers(
async (c: Context<BlankEnv & { Bindings: Bindings }, "/user/session/:id">) => {
const id = c.req.param("id");
const users = await sqlCred<UserType[]>`
SELECT u.*
FROM users u
JOIN login_sessions ls ON u.id = ls.uid
WHERE ls.id = ${id};
`;
if (users.length === 0) {
const response: ErrorResponse = {
message: "Cannot find user",
code: "ENTITY_NOT_FOUND",
errors: []
};
return c.json<ErrorResponse>(response, 404);
}
const user = users[0];
return c.json({
username: user.username,
nickname: user.nickname,
role: user.role
});
}
);

View File

@ -1,22 +1,21 @@
import logger from "log/logger.ts";
import { Redis } from "ioredis";
import logger from "@core/log/logger.ts";
import { redis } from "@core/db/redis.ts";
import { sql } from "@core/db/dbNew.ts";
import { number, ValidationError } from "yup";
import { createHandlers } from "./utils.ts";
import { getVideoInfo, getVideoInfoByBV } from "@crawler/net/videoInfo";
import { createHandlers } from "@/src/utils.ts";
import { getVideoInfo, getVideoInfoByBV } from "@core/net/getVideoInfo.ts";
import { idSchema } from "./snapshots.ts";
import { NetSchedulerError } from "@core/net/delegate.ts";
import type { Context } from "hono";
import type { Client } from "https://deno.land/x/postgres@v0.19.3/mod.ts";
import type { BlankEnv, BlankInput } from "hono/types";
import type { VideoInfoData } from "@core/net/bilibili.d.ts";
import { startTime, endTime } from "hono/timing";
const redis = new Redis({ maxRetriesPerRequest: null });
const CACHE_EXPIRATION_SECONDS = 60;
type ContextType = Context<BlankEnv, "/video/:id/info", BlankInput>;
async function insertVideoSnapshot(client: Client, data: VideoInfoData) {
async function insertVideoSnapshot(data: VideoInfoData) {
const views = data.stat.view;
const danmakus = data.stat.danmaku;
const replies = data.stat.reply;
@ -26,22 +25,16 @@ async function insertVideoSnapshot(client: Client, data: VideoInfoData) {
const favorites = data.stat.favorite;
const aid = data.aid;
const query: string = `
await sql`
INSERT INTO video_snapshot (aid, views, danmakus, replies, likes, coins, shares, favorites)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
VALUES (${aid}, ${views}, ${danmakus}, ${replies}, ${likes}, ${coins}, ${shares}, ${favorites})
`;
await client.queryObject(
query,
[aid, views, danmakus, replies, likes, coins, shares, favorites],
);
logger.log(`Inserted into snapshot for video ${aid} by videoInfo API.`, "api", "fn:insertVideoSnapshot");
}
export const videoInfoHandler = createHandlers(async (c: ContextType) => {
const client = c.get("db");
startTime(c, "parse", "Parse the request");
try {
const id = await idSchema.validate(c.req.param("id"));
let videoId: string | number = id as string;
@ -52,27 +45,33 @@ export const videoInfoHandler = createHandlers(async (c: ContextType) => {
}
const cacheKey = `cvsa:videoInfo:${videoId}`;
endTime(c, "parse");
startTime(c, "cache", "Check for cached data");
const cachedData = await redis.get(cacheKey);
endTime(c, "cache");
if (cachedData) {
return c.json(JSON.parse(cachedData));
}
startTime(c, "net", "Fetch data");
let result: VideoInfoData | number;
if (typeof videoId === "number") {
result = await getVideoInfo(videoId, "getVideoInfo");
} else {
result = await getVideoInfoByBV(videoId, "getVideoInfo");
}
endTime(c, "net");
if (typeof result === "number") {
return c.json({ message: "Error fetching video info", code: result }, 500);
}
startTime(c, "db", "Write data to database");
await redis.setex(cacheKey, CACHE_EXPIRATION_SECONDS, JSON.stringify(result));
await insertVideoSnapshot(client, result);
await insertVideoSnapshot(result);
endTime(c, "db");
return c.json(result);
} catch (e) {
if (e instanceof ValidationError) {
@ -83,4 +82,4 @@ export const videoInfoHandler = createHandlers(async (c: ContextType) => {
return c.json({ message: "Unhandled error", error: e }, 500);
}
}
});
});

View File

@ -1,22 +1,24 @@
import type { Context } from "hono";
import { createHandlers } from "./utils.ts";
import { createHandlers } from "src/utils.ts";
import type { BlankEnv, BlankInput } from "hono/types";
import { getVideoSnapshots, getVideoSnapshotsByBV } from "@core/db/videoSnapshot.ts";
import { getVideoSnapshots, getVideoSnapshotsByBV } from "db/snapshots.ts";
import type { VideoSnapshotType } from "@core/db/schema.d.ts";
import { boolean, mixed, number, object, ValidationError } from "yup";
import { ErrorResponse } from "src/schema";
import { startTime, endTime } from "hono/timing";
const SnapshotQueryParamsSchema = object({
ps: number().integer().optional().positive(),
pn: number().integer().optional().positive(),
offset: number().integer().optional().positive(),
reverse: boolean().optional(),
reverse: boolean().optional()
});
export const idSchema = mixed().test(
"is-valid-id",
'id must be a string starting with "av" followed by digits, or "BV" followed by 10 alphanumeric characters, or a positive integer',
async (value) => {
if (value && await number().integer().isValid(value)) {
if (value && (await number().integer().isValid(value))) {
const v = parseInt(value as string);
return Number.isInteger(v) && v > 0;
}
@ -34,13 +36,12 @@ export const idSchema = mixed().test(
}
return false;
},
}
);
type ContextType = Context<BlankEnv, "/video/:id/snapshots", BlankInput>;
export const getSnapshotsHanlder = createHandlers(async (c: ContextType) => {
const client = c.get("db");
startTime(c, "parse", "Parse the request");
try {
const idParam = await idSchema.validate(c.req.param("id"));
let videoId: string | number = idParam as string;
@ -70,23 +71,36 @@ export const getSnapshotsHanlder = createHandlers(async (c: ContextType) => {
let result: VideoSnapshotType[];
endTime(c, "parse");
startTime(c, "db", "Query the database");
if (typeof videoId === "number") {
result = await getVideoSnapshots(client, videoId, limit, pageOrOffset, reverse, mode);
result = await getVideoSnapshots(videoId, limit, pageOrOffset, reverse, mode);
} else {
result = await getVideoSnapshotsByBV(client, videoId, limit, pageOrOffset, reverse, mode);
result = await getVideoSnapshotsByBV(videoId, limit, pageOrOffset, reverse, mode);
}
endTime(c, "db");
const rows = result.map((row) => ({
...row,
aid: Number(row.aid),
aid: Number(row.aid)
}));
return c.json(rows);
} catch (e) {
} catch (e: unknown) {
if (e instanceof ValidationError) {
return c.json({ message: "Invalid query parameters", errors: e.errors }, 400);
const response: ErrorResponse<string> = {
code: "INVALID_QUERY_PARAMS",
message: "Invalid query parameters",
errors: e.errors
};
return c.json<ErrorResponse<string>>(response, 400);
} else {
return c.json({ message: "Unhandled error", error: e }, 500);
const response: ErrorResponse<unknown> = {
code: "UNKNOWN_ERROR",
message: "Unhandled error",
errors: [e]
};
return c.json<ErrorResponse<unknown>>(response, 500);
}
}
});

View File

@ -0,0 +1,2 @@
export * from "./[id]/info";
export * from "./[id]/snapshots";

View File

@ -0,0 +1,65 @@
import type { Context } from "hono";
import { createHandlers } from "src/utils.ts";
import type { BlankEnv, BlankInput } from "hono/types";
import { number, object, ValidationError } from "yup";
import { ErrorResponse } from "src/schema";
import { startTime, endTime } from "hono/timing";
import { getVideosInViewsRange } from "@/db/latestSnapshots";
const SnapshotQueryParamsSchema = object({
min_views: number().integer().optional().positive(),
max_views: number().integer().optional().positive()
});
type ContextType = Context<BlankEnv, "/videos", BlankInput>;
export const getVideosHanlder = createHandlers(async (c: ContextType) => {
startTime(c, "parse", "Parse the request");
try {
const queryParams = await SnapshotQueryParamsSchema.validate(c.req.query());
const { min_views, max_views } = queryParams;
if (!min_views && !max_views) {
const response: ErrorResponse<string> = {
code: "INVALID_QUERY_PARAMS",
message: "Invalid query parameters",
errors: ["Must provide one of these query parameters: min_views, max_views"]
};
return c.json<ErrorResponse<string>>(response, 400);
}
endTime(c, "parse");
startTime(c, "db", "Query the database");
const minViews = min_views ? min_views : 0;
const maxViews = max_views ? max_views : 2147483647;
const result = await getVideosInViewsRange(minViews, maxViews);
endTime(c, "db");
const rows = result.map((row) => ({
...row,
aid: Number(row.aid)
}));
return c.json(rows);
} catch (e: unknown) {
if (e instanceof ValidationError) {
const response: ErrorResponse<string> = {
code: "INVALID_QUERY_PARAMS",
message: "Invalid query parameters",
errors: e.errors
};
return c.json<ErrorResponse<string>>(response, 400);
} else {
const response: ErrorResponse<unknown> = {
code: "UNKNOWN_ERROR",
message: "Unhandled error",
errors: [e]
};
return c.json<ErrorResponse<unknown>>(response, 500);
}
}
});

View File

@ -0,0 +1 @@
export * from "./GET.ts";

View File

@ -1,103 +0,0 @@
export const singers = [
{
"name": "洛天依",
"color": "#66CCFF",
"birthday": "0712",
},
{
"name": "言和",
"color": "#00FFCC",
"birthday": "0711",
},
{
"name": "乐正绫",
"color": "#EE0000",
"birthday": "0412",
},
{
"name": "乐正龙牙",
"color": "#006666",
"birthday": "1002",
},
{
"name": "徵羽摩柯",
"color": "#0080FF",
"birthday": "1210",
},
{
"name": "墨清弦",
"color": "#FFFF00",
"birthday": "0520",
},
{
"name": "星尘",
"color": "#9999FF",
"birthday": "0812",
},
{
"name": "心华",
"color": "#EE82EE",
"birthday": "0210",
},
{
"name": "海伊",
"color": "#3399FF",
"birthday": "0722",
},
{
"name": "苍穹",
"color": "#8BC0B5",
"birthday": "0520",
},
{
"name": "赤羽",
"color": "#FF4004",
"birthday": "1126",
},
{
"name": "诗岸",
"color": "#F6BE72",
"birthday": "0119",
},
{
"name": "牧心",
"color": "#2A2859",
"birthday": "0807",
},
];
export interface Singer {
name: string;
color?: string;
birthday?: string;
message?: string;
}
export const specialSingers = [
{
"name": "雅音宫羽",
"message": "你是我最真模样,从来不曾遗忘。",
},
{
"name": "初音未来",
"message": "初始之音,响彻未来!",
},
];
export const pickSinger = () => {
const index = Math.floor(Math.random() * singers.length);
return singers[index];
};
export const pickSpecialSinger = () => {
const index = Math.floor(Math.random() * specialSingers.length);
return specialSingers[index];
};
export const getSingerForBirthday = (): Singer[] => {
const today = new Date();
const month = String(today.getMonth() + 1).padStart(2, "0");
const day = String(today.getDate()).padStart(2, "0");
const datestring = `${month}${day}`;
return singers.filter((singer) => singer.birthday === datestring);
};

View File

@ -0,0 +1,18 @@
import { Hono } from "hono";
import type { TimingVariables } from "hono/timing";
import { startServer } from "./startServer.ts";
import { configureRoutes } from "./routing.ts";
import { configureMiddleWares } from "./middleware.ts";
import { notFoundRoute } from "routes/404.ts";
type Variables = TimingVariables;
const app = new Hono<{ Variables: Variables }>();
app.notFound(notFoundRoute);
configureMiddleWares(app);
configureRoutes(app);
await startServer(app);
export const VERSION = "0.6.0";

View File

@ -0,0 +1,24 @@
import { Hono } from "hono";
import { timing } from "hono/timing";
import { Variables } from "hono/types";
import { pingHandler } from "routes/ping";
import { logger } from "middleware/logger.ts";
import { corsMiddleware } from "@/middleware/cors";
import { contentType } from "middleware/contentType.ts";
import { captchaMiddleware } from "middleware/captcha.ts";
import { bodyLimitForPing } from "middleware/bodyLimits.ts";
import { registerRateLimiter } from "middleware/rateLimiters.ts";
import { preetifyResponse } from "middleware/preetifyResponse.ts";
export function configureMiddleWares(app: Hono<{ Variables: Variables }>) {
app.use("*", corsMiddleware);
app.use("*", contentType);
app.use(timing());
app.use("*", preetifyResponse);
app.use("*", logger({}));
app.post("/user", registerRateLimiter);
app.post("/user", captchaMiddleware);
app.all("/ping", bodyLimitForPing, ...pingHandler);
}

View File

@ -0,0 +1,32 @@
import { rootHandler } from "routes";
import { pingHandler } from "routes/ping";
import { getUserByLoginSessionHandler, registerHandler } from "routes/user";
import { videoInfoHandler, getSnapshotsHanlder } from "routes/video";
import { Hono } from "hono";
import { Variables } from "hono/types";
import { createCaptchaSessionHandler, verifyChallengeHandler } from "routes/captcha";
import { getCaptchaDifficultyHandler } from "routes/captcha/difficulty/GET.ts";
import { getVideosHanlder } from "@/routes/videos";
import { loginHandler } from "@/routes/login/session/POST";
import { logoutHandler } from "@/routes/session";
export function configureRoutes(app: Hono<{ Variables: Variables }>) {
app.get("/", ...rootHandler);
app.all("/ping", ...pingHandler);
app.get("/videos", ...getVideosHanlder);
app.get("/video/:id/snapshots", ...getSnapshotsHanlder);
app.get("/video/:id/info", ...videoInfoHandler);
app.post("/login/session", ...loginHandler);
app.delete("/session/:id", ...logoutHandler);
app.post("/user", ...registerHandler);
app.get("/user/session/:id", ...getUserByLoginSessionHandler);
app.post("/captcha/session", ...createCaptchaSessionHandler);
app.get("/captcha/:id/result", ...verifyChallengeHandler);
app.get("/captcha/difficulty", ...getCaptchaDifficultyHandler);
}

66
packages/backend/src/schema.d.ts vendored Normal file
View File

@ -0,0 +1,66 @@
export type ErrorCode =
| "INVALID_QUERY_PARAMS"
| "UNKNOWN_ERROR"
| "INVALID_PAYLOAD"
| "INVALID_FORMAT"
| "INVALID_HEADER"
| "BODY_TOO_LARGE"
| "UNAUTHORIZED"
| "INVALID_CREDENTIALS"
| "ENTITY_NOT_FOUND"
| "SERVER_ERROR"
| "RATE_LIMIT_EXCEEDED"
| "ENTITY_EXISTS";
export interface ErrorResponse<E = string> {
code: ErrorCode;
message: string;
errors: E[] = [];
i18n?: {
key: string;
values?: {
[key: string]: string | number | Date;
};
};
}
export interface StatusResponse {
message: string;
}
export type CaptchaSessionResponse = ErrorResponse | CaptchaSessionRawResponse;
interface CaptchaSessionRawResponse {
success: boolean;
id: string;
g: string;
n: string;
t: number;
}
export interface LoginResponse {
uid: number;
username: string;
nickname: string | null;
role: string;
token: string;
}
export interface SignUpResponse {
username: string;
token: string;
}
export interface UserResponse {
username: string;
nickname: string | null;
role: string;
}
export type CaptchaVerificationRawResponse = {
token: string;
}
export type CaptchaVerificationResponse =
| ErrorResponse
| CaptchaVerificationRawResponse;

View File

@ -0,0 +1,91 @@
import { serve } from "bun";
import { Hono } from "hono";
import os from "os";
import { BlankSchema, Variables } from "hono/types";
function getLocalIpAddress(): string {
const interfaces = os.networkInterfaces();
for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name]!) {
if (iface.family === "IPv4" && !iface.internal) {
return iface.address;
}
}
}
return "localhost";
}
function logStartup(hostname: string, port: number, wasAutoIncremented: boolean, originalPort: number) {
const localUrl = `http://localhost:${port}`;
const networkIp = hostname === "0.0.0.0" ? getLocalIpAddress() : "";
const networkUrl = networkIp ? `http://${networkIp}:${port}` : "";
console.log("\n");
console.log("🚀 Server is running at:");
console.log(`> Local: ${localUrl}`);
if (networkIp) {
console.log(`> Network: ${networkUrl}`);
}
if (wasAutoIncremented) {
console.log(`\n⚠ Port ${originalPort} is in use, using port ${port} instead.`);
}
console.log("\nPress Ctrl+C to quit.");
}
export async function startServer(app: Hono<{ Variables: Variables }>) {
const NODE_ENV = process.env.NODE_ENV || "production";
const HOST = process.env.HOST ?? (NODE_ENV === "development" ? "0.0.0.0" : "127.0.0.1");
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
const DEFAULT_PORT = 3000;
const MAX_ATTEMPTS = 15;
if (PORT !== undefined) {
try {
const server = serve({
fetch: app.fetch,
hostname: HOST,
port: PORT
});
logStartup(HOST, PORT, false, DEFAULT_PORT);
return server;
} catch (e: any) {
console.error(`Failed to start server on port ${PORT}:`, e.message);
process.exit(1);
}
}
let attemptPort = DEFAULT_PORT;
let success = false;
let error: unknown = null;
for (let i = 0; i <= MAX_ATTEMPTS; i++) {
try {
const server = serve({
fetch: app.fetch,
hostname: HOST,
port: attemptPort
});
const wasAutoIncremented = attemptPort !== DEFAULT_PORT;
logStartup(HOST, attemptPort, wasAutoIncremented, DEFAULT_PORT);
return server;
} catch (e: any) {
if (e.code === "EADDRINUSE") {
attemptPort++;
} else {
error = e;
break;
}
}
}
if (!success) {
console.error(`Could not find an available port after ${MAX_ATTEMPTS + 1} attempts.`);
if (error) {
console.error("Last error:", (error as Error).message);
}
process.exit(1);
}
}

1
packages/backend/src/types.d.ts vendored Normal file
View File

@ -0,0 +1 @@
export * from "./schema";

View File

@ -0,0 +1,20 @@
{
"include": ["**/*.ts"],
"compilerOptions": {
"baseUrl": ".",
"target": "ESNext",
"module": "ESNext",
"useDefineForClassFields": true,
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"paths": {
"@core/*": ["../core/*"],
"@/*": ["./*"],
"@crawler/*": ["../crawler/*"]
},
"allowSyntheticDefaultImports": true,
"allowImportingTsExtensions": true,
"noEmit": true
}
}

104
packages/core/bun.lock Normal file
View File

@ -0,0 +1,104 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"dependencies": {
"chalk": "^5.4.1",
"ioredis": "^5.6.1",
"logform": "^2.7.0",
"postgres": "^3.4.5",
"winston": "^3.17.0",
},
"devDependencies": {
"@types/ioredis": "^5.0.0",
},
},
},
"packages": {
"@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="],
"@dabh/diagnostics": ["@dabh/diagnostics@2.0.3", "", { "dependencies": { "colorspace": "1.1.x", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA=="],
"@ioredis/commands": ["@ioredis/commands@1.2.0", "", {}, "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg=="],
"@types/ioredis": ["@types/ioredis@5.0.0", "", { "dependencies": { "ioredis": "*" } }, "sha512-zJbJ3FVE17CNl5KXzdeSPtdltc4tMT3TzC6fxQS0sQngkbFZ6h+0uTafsRqu+eSLIugf6Yb0Ea0SUuRr42Nk9g=="],
"@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="],
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
"color": ["color@3.2.1", "", { "dependencies": { "color-convert": "^1.9.3", "color-string": "^1.6.0" } }, "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA=="],
"color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"colorspace": ["colorspace@1.1.4", "", { "dependencies": { "color": "^3.1.3", "text-hex": "1.0.x" } }, "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w=="],
"debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="],
"fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="],
"fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ioredis": ["ioredis@5.6.1", "", { "dependencies": { "@ioredis/commands": "^1.1.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA=="],
"is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="],
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="],
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
"logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="],
"postgres": ["postgres@3.4.5", "", {}, "sha512-cDWgoah1Gez9rN3H4165peY9qfpEo+SA61oQv65O3cRUE1pOEoJWwddwcqKE8XZYjbblOJlYDlLV4h67HrEVDg=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="],
"stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="],
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="],
"triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"winston": ["winston@3.17.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw=="],
"winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="],
}
}

View File

@ -0,0 +1,5 @@
export const SECOND = 1000;
export const MINUTE = 60 * SECOND;
export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR;
export const WEEK = 7 * DAY;

View File

@ -0,0 +1,8 @@
import postgres from "postgres";
import { postgresConfigCred, postgresConfig } from "./pgConfigNew";
export const sql = postgres(postgresConfig);
export const sqlCred = postgres(postgresConfigCred);
export const sqlTest = postgres(postgresConfig);

Some files were not shown because too many files have changed in this diff Show More