Compare commits

...

26 Commits

Author SHA1 Message Date
CrescentLeaf
19657fd150 ui: 微調: 可以點擊外部關閉對話框 2025-09-25 17:26:57 +08:00
CrescentLeaf
a6cef76ecf feat: 手動刷新聯絡人列表 2025-09-25 17:26:44 +08:00
CrescentLeaf
ee8e0e531e fix: 對話中的成員 無法收到更新信息 2025-09-25 17:16:20 +08:00
CrescentLeaf
151dc31f2c chore: 規範化 client event listener 寫法 2025-09-25 17:14:37 +08:00
CrescentLeaf
0b1a4a53a5 chore: make lint happy 2025-09-25 17:14:09 +08:00
CrescentLeaf
02efac9a8e fix: 用戶添加自己為對話或重複導致的重複 2025-09-25 16:52:51 +08:00
CrescentLeaf
8d739dd863 chore: 添加 EventBus
* 並讓對話列表先用上了
2025-09-25 16:52:23 +08:00
CrescentLeaf
c0c6c6ed1c feat: 添加對話 2025-09-25 16:51:43 +08:00
CrescentLeaf
d26c67f06d fix: 無法正常在 private chat 獲取到對方 User 2025-09-25 16:48:06 +08:00
CrescentLeaf
35d60642c0 chore: 生成的 Private chat id 人類可讀 2025-09-25 16:40:30 +08:00
CrescentLeaf
a928577f2a fix: 打開不同對話時, 使用了同一個 ChatFragment
* 並修復了使用 key 時, 因爲卸載組件后 ref 丟失導致的錯誤
2025-09-25 16:26:46 +08:00
CrescentLeaf
4b93e5fd67 chore: deno task server 不再自動編譯前端
* 請使用 debug 或 手動 build
2025-09-25 15:04:50 +08:00
CrescentLeaf
d6454f51c8 feat: find user by account (aka userName or userId) 2025-09-25 14:53:53 +08:00
CrescentLeaf
efc0f49b66 feat: 文件權限檢驗
* 基於讀取 Cookie 中的驗證信息
* 因為 ServiceWorker 需要安全的上下文, 而我想要到處可用, 因此暫時折中使用這個辦法
2025-09-25 14:19:45 +08:00
CrescentLeaf
a860da96a0 depend: add cookie-parser 1.4.7 2025-09-25 14:19:18 +08:00
CrescentLeaf
692eb3d2a3 chore: 將令牌檢測函數移動到 TokenManager
* 這樣才叫 TokenManager 嘛X
2025-09-25 14:18:50 +08:00
CrescentLeaf
b6be09ef7c chore: 客戶端自動添加 token 和 device_id 到 Cookie 裡, 以便 HTTP 請求 2025-09-25 14:17:29 +08:00
CrescentLeaf
8e15c8126f chore(wip): 聯絡人 -> 對話
* 這是設計時留下的問題, 現在逐步改正
2025-09-25 13:02:37 +08:00
CrescentLeaf
80a42d5d86 feat(wip): 添加對話 對話框 2025-09-25 13:02:02 +08:00
CrescentLeaf
b8f3886a1b chore: fuck lint and make it happy 2025-09-25 12:57:08 +08:00
CrescentLeaf
5a80041ec3 ui: 微調對話框選項距離 2025-09-25 12:54:42 +08:00
CrescentLeaf
d76e7e2bf5 chore: make lint happy & fix typo 2025-09-25 12:53:07 +08:00
CrescentLeaf
4fa3e16ab7 fix: 令牌驗證額外添加是否為有效令牌
* 如果解密無效, 直接返回一個無效的令牌, 並加以判斷
2025-09-25 12:12:12 +08:00
CrescentLeaf
9cc3a2149e feat: 退出登錄 2025-09-25 12:12:04 +08:00
CrescentLeaf
fdf52c0548 feat(wip): 複製到剪貼薄 2025-09-25 11:36:03 +08:00
CrescentLeaf
a6ee231ad5 feat: 客戶端查看自己的用戶 ID 2025-09-25 11:35:35 +08:00
23 changed files with 253 additions and 132 deletions

12
client/EventBus.ts Normal file
View File

@@ -0,0 +1,12 @@
export default class EventBus {
static events: { [key: string]: () => void } = {}
static on(eventName: string, func: () => void) {
this.events[eventName] = func
}
static off(eventName: string) {
delete this.events[eventName]
}
static emit(eventName: string) {
this.events[eventName]()
}
}

View File

