This commit is contained in:
2026-05-23 19:54:03 +09:00
parent c3383b778b
commit 1fd95616a5
46 changed files with 3920 additions and 107 deletions
+5 -1
View File
@@ -11,7 +11,11 @@
"start": "pnpm -F backend start", "start": "pnpm -F backend start",
"dev": "pnpm -r --parallel --no-bail dev", "dev": "pnpm -r --parallel --no-bail dev",
"build": "pnpm -r --parallel build", "build": "pnpm -r --parallel build",
"build:sdk": "pnpm -F lynqchat-js build",
"migrator": "pnpm -F backend migrator" "migrator": "pnpm -F backend migrator"
}, },
"packageManager": "pnpm@10.29.1" "packageManager": "pnpm@10.29.1",
"dependencies": {
"vite-plugin-pwa": "^1.3.0"
}
} }
@@ -2,6 +2,7 @@ import generateUniqueId from "@/lib/id";
import { Entity, EntityRepositoryType, Index, ManyToOne, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core"; import { Entity, EntityRepositoryType, Index, ManyToOne, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core";
import { UserEntity } from "@/modules/entities/User"; import { UserEntity } from "@/modules/entities/User";
import { ChannelRepository } from "@/modules/repositories/Channel"; import { ChannelRepository } from "@/modules/repositories/Channel";
import { CommunityEntity } from "@/modules/entities/Community";
@Entity({ @Entity({
tableName: "channel", tableName: "channel",
@@ -32,6 +33,9 @@ export class ChannelEntity {
}) })
description!: string; description!: string;
@ManyToOne(() => CommunityEntity)
community!: CommunityEntity;
@ManyToOne(() => UserEntity) @ManyToOne(() => UserEntity)
createdBy!: UserEntity; createdBy!: UserEntity;
@@ -0,0 +1,49 @@
import generateUniqueId from "@/lib/id";
import { Entity, EntityRepositoryType, Index, ManyToOne, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core";
import { UserEntity } from "@/modules/entities/User";
import { CommunityRepository } from "@/modules/repositories/Community";
@Entity({
tableName: "community",
repository: () => CommunityRepository,
})
export class CommunityEntity {
[OptionalProps]?: "id" | "createdAt";
[EntityRepositoryType]?: CommunityRepository;
@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;
@Property({
type: "string",
nullable: true,
})
icon?: string;
@ManyToOne(() => UserEntity)
createdBy!: UserEntity;
@Property({
type: "datetime",
onCreate: () => new Date(),
})
createdAt!: Date;
}
@@ -9,9 +9,10 @@ export class ChannelRepository extends EntityRepository<ChannelEntity> {
name: z.string().trim().min(1).max(20), name: z.string().trim().min(1).max(20),
description: z.string().trim().min(1).max(4096), description: z.string().trim().min(1).max(4096),
userid: UserRepository.schema.shape.userid, userid: UserRepository.schema.shape.userid,
community: z.string().length(10),
}); });
async findChannel(limit: number = 20, sinceData?: string) { async listChannel(community: string, limit: number = 20, sinceData?: string) {
let since = sinceData ?? new Date(); let since = sinceData ?? new Date();
if ( if (
@@ -32,6 +33,7 @@ export class ChannelRepository extends EntityRepository<ChannelEntity> {
} }
const findResult = await this.find({ const findResult = await this.find({
community,
createdAt: { createdAt: {
$lt: since, $lt: since,
}, },
@@ -57,7 +59,7 @@ export class ChannelRepository extends EntityRepository<ChannelEntity> {
async editChannel( async editChannel(
target: ChannelEntity, target: ChannelEntity,
data: Partial<Omit<z.infer<typeof ChannelRepository.schema>, "userid">> data: Partial<Omit<z.infer<typeof ChannelRepository.schema>, "userid" | "community">>
) { ) {
await this.nativeUpdate(target, data); await this.nativeUpdate(target, data);
return target.id; return target.id;
@@ -0,0 +1,66 @@
import { EntityRepository } from "@mikro-orm/postgresql";
import { ErrorBase } from "@/errors";
import z from "zod/v3";
import { UserRepository } from "@/modules/repositories/User";
import type { CommunityEntity } from "@/modules/entities/Community";
export class CommunityRepository extends EntityRepository<CommunityEntity> {
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,
icon: z.string().url(),
});
async listCommunity(limit: number = 20, sinceData?: string) {
let since = sinceData ?? new Date();
if (
sinceData &&
!isNaN(new Date(sinceData).getTime())
) {
const itCommunity = await this.findOne({ id: sinceData });
if (!itCommunity) {
return ErrorBase({
bad: "client",
code: "community_not_found",
message: "対象のコミュニティが見つかりませんでした。",
});
}
since = itCommunity.createdAt;
}
const findResult = await this.find({
createdAt: {
$lt: since,
},
}, {
orderBy: {
createdAt: "DESC",
},
limit: limit,
});
return findResult ?? [];
}
async createCommunity(data: Omit<z.infer<typeof CommunityRepository.schema>, "icon">) {
const community = this.create({
...data,
createdBy: data.userid,
});
await this.em.persist(community).flush();
return community.id;
}
async editCommunity(
target: CommunityEntity,
data: Partial<Omit<z.infer<typeof CommunityRepository.schema>, "userid">>
) {
await this.nativeUpdate(target, data);
return target.id;
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ export default async function ChannelEdit(fastify: FastifyInstance) {
return res.code(400).send(req.token); return res.code(400).send(req.token);
const result = ChannelRepository.schema const result = ChannelRepository.schema
.omit({ userid: true }).partial() .omit({ userid: true, community: true }).partial()
.merge(z.object({ id: z.string().length(10) })) .merge(z.object({ id: z.string().length(10) }))
.refine(data => .refine(data =>
!Object.keys(data).length !Object.keys(data).length
@@ -0,0 +1,51 @@
import { DatabaseError, ErrorBase, 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 ChannelGet(fastify: FastifyInstance) {
const logger = new Logger("Endpoint | channel/get");
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({
id: z.string().length(10),
});
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.find({
id: result.data.id,
});
if (findResult[0] === undefined) {
return res.code(400).send(ErrorBase({
bad: "client",
code: "channel_not_found",
message: "対象のチャンネルが見つかりませんでした。",
}));
}
return res.send({
success: true,
channel: findResult[0],
});
} catch (err) {
logger.error("Database Error:", err);
return res.code(500).send(DatabaseError());
}
});
}
+6 -1
View File
@@ -2,8 +2,9 @@ import type { FastifyInstance } from "fastify";
import ChannelList from "./list"; import ChannelList from "./list";
import ChannelCreate from "./create"; import ChannelCreate from "./create";
import ChannelEdit from "./edit"; import ChannelEdit from "./edit";
import ChannelGet from "./get";
export default async function Setup(fastify: FastifyInstance) { export default async function Channel(fastify: FastifyInstance) {
await fastify.register(ChannelList, { await fastify.register(ChannelList, {
prefix: "/list", prefix: "/list",
}); });
@@ -15,4 +16,8 @@ export default async function Setup(fastify: FastifyInstance) {
await fastify.register(ChannelEdit, { await fastify.register(ChannelEdit, {
prefix: "/edit", prefix: "/edit",
}); });
await fastify.register(ChannelGet, {
prefix: "/get",
});
} }
+4 -2
View File
@@ -16,6 +16,7 @@ export default async function ChannelList(fastify: FastifyInstance) {
const bodySchema = z.object({ const bodySchema = z.object({
limit: z.number().optional(), limit: z.number().optional(),
since: z.string().optional(), since: z.string().optional(),
community: z.string().length(10),
}); });
const result = bodySchema.safeParse(req.body); const result = bodySchema.safeParse(req.body);
@@ -27,10 +28,11 @@ export default async function ChannelList(fastify: FastifyInstance) {
try { try {
const channelRepo = fastify.orm.em.getRepository(ChannelEntity); const channelRepo = fastify.orm.em.getRepository(ChannelEntity);
const findResult = await channelRepo.findChannel( const findResult = await channelRepo.listChannel(
result.data.community,
result.data.limit, result.data.limit,
result.data.since, result.data.since,
) ?? []; );
if ("error" in findResult) { if ("error" in findResult) {
return res.code(400).send(findResult); return res.code(400).send(findResult);
@@ -0,0 +1,55 @@
import { DatabaseError, InputError } from "@/errors";
import Logger from "@/lib/logger";
import { CommunityEntity } from "@/modules/entities/Community";
import { CommunityRepository } from "@/modules/repositories/Community";
import type { FastifyInstance } from "fastify";
export default async function CommunityCreate(fastify: FastifyInstance) {
const logger = new Logger("Endpoint | community/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 = CommunityRepository.schema.omit({ icon: true }).safeParse({
...req.body as any,
userid: req.token.user.userid,
});
if (!result.success) {
return res.code(400).send(InputError(result.error.issues));
}
try {
const communityRepo = fastify.orm.em.getRepository(CommunityEntity);
const id = await communityRepo.createCommunity(result.data);
return res.send({
success: true,
id,
});
} catch (err: any) {
if (err.name === "UniqueConstraintViolationException") {
const duplicate = err.constraint.replace("community_", "").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 { CommunityEntity } from "@/modules/entities/Community";
import { CommunityRepository } from "@/modules/repositories/Community";
import type { FastifyInstance } from "fastify";
import z from "zod/v3";
export default async function CommunityEdit(fastify: FastifyInstance) {
const logger = new Logger("Endpoint | community/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 = CommunityRepository.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 communityRepo = fastify.orm.em.getRepository(CommunityEntity);
const itCommunity = await communityRepo.findOne({ id: result.data.id });
if (!itCommunity) {
return res.code(400).send(ErrorBase({
bad: "client",
code: "community_not_found",
message: "対象のコミュニティが見つかりませんでした。",
}));
}
const id = await communityRepo.editCommunity(itCommunity, result.data);
return res.send({
success: true,
id,
});
} catch (err) {
logger.error("Database Error:", err);
return res.code(500).send(DatabaseError());
}
});
}
@@ -0,0 +1,51 @@
import { DatabaseError, ErrorBase, InputError } from "@/errors";
import Logger from "@/lib/logger";
import { CommunityEntity } from "@/modules/entities/Community";
import type { FastifyInstance } from "fastify";
import z from "zod/v3";
export default async function CommunityGet(fastify: FastifyInstance) {
const logger = new Logger("Endpoint | community/get");
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({
id: z.string().length(10),
});
const result = bodySchema.safeParse(req.body);
if (!result.success) {
return res.code(400).send(InputError(result.error.issues));
}
try {
const communityRepo = fastify.orm.em.getRepository(CommunityEntity);
const findResult = await communityRepo.find({
id: result.data.id,
});
if (findResult[0] === undefined) {
return res.code(400).send(ErrorBase({
bad: "client",
code: "community_not_found",
message: "対象のコミュニティが見つかりませんでした。",
}));
}
return res.send({
success: true,
community: findResult[0],
});
} catch (err) {
logger.error("Database Error:", err);
return res.code(500).send(DatabaseError());
}
});
}
@@ -0,0 +1,23 @@
import type { FastifyInstance } from "fastify";
import CommunityList from "./list";
import CommunityCreate from "./create";
import CommunityEdit from "./edit";
import CommunityGet from "./get";
export default async function Community(fastify: FastifyInstance) {
await fastify.register(CommunityList, {
prefix: "/list",
});
await fastify.register(CommunityCreate, {
prefix: "/create",
});
await fastify.register(CommunityEdit, {
prefix: "/edit",
});
await fastify.register(CommunityGet, {
prefix: "/get",
});
}
@@ -0,0 +1,58 @@
import { DatabaseError, InputError } from "@/errors";
import Logger from "@/lib/logger";
import { CommunityEntity } from "@/modules/entities/Community";
import { UserEntity } from "@/modules/entities/User";
import type { FastifyInstance } from "fastify";
import z from "zod/v3";
export default async function CommunityList(fastify: FastifyInstance) {
const logger = new Logger("Endpoint | community/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 communityRepo = fastify.orm.em.getRepository(CommunityEntity);
const findResult = await communityRepo.listCommunity(
result.data.limit,
result.data.since,
);
if ("error" in findResult) {
return res.code(400).send(findResult);
}
findResult.map((community) => ({
...community,
createdBy: community.createdBy.userid,
}));
return res.send({
success: true,
communitys: findResult.map(community => ({
...community,
createdBy: community.createdBy.userid,
})),
});
} catch (err) {
logger.error("Database Error:", err);
return res.code(500).send(DatabaseError());
}
});
}
+10
View File
@@ -3,6 +3,8 @@ import Setup from "./setup";
import Primary from "./primary"; import Primary from "./primary";
import Me from "./me"; import Me from "./me";
import ServerInfo from "./server-info"; import ServerInfo from "./server-info";
import Community from "./community";
import Channel from "./channel";
export default async function Routes(fastify: FastifyInstance) { export default async function Routes(fastify: FastifyInstance) {
await fastify.register(Setup, { await fastify.register(Setup, {
@@ -20,4 +22,12 @@ export default async function Routes(fastify: FastifyInstance) {
await fastify.register(Me, { await fastify.register(Me, {
prefix: "/me", prefix: "/me",
}); });
await fastify.register(Community, {
prefix: "/community",
});
await fastify.register(Channel, {
prefix: "/channel",
});
} }
+4 -2
View File
@@ -19,10 +19,12 @@
"vue": "^3.5.24", "vue": "^3.5.24",
"vue-router": "^5.0.2", "vue-router": "^5.0.2",
"vue-tsc": "^3.1.4", "vue-tsc": "^3.1.4",
"zod": "^4.3.6" "zod": "^4.3.6",
"zxcvbn": "^4.4.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.1" "@types/node": "^24.10.1",
"@types/zxcvbn": "^4.4.5"
}, },
"packageManager": "pnpm@10.29.1" "packageManager": "pnpm@10.29.1"
} }
+91 -17
View File
@@ -7,21 +7,42 @@
<div class="modals-container" /> <div class="modals-container" />
<main class="layout" v-if="serverInfo.success && account.success"> <main class="layout">
<div class="left-menu"> <div class="left-menu">
<div :class='$route.path === "/" ? "isActive" : ""'> <RouterLink
<img :src="serverInfo.icon" /> to="/"
</div> :class='$route.path === "/"
? "isActive"
: ""'
>
<img :src='"icon" in serverInfo
? serverInfo.icon
: "/assets/lynqchat.svg"'
/>
</RouterLink>
<div :class='$route.path === "/home" ? "isActive" : ""'> <RouterLink
<Icon icon="material-symbols:home-rounded" /> v-for="community of communitys"
</div> :to="`/community/${community.id}`"
:class='$route.path === `/community/${community.id}`
? "isActive"
: ""'
>
<img
v-if="community.icon"
:src="community.icon"
/>
<Icon
v-else
icon="material-symbols:groups-rounded"
/>
</RouterLink>
</div> </div>
<div class="content-main"> <div class="content-main">
<div class="content-header"> <div class="content-header">
{{ {{
$route.meta.title title
?? (serverInfo.success ?? (serverInfo.success
? serverInfo.name ? serverInfo.name
: null) : null)
@@ -61,7 +82,8 @@ main.layout {
-webkit-user-select: none; -webkit-user-select: none;
} }
.left-menu div { .left-menu a {
display: block;
position: relative; position: relative;
z-index: 0; z-index: 0;
border-radius: 0.5rem; border-radius: 0.5rem;
@@ -71,19 +93,20 @@ main.layout {
transition: background-color 200ms ease-out; transition: background-color 200ms ease-out;
} }
.left-menu div * { .left-menu a * {
font-size: 3rem; font-size: 3rem;
padding: 0.25rem; padding: 0.25rem;
border-radius: 0.5rem; border-radius: 0.5rem;
width: 3rem; width: 3rem;
height: 3rem; height: 3rem;
color: var(--text-color);
display: block; display: block;
overflow: hidden; overflow: hidden;
object-fit: contain; object-fit: contain;
box-sizing: border-box; box-sizing: border-box;
} }
.left-menu div.isActive::before { .left-menu a.isActive::before {
content: ""; content: "";
display: block; display: block;
position: absolute; position: absolute;
@@ -96,8 +119,8 @@ main.layout {
background-color: var(--text-color); background-color: var(--text-color);
} }
.left-menu div:hover, .left-menu a:hover,
.left-menu div.isActive { .left-menu a.isActive {
background-color: var(--border-color); background-color: var(--border-color);
} }
@@ -122,6 +145,7 @@ main.layout {
} }
.route-main { .route-main {
display: flex;
padding: 1.25rem; padding: 1.25rem;
padding-bottom: 0; padding-bottom: 0;
overflow: scroll; overflow: scroll;
@@ -156,16 +180,25 @@ main.layout {
</style> </style>
<script lang="ts" setup> <script lang="ts" setup>
import { RouterView, useRouter } from "vue-router"; import { RouterView, RouterLink, useRouter, useRoute } from "vue-router";
import routerStatus from "@/lib/router"; import routerStatus, { title } from "@/lib/router";
import { Icon } from "@iconify/vue"; import { Icon } from "@iconify/vue";
import Progress from "@/components/Progress.vue"; import Progress from "@/components/Progress.vue";
import { account, serverInfo } from "@/lib/account"; import { communitys, serverInfo } from "@/lib/account";
import { onBeforeUnmount, onMounted, watch } from "vue";
import { createModal } from "@/lib/modal";
import ErrorModal from "@/components/Modal/Error.vue";
const router = useRouter(); const router = useRouter();
const route = useRoute();
watch(route, () => {
if (typeof route.meta.title === "string")
title.value = route.meta.title;
});
if (!serverInfo.value.success) { if (!serverInfo.value.success) {
throw new Error(); throw new Error("サーバー情報の取得に失敗しました。");
} }
if (!serverInfo.value.isInitialized) { if (!serverInfo.value.isInitialized) {
@@ -175,4 +208,45 @@ if (!serverInfo.value.isInitialized) {
if (!serverInfo.value.isFirstAdminExists) { if (!serverInfo.value.isFirstAdminExists) {
router.replace("/setup/create-admin"); router.replace("/setup/create-admin");
} }
function handleError(event: ErrorEvent | PromiseRejectionEvent) {
let content = event instanceof PromiseRejectionEvent
? event.reason
: event;
if (content instanceof Error) {
content = content.message;
}
createModal({
component: ErrorModal,
props: {
error: content ?? "不明なエラー",
canClose: false,
},
});
}
onMounted(async () => {
window.addEventListener("error", handleError);
window.addEventListener("unhandledrejection", handleError);
if ("serviceWorker" in navigator) {
const swFile = import.meta.env.MODE === "production"
? "/sw.js"
: "/dev-sw.js?dev-sw";
navigator.serviceWorker.register(swFile, {
type: import.meta.env.MODE === "production"
? "classic"
: "module",
scope: "/",
});
}
});
onBeforeUnmount(() => {
window.removeEventListener("error", handleError);
window.removeEventListener("unhandledrejection", handleError);
});
</script> </script>
@@ -0,0 +1,47 @@
<template>
<div class="modal">
<Icon
icon="line-md:close-circle"
class="error-badge"
width="4rem"
/>
<span class="modal-title">問題が発生しました</span>
<p v-html="error" />
<Button
name="ホームに戻る"
color="accent"
@click='$emit("PleaseClose")'
/>
</div>
</template>
<style scoped>
.modal {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0.75rem;
}
.modal-title {
font-size: 1.1rem;
font-weight: bold;
}
.error-badge {
color: var(--error-color);
}
</style>
<script setup lang="ts">
import { Icon } from "@iconify/vue";
import Button from "@/components/Button.vue";
defineProps<{
error: any;
}>();
</script>
@@ -3,6 +3,7 @@
<label :for="id">{{ label }}</label> <label :for="id">{{ label }}</label>
<textarea <textarea
autocomplete="off"
v-model="model" v-model="model"
v-bind="$attrs" v-bind="$attrs"
:class="$attrs.class" :class="$attrs.class"
+1 -1
View File
@@ -66,6 +66,6 @@ label {
padding: 3rem 6rem; padding: 3rem 6rem;
width: fit-content; width: fit-content;
height: fit-content; height: fit-content;
max-width: 90dvw; max-width: 70dvw;
max-height: 90dvh; max-height: 90dvh;
} }
+27 -28
View File
@@ -1,18 +1,33 @@
import client, { initClient } 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に詰め込むのをやめる
*/
await initClient(); await initClient();
export let serverInfo = ref<ApiMap["server-info"]["response"]>(await client.value.request("server-info")); 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 account = ref<ApiMap["me"]["response"]>(await client.value.request("me"));
export let channels = ref<Extract<ApiMap["channel/list"]["response"], { channels: any }>["channels"]>([]); let communitys = ref<Extract<ApiMap["community/list"]["response"], { communitys: any }>["communitys"]>([]);
export let lastLoadedChannel = ref<string>(); let lastLoadedCommunity = ref<string>();
export const reloadCommunitys = async () => {
lastLoadedCommunity.value = undefined;
const response = await client.value.request("community/list");
if (!response.success)
return;
communitys.value = response.communitys;
if (response.communitys.length > 0) {
lastLoadedCommunity.value = response.communitys[response.communitys.length - 1]?.id;
}
return;
}
await reloadCommunitys();
export {
communitys,
lastLoadedCommunity,
}
export const reloadServerInfo = async () => { export const reloadServerInfo = async () => {
serverInfo.value = await client.value.request("server-info"); serverInfo.value = await client.value.request("server-info");
@@ -24,34 +39,18 @@ export const reloadAccount = async () => {
account.value = await client.value.request("me"); account.value = await client.value.request("me");
} }
export const initChannels = async () => { export const loadCommunitys = async () => {
lastLoadedChannel.value = undefined; const response = await client.value.request("community/list", lastLoadedCommunity.value ? {
since: lastLoadedCommunity.value,
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); } : undefined);
if (!response.success) { if (!response.success) {
return false; return false;
} }
channels.value = channels.value.concat(response.channels); communitys.value = communitys.value.concat(response.communitys);
if (response.channels.length > 0) { if (response.communitys.length > 0) {
lastLoadedChannel.value = response.channels[response.channels.length - 1]?.id; lastLoadedCommunity.value = response.communitys[response.communitys.length - 1]?.id;
} }
return true; return true;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
import Dexie, { type EntityTable } from "dexie"; import Dexie, { type EntityTable } from "dexie";
export interface Settings { interface Settings {
id: number; id: number;
name: string; name: string;
value: string; value: string;
+4 -2
View File
@@ -1,4 +1,4 @@
import { reactive } from "vue"; import { reactive, ref } from "vue";
const routerStatus = reactive<{ const routerStatus = reactive<{
isLoad: boolean; isLoad: boolean;
@@ -6,4 +6,6 @@ const routerStatus = reactive<{
isLoad: false, isLoad: false,
}); });
export default routerStatus; export default routerStatus;
export const title = ref<string | undefined>(undefined);
+27
View File
@@ -0,0 +1,27 @@
const swSelf = globalThis as unknown as ServiceWorkerGlobalScope;
swSelf.addEventListener("install", (event) => {
event.waitUntil(swSelf.skipWaiting());
});
swSelf.addEventListener("activate", (event) => {
event.waitUntil(swSelf.clients.claim());
});
swSelf.addEventListener("fetch", (event) => {
const request = event.request;
if (request.url.indexOf("http") !== 0)
return;
event.respondWith((async () => {
try {
const res = await fetch(request);
return res;
} catch (err) {
return new Response("Network error", {
status: 504,
});
}
})());
});
+17 -2
View File
@@ -46,6 +46,13 @@ const router = createRouter({
}, },
component: () => import("@/routes/signin.vue"), component: () => import("@/routes/signin.vue"),
}, },
{
path: "/community/:id",
meta: {
title: "コミュニティ",
},
component: () => import("@/routes/community/index.vue"),
},
{ {
path: "/:NotFound(.*)*", path: "/:NotFound(.*)*",
meta: { meta: {
@@ -55,11 +62,19 @@ const router = createRouter({
}, },
], ],
}); });
router.beforeEach(() => { router.beforeEach((to, from) => {
if (to.path === from.path)
return false;
routerStatus.isLoad = true; routerStatus.isLoad = true;
return; return;
}); });
router.afterEach((to) => { router.afterEach((to, from) => {
if (to.path === from.path) {
routerStatus.isLoad = false;
return false;
}
const title = to.meta.title; const title = to.meta.title;
let serverName = "LynqChat"; let serverName = "LynqChat";
@@ -0,0 +1,89 @@
<template>
<Progress
v-if="isProcessing"
:size="24"
class="processing-progress"
/>
</template>
<style scoped>
.processing-progress {
margin: auto;
}
</style>
<script lang="ts" setup>
import { useRoute, useRouter } from "vue-router";
import { account, serverInfo } from "@/lib/account";
import client from "@/lib/client";
import { createModal } from "@/lib/modal";
import GoHomeError from "@/components/Modal/GoHomeError.vue";
import { ref } from "vue";
import Progress from "@/components/Progress.vue";
import { title } from "@/lib/router";
const route = useRoute();
const router = useRouter();
const isProcessing = ref<boolean>(false);
if (!serverInfo.value.success) {
throw new Error("サーバー情報の取得に失敗しました。");
}
if (!serverInfo.value.isInitialized) {
router.replace("/setup/initialization");
}
if (!account.value.success) {
switch (account.value.error.bad) {
case "client":
router.replace("/signin");
break;
default:
throw new Error("アカウント情報の取得に失敗しました。");
}
}
(async () => {
isProcessing.value = true;
if (!serverInfo.value.success) {
throw new Error("サーバー情報の取得に失敗しました。");
}
const communityId = route.params.id;
if (typeof communityId !== "string") {
isProcessing.value = false;
createModal({
component: GoHomeError,
onClose: async () => await router.push("/"),
props: {
error: "不正なアクセスです。",
},
});
return;
}
const community = await client.value.request("community/get", {
id: communityId,
});
if (!community.success) {
isProcessing.value = false;
createModal({
component: GoHomeError,
onClose: async () => await router.push("/"),
props: {
error: "コミュニティが取得できませんでした。",
},
});
return;
}
document.title = `${community.community.name} | ${serverInfo.value.name}`;
title.value = community.community.name;
isProcessing.value = false;
})();
</script>
+7 -1
View File
@@ -8,6 +8,12 @@ import { useRouter } from "vue-router";
const router = useRouter(); const router = useRouter();
if (!account.value.success) { if (!account.value.success) {
router.replace("/signin"); switch (account.value.error.bad) {
case "client":
router.replace("/signin");
break;
default:
throw new Error("アカウント情報の取得に失敗しました。");
}
} }
</script> </script>
@@ -10,7 +10,7 @@
<form novalidate @submit="submit"> <form novalidate @submit="submit">
<Input <Input
label="ユーザーID" label="ユーザーID"
autocomplete="off" autocomplete="userid"
v-model="userid" v-model="userid"
> >
<span <span
@@ -48,6 +48,13 @@
<Icon icon="material-symbols:error-outline-rounded" /> <Icon icon="material-symbols:error-outline-rounded" />
{{ passwordIssue.message }} {{ passwordIssue.message }}
</span> </span>
<span
class="password-strength"
v-if="passwordStrength.message"
>
{{ passwordStrength.message }}
</span>
</InputPassword> </InputPassword>
<Button <Button
@@ -71,6 +78,7 @@ form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
max-width: 30rem;
} }
.input-issue { .input-issue {
@@ -96,9 +104,10 @@ import Button from "@/components/Button.vue";
import { ref } from "vue"; import { ref } from "vue";
import z from "zod/v3"; import z from "zod/v3";
import { computed } from "vue"; import { computed } from "vue";
import zxcvbn from "zxcvbn";
import { createModal } from "@/lib/modal"; import { createModal } from "@/lib/modal";
import Loading from "@/components/Modal/Loading.vue"; import Loading from "@/components/Modal/Loading.vue";
import Error from "@/components/Modal/Error.vue"; import ErrorModal from "@/components/Modal/Error.vue";
import Success from "@/components/Modal/Success.vue"; import Success from "@/components/Modal/Success.vue";
import InputPassword from "@/components/InputPassword.vue"; import InputPassword from "@/components/InputPassword.vue";
import client from "@/lib/client"; import client from "@/lib/client";
@@ -109,7 +118,7 @@ const router = useRouter();
const isProcessing = ref<boolean>(false); const isProcessing = ref<boolean>(false);
if (!serverInfo.value.success) { if (!serverInfo.value.success) {
throw new Error(); throw new Error("サーバー情報の取得に失敗しました。");
} }
if (serverInfo.value.isFirstAdminExists) { if (serverInfo.value.isFirstAdminExists) {
@@ -127,6 +136,21 @@ const useridIssue = computed(() => getIssueFromPath("userid", result.value));
const emailIssue = computed(() => getIssueFromPath("email", result.value)); const emailIssue = computed(() => getIssueFromPath("email", result.value));
const passwordIssue = computed(() => getIssueFromPath("password", result.value)); const passwordIssue = computed(() => getIssueFromPath("password", result.value));
const passwordScores = [
"危険なパスワード",
"推測可能なパスワード",
"推測しやすいパスワード",
"安全なパスワード",
"強力なパスワード",
];
const passwordStrength = computed(() => {
const evaluation = zxcvbn(password.value);
return {
message: passwordScores[evaluation.score],
score: evaluation.score,
}
});
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; 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;
const schema = z.object({ const schema = z.object({
userid: z.string({ message: "文字列で入力してください。" }) userid: z.string({ message: "文字列で入力してください。" })
@@ -160,7 +184,7 @@ const submit = async (e: Event) => {
closeLoadingModal(); closeLoadingModal();
return createModal({ return createModal({
component: Error, component: ErrorModal,
onClose: () => isProcessing.value = false, onClose: () => isProcessing.value = false,
props: { props: {
error: `不正な入力です。<br>${messages.join("<br>")}`, error: `不正な入力です。<br>${messages.join("<br>")}`,
@@ -175,7 +199,7 @@ const submit = async (e: Event) => {
closeLoadingModal(); closeLoadingModal();
return createModal({ return createModal({
component: Error, component: ErrorModal,
onClose: () => isProcessing.value = false, onClose: () => isProcessing.value = false,
props: { props: {
error: response.error.message, error: response.error.message,
@@ -28,7 +28,6 @@
<Textarea <Textarea
label="サーバー説明" label="サーバー説明"
autocomplete="on"
v-model="description" v-model="description"
> >
<span <span
@@ -97,7 +96,7 @@ import z from "zod/v3";
import { computed } from "vue"; import { computed } from "vue";
import { createModal } from "@/lib/modal"; import { createModal } from "@/lib/modal";
import Loading from "@/components/Modal/Loading.vue"; import Loading from "@/components/Modal/Loading.vue";
import Error from "@/components/Modal/Error.vue"; import ErrorModal from "@/components/Modal/Error.vue";
import Success from "@/components/Modal/Success.vue"; import Success from "@/components/Modal/Success.vue";
import client from "@/lib/client"; import client from "@/lib/client";
import { getIssueFromPath } from "@/lib/validation"; import { getIssueFromPath } from "@/lib/validation";
@@ -108,7 +107,7 @@ const router = useRouter();
const isProcessing = ref<boolean>(false); const isProcessing = ref<boolean>(false);
if (!serverInfo.value.success) { if (!serverInfo.value.success) {
throw new Error(); throw new Error("サーバー情報の取得に失敗しました。");
} }
if (serverInfo.value.isInitialized) { if (serverInfo.value.isInitialized) {
@@ -154,7 +153,7 @@ const submit = async (e: Event) => {
closeLoadingModal(); closeLoadingModal();
return createModal({ return createModal({
component: Error, component: ErrorModal,
onClose: () => isProcessing.value = false, onClose: () => isProcessing.value = false,
props: { props: {
error: `不正な入力です。<br>${messages.join("<br>")}`, error: `不正な入力です。<br>${messages.join("<br>")}`,
@@ -169,7 +168,7 @@ const submit = async (e: Event) => {
closeLoadingModal(); closeLoadingModal();
return createModal({ return createModal({
component: Error, component: ErrorModal,
onClose: () => isProcessing.value = false, onClose: () => isProcessing.value = false,
props: { props: {
error: response.error.message, error: response.error.message,
+16 -5
View File
@@ -62,11 +62,12 @@ form {
import Button from "@/components/Button.vue"; import Button from "@/components/Button.vue";
import Input from "@/components/Input.vue"; import Input from "@/components/Input.vue";
import InputPassword from "@/components/InputPassword.vue"; import InputPassword from "@/components/InputPassword.vue";
import ErrorModal from "@/components/Modal/Error.vue";
import Loading from "@/components/Modal/Loading.vue"; import Loading from "@/components/Modal/Loading.vue";
import Success from "@/components/Modal/Success.vue"; import Success from "@/components/Modal/Success.vue";
import { account, reloadAccount, serverInfo } from "@/lib/account"; import { account, reloadAccount, reloadCommunitys, serverInfo } from "@/lib/account";
import client from "@/lib/client"; import client from "@/lib/client";
import Database from "@/lib/db"; import Database, { getByIndex } from "@/lib/db";
import { createModal } from "@/lib/modal"; import { createModal } from "@/lib/modal";
import { getIssueFromPath } from "@/lib/validation"; import { getIssueFromPath } from "@/lib/validation";
import { Icon } from "@iconify/vue"; import { Icon } from "@iconify/vue";
@@ -82,13 +83,21 @@ if (account.value.success) {
} }
if (!serverInfo.value.success) { if (!serverInfo.value.success) {
throw new Error(); throw new Error("サーバー情報の取得に失敗しました。");
} }
if (!serverInfo.value.isInitialized) { if (!serverInfo.value.isInitialized) {
router.replace("/setup/initialization"); router.replace("/setup/initialization");
} }
(async () => {
const db = new Database();
const tokenRow = await getByIndex(db.settings, "name", "token");
if (tokenRow) {
await db.settings.delete(tokenRow.id);
}
})();
const userid = ref<string>(""); const userid = ref<string>("");
const password = ref<string>(""); const password = ref<string>("");
const useridIssue = computed(() => getIssueFromPath("userid", result.value)); const useridIssue = computed(() => getIssueFromPath("userid", result.value));
@@ -121,7 +130,7 @@ const submit = async (e: Event) => {
closeLoadingModal(); closeLoadingModal();
return createModal({ return createModal({
component: Error, component: ErrorModal,
onClose: () => isProcessing.value = false, onClose: () => isProcessing.value = false,
props: { props: {
error: `不正な入力です。<br>${messages.join("<br>")}`, error: `不正な入力です。<br>${messages.join("<br>")}`,
@@ -136,7 +145,7 @@ const submit = async (e: Event) => {
closeLoadingModal(); closeLoadingModal();
return createModal({ return createModal({
component: Error, component: ErrorModal,
onClose: () => isProcessing.value = false, onClose: () => isProcessing.value = false,
props: { props: {
error: response.error.message, error: response.error.message,
@@ -150,7 +159,9 @@ const submit = async (e: Event) => {
name: "token", name: "token",
value: response.token, value: response.token,
}); });
await reloadAccount(); await reloadAccount();
await reloadCommunitys();
closeLoadingModal(); closeLoadingModal();
+1 -1
View File
@@ -5,7 +5,7 @@
"types": ["vite/client"], "types": ["vite/client"],
"typeRoots": ["./node_modules/"], "typeRoots": ["./node_modules/"],
"target": "ES2023", "target": "ES2023",
"lib": ["ES2023", "DOM"], "lib": ["ES2023", "DOM", "WebWorker", "WebWorker.Iterable"],
"module": "ESNext", "module": "ESNext",
/* Linting */ /* Linting */
+19 -1
View File
@@ -1,13 +1,31 @@
import { defineConfig, loadEnv } from "vite"; import { defineConfig, loadEnv } from "vite";
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue";
import path from "path"; import path from "path";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "") const env = loadEnv(mode, process.cwd(), "")
const apiPort = Number(env.VITE_API_PORT) || 3300 const apiPort = Number(env.VITE_API_PORT) || 3300
return { return {
plugins: [vue()], plugins: [
vue(),
VitePWA({
strategies: "injectManifest",
srcDir: "./src/lib",
filename: "sw.ts",
injectRegister: false,
manifest: false,
injectManifest: {
injectionPoint: undefined,
},
devOptions: {
enabled: true,
type: "module",
},
}),
],
server: { server: {
hmr: { hmr: {
clientPort: 5173, clientPort: 5173,
@@ -9,7 +9,7 @@ import AuthError from "../..//modules/error/auth";
export default interface ChannelCreate { export default interface ChannelCreate {
"channel/create": { "channel/create": {
body: Omit<Channel, "userid">; body: Pick<Channel, "name", "description", "community">;
response: (Success & { response: (Success & {
id: string; id: string;
}) | InputError | AuthError | DatabaseError | YetInitializationError | UnknownError; }) | InputError | AuthError | DatabaseError | YetInitializationError | UnknownError;
@@ -5,16 +5,12 @@ import UnknownError from "../../modules/error/unknown";
import Success from "../../modules/response/success"; import Success from "../../modules/response/success";
import Channel from "../../modules/channel"; import Channel from "../../modules/channel";
import YetInitializationError from "../../modules/error/yet_init"; import YetInitializationError from "../../modules/error/yet_init";
import AuthError from "../..//modules/error/auth"; import AuthError from "../../modules/error/auth";
import { RequireAtLeastOne } from "../../modules/generic";
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 { export default interface ChannelEdit {
"channel/edit": { "channel/edit": {
body: RequireAtLeastOne<Omit<Channel, "userid">>; body: RequireAtLeastOne<Omit<Channel, "userid", "community">>;
response: (Success & { response: (Success & {
id: string; id: string;
}) | InputError | AuthError | DatabaseError | YetInitializationError | UnknownError; }) | InputError | AuthError | DatabaseError | YetInitializationError | UnknownError;
@@ -0,0 +1,19 @@
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 ChannelGet {
"channel/get": {
body: {
id: string;
};
response: (Success & {
channel: Channel;
}) | InputError | AuthError | DatabaseError | YetInitializationError | UnknownError;
};
}
@@ -9,16 +9,13 @@ import AuthError from "../..//modules/error/auth";
export default interface ChannelList { export default interface ChannelList {
"channel/list": { "channel/list": {
body?: { body: {
limit?: number; limit?: number;
since?: string | Date; since?: string | Date;
community: string;
}; };
response: (Success & { response: (Success & {
channels: (Omit<Channel, "userid"> & { channels: Channel[];
id: string;
createdBy: string;
createdAt: string;
})[];
}) | InputError | AuthError | DatabaseError | YetInitializationError | UnknownError; }) | InputError | AuthError | DatabaseError | YetInitializationError | UnknownError;
}; };
} }
@@ -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 Community from "../../modules/community";
import YetInitializationError from "../../modules/error/yet_init";
import AuthError from "../../modules/error/auth";
export default interface CommunityCreate {
"community/create": {
body: Pick<Community, "name", "description">;
response: (Success & {
id: string;
}) | InputError | AuthError | DatabaseError | YetInitializationError | UnknownError;
};
}
@@ -0,0 +1,18 @@
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 Community from "../../modules/community";
import YetInitializationError from "../../modules/error/yet_init";
import AuthError from "../../modules/error/auth";
import { RequireAtLeastOne } from "../../modules/generic";
export default interface CommunityEdit {
"community/edit": {
body: RequireAtLeastOne<Omit<Community, "userid">>;
response: (Success & {
id: string;
}) | InputError | AuthError | DatabaseError | YetInitializationError | UnknownError;
};
}
@@ -0,0 +1,19 @@
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 Community from "../../modules/community";
import YetInitializationError from "../../modules/error/yet_init";
import AuthError from "../../modules/error/auth";
export default interface CommunityGet {
"community/get": {
body: {
id: string;
};
response: (Success & {
community: Community;
}) | InputError | AuthError | DatabaseError | YetInitializationError | UnknownError;
};
}
@@ -0,0 +1,20 @@
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 Community from "../../modules/community";
import YetInitializationError from "../../modules/error/yet_init";
import AuthError from "../../modules/error/auth";
export default interface CommunityList {
"community/list": {
body?: {
limit?: number;
since?: string | Date;
};
response: (Success & {
communitys: Community[];
}) | InputError | AuthError | DatabaseError | YetInitializationError | UnknownError;
};
}
+10
View File
@@ -1,6 +1,11 @@
import ChannelCreate from "./api/channel/create"; import ChannelCreate from "./api/channel/create";
import ChannelEdit from "./api/channel/edit"; import ChannelEdit from "./api/channel/edit";
import ChannelGet from "./api/channel/get";
import ChannelList from "./api/channel/list"; import ChannelList from "./api/channel/list";
import CommunityCreate from "./api/community/create";
import CommunityEdit from "./api/community/edit";
import CommunityGet from "./api/community/get";
import CommunityList from "./api/community/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";
@@ -15,8 +20,13 @@ type ApiMap =
PrimarySignin & PrimarySignin &
PrimarySignup & PrimarySignup &
Me & Me &
CommunityCreate &
CommunityEdit &
CommunityGet &
CommunityList &
ChannelCreate & ChannelCreate &
ChannelEdit & ChannelEdit &
ChannelGet &
ChannelList; ChannelList;
export default ApiMap; export default ApiMap;
@@ -1,5 +1,8 @@
export default interface Channel { export default interface Channel {
id: string;
name: string; name: string;
description: string; description: string;
userid: string; community: string;
createdBy: string;
createdAt: Date;
} }
@@ -0,0 +1,8 @@
export default interface Community {
id: string;
name: string;
description: string;
icon: string | null;
createdBy: string;
createdAt: Date;
}
@@ -0,0 +1,4 @@
export type RequireAtLeastOne<T, Keys extends keyof T = keyof T> =
Keys extends keyof T
? Required<Pick<T, Keys>> & Partial<Omit<T, Keys>>
: never;
+4 -2
View File
@@ -26,8 +26,10 @@ export default class LynqChat<
this.retry = options.retry ?? 5; this.retry = options.retry ?? 5;
this.waiting = options.waiting ?? 500; this.waiting = options.waiting ?? 500;
if (this.retry < 1) throw new lynqError("Invalid retry count."); if (this.retry < 1)
if (this.waiting < 1) throw new lynqError("Invalid base waiting time."); 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) if (options.origin !== new URL(options.origin).origin)
throw new lynqError("Invalid origin."); throw new lynqError("Invalid origin.");
} }
+2966 -12
View File
File diff suppressed because it is too large Load Diff