First commit
This commit is contained in:
Executable
+10
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
/postgres-data-nix/
|
||||
.idea.private/
|
||||
config/config.yaml
|
||||
temp
|
||||
*log*
|
||||
!.log/.gitignore
|
||||
*.env*
|
||||
!.env.example
|
||||
@@ -0,0 +1,55 @@
|
||||
# LynqChat
|
||||
チャットアプリです。
|
||||
|
||||
# サーバー作成
|
||||
|
||||
## 本体
|
||||
**要件**
|
||||
- Node.js v22.15.0以上v23未満
|
||||
- pnpm v10.17.0以上
|
||||
|
||||
```bash
|
||||
# configのサンプルをコピー
|
||||
# config/config.yamlをコメントに従って変更してください。
|
||||
cp config/example.yaml config/config.yaml
|
||||
|
||||
# フロントエンドのビルド
|
||||
cd packages/frontend
|
||||
pnpm i
|
||||
pnpm run build
|
||||
|
||||
# バックエンドのビルド
|
||||
cd ../backend
|
||||
pnpm i
|
||||
pnpm run build
|
||||
|
||||
# データベースのマイグレーション
|
||||
pnpm mikro migration:up
|
||||
|
||||
# 起動
|
||||
pnpm run start
|
||||
```
|
||||
|
||||
## PostgreSQL
|
||||
サーバーがある場合はスキップして構いません。
|
||||
空のデータベースが必要です。
|
||||
現時点で、暗号化通信には対応していません。
|
||||
|
||||
### Nix
|
||||
> **WARNING**
|
||||
> ローカルのNix Storeにパスワードが平文で保存されます。
|
||||
|
||||
WSLの`/mnt/*`では、権限の制約によって動作しません。
|
||||
`/home/*`などのディレクトリで使用してください。
|
||||
|
||||
```bash
|
||||
# Configコピー
|
||||
cp ./config/example.yaml ./config/config.yaml
|
||||
|
||||
# 起動
|
||||
nix develop
|
||||
|
||||
# 停止:
|
||||
pg_ctl -D postgres-data-nix stop
|
||||
exit
|
||||
```
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
# サーバー設定
|
||||
server:
|
||||
# 配信ポート
|
||||
# number
|
||||
# 0から65535が使用できます。
|
||||
port: 3300
|
||||
|
||||
# 配信ホスト
|
||||
# string
|
||||
host: 0.0.0.0
|
||||
|
||||
# 信用するX-Forwarded-*
|
||||
# string | Array<string> | boolean | (string, number) => boolean
|
||||
# Fastifyのドキュメントで定められているFunctionは、
|
||||
# trustProxy: !!js/function >
|
||||
# function (addr, hop) {/* Here your code. */}
|
||||
# である必要があります。
|
||||
# この例はRFC1918で定めるプライベートIPアドレスです。
|
||||
# 詳細: https://fastify.dev/docs/latest/Reference/Server/#trustproxy
|
||||
trustProxy:
|
||||
- 10.0.0.0/8
|
||||
- 172.16.0.0/12
|
||||
- 192.168.0.0/16
|
||||
|
||||
# PostgreSQL設定
|
||||
database:
|
||||
# ユーザー名
|
||||
# string
|
||||
user: user
|
||||
|
||||
# パスワード
|
||||
# string
|
||||
password: password
|
||||
|
||||
# ホスト
|
||||
# string
|
||||
host: localhost
|
||||
|
||||
# ポート
|
||||
# number
|
||||
# 0から65535が使用できます。
|
||||
port: 5432
|
||||
|
||||
# データベース名
|
||||
database: chat
|
||||
Generated
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1771848320,
|
||||
"narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "2fc6539b481e1d2569f25f8799236694180c0993",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
description = "PostgreSQL";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs = { self, nixpkgs, ... }@inputs:
|
||||
let
|
||||
pkgsFor = system: import nixpkgs { inherit system; };
|
||||
|
||||
shellHook = ''
|
||||
CONFIG_FILE=./config/config.yaml
|
||||
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
DB_USER=$(yq -r ".database.user" "$CONFIG_FILE")
|
||||
DB_PASSWORD=$(yq -r ".database.password" "$CONFIG_FILE")
|
||||
DB_PORT=$(yq -r ".database.port" "$CONFIG_FILE")
|
||||
DB_NAME=$(yq -r ".database.database" "$CONFIG_FILE")
|
||||
else
|
||||
echo "config.yaml not found, using defaults."
|
||||
exit
|
||||
fi
|
||||
|
||||
: "$${DB_USER:=$${USER}}"
|
||||
: "$${DB_PASSWORD:=password}"
|
||||
: "$${DB_PORT:=5432}"
|
||||
: "$${DB_NAME:=postgres}"
|
||||
|
||||
export PGDATA="$PWD/postgres-data-nix"
|
||||
export PGHOST="$PGDATA"
|
||||
|
||||
mkdir -p "$PGDATA"
|
||||
chmod 700 "$PGDATA"
|
||||
|
||||
mkdir -p "$PWD/.log"
|
||||
chmod 700 "$PWD/.log"
|
||||
|
||||
if [ ! -f "$PGDATA/PG_VERSION" ]; then
|
||||
echo "Initializing database..."
|
||||
initdb -D "$PGDATA" > .log/postgres.log 2>&1
|
||||
|
||||
echo "unix_socket_directories = '$PGDATA'" >> "$PGDATA/postgresql.conf"
|
||||
echo "listen_addresses = '127.0.0.1'" >> "$PGDATA/postgresql.conf"
|
||||
|
||||
pg_ctl -D "$PGDATA" -l .log/postgres.log -o "-k $PGDATA -p $DB_PORT" start
|
||||
|
||||
sleep 1
|
||||
|
||||
psql --dbname postgres -v ON_ERROR_STOP=1 <<EOSQL > .log/postgres.log 2>&1
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '$DB_USER') THEN
|
||||
EXECUTE format('CREATE ROLE %I LOGIN PASSWORD %L', '$DB_USER', '$DB_PASSWORD');
|
||||
END IF;
|
||||
END
|
||||
\$\$;
|
||||
EOSQL
|
||||
|
||||
psql --dbname postgres -v ON_ERROR_STOP=1 <<EOSQL > .log/postgres.log 2>&1
|
||||
CREATE DATABASE "$DB_NAME" OWNER "$DB_USER";
|
||||
EOSQL
|
||||
else
|
||||
pg_ctl -D "$PGDATA" -l .log/postgres.log -o "-k $PGDATA -p $DB_PORT" start
|
||||
fi
|
||||
'';
|
||||
|
||||
in
|
||||
{
|
||||
devShells.x86_64-linux.default = let
|
||||
pkgs = pkgsFor "x86_64-linux";
|
||||
in pkgs.mkShell {
|
||||
packages = [ pkgs.postgresql_18 pkgs.yq ];
|
||||
shellHook = shellHook;
|
||||
};
|
||||
|
||||
devShells.aarch64-linux.default = let
|
||||
pkgs = pkgsFor "aarch64-linux";
|
||||
in pkgs.mkShell {
|
||||
packages = [ pkgs.postgresql_18 pkgs.yq ];
|
||||
shellHook = shellHook;
|
||||
};
|
||||
};
|
||||
}
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "lynq-chat",
|
||||
"version": "1.0.0-alpha.0",
|
||||
"description": "A flexible chat software.",
|
||||
"author": {
|
||||
"name": "Last2014",
|
||||
"email": "info@last2014.com",
|
||||
"url": "https://about.last2014.com"
|
||||
},
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.29.1"
|
||||
}
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=production node .",
|
||||
"build": "tsc && tsc-alias",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"mikro": "tsx ./node_modules/@mikro-orm/cli/esm.js"
|
||||
},
|
||||
"author": {
|
||||
"name": "Last2014",
|
||||
"email": "info@last2014.com",
|
||||
"url": "https://about.last2014.com"
|
||||
},
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.29.1",
|
||||
"dependencies": {
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@mikro-orm/core": "^6.6.7",
|
||||
"@mikro-orm/migrations": "^6.6.7",
|
||||
"@mikro-orm/postgresql": "^6.6.7",
|
||||
"@mikro-orm/reflection": "^6.6.7",
|
||||
"@types/node": "^25.2.2",
|
||||
"argon2": "^0.44.0",
|
||||
"fastify": "^5.7.4",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"fs": "0.0.1-security",
|
||||
"os": "^0.1.2",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"util": "^0.12.5",
|
||||
"uuid": "^13.0.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^10.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ZodIssue } from "zod/v3";
|
||||
|
||||
type ErrorType<R = undefined> = {
|
||||
bad: "client" | "server";
|
||||
code: string;
|
||||
message: string;
|
||||
reason?: R,
|
||||
}
|
||||
|
||||
export const ErrorBase = <R = undefined>(err: ErrorType<R>) => {return {
|
||||
success: false,
|
||||
error: err,
|
||||
}}
|
||||
|
||||
export const DatabaseError = () =>
|
||||
ErrorBase({
|
||||
bad: "server",
|
||||
code: "database_error",
|
||||
message: "サーバーでデータベースの問題が発生しました。",
|
||||
});
|
||||
|
||||
export const InputError = (issues: ZodIssue[]) =>
|
||||
ErrorBase({
|
||||
bad: "client",
|
||||
code: "input_wrong",
|
||||
message: "入力に問題があります。",
|
||||
reason: issues.map(issue => ({
|
||||
code: issue.code,
|
||||
path: issue.path,
|
||||
message: issue.message,
|
||||
})),
|
||||
});
|
||||
Executable
+165
@@ -0,0 +1,165 @@
|
||||
import Fastify from "fastify";
|
||||
import config from "@/lib/config";
|
||||
import { accessSync, constants as fsConst } from "node:fs";
|
||||
import staticStream from "@fastify/static";
|
||||
import { styleText } from "node:util";
|
||||
import Routes from "@/routes";
|
||||
import Database from "@/lib/db";
|
||||
import logger from "@/lib/logger";
|
||||
import AccessLog from "@/lib/access";
|
||||
import Authorization from "@/lib/auth";
|
||||
import { RequestContext } from "@mikro-orm/core";
|
||||
import { DatabaseError, ErrorBase } from "@/errors";
|
||||
import { ConfigEntity } from "@/modules/entities/Config";
|
||||
|
||||
process.title = "Chat";
|
||||
logger.info("Process started...");
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
process.title = "Chat backend";
|
||||
logger.warn(styleText(["red", "bold", "bgYellow"], "Development environment avaiable!!"));
|
||||
}
|
||||
|
||||
const fastify = Fastify({
|
||||
logger: false,
|
||||
trustProxy: config.server.trustProxy,
|
||||
bodyLimit: 1024 * 100,
|
||||
});
|
||||
|
||||
try {
|
||||
fastify.setErrorHandler((err, req, res) => {
|
||||
res.header("Content-Type", "application/json");
|
||||
|
||||
if (err instanceof SyntaxError && /JSON/.test(err.message)) {
|
||||
return res.status(400).send(ErrorBase({
|
||||
bad: "client",
|
||||
code: "input_none",
|
||||
message: "入力がありません。",
|
||||
}));
|
||||
}
|
||||
|
||||
logger.error("Unknown error: ", err);
|
||||
|
||||
return res.status(500).send(ErrorBase({
|
||||
bad: "server",
|
||||
code: "unknown_error",
|
||||
message: "不明なエラーが発生しました。",
|
||||
}));
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
try {
|
||||
accessSync(`${import.meta.dirname}/../../frontend/dist/index.html`, fsConst.R_OK);
|
||||
|
||||
await fastify.register(staticStream, {
|
||||
root: `${import.meta.dirname}/../../frontend/dist`,
|
||||
index: "/",
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Error: It's in production but the frontend dist is not found.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fastify.setNotFoundHandler((req, res) => {
|
||||
if (req.url.startsWith("/api")) {
|
||||
res.header("Content-Type", "application/json");
|
||||
|
||||
return res.code(404).send(ErrorBase({
|
||||
bad: "client",
|
||||
code: "endpoint_not_found",
|
||||
message: "エンドポイントが見つかりませんでした。",
|
||||
}));
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return res.sendFile("index.html");
|
||||
}
|
||||
|
||||
return res.code(500).send();
|
||||
});
|
||||
|
||||
await fastify.register(Database);
|
||||
|
||||
fastify.addHook("onRequest", (req, res, done) => {
|
||||
RequestContext.create(fastify.orm.em, done);
|
||||
});
|
||||
|
||||
fastify.addHook("onRequest", async (req, res) => {
|
||||
if (
|
||||
req.url.startsWith("/api") &&
|
||||
!req.url.startsWith("/api/setup")
|
||||
) {
|
||||
try {
|
||||
const configCount = await fastify.orm.em.count(ConfigEntity);
|
||||
|
||||
if (configCount === 0) {
|
||||
return res.code(409).send(ErrorBase({
|
||||
bad: "client",
|
||||
code: "yet_initialization",
|
||||
message: "初期設定が行われていません。",
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Database Error: Could not check if already initialization:", err);
|
||||
|
||||
return res.code(500).send(DatabaseError());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await fastify.register(AccessLog);
|
||||
await fastify.register(Authorization);
|
||||
|
||||
fastify.removeAllContentTypeParsers();
|
||||
|
||||
fastify.addContentTypeParser("application/json", { parseAs: "string" }, (req, body, done) => {
|
||||
try {
|
||||
const json = JSON.parse(
|
||||
typeof body === "string"
|
||||
? body
|
||||
: body.toString("utf-8")
|
||||
);
|
||||
|
||||
done(null, json);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
(err as any).statusCode = 400;
|
||||
done(err, undefined);
|
||||
} else {
|
||||
done(new Error("Invalid JSON"), undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await fastify.register(Routes, {
|
||||
prefix: "/api",
|
||||
});
|
||||
|
||||
const addr = await fastify.listen({
|
||||
port: config.server.port,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
logger.info(`Listening at ${addr}`);
|
||||
} else {
|
||||
logger.info(`Backend listening at ${addr}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const shutdown = async () => {
|
||||
try {
|
||||
await fastify.close();
|
||||
logger.log("Server downed.");
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
logger.error("Server down failed", err);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
process.once("SIGINT", shutdown);
|
||||
process.once("SIGTERM", shutdown);
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { FastifyPluginCallback } from "fastify";
|
||||
import logger from "@/lib/logger";
|
||||
import fp from "fastify-plugin";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
startTime: bigint;
|
||||
}
|
||||
}
|
||||
|
||||
const AccessLog: FastifyPluginCallback = (fastify) => {
|
||||
fastify.addHook("onRequest", (req, res, done) => {
|
||||
req.startTime = process.hrtime.bigint();
|
||||
|
||||
logger.info(`${req.method} ${req.url} from ${req.ip}`);
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
fastify.addHook("onResponse", (req, res, done) => {
|
||||
const duration = (Number(
|
||||
process.hrtime.bigint() - req.startTime
|
||||
) / 1_000_000).toFixed(2);
|
||||
|
||||
logger.info(`${req.method} ${req.url} ${res.statusCode} from ${req.ip} - ${duration}ms`);
|
||||
|
||||
done();
|
||||
});
|
||||
}
|
||||
|
||||
export default fp(AccessLog);
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FastifyPluginCallback } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
import { TokenEntity } from "@/modules/entities/Token";
|
||||
import logger from "./logger";
|
||||
import { DatabaseError, ErrorBase } from "@/errors";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
token: TokenEntity | ReturnType<typeof ErrorBase>;
|
||||
}
|
||||
}
|
||||
|
||||
const Authorization: FastifyPluginCallback = (fastify) => {
|
||||
fastify.addHook("onRequest", async (req, res) => {
|
||||
const token = req.headers["authorization"];
|
||||
if (typeof token !== "string") {
|
||||
return req.token = ErrorBase({
|
||||
bad: "client",
|
||||
code: "token_none",
|
||||
message: "トークンが設定されていません。",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fastify.orm.em.getRepository(TokenEntity).authToken(token);
|
||||
|
||||
req.token = result;
|
||||
} catch (err) {
|
||||
logger.error("Database Error: Token authorization failed:", err);
|
||||
|
||||
return res.code(500).send(DatabaseError());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default fp(Authorization);
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
import z from "zod/v3";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { parse as yamlParse } from "yaml";
|
||||
import { EOL } from "node:os";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
const schema = z.object({
|
||||
server: z.object({
|
||||
port: z.number().min(0).max(65535),
|
||||
host: z.string().ip(),
|
||||
trustProxy: z.union([
|
||||
z.string(),
|
||||
z.array(z.string()),
|
||||
z.boolean(),
|
||||
z.function()
|
||||
.args(z.string(), z.number())
|
||||
.returns(z.boolean()),
|
||||
]),
|
||||
}),
|
||||
database: z.object({
|
||||
user: z.string(),
|
||||
password: z.string(),
|
||||
host: z.string(),
|
||||
port: z.number().min(0).max(65535),
|
||||
database: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const config = (() => {
|
||||
try {
|
||||
const configFile = readFileSync(
|
||||
`${import.meta.dirname}/../../../../config/config.yaml`,
|
||||
"utf-8",
|
||||
);
|
||||
const configObj = yamlParse(configFile);
|
||||
const result = schema.safeParse(configObj);
|
||||
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues.map(i => ` ${i.message}`);
|
||||
logger.error(`Config file:${EOL}${issues.join(EOL)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.message.includes("no such file or directory, open")
|
||||
) {
|
||||
logger.error("Config file: Config file is not found.");
|
||||
} else {
|
||||
logger.error(`Config file: ${err}`);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
export default config;
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
import { MikroORM } from "@mikro-orm/postgresql";
|
||||
import config from "@/mikro-orm.config";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
orm: MikroORM;
|
||||
}
|
||||
}
|
||||
|
||||
const Database: FastifyPluginAsync = async (fastify) => {
|
||||
const orm = await MikroORM.init(config);
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
try {
|
||||
await orm.schema.updateSchema();
|
||||
logger.info("Database migration completed.");
|
||||
} catch (err) {
|
||||
logger.error("Migration failed:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fastify.decorate("orm", orm);
|
||||
|
||||
fastify.addHook("onClose", async () => {
|
||||
await orm.close(true);
|
||||
});
|
||||
};
|
||||
|
||||
export default fp(Database);
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"namespaces": [
|
||||
"public"
|
||||
],
|
||||
"name": "public",
|
||||
"tables": [
|
||||
{
|
||||
"columns": {
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 255,
|
||||
"mappedType": "string"
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "varchar(255)",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 255,
|
||||
"mappedType": "string"
|
||||
}
|
||||
},
|
||||
"name": "config",
|
||||
"schema": "public",
|
||||
"indexes": [
|
||||
{
|
||||
"keyName": "config_pkey",
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"composite": false,
|
||||
"constraint": true,
|
||||
"primary": true,
|
||||
"unique": true
|
||||
}
|
||||
],
|
||||
"checks": [],
|
||||
"foreignKeys": {},
|
||||
"nativeEnums": {}
|
||||
},
|
||||
{
|
||||
"columns": {
|
||||
"userid": {
|
||||
"name": "userid",
|
||||
"type": "varchar(20)",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 20,
|
||||
"mappedType": "string"
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(30)",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 30,
|
||||
"mappedType": "string"
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(254)",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 254,
|
||||
"mappedType": "string"
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "varchar(60)",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 60,
|
||||
"mappedType": "string"
|
||||
}
|
||||
},
|
||||
"name": "user",
|
||||
"schema": "public",
|
||||
"indexes": [
|
||||
{
|
||||
"columnNames": [
|
||||
"email"
|
||||
],
|
||||
"composite": false,
|
||||
"keyName": "user_email_unique",
|
||||
"constraint": true,
|
||||
"primary": false,
|
||||
"unique": true
|
||||
},
|
||||
{
|
||||
"keyName": "user_pkey",
|
||||
"columnNames": [
|
||||
"userid"
|
||||
],
|
||||
"composite": false,
|
||||
"constraint": true,
|
||||
"primary": true,
|
||||
"unique": true
|
||||
}
|
||||
],
|
||||
"checks": [],
|
||||
"foreignKeys": {},
|
||||
"nativeEnums": {}
|
||||
}
|
||||
],
|
||||
"nativeEnums": {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20260302212455 extends Migration {
|
||||
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(`alter table "user" add column "password" varchar(60) not null;`);
|
||||
}
|
||||
|
||||
override async down(): Promise<void> {
|
||||
this.addSql(`alter table "user" drop column "password";`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from "@mikro-orm/postgresql";
|
||||
import config from "@/lib/config";
|
||||
import { TsMorphMetadataProvider } from "@mikro-orm/reflection";
|
||||
import { Migrator } from "@mikro-orm/migrations";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
export default defineConfig({
|
||||
entities: ["./dist/modules/entities/**/*.js"],
|
||||
entitiesTs: ["./src/modules/entities/**/*.ts"],
|
||||
migrations: {
|
||||
path: "./dist/migrations",
|
||||
pathTs: "./src/migrations",
|
||||
allOrNothing: true,
|
||||
transactional: true,
|
||||
disableForeignKeys: false,
|
||||
},
|
||||
extensions: [Migrator],
|
||||
metadataProvider: TsMorphMetadataProvider,
|
||||
debug: process.env.NODE_ENV !== "production",
|
||||
logger: (message: string) => {
|
||||
logger.log(`[MikroORM] ${message}`);
|
||||
},
|
||||
|
||||
dbName: config.database.database,
|
||||
user: config.database.user,
|
||||
password: config.database.password,
|
||||
host: config.database.host,
|
||||
port: config.database.port,
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Entity, PrimaryKey, Property } from "@mikro-orm/core";
|
||||
|
||||
@Entity({ tableName: "config" })
|
||||
export class ConfigEntity {
|
||||
@PrimaryKey({ type: "string" })
|
||||
name!: string;
|
||||
|
||||
@Property({ type: "text" })
|
||||
value!: string;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Entity, EntityRepositoryType, Index, ManyToOne, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core";
|
||||
import { UserEntity } from "@/modules/entities/User";
|
||||
import { TokenRepository } from "@/modules/repositories/Token";
|
||||
|
||||
@Entity({
|
||||
tableName: "token",
|
||||
repository: () => TokenRepository,
|
||||
})
|
||||
export class TokenEntity {
|
||||
[EntityRepositoryType]?: TokenRepository;
|
||||
[OptionalProps]?: "createdAt";
|
||||
|
||||
@PrimaryKey({ type: "string", length: 64 })
|
||||
name!: string;
|
||||
|
||||
@Property({ type: "text" })
|
||||
passphrase!: string;
|
||||
|
||||
@ManyToOne(() => UserEntity)
|
||||
@Index()
|
||||
user!: UserEntity;
|
||||
|
||||
@Property()
|
||||
isNative!: boolean;
|
||||
|
||||
@Property({ onCreate: () => new Date() })
|
||||
createdAt!: Date;
|
||||
|
||||
@Property({ nullable: true })
|
||||
lastUsedAt?: Date;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Entity, EntityRepositoryType, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core";
|
||||
import { UserRepository } from "@/modules/repositories/User";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
@Entity({
|
||||
tableName: "user",
|
||||
repository: () => UserRepository,
|
||||
})
|
||||
export class UserEntity {
|
||||
[EntityRepositoryType]?: UserRepository;
|
||||
[OptionalProps]?: "uuid" | "isSuspended" | "createdAt";
|
||||
|
||||
@PrimaryKey({ length: 36 })
|
||||
uuid: string = uuid();
|
||||
|
||||
@Property({ unique: true, length: 20 })
|
||||
userid!: string;
|
||||
|
||||
@Property({ length: 30 })
|
||||
username!: string;
|
||||
|
||||
@Property({ unique: true, length: 256 })
|
||||
email!: string;
|
||||
|
||||
@Property({ type: "text" })
|
||||
password!: string;
|
||||
|
||||
@Property({ default: false })
|
||||
isAdmin: boolean = false;
|
||||
|
||||
@Property({ default: false })
|
||||
isSuspended: boolean = false;
|
||||
|
||||
@Property({ onCreate: () => new Date() })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { EntityRepository } from "@mikro-orm/postgresql";
|
||||
import type { TokenEntity } from "@/modules/entities/Token";
|
||||
import { hash, argon2id, verify as argon2Verify } from "argon2";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { UserEntity } from "@/modules/entities/User";
|
||||
import { ErrorBase } from "@/errors";
|
||||
|
||||
export class TokenRepository extends EntityRepository<TokenEntity> {
|
||||
async createToken(user: UserEntity, isNative: boolean) {
|
||||
const name = randomBytes(32).toString("hex");
|
||||
const passphrase = randomBytes(32).toString("hex");
|
||||
|
||||
const hashed = await hash(passphrase, {
|
||||
type: argon2id,
|
||||
memoryCost: 2 ** 16,
|
||||
timeCost: 3,
|
||||
parallelism: 1,
|
||||
});
|
||||
|
||||
const token = this.create({
|
||||
name,
|
||||
passphrase: hashed,
|
||||
isNative,
|
||||
user,
|
||||
});
|
||||
|
||||
await this.em.persist(token).flush();
|
||||
|
||||
return `${name}_${passphrase}`;
|
||||
}
|
||||
|
||||
async authToken(
|
||||
tokenStr: string
|
||||
): Promise<TokenEntity | ReturnType<typeof ErrorBase>> {
|
||||
const tokenArr = tokenStr.split("_");
|
||||
|
||||
if (
|
||||
tokenArr[0]?.length !== 64 ||
|
||||
tokenArr[1]?.length !== 64
|
||||
)
|
||||
return ErrorBase({
|
||||
bad: "client",
|
||||
code: "token_length_wrong",
|
||||
message: "トークンの文字数が不正です。",
|
||||
});
|
||||
|
||||
const token = await this.findOne({ name: tokenArr[0] }, { populate: ["user"] });
|
||||
if (!token)
|
||||
return ErrorBase({
|
||||
bad: "client",
|
||||
code: "token_invalid",
|
||||
message: "トークンが不正です。",
|
||||
});
|
||||
|
||||
const isOk = await argon2Verify(token.passphrase, tokenArr[1]);
|
||||
if (!isOk)
|
||||
return ErrorBase({
|
||||
bad: "client",
|
||||
code: "token_invalid",
|
||||
message: "トークンが不正です。",
|
||||
});
|
||||
|
||||
token.lastUsedAt = new Date()
|
||||
await this.em.persist(token).flush();
|
||||
|
||||
return token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { EntityRepository } from "@mikro-orm/postgresql";
|
||||
import { UserEntity } from "@/modules/entities/User";
|
||||
import { hash, argon2id, verify as argon2Verify } from "argon2";
|
||||
import z from "zod/v3";
|
||||
import EmailRegex from "@/regexs/email";
|
||||
|
||||
export class UserRepository extends EntityRepository<UserEntity> {
|
||||
public static schema = z.object({
|
||||
userid: z.string().trim().min(3).max(20),
|
||||
username: z.string().trim().min(3).max(30),
|
||||
email: z.string().min(6).trim().max(254).regex(EmailRegex),
|
||||
password: z.string().trim().min(8),
|
||||
isAdmin: z.boolean(),
|
||||
});
|
||||
|
||||
async createUser(data: z.infer<typeof UserRepository.schema>) {
|
||||
const hashed = await hash(data.password, {
|
||||
type: argon2id,
|
||||
memoryCost: 2 ** 16,
|
||||
timeCost: 3,
|
||||
parallelism: 1,
|
||||
});
|
||||
|
||||
const user = this.create({
|
||||
...data,
|
||||
password: hashed,
|
||||
});
|
||||
|
||||
await this.em.persist(user).flush();
|
||||
return;
|
||||
}
|
||||
|
||||
async findByUserId(userid: string) {
|
||||
return this.findOne({ userid });
|
||||
}
|
||||
|
||||
async findByEmail(email: string) {
|
||||
return this.findOne({ email });
|
||||
}
|
||||
|
||||
async authUser(data: {
|
||||
userid: string;
|
||||
password: string;
|
||||
}) {
|
||||
const user = await this.findByUserId(data.userid);
|
||||
if (!user)
|
||||
return "userid_wrong";
|
||||
|
||||
const isOk = await argon2Verify(user.password, data.password);
|
||||
if (!isOk)
|
||||
return "password_wrong";
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
const EmailRegex = /^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:([0-9]{1,3}\.){3}[0-9]{1,3})\])$/i;
|
||||
|
||||
export default EmailRegex;
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import Setup from "./setup";
|
||||
import Primary from "./primary";
|
||||
import Me from "./me";
|
||||
|
||||
export default async function Routes(fastify: FastifyInstance) {
|
||||
await fastify.register(Setup, {
|
||||
prefix: "/setup",
|
||||
});
|
||||
|
||||
await fastify.register(Primary, {
|
||||
prefix: "/primary",
|
||||
});
|
||||
|
||||
await fastify.register(Me, {
|
||||
prefix: "/me",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
export default async function Me(fastify: FastifyInstance) {
|
||||
fastify.post("/", async (req, res) => {
|
||||
if ("error" in req.token)
|
||||
return res.code(400).send(req.token);
|
||||
|
||||
const { password, email, ...safeUser } = req.token.user;
|
||||
return res.send(safeUser);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import SignUp from "./signup";
|
||||
import SignIn from "./signin";
|
||||
|
||||
export default async function Primary(fastify: FastifyInstance) {
|
||||
await fastify.register(SignUp, {
|
||||
prefix: "/signup",
|
||||
});
|
||||
|
||||
await fastify.register(SignIn, {
|
||||
prefix: "/signin",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { DatabaseError, ErrorBase, InputError } from "@/errors";
|
||||
import logger from "@/lib/logger";
|
||||
import { TokenEntity } from "@/modules/entities/Token";
|
||||
import { UserEntity } from "@/modules/entities/User";
|
||||
import { UserRepository } from "@/modules/repositories/User";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
export default function SignIn(fastify: FastifyInstance) {
|
||||
fastify.post("/", async (req, res) => {
|
||||
res.header("Content-Type", "application/json");
|
||||
|
||||
const schema = UserRepository.schema.pick({
|
||||
userid: true,
|
||||
password: true,
|
||||
});
|
||||
|
||||
const result = schema.safeParse(req.body);
|
||||
|
||||
if (!result.success) {
|
||||
return res.code(400).send(InputError(result.error.issues));
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await fastify.orm.em.getRepository(UserEntity).authUser(result.data);
|
||||
|
||||
if (typeof user === "string") {
|
||||
return res.code(400).send(ErrorBase({
|
||||
bad: "client",
|
||||
code: "auth_input_wrong",
|
||||
message: "ユーザー名かパスワードが違います。",
|
||||
}));
|
||||
}
|
||||
|
||||
const token = await fastify.orm.em.getRepository(TokenEntity).createToken(user, true);
|
||||
|
||||
return res.send({
|
||||
success: true,
|
||||
token: token,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Database Error: User auth or token create failed.", err);
|
||||
|
||||
return res.code(500).send(DatabaseError());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { UserEntity } from "@/modules/entities/User";
|
||||
import logger from "@/lib/logger";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { UserRepository } from "@/modules/repositories/User";
|
||||
import { DatabaseError, InputError } from "@/errors";
|
||||
|
||||
export default function SignUp(fastify: FastifyInstance) {
|
||||
fastify.post("/", async (req, res) => {
|
||||
res.header("Content-Type", "application/json");
|
||||
|
||||
const result = UserRepository.schema.safeParse(req.body);
|
||||
|
||||
if (!result.success) {
|
||||
return res.code(400).send(InputError(result.error.issues));
|
||||
}
|
||||
|
||||
try {
|
||||
await fastify.orm.em.getRepository(UserEntity).createUser(result.data);
|
||||
|
||||
return res.code(200).send({
|
||||
success: true,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Database Error: User create failed.", err);
|
||||
|
||||
return res.code(500).send(DatabaseError());
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ConfigEntity } from "@/modules/entities/Config";
|
||||
import { UserEntity } from "@/modules/entities/User";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
export default async function ServerInfo(fastify: FastifyInstance) {
|
||||
fastify.post("/", async (req, res) => {
|
||||
const config = fastify.orm.em.getRepository(ConfigEntity);
|
||||
const user = fastify.orm.em.getRepository(UserEntity);
|
||||
const configCount = await config.count();
|
||||
const userCount = await user.count();
|
||||
|
||||
return res.send({
|
||||
isInitialized: configCount > 0,
|
||||
isFirstAdminExists: userCount > 0,
|
||||
userCount,
|
||||
});
|
||||
});
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { UserEntity } from "@/modules/entities/User";
|
||||
import logger from "@/lib/logger";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { UserRepository } from "@/modules/repositories/User";
|
||||
import { DatabaseError, ErrorBase, InputError } from "@/errors";
|
||||
import { ConfigEntity } from "@/modules/entities/Config";
|
||||
|
||||
export default function CreateAdmin(fastify: FastifyInstance) {
|
||||
fastify.post("/", async (req, res) => {
|
||||
res.header("Content-Type", "application/json");
|
||||
|
||||
const result = UserRepository.schema.omit({ isAdmin: true }).safeParse(req.body);
|
||||
|
||||
if (!result.success) {
|
||||
return res.code(400).send(InputError(result.error.issues));
|
||||
}
|
||||
|
||||
try {
|
||||
const configCount = await fastify.orm.em.count(ConfigEntity);
|
||||
|
||||
if (configCount === 0) {
|
||||
return res.code(409).send(ErrorBase({
|
||||
bad: "client",
|
||||
code: "yet_initialization",
|
||||
message: "初期設定が行われていません。",
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Database Error: Could not check if already initialization:", err);
|
||||
|
||||
return res.code(500).send(DatabaseError());
|
||||
}
|
||||
|
||||
try {
|
||||
const userCount = await fastify.orm.em.getRepository(UserEntity).count();
|
||||
|
||||
if (userCount > 0) {
|
||||
return res.code(409).send(ErrorBase({
|
||||
bad: "client",
|
||||
code: "first_admin_already_exists",
|
||||
message: "最初の管理者ユーザーは既に存在します。",
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Database Error: Could not check if administrator exists:", err);
|
||||
|
||||
return res.code(500).send(DatabaseError());
|
||||
}
|
||||
|
||||
try {
|
||||
await fastify.orm.em.getRepository(UserEntity).createUser({
|
||||
...result.data,
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
logger.warn("First administrator account has been created.")
|
||||
|
||||
return res.code(200).send({
|
||||
success: true,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Database Error: Admin user create failed.", err);
|
||||
|
||||
return res.code(500).send(DatabaseError());
|
||||
}
|
||||
});
|
||||
}
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import CreateAdmin from "./create-admin";
|
||||
import Initialization from "./initialization";
|
||||
|
||||
export default async function Setup(fastify: FastifyInstance) {
|
||||
await fastify.register(Initialization, {
|
||||
prefix: "/initialization",
|
||||
});
|
||||
|
||||
await fastify.register(CreateAdmin, {
|
||||
prefix: "/create-admin",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { DatabaseError, ErrorBase, InputError } from "@/errors";
|
||||
import logger from "@/lib/logger";
|
||||
import { ConfigEntity } from "@/modules/entities/Config";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import z from "zod/v3";
|
||||
|
||||
export default function Initialization(fastify: FastifyInstance) {
|
||||
fastify.post("/", async (req, res) => {
|
||||
res.header("Content-Type", "application/json");
|
||||
|
||||
const bodySchema = z.object({
|
||||
name: z.string().trim().min(1).max(20),
|
||||
description: z.string().trim().min(1),
|
||||
requiredInvitationCode: z.boolean(),
|
||||
force: z.literal("use_force_initialization").refine(() => process.env.NODE_ENV !== "production").optional(),
|
||||
});
|
||||
|
||||
const result = bodySchema.safeParse(req.body);
|
||||
|
||||
if (!result.success) {
|
||||
return res.code(400).send(InputError(result.error.issues));
|
||||
}
|
||||
|
||||
try {
|
||||
if (result.data.force) {
|
||||
await fastify.orm.em.nativeDelete(ConfigEntity, {});
|
||||
fastify.orm.em.clear();
|
||||
}
|
||||
|
||||
const configCount = await fastify.orm.em.count(ConfigEntity);
|
||||
|
||||
if (configCount > 0) {
|
||||
return res.code(409).send(ErrorBase({
|
||||
bad: "client",
|
||||
code: "already_initialization",
|
||||
message: "既に初期設定が行われています。",
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Database Error: Could not check if yet initialization:", err);
|
||||
|
||||
return res.code(500).send(DatabaseError());
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = Object.entries(result.data).filter(([key]) => key !== "force");
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
const entity = fastify.orm.em.create(ConfigEntity, {
|
||||
name: key,
|
||||
value: typeof value === "string"
|
||||
? value
|
||||
: String(value),
|
||||
});
|
||||
|
||||
fastify.orm.em.persist(entity);
|
||||
}
|
||||
|
||||
await fastify.orm.em.flush();
|
||||
|
||||
return res.code(200).send({
|
||||
success: true,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Database Error: Server initialization failed:", err);
|
||||
|
||||
return res.code(500).send(DatabaseError());
|
||||
}
|
||||
});
|
||||
}
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"baseUrl": "./",
|
||||
"typeRoots": [
|
||||
"./node_modules/@types",
|
||||
"./src/types",
|
||||
],
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strict": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"moduleDetection": "force",
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
},
|
||||
"removeComments": true,
|
||||
},
|
||||
"tsc-alias": {
|
||||
"resolveFullPaths": true,
|
||||
},
|
||||
}
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
# Nocos Frontend
|
||||
Vue 3.5 + Vue Router 5.0 + Vite 7.2
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LynqChat</title>
|
||||
<link rel="icon" href="/lynqchat.svg" type="image/svg+xml">
|
||||
</head>
|
||||
<body>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
font-size: 16px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html, body {
|
||||
background-color: #1b1b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
position: fixed;
|
||||
inset: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.progress {
|
||||
border: 0.4rem solid #425C97;
|
||||
border-radius: 100%;
|
||||
border-top: 0.4rem solid #ffffff;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="loading">
|
||||
<img
|
||||
class="logo"
|
||||
src="/lynqchat.svg"
|
||||
/>
|
||||
|
||||
<div class="progress" />
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "AGPL-3.0-only",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"dexie": "^4.3.0",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.0",
|
||||
"vue-tsc": "^3.1.4"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.1"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg width="4096" height="4096" viewBox="0 0 4096 4096" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="4096" height="4096" fill="#1E1E1E"/>
|
||||
<rect width="4096" height="4096" fill="#425C97"/>
|
||||
<rect x="546" y="1086" width="155.544" height="1011.04" rx="77.7722" fill="white"/>
|
||||
<rect x="1168.18" y="1941.49" width="155.544" height="622.177" rx="77.7722" transform="rotate(90 1168.18 1941.49)" fill="white"/>
|
||||
<path d="M1674.03 1913.56C1691.75 1951.65 1679.85 1996.96 1645.71 2021.43C1601.21 2053.32 1538.68 2036.9 1515.59 1987.26L1325.79 1579.23C1298.84 1521.31 1341.13 1455 1405.01 1455C1438.99 1455 1469.9 1474.71 1484.23 1505.52L1674.03 1913.56Z" fill="white"/>
|
||||
<path d="M1520.23 2377.11C1505.89 2407.93 1474.99 2427.64 1441 2427.64C1377.13 2427.64 1334.84 2361.33 1361.78 2303.41L1732.93 1505.52C1747.26 1474.71 1778.16 1455 1812.15 1455C1876.03 1455 1918.32 1521.31 1891.37 1579.23L1520.23 2377.11Z" fill="white"/>
|
||||
<path d="M2849 1439C2959.46 1439 3049 1528.54 3049 1639V2021.23C3049 2064.18 3014.18 2099 2971.23 2099C2928.28 2099 2893.46 2064.18 2893.46 2021.23V1639C2893.46 1614.83 2874.17 1595.16 2850.15 1594.55L2849 1594.54H2249L2247.85 1594.55C2223.83 1595.16 2204.54 1614.83 2204.54 1639V2021.23C2204.54 2064.18 2169.72 2099 2126.77 2099C2083.82 2099 2049 2064.18 2049 2021.23V1639C2049 1528.54 2138.54 1439 2249 1439H2849Z" fill="white"/>
|
||||
<path d="M2749 1766.77H3389C3434.41 1766.77 3471.23 1803.59 3471.23 1849V2149C3471.23 2216.51 3416.51 2271.23 3349 2271.23H2749C2681.49 2271.23 2626.77 2216.51 2626.77 2149V1889C2626.77 1821.49 2681.49 1766.77 2749 1766.77Z" stroke="white" stroke-width="155.54"/>
|
||||
<path d="M3393.46 1689C3479.62 1689 3549.46 1758.84 3549.46 1845V2931C3549.46 2974.08 3514.54 3009 3471.46 3009C3428.38 3009 3393.46 2974.08 3393.46 2931V1689Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
Executable
+27
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<Progress
|
||||
v-show="routerStatus.isLoad"
|
||||
class="router-progress"
|
||||
:size="6"
|
||||
/>
|
||||
|
||||
<main>
|
||||
<RouterView
|
||||
:key="$route.fullPath"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.router-progress {
|
||||
position: fixed;
|
||||
inset: 0.5rem 0.5rem 0 auto;
|
||||
z-index: 9999;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { RouterView } from "vue-router";
|
||||
import routerStatus from "@/lib/router";
|
||||
import Progress from "@/components/Progress.vue";
|
||||
</script>
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="progress" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.progress {
|
||||
border: v-bind(borderSize) solid #425C97;
|
||||
border-radius: 100%;
|
||||
border-top: v-bind(borderSize) solid #ffffff;
|
||||
width: v-bind(size);
|
||||
height: v-bind(size);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const props = defineProps<{
|
||||
size: number;
|
||||
}>();
|
||||
|
||||
const size = `${props.size * 0.25}rem`;
|
||||
const borderSize = `${props.size / 3}px`;
|
||||
</script>
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
:root {
|
||||
--bg-color: #ffffff;
|
||||
--text-color: #000000;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-color: #1b1b1b;
|
||||
--text-color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
font-size: 16px;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Dexie, { type EntityTable } from "dexie";
|
||||
|
||||
export interface Settings {
|
||||
id: number;
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
id: number;
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export default class extends Dexie {
|
||||
server!: EntityTable<Server, "id">;
|
||||
settings!: EntityTable<Settings, "id">;
|
||||
|
||||
constructor() {
|
||||
super("lynq-chat");
|
||||
|
||||
this.version(1).stores({
|
||||
server: "++id,&name",
|
||||
settings: "++id,&name",
|
||||
});
|
||||
|
||||
/*(async () => {
|
||||
await this.open();
|
||||
|
||||
await this.transaction("rw", this.settings, async () => {
|
||||
const defaults = {
|
||||
"key": "value",
|
||||
};
|
||||
|
||||
for (const [name, value] of Object.entries(defaults)) {
|
||||
const exists = await this.settings
|
||||
.where("name")
|
||||
.equals(name)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await this.settings.add({ name, value });
|
||||
}
|
||||
}
|
||||
});
|
||||
})();*/
|
||||
};
|
||||
}
|
||||
|
||||
export async function getByIndex<T>(
|
||||
table: EntityTable<T, any>,
|
||||
index: string,
|
||||
indexValue: any
|
||||
): Promise<T | undefined> {
|
||||
return await table.where(index).equals(indexValue).first();
|
||||
}
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
import { reactive } from "vue";
|
||||
|
||||
const routerStatus = reactive<{
|
||||
isLoad: boolean;
|
||||
}>({
|
||||
isLoad: false,
|
||||
});
|
||||
|
||||
export default routerStatus;
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
import { createApp } from "vue";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import routerStatus from "@/lib/router";
|
||||
|
||||
import "@/global.css";
|
||||
import Layout from "@/Layout.vue";
|
||||
|
||||
const app = createApp(Layout);
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
component: () => import("@/routes/index.vue"),
|
||||
},
|
||||
],
|
||||
});
|
||||
// @ts-ignore 余分な引数の警告
|
||||
router.beforeEach((to, from, next) => {
|
||||
routerStatus.isLoad = true;
|
||||
next();
|
||||
});
|
||||
router.afterEach(() => {
|
||||
routerStatus.isLoad = false;
|
||||
});
|
||||
app.use(router);
|
||||
|
||||
app.mount("body");
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<h1>Hello World</h1>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
</script>
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export {}
|
||||
|
||||
declare global {
|
||||
}
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"typeRoots": ["./node_modules/"],
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "ESNext",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
/* Alias */
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
},
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*.ts",
|
||||
"./src/**/*.tsx",
|
||||
"./src/**/*.vue",
|
||||
],
|
||||
}
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
},
|
||||
},
|
||||
}
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "")
|
||||
const apiPort = Number(env.VITE_API_PORT) || 3300
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
hmr: {
|
||||
clientPort: 5173,
|
||||
protocol: "ws",
|
||||
},
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: `http://localhost:${apiPort}`,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "lynqchat",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {},
|
||||
"packageManager": "pnpm@10.29.1",
|
||||
"dependencies": {
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import SetupCreateAdmin from "./setup/create-admin";
|
||||
import SetupInitilization from "./setup/initialization";
|
||||
|
||||
type ApiMap =
|
||||
SetupInitilization &
|
||||
SetupCreateAdmin;
|
||||
|
||||
export default ApiMap;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { InputError, InputNoneError } from "../../modules/error/input";
|
||||
import ErrorBase from "../../modules/error";
|
||||
import DatabaseError from "../../modules/error/database";
|
||||
|
||||
export default interface SetupCreateAdmin {
|
||||
"setup/create-admin": {
|
||||
body: {
|
||||
userid: string;
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
response: Success | DatabaseError | ErrorBase<{
|
||||
bad: "client",
|
||||
code: "yet_initialization",
|
||||
message: "初期設定が行われていません。",
|
||||
}> | ErrorBase<{
|
||||
bad: "client",
|
||||
code: "first_admin_already_exists",
|
||||
message: "最初の管理者ユーザーは既に存在します。",
|
||||
}> | InputError | InputNoneError;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { InputError, InputNoneError } from "../../modules/error/input";
|
||||
import ErrorBase from "../../modules/error";
|
||||
import DatabaseError from "../../modules/error/database";
|
||||
|
||||
export default interface SetupInitilization {
|
||||
"setup/initilization": {
|
||||
body: {
|
||||
name: string;
|
||||
description: string;
|
||||
requiredInvitationCode: boolean;
|
||||
force?: "use_force_initialization";
|
||||
};
|
||||
response: Success | DatabaseError | ErrorBase<{
|
||||
bad: "client",
|
||||
code: "already_initialization",
|
||||
message: "既に初期設定が行われています。",
|
||||
}> | InputError | InputNoneError;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import ErrorBase from ".";
|
||||
|
||||
type DatabaseError = ErrorBase<{
|
||||
bad: "server",
|
||||
code: "database_error",
|
||||
message: "サーバーでデータベースの問題が発生しました。",
|
||||
}>;
|
||||
|
||||
export default DatabaseError;
|
||||
@@ -0,0 +1,13 @@
|
||||
type ErrorType<R = unknown> = {
|
||||
bad: "client" | "server";
|
||||
code: string;
|
||||
message: string;
|
||||
reason?: R,
|
||||
}
|
||||
|
||||
type ErrorBase<E extends ErrorType<any>> = {
|
||||
success: false;
|
||||
error: E;
|
||||
}
|
||||
|
||||
export default ErrorBase;
|
||||
@@ -0,0 +1,16 @@
|
||||
export type InputError = ErrorBase<{
|
||||
bad: "client";
|
||||
code: "input_wrong";
|
||||
message: "入力に問題があります。";
|
||||
reason: {
|
||||
code: string,
|
||||
path: string,
|
||||
message: string,
|
||||
}[];
|
||||
}>;
|
||||
|
||||
export type InputNoneError = ErrorBase<{
|
||||
bad: "client",
|
||||
code: "input_none",
|
||||
message: "入力がありません。",
|
||||
}>;
|
||||
@@ -0,0 +1,3 @@
|
||||
export default interface Success {
|
||||
success: true;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import lynqError from "./lib/error";
|
||||
import lynqFetch from "./lib/fetch";
|
||||
|
||||
interface Options {
|
||||
origin: string;
|
||||
retry?: number;
|
||||
waiting?: number;
|
||||
}
|
||||
|
||||
type BodyArgs<M, E extends keyof M> =
|
||||
M[E] extends { body: never } ? [] :
|
||||
M[E] extends { body: infer B } ? [body: B] :
|
||||
M[E] extends { body?: infer B } ? [body?: B] :
|
||||
[];
|
||||
|
||||
export default class LynqChat<
|
||||
M extends { [K in keyof M]: { body?: any; response: any } }
|
||||
> {
|
||||
readonly origin: string;
|
||||
readonly retry: number;
|
||||
readonly waiting: number;
|
||||
private _token: string | null = null;
|
||||
|
||||
constructor(options: Options) {
|
||||
this.origin = options.origin;
|
||||
this.retry = options.retry ?? 5;
|
||||
this.waiting = options.waiting ?? 500;
|
||||
|
||||
if (this.retry < 1) throw new lynqError("Invalid retry count.");
|
||||
if (this.waiting < 1) throw new lynqError("Invalid base waiting time.");
|
||||
if (options.origin !== new URL(options.origin).origin)
|
||||
throw new lynqError("Invalid origin.");
|
||||
}
|
||||
|
||||
get token(): string | null {
|
||||
return this._token;
|
||||
}
|
||||
|
||||
set token(token: string) {
|
||||
if (token.length !== 64) throw new lynqError("Invalid token.");
|
||||
this._token = token;
|
||||
}
|
||||
|
||||
public async request<E extends keyof M>(
|
||||
endpoint: E,
|
||||
...args: BodyArgs<M, E>
|
||||
): Promise<M[E]["response"]>;
|
||||
public async request(
|
||||
endpoint: string,
|
||||
body?: any
|
||||
): Promise<any>;
|
||||
public async request(
|
||||
endpoint: string,
|
||||
...args: any[]
|
||||
): Promise<any> {
|
||||
|
||||
const req = await lynqFetch(
|
||||
this.origin,
|
||||
this.retry,
|
||||
this.waiting,
|
||||
endpoint,
|
||||
{
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
body: args[0],
|
||||
}
|
||||
);
|
||||
|
||||
return await req.json();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default class lynqError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "lynqError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import lynqError from "./error";
|
||||
|
||||
export default async function lynqFetch(
|
||||
origin: string,
|
||||
retryCount: number,
|
||||
waitingTime: number,
|
||||
endpoint: string,
|
||||
init?: RequestInit,
|
||||
) {
|
||||
const waiting = (ms: number) =>
|
||||
new Promise<void>(resolve => setTimeout(resolve, ms));
|
||||
|
||||
let lastError;
|
||||
|
||||
for (let i = 0; i < retryCount; i++) {
|
||||
try {
|
||||
if (init?.signal?.aborted) {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
|
||||
const req = await fetch(new URL(`/api/${endpoint}`, origin), init);
|
||||
|
||||
if (
|
||||
Math.floor(req.status / 200) === 1 ||
|
||||
Math.floor(req.status / 300) === 1
|
||||
) {
|
||||
return req;
|
||||
}
|
||||
|
||||
if (Math.floor(req.status / 400) === 1)
|
||||
throw new lynqError(`Client error: HTTP${req.status} - ${req.statusText}`);
|
||||
|
||||
lastError = new Error(`Request failed: HTTP${req.status} - ${req.statusText}`);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (
|
||||
err instanceof lynqError &&
|
||||
err.name === "Client error" &&
|
||||
err.message.startsWith("HTTP4")
|
||||
)
|
||||
throw err;
|
||||
|
||||
lastError = err;
|
||||
}
|
||||
|
||||
if (i < retryCount - 1) {
|
||||
const waitTime = waitingTime * 2 ** i;
|
||||
await waiting(waitTime);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("Unknown Error");
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "./dist",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"declarationDir": "./dist/types",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": "./src/",
|
||||
"typeRoots": [
|
||||
"./node_modules/@types",
|
||||
],
|
||||
},
|
||||
"tsc-alias": {
|
||||
"resolveFullPaths": true,
|
||||
},
|
||||
}
|
||||
Generated
+3276
File diff suppressed because it is too large
Load Diff
Executable
+17
@@ -0,0 +1,17 @@
|
||||
packages:
|
||||
- packages/*
|
||||
minimumReleaseAge: 10080
|
||||
minimumReleaseAgeExclude:
|
||||
- fastify
|
||||
- pnpm@10.29.1
|
||||
- esbuild
|
||||
- vite
|
||||
- rolldown
|
||||
- fastify
|
||||
- pnpm
|
||||
- "@types/node"
|
||||
- "@esbuild/*"
|
||||
- "@rolldown/*"
|
||||
onlyBuiltDependencies:
|
||||
- argon2
|
||||
- esbuild
|
||||
Reference in New Issue
Block a user