@@ -6,13 +6,11 @@ import data from "../Data.ts"
import { checkApiSuccessOrSncakbar } from "../ui/snackbar.ts" import { checkApiSuccessOrSncakbar } from "../ui/snackbar.ts"
import randomUUID from "../randomUUID.ts" import randomUUID from "../randomUUID.ts"
type UnknownObject = { [key: string]: unknown }
class Client { class Client {
static sessionId = randomUUID() static sessionId = randomUUID()
static myUserProfile?: User static myUserProfile?: User
static socket?: Socket static socket?: Socket
static events: { [key: string]: (data: UnknownObject) => UnknownObject | void } = {} static events: { [key: string]: ((data: unknown) => void)[] } = {}
static connected = false static connected = false
static connect() { static connect() {
if (data.device_id == null) if (data.device_id == null)
@@ -37,17 +35,16 @@ class Client {
this.socket!.on("disconnect", () => { this.socket!.on("disconnect", () => {
this.connected = false this.connected = false
}) })
this.socket!.on("The_White_Silk", (name: string, data: UnknownObject, callback: (ret: UnknownObject) => void) => { this.socket!.on("The_White_Silk", (name: string, data: unknown, callback: (ret: unknown) => void) => {
try { try {
if (name == null || data == null) return if (name == null || data == null) return
const re = this.events[name]?.(data) this.events[name]?.forEach((v) => v(data))
re && callback(re)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} }
}) })
} }
static invoke(method: CallMethod, args: UnknownObject = {}, timeout: number = 5000): Promise<ApiCallbackMessage> { static invoke(method: CallMethod, args: unknown = {}, timeout: number = 5000): Promise<ApiCallbackMessage> {
if (this.socket == null || (!this.connected && !CallableMethodBeforeAuth.includes(method))) { if (this.socket == null || (!this.connected && !CallableMethodBeforeAuth.includes(method))) {
return new Promise((reslove) => { return new Promise((reslove) => {
setTimeout(async () => reslove(await this.invoke(method, args, timeout)), 500) setTimeout(async () => reslove(await this.invoke(method, args, timeout)), 500)
@@ -67,8 +64,11 @@ class Client {
const re = await this.invoke("User.auth", { const re = await this.invoke("User.auth", {
access_token: token access_token: token
}, timeout) }, timeout)
if (re.code == 200) if (re.code == 200) {
await this.updateCachedProfile() await this.updateCachedProfile()
document.cookie = 'token=' + token
document.cookie = 'device_id=' + data.device_id
}
return re return re
} }
static async updateCachedProfile() { static async updateCachedProfile() {
@@ -76,11 +76,18 @@ class Client {
token: data.access_token token: data.access_token
})).data as unknown as User })).data as unknown as User
} }
static on(eventName: ClientEvent, func: (data: UnknownObject) => UnknownObject | void) { static on(eventName: ClientEvent, func: (data: unknown) => void) {
this.events[eventName] = func if (this.events[eventName] == null)
this.events[eventName] = []
if (this.events[eventName].indexOf(func) == -1)
this.events[eventName].push(func)
} }
static off(eventName: ClientEvent) { static off(eventName: ClientEvent, func: (data: unknown) => void) {
delete this.events[eventName] if (this.events[eventName] == null)
this.events[eventName] = []
const index = this.events[eventName].indexOf(func)
if (index != -1)
this.events[eventName].splice(index, 1)
} }
} }

View File

@@ -23,6 +23,7 @@
<span slot="headline">错误</span> <span slot="headline">错误</span>
<span slot="description" id="ErrorDialog_Message"></span> <span slot="description" id="ErrorDialog_Message"></span>
</mdui-dialog> </mdui-dialog>
<input style="display: none;" id="copy_to_clipboard_fallback"></input>
<script nomodule> <script nomodule>
alert('很抱歉, 此应用无法在较旧的浏览器运行, 请使用基于 Chromium 89+ 的浏览器(内核)使用 :(') alert('很抱歉, 此应用无法在较旧的浏览器运行, 请使用基于 Chromium 89+ 的浏览器(内核)使用 :(')

View File

@@ -18,8 +18,9 @@ import UserProfileDialog from "./dialog/UserProfileDialog.tsx"
import ContactsList from "./main/ContactsList.tsx" import ContactsList from "./main/ContactsList.tsx"
import RecentsList from "./main/RecentsList.tsx" import RecentsList from "./main/RecentsList.tsx"
import useAsyncEffect from "./useAsyncEffect.ts" import useAsyncEffect from "./useAsyncEffect.ts"
import ChatInfoDialog from "./dialog/ChatInfoDialog.tsx"; import ChatInfoDialog from "./dialog/ChatInfoDialog.tsx"
import Chat from "../api/client_data/Chat.ts"; import Chat from "../api/client_data/Chat.ts"
import AddContactDialog from './dialog/AddContactDialog.tsx'
declare global { declare global {
namespace React { namespace React {
@@ -56,6 +57,8 @@ export default function App() {
useEventListener(openMyUserProfileDialogButtonRef, 'click', (_event) => { useEventListener(openMyUserProfileDialogButtonRef, 'click', (_event) => {
userProfileDialogRef.current!.open = true userProfileDialogRef.current!.open = true
}) })
const addContactDialogRef = React.useRef<Dialog>(null)
const chatInfoDialogRef = React.useRef<Dialog>(null) const chatInfoDialogRef = React.useRef<Dialog>(null)
const [chatInfo, setChatInfo] = React.useState(null as unknown as Chat) const [chatInfo, setChatInfo] = React.useState(null as unknown as Chat)
@@ -120,6 +123,9 @@ export default function App() {
setIsShowChatFragment(true) setIsShowChatFragment(true)
}} }}
chat={chatInfo} /> chat={chatInfo} />
<AddContactDialog
addContactDialogRef={addContactDialogRef} />
<mdui-navigation-rail contained value="Recents" ref={navigationRailRef}> <mdui-navigation-rail contained value="Recents" ref={navigationRailRef}>
<mdui-button-icon slot="top"> <mdui-button-icon slot="top">
@@ -127,7 +133,7 @@ export default function App() {
</mdui-button-icon> </mdui-button-icon>
<mdui-navigation-rail-item icon="watch_later--outlined" active-icon="watch_later--filled" value="Recents"></mdui-navigation-rail-item> <mdui-navigation-rail-item icon="watch_later--outlined" active-icon="watch_later--filled" value="Recents"></mdui-navigation-rail-item>
<mdui-navigation-rail-item icon="contacts--outlined" active-icon="contacts--filled" value="Contacts"></mdui-navigation-rail-item> <mdui-navigation-rail-item icon="chat--outlined" active-icon="chat--filled" value="Contacts"></mdui-navigation-rail-item>
<mdui-button-icon icon="settings" slot="bottom"></mdui-button-icon> <mdui-button-icon icon="settings" slot="bottom"></mdui-button-icon>
</mdui-navigation-rail> </mdui-navigation-rail>
@@ -148,9 +154,10 @@ export default function App() {
setRecentsList={setRecentsList} /> setRecentsList={setRecentsList} />
} }
{ {
// 联系人列表 // 對話列表
<ContactsList <ContactsList
setChatInfo={setChatInfo} setChatInfo={setChatInfo}
addContactDialogRef={addContactDialogRef as any}
chatInfoDialogRef={chatInfoDialogRef as any} chatInfoDialogRef={chatInfoDialogRef as any}
display={navigationItemSelected == "Contacts"} /> display={navigationItemSelected == "Contacts"} />
} }
@@ -168,12 +175,13 @@ export default function App() {
textAlign: 'center', textAlign: 'center',
alignSelf: 'center', alignSelf: 'center',
}}> }}>
... ...
</div> </div>
} }
{ {
isShowChatFragment && <ChatFragment isShowChatFragment && <ChatFragment
target={currentChatId} /> target={currentChatId}
key={currentChatId} />
} }
</div> </div>
</div> </div>

