Chg: exactOptionalPropertyTypesをfalseに変更 / Chg(Security): トークンが不正な場合のエラーを全てtoken_invalidに変更 / New: channelテーブル・リポジトリ / Chg: configテーブルのvalueをstringに / Chg: configテーブルのlengthを4096に / New: messageテーブル / Chg: 安全のためuserテーブルのOptionalPropsにidを追加 / New: channel/createエンドポイント / New: channel/listエンドポイント / New: channel/editエンドポイント / Enhance: primary/signupエンドポイントの重複エラーの実装で末尾カンマなどの改善 / Chg: setup/initializationのdescriptionに最大文字数4096を制定 / Chg: serverInfoをdefault exportからexportに変更 / New: フロントエンドでmeを読み込み / New: フロントエンドでchannelを読み込み / New: client.tsでトークンがある場合はトークンを指定 / Chg: clientをrefに / Del: IndexedDBからserverテーブルを削除 / Fix: Dexieのclassに命名 / Feat: フロントエンドでのサインインページ / Fix: L.jsで任意のbodyがあるエンドポイントが定義できない問題を修正 / Del: L.jsのserver-infoでの不要なimportを削除 / Fix: L.jsでトークンのエラーを追加 / Fix: L.jsのUserSchemaにlastUsedAtを追加

