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:
@@ -16,10 +16,10 @@ const Authorization: FastifyPluginCallback = (fastify) => {
|
|||||||
fastify.addHook("onRequest", async (req, res) => {
|
fastify.addHook("onRequest", async (req, res) => {
|
||||||
let token = req.headers["authorization"];
|
let token = req.headers["authorization"];
|
||||||
if (typeof token !== "string") {
|
if (typeof token !== "string") {
|
||||||
return req.token = ErrorBase({
|
return ErrorBase({
|
||||||
bad: "client",
|
bad: "client",
|
||||||
code: "token_none",
|
code: "token_invalid",
|
||||||
message: "トークンが設定されていません。",
|
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" })
|
@PrimaryKey({ type: "string" })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@Property({ type: "text" })
|
@Property({
|
||||||
|
type: "string",
|
||||||
|
length: 4096,
|
||||||
|
})
|
||||||
value!: string;
|
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 {
|
export class UserEntity {
|
||||||
[EntityRepositoryType]?: UserRepository;
|
[EntityRepositoryType]?: UserRepository;
|
||||||
[OptionalProps]?: "profile" | "isSuspended" | "createdAt";
|
[OptionalProps]?: "id" | "profile" | "isSuspended" | "createdAt";
|
||||||
|
|
||||||
@PrimaryKey({
|
@PrimaryKey({
|
||||||
type: "string",
|
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({
|
return ErrorBase({
|
||||||
bad: "client",
|
bad: "client",
|
||||||
code: "token_length_wrong",
|
code: "token_invalid",
|
||||||
message: "トークンの文字数が不正です。",
|
message: "トークンが不正です。",
|
||||||
});
|
})
|
||||||
|
|
||||||
const token = await this.findOne({ name: tokenArr[0] }, { populate: ["user"] });
|
const token = await this.findOne({ name: tokenArr[0] }, { populate: ["user"] });
|
||||||
if (!token)
|
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",
|
validation: "regex",
|
||||||
code: "invalid_string",
|
code: "invalid_string",
|
||||||
message: "Duplicate",
|
message: "Duplicate",
|
||||||
path: [duplicate]
|
path: [duplicate],
|
||||||
}
|
},
|
||||||
]))
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function SetupInitialization(fastify: FastifyInstance) {
|
|||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
name: z.string().trim().min(1).max(20),
|
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(),
|
requiredInvitationCode: z.boolean(),
|
||||||
force: z.literal("use_force_initialization").refine(() => process.env.NODE_ENV !== "production").optional(),
|
force: z.literal("use_force_initialization").refine(() => process.env.NODE_ENV !== "production").optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"./src/types",
|
"./src/types",
|
||||||
],
|
],
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"exactOptionalPropertyTypes": true,
|
"exactOptionalPropertyTypes": false,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ main.layout {
|
|||||||
import { RouterView, useRouter } from "vue-router";
|
import { RouterView, useRouter } from "vue-router";
|
||||||
import routerStatus from "@/lib/router";
|
import routerStatus from "@/lib/router";
|
||||||
import Progress from "@/components/Progress.vue";
|
import Progress from "@/components/Progress.vue";
|
||||||
import serverInfo from "@/lib/account";
|
import { serverInfo } from "@/lib/account";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|||||||
@@ -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 type ApiMap from "lynqchat-js/1.0.0-alpha.0/map";
|
||||||
import { ref } from "vue";
|
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 () => {
|
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;
|
||||||
|
}
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
import LynqChat from "lynqchat-js";
|
import LynqChat from "lynqchat-js";
|
||||||
import type ApiMap from "lynqchat-js/1.0.0-alpha.0/map";
|
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,
|
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;
|
export default client;
|
||||||
@@ -6,14 +6,7 @@ export interface Settings {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Server {
|
export default class Database extends Dexie {
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class extends Dexie {
|
|
||||||
server!: EntityTable<Server, "id">;
|
|
||||||
settings!: EntityTable<Settings, "id">;
|
settings!: EntityTable<Settings, "id">;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createApp } from "vue";
|
|||||||
import { createRouter, createWebHistory } from "vue-router";
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
import routerStatus from "@/lib/router";
|
import routerStatus from "@/lib/router";
|
||||||
import { createHead } from "@unhead/vue/client";
|
import { createHead } from "@unhead/vue/client";
|
||||||
import serverInfo from "@/lib/account";
|
import { serverInfo } from "@/lib/account";
|
||||||
|
|
||||||
import "@/global.css";
|
import "@/global.css";
|
||||||
import Layout from "@/Layout.vue";
|
import Layout from "@/Layout.vue";
|
||||||
@@ -39,6 +39,13 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
component: () => import("@/routes/setup/create-admin.vue"),
|
component: () => import("@/routes/setup/create-admin.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/signin",
|
||||||
|
meta: {
|
||||||
|
title: "サインイン",
|
||||||
|
},
|
||||||
|
component: () => import("@/routes/signin.vue"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/:NotFound(.*)*",
|
path: "/:NotFound(.*)*",
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -3,4 +3,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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>
|
</script>
|
||||||
@@ -89,7 +89,7 @@ form {
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import serverInfo, { reloadServerInfo } from "@/lib/account";
|
import { serverInfo, reloadServerInfo } from "@/lib/account";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import Input from "@/components/Input.vue";
|
import Input from "@/components/Input.vue";
|
||||||
import Button from "@/components/Button.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) {
|
if (!response.success) {
|
||||||
closeLoadingModal();
|
closeLoadingModal();
|
||||||
@@ -191,7 +191,7 @@ const submit = async (e: Event) => {
|
|||||||
onClose: async () => {
|
onClose: async () => {
|
||||||
isProcessing.value = false;
|
isProcessing.value = false;
|
||||||
await reloadServerInfo();
|
await reloadServerInfo();
|
||||||
router.push("/");
|
router.push("/signin");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ form {
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import serverInfo, { reloadServerInfo } from "@/lib/account";
|
import { serverInfo, reloadServerInfo } from "@/lib/account";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import Input from "@/components/Input.vue";
|
import Input from "@/components/Input.vue";
|
||||||
import Toggle from "@/components/Toggle.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) {
|
if (!response.success) {
|
||||||
closeLoadingModal();
|
closeLoadingModal();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,11 +5,12 @@ import Success from "../../modules/response/success";
|
|||||||
import YetInitializationError from "../../modules/error/yet_init";
|
import YetInitializationError from "../../modules/error/yet_init";
|
||||||
import UserSchema from "../../modules/user";
|
import UserSchema from "../../modules/user";
|
||||||
import UnknownError from "../../modules/error/unknown";
|
import UnknownError from "../../modules/error/unknown";
|
||||||
|
import AuthError from "../../modules/error/auth";
|
||||||
|
|
||||||
export default interface Me {
|
export default interface Me {
|
||||||
"me": {
|
"me": {
|
||||||
body: never;
|
body: never;
|
||||||
response: (Success & Omit<UserSchema, "password" | "email">)
|
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 ErrorBase from "../modules/error";
|
||||||
import DatabaseError from "../modules/error/database";
|
import DatabaseError from "../modules/error/database";
|
||||||
import UnknownError from "../modules/error/unknown";
|
import UnknownError from "../modules/error/unknown";
|
||||||
|
|||||||
+7
-1
@@ -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 Me from "./api/me";
|
||||||
import PrimarySignin from "./api/primary/signin";
|
import PrimarySignin from "./api/primary/signin";
|
||||||
import PrimarySignup from "./api/primary/signup";
|
import PrimarySignup from "./api/primary/signup";
|
||||||
@@ -11,6 +14,9 @@ type ApiMap =
|
|||||||
ServerInfo &
|
ServerInfo &
|
||||||
PrimarySignin &
|
PrimarySignin &
|
||||||
PrimarySignup &
|
PrimarySignup &
|
||||||
Me;
|
Me &
|
||||||
|
ChannelCreate &
|
||||||
|
ChannelEdit &
|
||||||
|
ChannelList;
|
||||||
|
|
||||||
export default ApiMap;
|
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;
|
isSuspended: boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
lastUsedAt: string;
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@ type BodyArgs<M, E extends keyof M> =
|
|||||||
[];
|
[];
|
||||||
|
|
||||||
export default class LynqChat<
|
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 origin: string;
|
||||||
readonly retry: number;
|
readonly retry: number;
|
||||||
@@ -37,7 +37,13 @@ export default class LynqChat<
|
|||||||
}
|
}
|
||||||
|
|
||||||
set token(token: string) {
|
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;
|
this._token = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +65,9 @@ export default class LynqChat<
|
|||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
...(this._token ? {
|
||||||
|
Authorization: `Bearer ${this._token}`
|
||||||
|
} : {}),
|
||||||
},
|
},
|
||||||
body,
|
body,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user