View File

@@ -19,6 +19,7 @@ import RecentsList from "./main/RecentsList.tsx"
import useAsyncEffect from "./useAsyncEffect.ts" import useAsyncEffect from "./useAsyncEffect.ts"
import ChatInfoDialog from "./dialog/ChatInfoDialog.tsx" import ChatInfoDialog from "./dialog/ChatInfoDialog.tsx"
import Chat from "../api/client_data/Chat.ts" import Chat from "../api/client_data/Chat.ts"
import AddContactDialog from './dialog/AddContactDialog.tsx'
declare global { declare global {
namespace React { namespace React {
@@ -55,6 +56,8 @@ export default function AppMobile() {
useEventListener(openMyUserProfileDialogButtonRef, 'click', (_event) => { useEventListener(openMyUserProfileDialogButtonRef, 'click', (_event) => {
userProfileDialogRef.current!.open = true userProfileDialogRef.current!.open = true
}) })
const addContactDialogRef = React.useRef<Dialog>(null)
const chatInfoDialogRef = React.useRef<Dialog>(null) const chatInfoDialogRef = React.useRef<Dialog>(null)
const [chatInfo, setChatInfo] = React.useState(null as unknown as Chat) const [chatInfo, setChatInfo] = React.useState(null as unknown as Chat)
@@ -109,6 +112,7 @@ export default function AppMobile() {
<ChatFragment <ChatFragment
showReturnButton={true} showReturnButton={true}
onReturnButtonClicked={() => setIsShowChatFragment(false)} onReturnButtonClicked={() => setIsShowChatFragment(false)}
key={currentChatId}
target={currentChatId} /> target={currentChatId} />
</div> </div>
</mdui-dialog> </mdui-dialog>
@@ -138,6 +142,9 @@ export default function AppMobile() {
setIsShowChatFragment(true) setIsShowChatFragment(true)
}} }}
chat={chatInfo} /> chat={chatInfo} />
<AddContactDialog
addContactDialogRef={addContactDialogRef} />
<mdui-top-app-bar style={{ <mdui-top-app-bar style={{
position: 'sticky', position: 'sticky',
@@ -149,7 +156,7 @@ export default function AppMobile() {
<mdui-top-app-bar-title>{ <mdui-top-app-bar-title>{
({ ({
Recents: "最近對話", Recents: "最近對話",
Contacts: "聯絡人" Contacts: "所有對話"
})[navigationItemSelected] })[navigationItemSelected]
}</mdui-top-app-bar-title> }</mdui-top-app-bar-title>
<div style={{ <div style={{
@@ -181,9 +188,10 @@ export default function AppMobile() {
setRecentsList={setRecentsList} /> setRecentsList={setRecentsList} />
} }
{ {
// 联系人列表 // 對話列表
<ContactsList <ContactsList
setChatInfo={setChatInfo} setChatInfo={setChatInfo}
addContactDialogRef={addContactDialogRef as any}
chatInfoDialogRef={chatInfoDialogRef as any} chatInfoDialogRef={chatInfoDialogRef as any}
display={navigationItemSelected == "Contacts"} /> display={navigationItemSelected == "Contacts"} />
} }
@@ -193,7 +201,7 @@ export default function AppMobile() {
bottom: '0', bottom: '0',
}}> }}>
<mdui-navigation-bar-item icon="watch_later--outlined" active-icon="watch_later--filled" value="Recents"></mdui-navigation-bar-item> <mdui-navigation-bar-item icon="watch_later--outlined" active-icon="watch_later--filled" value="Recents"></mdui-navigation-bar-item>
<mdui-navigation-bar-item icon="contacts--outlined" active-icon="contacts--filled" value="Contacts"></mdui-navigation-bar-item> <mdui-navigation-bar-item icon="chat--outlined" active-icon="chat--filled" value="Contacts"></mdui-navigation-bar-item>
</mdui-navigation-bar> </mdui-navigation-bar>
</div> </div>
) )

View File

@@ -100,7 +100,7 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
chat: string chat: string
msg: Message msg: Message
} }
Client.on('Client.onMessage', (data: unknown) => { function callback(data: unknown) {
const { chat, msg } = (data as OnMessageData) const { chat, msg } = (data as OnMessageData)
if (target == chat) { if (target == chat) {
setMessagesList(messagesList.concat([msg])) setMessagesList(messagesList.concat([msg]))
@@ -110,9 +110,11 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
behavior: "smooth", behavior: "smooth",
}), 100) }), 100)
} }
}) }
Client.on('Client.onMessage', callback)
return () => { return () => {
Client.off('Client.onMessage') Client.off('Client.onMessage', callback)
} }
}) })