This commit is contained in:
2026-03-30 11:37:57 +09:00
parent 6b54ae4306
commit d129c95aa4
33 changed files with 683 additions and 39 deletions
+3 -3
View File
@@ -16,10 +16,10 @@ const Authorization: FastifyPluginCallback = (fastify) => {
fastify.addHook("onRequest", async (req, res) => {
let token = req.headers["authorization"];
if (typeof token !== "string") {
return req.token = ErrorBase({
return ErrorBase({
bad: "client",
code: "token_none",
message: "トークンが設定されていません。",
code: "token_invalid",
message: "トークンが不正です。",
});
}
@@ -0,0 +1,43 @@
import generateUniqueId from "@/lib/id";
import { Entity, EntityRepositoryType, Index, ManyToOne, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core";
import { UserEntity } from "@/modules/entities/User";
import { ChannelRepository } from "@/modules/repositories/Channel";
@Entity({
tableName: "channel",
repository: () => ChannelRepository,
})
export class ChannelEntity {
[EntityRepositoryType]?: ChannelRepository;
[OptionalProps]?: "id" | "createdAt";
@PrimaryKey({
type: "string",
length: 10,
onCreate: () => generateUniqueId(),
})
id!: string;
@Property({
type: "string",
length: 20,
unique: true,
})
@Index()
name!: string;
@Property({
type: "string",
length: 4096,
})
description!: string;
@ManyToOne(() => UserEntity)
createdBy!: UserEntity;
@Property({
type: "datetime",
onCreate: () => new Date(),
})
createdAt!: Date;
}
@@ -11,6 +11,9 @@ export class ConfigEntity {
@PrimaryKey({ type: "string" })
name!: string;
@Property({ type: "text" })
@Property({
type: "string",
length: 4096,
})
value!: string;
}
@@ -0,0 +1,36 @@
import generateUniqueId from "@/lib/id";
import { Entity, Index, ManyToOne, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core";
import { UserEntity } from "@/modules/entities/User";
import { ChannelEntity } from "@/modules/entities/Channel";
@Entity({
tableName: "message",
})
export class MessageEntity {
[OptionalProps]?: "id" | "createdAt";
@PrimaryKey({
type: "string",
length: 10,
onCreate: () => generateUniqueId(),
})
id!: string;
@Property({
type: "string",
length: 4096,
})
message!: string;
@ManyToOne(() => ChannelEntity)
channel!: ChannelEntity;
@ManyToOne(() => UserEntity)
createdBy!: UserEntity;
@Property({
type: "datetime",
onCreate: () => new Date(),
})
createdAt!: Date;
}
@@ -8,7 +8,7 @@ import generateUniqueId from "@/lib/id";
})
export class UserEntity {
[EntityRepositoryType]?: UserRepository;
[OptionalProps]?: "profile" | "isSuspended" | "createdAt";
[OptionalProps]?: "id" | "profile" | "isSuspended" | "createdAt";
@PrimaryKey({
type: "string",
@@ -0,0 +1,65 @@
import { EntityRepository } from "@mikro-orm/postgresql";
import type { ChannelEntity } from "@/modules/entities/Channel";
import { ErrorBase } from "@/errors";
import z from "zod/v3";
import { UserRepository } from "@/modules/repositories/User";
export class ChannelRepository extends EntityRepository<ChannelEntity> {
public static schema = z.object({
name: z.string().trim().min(1).max(20),
description: z.string().trim().min(1).max(4096),
userid: UserRepository.schema.shape.userid,
});
async findChannel(limit: number = 20, sinceData?: string) {
let since = sinceData ?? new Date();
if (
sinceData &&
!isNaN(new Date(sinceData).getTime())
) {
const itChannel = await this.findOne({ id: sinceData });
if (!itChannel) {
return ErrorBase({
bad: "client",
code: "channel_not_found",
message: "対象のチャンネルが見つかりませんでした。",
});
}
since = itChannel.createdAt;
}
const findResult = await this.find({
createdAt: {
$lt: since,
},
}, {
orderBy: {
createdAt: "DESC",
},
limit: limit,
});
return findResult ?? [];
}
async createChannel(data: z.infer<typeof ChannelRepository.schema>) {
const channel = this.create({
...data,
createdBy: data.userid,
});
await this.em.persist(channel).flush();
return channel.id;
}
async editChannel(
target: ChannelEntity,
data: Partial<Omit<z.infer<typeof ChannelRepository.schema>, "userid">>
) {
await this.nativeUpdate(target, data);
return target.id;
}
}
@@ -52,9 +52,9 @@ export class TokenRepository extends EntityRepository<TokenEntity> {
)
return ErrorBase({
bad: "client",
code: "token_length_wrong",
message: "トークンの文字数が不正です。",
});
code: "token_invalid",
message: "トークンが不正です。",
})
const token = await this.findOne({ name: tokenArr[0] }, { populate: ["user"] });
if (!token)
@@ -0,0 +1,55 @@
import { DatabaseError, InputError } from "@/errors";
import Logger from "@/lib/logger";
import { ChannelEntity } from "@/modules/entities/Channel";
import { ChannelRepository } from "@/modules/repositories/Channel";
import type { FastifyInstance } from "fastify";
export default async function ChannelCreate(fastify: FastifyInstance) {
const logger = new Logger("Endpoint | channel/create");
fastify.post("/", async (req, res) => {
res.header("Content-Type", "application/json");
if ("error" in req.token)
return res.code(400).send(req.token);
const result = ChannelRepository.schema.safeParse({
...req.body as any,
userid: req.token.user.userid,
});
if (!result.success) {
return res.code(400).send(InputError(result.error.issues));
}
try {
const channelRepo = fastify.orm.em.getRepository(ChannelEntity);
const id = await channelRepo.createChannel(result.data);
return res.send({
success: true,
id,
});
} catch (err: any) {
if (err.name === "UniqueConstraintViolationException") {
const duplicate = err.constraint.replace("channel_", "").replace("_unique", "");
if (duplicate !== "id") {
return res.code(400).send(InputError([
{
validation: "regex",
code: "invalid_string",
message: "Duplicate",
path: [duplicate],
},
]));
}
}
logger.error("Database Error:", err);
return res.code(500).send(DatabaseError());
}
});
}
@@ -0,0 +1,52 @@
import { DatabaseError, ErrorBase, InputError } from "@/errors";
import Logger from "@/lib/logger";
import { ChannelEntity } from "@/modules/entities/Channel";
import { ChannelRepository } from "@/modules/repositories/Channel";
import type { FastifyInstance } from "fastify";
import z from "zod/v3";
export default async function ChannelEdit(fastify: FastifyInstance) {
const logger = new Logger("Endpoint | channel/edit");
fastify.post("/", async (req, res) => {
res.header("Content-Type", "application/json");
if ("error" in req.token)
return res.code(400).send(req.token);
const result = ChannelRepository.schema
.omit({ userid: true }).partial()
.merge(z.object({ id: z.string().length(10) }))
.refine(data =>
!Object.keys(data).length
).safeParse(req.body);
if (!result.success) {
return res.code(400).send(InputError(result.error.issues));
}
try {
const channelRepo = fastify.orm.em.getRepository(ChannelEntity);
const itChannel = await channelRepo.findOne({ id: result.data.id });
if (!itChannel) {
return res.code(400).send(ErrorBase({
bad: "client",
code: "channel_not_found",
message: "対象のチャンネルが見つかりませんでした。",
}));
}
const id = await channelRepo.editChannel(itChannel, result.data);
return res.send({
success: true,
id,
});
} catch (err) {
logger.error("Database Error:", err);
return res.code(500).send(DatabaseError());
}
});
}
@@ -0,0 +1,18 @@
import type { FastifyInstance } from "fastify";
import ChannelList from "./list";
import ChannelCreate from "./create";
import ChannelEdit from "./edit";
export default async function Setup(fastify: FastifyInstance) {
await fastify.register(ChannelList, {
prefix: "/list",
});
await fastify.register(ChannelCreate, {
prefix: "/create",
});
await fastify.register(ChannelEdit, {
prefix: "/edit",
});
}
@@ -0,0 +1,49 @@
import { DatabaseError, InputError } from "@/errors";
import Logger from "@/lib/logger";
import { ChannelEntity } from "@/modules/entities/Channel";
import type { FastifyInstance } from "fastify";
import z from "zod/v3";
export default async function ChannelList(fastify: FastifyInstance) {
const logger = new Logger("Endpoint | channel/list");
fastify.post("/", async (req, res) => {
res.header("Content-Type", "application/json");
if ("error" in req.token)
return res.code(400).send(req.token);
const bodySchema = z.object({
limit: z.number().optional(),
since: z.string().optional(),
});
const result = bodySchema.safeParse(req.body);
if (!result.success) {
return res.code(400).send(InputError(result.error.issues));
}
try {
const channelRepo = fastify.orm.em.getRepository(ChannelEntity);
const findResult = await channelRepo.findChannel(
result.data.limit,
result.data.since,
) ?? [];
if ("error" in findResult) {
return res.code(400).send(findResult);
}
return res.send({
success: true,
channels: findResult,
});
} catch (err) {
logger.error("Database Error:", err);
return res.code(500).send(DatabaseError());
}
});
}
@@ -40,9 +40,9 @@ export default function PrimarySignUp(fastify: FastifyInstance) {
validation: "regex",
code: "invalid_string",
message: "Duplicate",
path: [duplicate]
}
]))
path: [duplicate],
},
]));
}
}
@@ -13,7 +13,7 @@ export default function SetupInitialization(fastify: FastifyInstance) {
const bodySchema = z.object({
name: z.string().trim().min(1).max(20),
description: z.string().trim().min(1),
description: z.string().trim().min(1).max(4096),
requiredInvitationCode: z.boolean(),
force: z.literal("use_force_initialization").refine(() => process.env.NODE_ENV !== "production").optional(),
});
+1 -1
View File
@@ -10,7 +10,7 @@
"./src/types",
],
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"exactOptionalPropertyTypes": false,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
+1 -1
View File
@@ -125,7 +125,7 @@ main.layout {
import { RouterView, useRouter } from "vue-router";
import routerStatus from "@/lib/router";
import Progress from "@/components/Progress.vue";
import serverInfo from "@/lib/account";
import { serverInfo } from "@/lib/account";
const router = useRouter();
+50 -4
View File
@@ -1,11 +1,57 @@
import client from "@/lib/client";
import client, { initClient } from "@/lib/client";
import type ApiMap from "lynqchat-js/1.0.0-alpha.0/map";
import { ref } from "vue";
/*
[TODO]
キャッシュの類を全部account.tsに詰め込むのをやめる
*/
let serverInfo = ref<ApiMap["server-info"]["response"]>(await client.request("server-info"));
await initClient();
export let serverInfo = ref<ApiMap["server-info"]["response"]>(await client.value.request("server-info"));
export let account = ref<ApiMap["me"]["response"]>(await client.value.request("me"));
export let channels = ref<Extract<ApiMap["channel/list"]["response"], { channels: any }>["channels"]>([]);
export let lastLoadedChannel = ref<string>();
export const reloadServerInfo = async () => {
serverInfo.value = await client.request("server-info");
serverInfo.value = await client.value.request("server-info");
}
export default serverInfo;
export const reloadAccount = async () => {
await initClient();
account.value = await client.value.request("me");
}
export const initChannels = async () => {
lastLoadedChannel.value = undefined;
const response = await client.value.request("channel/list");
if (!response.success) {
return false;
}
channels.value = response.channels;
if (response.channels.length > 0) {
lastLoadedChannel.value = response.channels[response.channels.length - 1]?.id;
}
return true;
}
export const loadChannels = async () => {
const response = await client.value.request("channel/list", lastLoadedChannel.value ? {
since: lastLoadedChannel.value,
} : undefined);
if (!response.success) {
return false;
}
channels.value = channels.value.concat(response.channels);
if (response.channels.length > 0) {
lastLoadedChannel.value = response.channels[response.channels.length - 1]?.id;
}
return true;
}
+13 -2
View File
@@ -1,8 +1,19 @@
import LynqChat from "lynqchat-js";
import type ApiMap from "lynqchat-js/1.0.0-alpha.0/map";
import Database, { getByIndex } from "./db";
import { ref } from "vue";
const client = new LynqChat<ApiMap>({
const client = ref<LynqChat<ApiMap>>(new LynqChat<ApiMap>({
origin: window.origin,
});
}));
export const initClient = async () => {
const db = new Database();
const row = await getByIndex(db.settings, "name", "token");
if (row?.value) {
client.value.token = row.value;
}
}
export default client;
+1 -8
View File
@@ -6,14 +6,7 @@ export interface Settings {
value: string;
}
export interface Server {
id: number;
name: string;
value: string;
}
export default class extends Dexie {
server!: EntityTable<Server, "id">;
export default class Database extends Dexie {
settings!: EntityTable<Settings, "id">;
constructor() {
+8 -1
View File
@@ -2,7 +2,7 @@ import { createApp } from "vue";
import { createRouter, createWebHistory } from "vue-router";
import routerStatus from "@/lib/router";
import { createHead } from "@unhead/vue/client";
import serverInfo from "@/lib/account";
import { serverInfo } from "@/lib/account";
import "@/global.css";
import Layout from "@/Layout.vue";
@@ -39,6 +39,13 @@ const router = createRouter({
},
component: () => import("@/routes/setup/create-admin.vue"),
},
{
path: "/signin",
meta: {
title: "サインイン",
},
component: () => import("@/routes/signin.vue"),
},
{
path: "/:NotFound(.*)*",
meta: {
+8
View File
@@ -3,4 +3,12 @@
</template>
<script lang="ts" setup>
import { account } from "@/lib/account";
import { useRouter } from "vue-router";
const router = useRouter();
if (!account.value.success) {
router.replace("/signin");
}
</script>
@@ -89,7 +89,7 @@ form {
</style>
<script lang="ts" setup>
import serverInfo, { reloadServerInfo } from "@/lib/account";
import { serverInfo, reloadServerInfo } from "@/lib/account";
import { useRouter } from "vue-router";
import Input from "@/components/Input.vue";
import Button from "@/components/Button.vue";
@@ -169,7 +169,7 @@ const submit = async (e: Event) => {
});
}
const response = await client.request("setup/create-admin", result.value.data);
const response = await client.value.request("setup/create-admin", result.value.data);
if (!response.success) {
closeLoadingModal();
@@ -191,7 +191,7 @@ const submit = async (e: Event) => {
onClose: async () => {
isProcessing.value = false;
await reloadServerInfo();
router.push("/");
router.push("/signin");
}
});
}
@@ -87,7 +87,7 @@ form {
</style>
<script lang="ts" setup>
import serverInfo, { reloadServerInfo } from "@/lib/account";
import { serverInfo, reloadServerInfo } from "@/lib/account";
import { useRouter } from "vue-router";
import Input from "@/components/Input.vue";
import Toggle from "@/components/Toggle.vue";
@@ -163,7 +163,7 @@ const submit = async (e: Event) => {
});
}
const response = await client.request("setup/initialization", result.value.data);
const response = await client.value.request("setup/initialization", result.value.data);
if (!response.success) {
closeLoadingModal();
+165
View File
@@ -0,0 +1,165 @@
<template>
<form novalidate @submit="submit">
<Input
label="ユーザーID"
autocomplete="off"
v-model="userid"
>
<span
class="input-issue"
v-if="useridIssue"
>
<Icon icon="material-symbols:error-outline-rounded" />
{{ useridIssue.message }}
</span>
</Input>
<InputPassword
label="パスワード"
v-model="password"
>
<span
class="input-issue"
v-if="passwordIssue"
>
<Icon icon="material-symbols:error-outline-rounded" />
{{ passwordIssue.message }}
</span>
</InputPassword>
<Button
name="サインイン"
type="submit"
color="accent"
:disabled="!result.success || isProcessing"
/>
</form>
</template>
<style scoped>
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.input-issue {
display: flex;
gap: 0.25rem;
font-size: 0.85rem;
color: var(--error-color);
user-select: none;
-webkit-user-select: none;
}
.input-issue svg {
font-size: 1rem;
margin: auto 0;
}
</style>
<script lang="ts" setup>
import Button from "@/components/Button.vue";
import Input from "@/components/Input.vue";
import InputPassword from "@/components/InputPassword.vue";
import Loading from "@/components/Modal/Loading.vue";
import Success from "@/components/Modal/Success.vue";
import { account, reloadAccount, serverInfo } from "@/lib/account";
import client from "@/lib/client";
import Database from "@/lib/db";
import { createModal } from "@/lib/modal";
import { getIssueFromPath } from "@/lib/validation";
import { Icon } from "@iconify/vue";
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import z from "zod/v3";
const router = useRouter();
const isProcessing = ref<boolean>(false);
if (account.value.success) {
router.replace("/");
}
if (!serverInfo.value.success) {
throw new Error();
}
if (!serverInfo.value.isInitialized) {
router.replace("/setup/initialization");
}
const userid = ref<string>("");
const password = ref<string>("");
const useridIssue = computed(() => getIssueFromPath("userid", result.value));
const passwordIssue = computed(() => getIssueFromPath("password", result.value));
const schema = z.object({
userid: z.string({ message: "文字列で入力してください。" })
.trim().min(3, "3文字以上で入力してください。")
.max(20, "20文字以内で入力してください。"),
password: z.string({ message: "文字列で入力してください。" })
.trim().min(8, "8文字以上で入力してください。"),
});
const result = computed(() => schema.safeParse({
userid: userid.value,
password: password.value,
}));
const submit = async (e: Event) => {
e.preventDefault();
isProcessing.value = true;
const closeLoadingModal = createModal({
component: Loading,
});
if (!result.value.success) {
const messages = result.value.error.issues.map(issue => issue.message);
closeLoadingModal();
return createModal({
component: Error,
onClose: () => isProcessing.value = false,
props: {
error: `不正な入力です。<br>${messages.join("<br>")}`,
canClose: true,
},
});
}
const response = await client.value.request("primary/signin", result.value.data);
if (!response.success) {
closeLoadingModal();
return createModal({
component: Error,
onClose: () => isProcessing.value = false,
props: {
error: response.error.message,
canClose: true,
},
});
}
const db = new Database();
await db.settings.add({
name: "token",
value: response.token,
});
await reloadAccount();
closeLoadingModal();
return createModal({
component: Success,
onClose: async () => {
isProcessing.value = false;
router.push("/");
}
});
}
</script>
@@ -0,0 +1,17 @@
import InputError from "../../modules/error/input";
import ErrorBase from "../../modules/error";
import DatabaseError from "../../modules/error/database";
import UnknownError from "../../modules/error/unknown";
import Success from "../../modules/response/success";
import Channel from "../../modules/channel";
import YetInitializationError from "../../modules/error/yet_init";
import AuthError from "../..//modules/error/auth";
export default interface ChannelCreate {
"channel/create": {
body: Omit<Channel, "userid">;
response: (Success & {
id: string;
}) | InputError | AuthError | DatabaseError | YetInitializationError | UnknownError;
};
}
@@ -0,0 +1,22 @@
import InputError from "../../modules/error/input";
import ErrorBase from "../../modules/error";
import DatabaseError from "../../modules/error/database";
import UnknownError from "../../modules/error/unknown";
import Success from "../../modules/response/success";
import Channel from "../../modules/channel";
import YetInitializationError from "../../modules/error/yet_init";
import AuthError from "../..//modules/error/auth";
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> =
Keys extends keyof T
? Required<Pick<T, Keys>> & Partial<Omit<T, Keys>>
: never;
export default interface ChannelEdit {
"channel/edit": {
body: RequireAtLeastOne<Omit<Channel, "userid">>;
response: (Success & {
id: string;
}) | InputError | AuthError | DatabaseError | YetInitializationError | UnknownError;
};
}
@@ -0,0 +1,24 @@
import InputError from "../../modules/error/input";
import ErrorBase from "../../modules/error";
import DatabaseError from "../../modules/error/database";
import UnknownError from "../../modules/error/unknown";
import Success from "../../modules/response/success";
import Channel from "../../modules/channel";
import YetInitializationError from "../../modules/error/yet_init";
import AuthError from "../..//modules/error/auth";
export default interface ChannelList {
"channel/list": {
body?: {
limit?: number;
since?: string | Date;
};
response: (Success & {
channels: (Omit<Channel, "userid"> & {
id: string;
createdBy: string;
createdAt: string;
})[];
}) | InputError | AuthError | DatabaseError | YetInitializationError | UnknownError;
};
}
+2 -1
View File
@@ -5,11 +5,12 @@ import Success from "../../modules/response/success";
import YetInitializationError from "../../modules/error/yet_init";
import UserSchema from "../../modules/user";
import UnknownError from "../../modules/error/unknown";
import AuthError from "../../modules/error/auth";
export default interface Me {
"me": {
body: never;
response: (Success & Omit<UserSchema, "password" | "email">)
| DatabaseError | InputError | YetInitializationError | UnknownError;
| DatabaseError | InputError | AuthError | YetInitializationError | UnknownError;
};
}
@@ -1,4 +1,3 @@
import { InputError, InputNoneError } from "../modules/error/input";
import ErrorBase from "../modules/error";
import DatabaseError from "../modules/error/database";
import UnknownError from "../modules/error/unknown";
+7 -1
View File
@@ -1,3 +1,6 @@
import ChannelCreate from "./api/channel/create";
import ChannelEdit from "./api/channel/edit";
import ChannelList from "./api/channel/list";
import Me from "./api/me";
import PrimarySignin from "./api/primary/signin";
import PrimarySignup from "./api/primary/signup";
@@ -11,6 +14,9 @@ type ApiMap =
ServerInfo &
PrimarySignin &
PrimarySignup &
Me;
Me &
ChannelCreate &
ChannelEdit &
ChannelList;
export default ApiMap;
@@ -0,0 +1,5 @@
export default interface Channel {
name: string;
description: string;
userid: string;
}
@@ -0,0 +1,9 @@
import ErrorBase from ".";
type AuthError = ErrorBase<{
bad: "client",
code: "token_invalid",
message: "トークンが不正です。",
}>;
export default AuthError;
@@ -7,4 +7,5 @@ export default interface UserSchema {
isSuspended: boolean;
isAdmin: boolean;
createdAt: string;
lastUsedAt: string;
}
+11 -2
View File
@@ -14,7 +14,7 @@ type BodyArgs<M, E extends keyof M> =
[];
export default class LynqChat<
M extends { [K in keyof M]: { body: any; response: any } }
M extends { [K in keyof M]: { body?: any; response: any } }
> {
readonly origin: string;
readonly retry: number;
@@ -37,7 +37,13 @@ export default class LynqChat<
}
set token(token: string) {
if (token.length !== 64) throw new lynqError("Invalid token.");
const tokenArr = token.split("_");
if (
tokenArr[0]?.length !== 64 ||
tokenArr[1]?.length !== 64
)
throw new lynqError("Invalid token.");
this._token = token;
}
@@ -59,6 +65,9 @@ export default class LynqChat<
cache: "no-store",
headers: {
"Content-Type": "application/json",
...(this._token ? {
Authorization: `Bearer ${this._token}`
} : {}),
},
body,
}