View File

@@ -0,0 +1,14 @@
import { $ } from 'mdui/jq'
export default function copyToClipboard(text: string) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text)
} else {
const input = $('#copy_to_clipboard_fallback').get(0) as HTMLInputElement
input.value = text
input.select()
input.setSelectionRange(0, 1145141919810)
document.execCommand('copy')
input.clearSelection()
}
}

View File

@@ -0,0 +1,41 @@
import * as React from 'react'
import { Button, Dialog, TextField } from "mdui"
import useEventListener from "../useEventListener.ts"
import { checkApiSuccessOrSncakbar, snackbar } from "../snackbar.ts"
import Client from "../../api/Client.ts"
import * as CryptoJS from 'crypto-js'
import data from "../../Data.ts"
import EventBus from "../../EventBus.ts"
interface Refs {
addContactDialogRef: React.MutableRefObject<Dialog | null>
}
export default function AddContactDialog({
addContactDialogRef,
}: Refs) {
const inputUserAccountRef = React.useRef<TextField>(null)
return (
<mdui-dialog close-on-overlay-click close-on-esc headline="添加對話" ref={addContactDialogRef}>
, ...
<mdui-text-field style={{ marginTop: "10px", }} label="對方的 用戶 ID / 用戶名" ref={inputUserAccountRef as any}></mdui-text-field>
<mdui-button slot="action" variant="text" onClick={() => addContactDialogRef.current!.open = false}></mdui-button>
<mdui-button slot="action" variant="text" onClick={async () => {
const re = await Client.invoke("User.addContact", {
account: inputUserAccountRef.current!.value,
token: data.access_token,
})
if (checkApiSuccessOrSncakbar(re, "添加失敗")) return
snackbar({
message: "添加成功!",
placement: "top",
})
EventBus.emit('ContactsList.updateContacts')
addContactDialogRef.current!.open = false
}}></mdui-button>
</mdui-dialog>
)
}

View File

@@ -20,8 +20,8 @@ export default function LoginDialog({
loginDialogRef, loginDialogRef,
registerDialogRef registerDialogRef
}: Refs) { }: Refs) {
const loginButtonRef: React.MutableRefObject<Button | null> = React.useRef(null) const loginButtonRef = React.useRef<Button>(null)
const registerButtonRef: React.MutableRefObject<Button | null> = React.useRef(null) const registerButtonRef = React.useRef<Button>(null)
useEventListener(registerButtonRef, 'click', () => registerDialogRef.current!.open = true) useEventListener(registerButtonRef, 'click', () => registerDialogRef.current!.open = true)
useEventListener(loginButtonRef, 'click', async () => { useEventListener(loginButtonRef, 'click', async () => {
const account = loginInputAccountRef.current!.value const account = loginInputAccountRef.current!.value
@@ -41,11 +41,11 @@ export default function LoginDialog({
return ( return (
<mdui-dialog headline="登錄" ref={loginDialogRef}> <mdui-dialog headline="登錄" ref={loginDialogRef}>
<mdui-text-field label="用戶 ID / 用戶名" ref={loginInputAccountRef}></mdui-text-field> <mdui-text-field label="用戶 ID / 用戶名" ref={loginInputAccountRef as any}></mdui-text-field>
<div style={{ <div style={{
height: "10px", height: "10px",
}}></div> }}></div>
<mdui-text-field label="密碼" type="password" toggle-password ref={loginInputPasswordRef}></mdui-text-field> <mdui-text-field label="密碼" type="password" toggle-password ref={loginInputPasswordRef as any}></mdui-text-field>
<mdui-button slot="action" variant="text" ref={registerButtonRef}></mdui-button> <mdui-button slot="action" variant="text" ref={registerButtonRef}></mdui-button>
<mdui-button slot="action" variant="text" ref={loginButtonRef}></mdui-button> <mdui-button slot="action" variant="text" ref={loginButtonRef}></mdui-button>

View File

@@ -23,8 +23,8 @@ export default function RegisterDialog({
registerInputPasswordRef, registerInputPasswordRef,
registerDialogRef registerDialogRef
}: Refs) { }: Refs) {
const registerBackButtonRef: React.MutableRefObject<Button | null> = React.useRef(null) const registerBackButtonRef = React.useRef<Button>(null)
const doRegisterButtonRef: React.MutableRefObject<Button | null> = React.useRef(null) const doRegisterButtonRef = React.useRef<Button>(null)
useEventListener(registerBackButtonRef, 'click', () => registerDialogRef.current!.open = false) useEventListener(registerBackButtonRef, 'click', () => registerDialogRef.current!.open = false)
useEventListener(doRegisterButtonRef, 'click', async () => { useEventListener(doRegisterButtonRef, 'click', async () => {
const username = registerInputUserNameRef.current!.value const username = registerInputUserNameRef.current!.value
@@ -50,15 +50,15 @@ export default function RegisterDialog({
return ( return (
<mdui-dialog headline="注冊" ref={registerDialogRef}> <mdui-dialog headline="注冊" ref={registerDialogRef}>
<mdui-text-field label="用戶名 (可選)" ref={registerInputUserNameRef}></mdui-text-field> <mdui-text-field label="用戶名 (可選)" ref={registerInputUserNameRef as any}></mdui-text-field>
<div style={{ <div style={{
height: "10px", height: "10px",
}}></div> }}></div>
<mdui-text-field label="昵稱" ref={registerInputNickNameRef}></mdui-text-field> <mdui-text-field label="昵稱" ref={registerInputNickNameRef as any}></mdui-text-field>
<div style={{ <div style={{
height: "10px", height: "10px",
}}></div> }}></div>
<mdui-text-field label="密码" type="password" toggle-password ref={registerInputPasswordRef}></mdui-text-field> <mdui-text-field label="密码" type="password" toggle-password ref={registerInputPasswordRef as any}></mdui-text-field>
<mdui-button slot="action" variant="text" ref={registerBackButtonRef}></mdui-button> <mdui-button slot="action" variant="text" ref={registerBackButtonRef}></mdui-button>
<mdui-button slot="action" variant="text" ref={doRegisterButtonRef}></mdui-button> <mdui-button slot="action" variant="text" ref={doRegisterButtonRef}></mdui-button>

View File

@@ -1,5 +1,5 @@
import * as React from 'react' import * as React from 'react'
import { Button, Dialog, TextField } from "mdui" import { Button, Dialog, TextField, dialog } from "mdui"
import useEventListener from "../useEventListener.ts" import useEventListener from "../useEventListener.ts"
import { checkApiSuccessOrSncakbar, snackbar } from "../snackbar.ts" import { checkApiSuccessOrSncakbar, snackbar } from "../snackbar.ts"
import Client from "../../api/Client.ts" import Client from "../../api/Client.ts"
@@ -63,7 +63,6 @@ export default function UserProfileDialog({
</div> </div>
<mdui-divider style={{ <mdui-divider style={{
marginTop: "10px", marginTop: "10px",
marginBottom: "10px",
}}></mdui-divider> }}></mdui-divider>
<mdui-list> <mdui-list>
@@ -71,8 +70,35 @@ export default function UserProfileDialog({
{ {
isMySelf && <> isMySelf && <>
<mdui-list-item icon="edit" rounded onClick={() => userProfileEditDialogRef.current!.open = true}></mdui-list-item> <mdui-list-item icon="edit" rounded onClick={() => userProfileEditDialogRef.current!.open = true}></mdui-list-item>
{/*
<mdui-list-item icon="settings" rounded>賬號設定</mdui-list-item> <mdui-list-item icon="settings" rounded>賬號設定</mdui-list-item>
<mdui-list-item icon="lock" rounded>隱私設定</mdui-list-item> <mdui-list-item icon="lock" rounded>隱私設定</mdui-list-item>
*/}
<mdui-divider style={{
marginTop: "10px",
marginBottom: "10px",
}}></mdui-divider>
<mdui-list-item icon="logout" rounded onClick={() => dialog({
headline: "退出登錄",
description: "確定要退出登錄嗎? (若您的賬號未設定 用戶名, 請無務必複製 用戶 ID, 以免丟失賬號!)",
actions: [
{
text: "取消",
onClick: () => {
return true
},
},
{
text: "確定",
onClick: () => {
data.access_token = ''
data.apply()
location.reload()
return true
},
}
],
})}>退</mdui-list-item>
</> </>
} }
</mdui-list> </mdui-list>
@@ -102,10 +128,14 @@ export default function UserProfileDialog({
</div> </div>
<mdui-divider style={{ <mdui-divider style={{
marginTop: "10px", marginTop: "10px",
marginBottom: "10px",
}}></mdui-divider> }}></mdui-divider>
<mdui-text-field variant="outlined" label="用戶" value={user?.username || ''} ref={editUserNameRef as any}></mdui-text-field> <mdui-text-field style={{ marginTop: "10px", }} variant="outlined" label="用戶 ID" value={user?.id || ''} readonly onClick={(e) => {
const input = e.target as HTMLInputElement
input.select()
input.setSelectionRange(0, 1145141919810)
}}></mdui-text-field>
<mdui-text-field style={{ marginTop: "20px", }} variant="outlined" label="用戶名" value={user?.username || ''} ref={editUserNameRef as any}></mdui-text-field>
<mdui-button slot="action" variant="text" onClick={() => userProfileEditDialogRef.current!.open = false}></mdui-button> <mdui-button slot="action" variant="text" onClick={() => userProfileEditDialogRef.current!.open = false}></mdui-button>
<mdui-button slot="action" variant="text" onClick={async () => { <mdui-button slot="action" variant="text" onClick={async () => {

View File

@@ -7,10 +7,12 @@ import Client from "../../api/Client.ts"
import data from "../../Data.ts" import data from "../../Data.ts"
import { checkApiSuccessOrSncakbar } from "../snackbar.ts" import { checkApiSuccessOrSncakbar } from "../snackbar.ts"
import Chat from "../../api/client_data/Chat.ts" import Chat from "../../api/client_data/Chat.ts"
import EventBus from "../../EventBus.ts"
interface Args extends React.HTMLAttributes<HTMLElement> { interface Args extends React.HTMLAttributes<HTMLElement> {
display: boolean display: boolean
chatInfoDialogRef: React.MutableRefObject<Dialog> chatInfoDialogRef: React.MutableRefObject<Dialog>
addContactDialogRef: React.MutableRefObject<Dialog>
setChatInfo: React.Dispatch<React.SetStateAction<Chat>> setChatInfo: React.Dispatch<React.SetStateAction<Chat>>
} }
@@ -18,6 +20,7 @@ export default function ContactsList({
display, display,
setChatInfo, setChatInfo,
chatInfoDialogRef, chatInfoDialogRef,
addContactDialogRef,
...props ...props
}: Args) { }: Args) {
const searchRef = React.useRef<HTMLElement>(null) const searchRef = React.useRef<HTMLElement>(null)
@@ -30,13 +33,17 @@ export default function ContactsList({
}) })
useAsyncEffect(async () => { useAsyncEffect(async () => {
const re = await Client.invoke("User.getMyContacts", { async function updateContacts() {
token: data.access_token, const re = await Client.invoke("User.getMyContacts", {
}) token: data.access_token,
if (re.code != 200) })
return checkApiSuccessOrSncakbar(re, "获取联络人列表失败") if (re.code != 200)
return checkApiSuccessOrSncakbar(re, "获取對話列表失败")
setContactsList(re.data!.contacts_list as Chat[])
setContactsList(re.data!.contacts_list as Chat[])
}
updateContacts()
EventBus.on('ContactsList.updateContacts', () => updateContacts())
}) })
return <mdui-list style={{ return <mdui-list style={{
@@ -54,8 +61,11 @@ export default function ContactsList({
<mdui-list-item rounded style={{ <mdui-list-item rounded style={{
width: '100%', width: '100%',
marginTop: '13px', marginTop: '13px',
}} icon="person_add" onClick={() => addContactDialogRef.current!.open = true}></mdui-list-item>
<mdui-list-item rounded style={{
width: '100%',
marginBottom: '15px', marginBottom: '15px',
}} icon="person_add"></mdui-list-item> }} icon="refresh" onClick={() => EventBus.emit('ContactsList.updateContacts')}></mdui-list-item>
{/* <mdui-list-item rounded style={{ {/* <mdui-list-item rounded style={{
width: '100%', width: '100%',
marginBottom: '15px', marginBottom: '15px',

View File

@@ -3,6 +3,6 @@ import * as React from 'react'
export default function useEventListener<T extends HTMLElement | null>(ref: React.MutableRefObject<T>, eventName: string, callback: (event: Event) => void) { export default function useEventListener<T extends HTMLElement | null>(ref: React.MutableRefObject<T>, eventName: string, callback: (event: Event) => void) {
React.useEffect(() => { React.useEffect(() => {
ref.current!.addEventListener(eventName, callback) ref.current!.addEventListener(eventName, callback)
return () => ref.current!.removeEventListener(eventName, callback) return () => ref.current?.removeEventListener(eventName, callback)
}, [ref, eventName, callback]) }, [ref, eventName, callback])
} }

View File

@@ -1,6 +1,6 @@
{ {
"tasks": { "tasks": {
"server": "deno task build && deno run --allow-read --allow-write --allow-env --allow-net --allow-sys --allow-run=deno ./server/main.ts", "server": "deno run --allow-read --allow-write --allow-env --allow-net --allow-sys --allow-run=deno ./server/main.ts",
"debug": "deno task build && deno run --watch --allow-read --allow-write --allow-env --allow-net --allow-sys --allow-run=deno ./server/main.ts", "debug": "deno task build && deno run --watch --allow-read --allow-write --allow-env --allow-net --allow-sys --allow-run=deno ./server/main.ts",
"build": "cd ./client && deno task build" "build": "cd ./client && deno task build"
}, },
@@ -8,6 +8,7 @@
"chalk": "npm:chalk@5.4.1", "chalk": "npm:chalk@5.4.1",
"file-type": "npm:file-type@21.0.0", "file-type": "npm:file-type@21.0.0",
"express": "npm:express@5.1.0", "express": "npm:express@5.1.0",
"socket.io": "npm:socket.io@4.8.1" "socket.io": "npm:socket.io@4.8.1",
"cookie-parser": "npm:cookie-parser@1.4.7"
} }
} }

View File

@@ -3,6 +3,7 @@ import ApiManager from "./ApiManager.ts"
import { CallMethod, ClientEvent } from './ApiDeclare.ts' import { CallMethod, ClientEvent } from './ApiDeclare.ts'
import User from "../data/User.ts" import User from "../data/User.ts"
import Token from "./Token.ts" import Token from "./Token.ts"
import TokenManager from './TokenManager.ts'
import * as SocketIo from "socket.io" import * as SocketIo from "socket.io"
export default abstract class BaseApi { export default abstract class BaseApi {
@@ -24,12 +25,7 @@ export default abstract class BaseApi {
return false return false
} }
checkToken(token: Token, deviceId: string) { checkToken(token: Token, deviceId: string) {
if (token.expired_time < Date.now()) return false return TokenManager.checkToken(token, deviceId)
if (!User.findById(token.author)) return false
if (deviceId != null)
if (token.device_id != deviceId)
return false
return true
} }
registerEvent(name: CallMethod, func: EventCallbackFunction) { registerEvent(name: CallMethod, func: EventCallbackFunction) {
if (!name.startsWith(this.getName() + ".")) throw Error("注冊的事件應該與接口集合命名空間相匹配: " + name) if (!name.startsWith(this.getName() + ".")) throw Error("注冊的事件應該與接口集合命名空間相匹配: " + name)

View File

@@ -90,11 +90,7 @@ export default class ChatApi extends BaseApi {
} }
const id = MessagesManager.getInstanceForChat(chat).addMessage(msg) const id = MessagesManager.getInstanceForChat(chat).addMessage(msg)
const users: string[] = [] const users: string[] = UserChatLinker.getChatMembers(chat.bean.id)
if (chat.bean.type == 'private') {
users.push(token.author as string)
}
for (const user of users) { for (const user of users) {
if (ApiManager.checkUserIsOnline(user)) { if (ApiManager.checkUserIsOnline(user)) {
const sockets = ApiManager.getUserClientSockets(user) const sockets = ApiManager.getUserClientSockets(user)

View File

@@ -1,55 +0,0 @@
import { Buffer } from "node:buffer"
import config from "../config.ts"
import User from "../data/User.ts"
import crypto from 'node:crypto'
import Token from "./Token.ts"
function normalizeKey(key: string, keyLength = 32) {
const hash = crypto.createHash('sha256')
hash.update(key)
const keyBuffer = hash.digest()
return keyLength ? keyBuffer.slice(0, keyLength) : keyBuffer
}
export default class FileTokenManager {
static makeAuth(user: User) {
return crypto.createHash("sha256").update(user.bean.id + user.getPassword() + config.salt + '_file').digest().toString('hex')
}
static encode(token: Token) {
return crypto.createCipheriv("aes-256-gcm", normalizeKey(config.aes_key + '_file'), '01234567890123456').update(
JSON.stringify(token)
).toString('hex')
}
static decode(token: string) {
if (token == null) throw new Error('令牌為空!')
return JSON.parse(crypto.createDecipheriv("aes-256-gcm", normalizeKey(config.aes_key + '_file'), '01234567890123456').update(
Buffer.from(token, 'hex')
).toString()) as Token
}
/**
* 簽發文件令牌
*/
static make(user: User, device_id: string) {
const time = Date.now()
return this.encode({
author: user.bean.id,
auth: this.makeAuth(user),
made_time: time,
// 過期時間: 2分鐘
expired_time: time + (1 * 1000 * 60 * 2),
device_id: device_id
})
}
/**
* 校驗文件令牌
*/
static check(user: User, token: string) {
const tk = this.decode(token)
return (
this.makeAuth(user) == tk.auth
&& tk.expired_time < Date.now()
)
}
}

View File

@@ -22,10 +22,13 @@ export default class TokenManager {
).toString('hex') ).toString('hex')
} }
static decode(token: string) { static decode(token: string) {
if (token == null) throw new Error('令牌為空!') try {
return JSON.parse(crypto.createDecipheriv("aes-256-gcm", normalizeKey(config.aes_key), '01234567890123456').update( return JSON.parse(crypto.createDecipheriv("aes-256-gcm", normalizeKey(config.aes_key), '01234567890123456').update(
Buffer.from(token, 'hex') Buffer.from(token, 'hex')
).toString()) as Token ).toString()) as Token
} catch(e) {
return {} as Token
}
} }
static make(user: User, time_: number | null | undefined, device_id: string) { static make(user: User, time_: number | null | undefined, device_id: string) {
@@ -51,4 +54,15 @@ export default class TokenManager {
return this.makeAuth(user) == tk.auth return this.makeAuth(user) == tk.auth
} }
/**
* 嚴格檢驗令牌: 時間, 用戶, (設備 ID)
*/
static checkToken(token: Token, deviceId?: string) {
if (token.expired_time < Date.now()) return false
if (!token.author || !User.findById(token.author)) return false
if (deviceId != null)
if (token.device_id != deviceId)
return false
return true
}
} }

View File

@@ -26,7 +26,7 @@ export default class UserApi extends BaseApi {
msg: "登錄令牌失效", msg: "登錄令牌失效",
code: 401, code: 401,
} }
if (!User.findById(access_token.author)) return { if (!access_token.author || !User.findById(access_token.author)) return {
msg: "賬號不存在", msg: "賬號不存在",
code: 401, code: 401,
} }
@@ -68,7 +68,7 @@ export default class UserApi extends BaseApi {
code: 400, code: 400,
} }
const user = (User.findByUserName(args.account as string) || User.findById(args.account as string)) as User const user = User.findByAccount(args.account as string) as User
if (user == null) return { if (user == null) return {
msg: "賬號或密碼錯誤", msg: "賬號或密碼錯誤",
code: 400, code: 400,
@@ -226,7 +226,7 @@ export default class UserApi extends BaseApi {
}) })
// 添加聯絡人 // 添加聯絡人
this.registerEvent("User.addContact", (args, { deviceId }) => { this.registerEvent("User.addContact", (args, { deviceId }) => {
if (this.checkArgsMissing(args, ['token', 'contact_chat_id'])) return { if (this.checkArgsMissing(args, ['token']) || (args.chat_id == null && args.account == null)) return {
msg: "參數缺失", msg: "參數缺失",
code: 400, code: 400,
} }
@@ -237,8 +237,20 @@ export default class UserApi extends BaseApi {
msg: "令牌無效", msg: "令牌無效",
} }
const user = User.findById(token.author) const user = User.findById(token.author) as User
user!.addContact(args.contact_chat_id as string) if (args.chat_id)
user!.addContact(args.chat_id as string)
else if (args.account) {
const targetUser = User.findByAccount(args.account as string) as User
if (targetUser == null) {
return {
msg: "找不到用戶",
code: 404,
}
}
const chat = ChatPrivate.findOrCreateForPrivate(user, targetUser)
user!.addContact(chat.bean.id)
}
return { return {
msg: "成功", msg: "成功",

View File

@@ -88,13 +88,16 @@ export default class Chat {
userIds.forEach((v) => UserChatLinker.unlinkUserAndChat(v, this.bean.id)) userIds.forEach((v) => UserChatLinker.unlinkUserAndChat(v, this.bean.id))
} }
getAnotherUserForPrivate(userMySelf: User) { getAnotherUserForPrivate(userMySelf: User) {
const user_a_id = this.getMembersList()[0] const members = this.getMembersList()
const user_b_id = this.getMembersList()[0] const user_a_id = members[0]
const user_b_id = members[1]
if (members.length == 1 && user_a_id == userMySelf.bean.id)
return userMySelf
// 注意: 這裏已經確定了 Chat, 不需要再指定對方用戶 // 注意: 這裏已經確定了 Chat, 不需要再指定對方用戶
if (user_a_id == userMySelf.bean.id) if (user_a_id == userMySelf.bean.id)
return User.findById(user_b_id as string) return User.findById(user_b_id as string)
if (user_b_id == userMySelf.bean.id) if (user_b_id == userMySelf.bean.id)
return userMySelf return User.findById(user_a_id as string)
return null return null
} }

View File

@@ -8,7 +8,7 @@ export default class ChatPrivate extends Chat {
} }
static getChatIdByUsersId(userIdA: string, userIdB: string) { static getChatIdByUsersId(userIdA: string, userIdB: string) {
return [userIdA, userIdB].sort().join('-') return [userIdA, userIdB].sort().join('------')
} }
static createForPrivate(userA: User, userB: User) { static createForPrivate(userA: User, userB: User) {

View File

@@ -13,7 +13,7 @@ import { SQLInputValue } from "node:sqlite"
import DataWrongError from "../api/DataWrongError.ts" import DataWrongError from "../api/DataWrongError.ts"
import ChatPrivate from "./ChatPrivate.ts" import ChatPrivate from "./ChatPrivate.ts"
import Chat from "./Chat.ts" import Chat from "./Chat.ts"
import ChatBean from "./ChatBean.ts"; import ChatBean from "./ChatBean.ts"
type UserBeanKey = keyof UserBean type UserBeanKey = keyof UserBean
@@ -103,6 +103,9 @@ export default class User {
console.error(chalk.red(`警告: 查询 username = ${userName} 时, 查询到多个相同用户名的用户`)) console.error(chalk.red(`警告: 查询 username = ${userName} 时, 查询到多个相同用户名的用户`))
return new User(beans[0]) return new User(beans[0])
} }
static findByAccount(account: string) {
return User.findByUserName(account) || User.findById(account)
}
declare bean: UserBean declare bean: UserBean
constructor(bean: UserBean) { constructor(bean: UserBean) {
@@ -121,6 +124,7 @@ export default class User {
} }
addContact(chatId: string) { addContact(chatId: string) {
const ls = this.getContactsList() const ls = this.getContactsList()
if (ls.indexOf(chatId) != -1 || ChatPrivate.getChatIdByUsersId(this.bean.id, this.bean.id) == chatId) return
ls.push(chatId) ls.push(chatId)
this.setAttr("contacts_list", JSON.stringify(ls)) this.setAttr("contacts_list", JSON.stringify(ls))
} }

View File

@@ -11,21 +11,38 @@ import process from "node:process"
import chalk from "chalk" import chalk from "chalk"
import child_process from "node:child_process" import child_process from "node:child_process"
import FileManager from "./data/FileManager.ts" import FileManager from "./data/FileManager.ts"
import TokenManager from "./api/TokenManager.ts"
import UserChatLinker from "./data/UserChatLinker.ts"
import path from "node:path" import path from "node:path"
import cookieParser from 'cookie-parser'
const app = express() const app = express()
app.use('/', express.static(config.data_path + '/page_compiled')) app.use('/', express.static(config.data_path + '/page_compiled'))
app.use(cookieParser())
app.get('/uploaded_files/:hash', (req, res) => { app.get('/uploaded_files/:hash', (req, res) => {
const hash = req.params.hash as string const hash = req.params.hash as string
res.setHeader('Content-Type', 'text/plain')
if (hash == null) { if (hash == null) {
res.sendStatus(404) res.send("404 Not Found", 404)
res.send("404 Not Found")
return return
} }
const file = FileManager.findByHash(hash) const file = FileManager.findByHash(hash)
if (file.getChatId() != null) {
const userToken = TokenManager.decode(req.cookies.token)
console.log(userToken, req.cookies.device_id)
if (!TokenManager.checkToken(userToken, req.cookies.device_id)) {
res.send("401 UnAuthorized", 401)
return
}
if (!UserChatLinker.checkUserIsLinkedToChat(userToken.author, file.getChatId())) {
res.send("403 Forbidden", 403)
return
}
}
if (file == null) { if (file == null) {
res.sendStatus(404) res.send("404 Not Found", 404)
res.send("404 Not Found")
return return
} }