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 randomUUID from "../randomUUID.ts"
type UnknownObject = { [key: string]: unknown }
class Client {
static sessionId = randomUUID()
static myUserProfile?: User
static socket?: Socket
static events: { [key: string]: (data: UnknownObject) => UnknownObject | void } = {}
static events: { [key: string]: ((data: unknown) => void)[] } = {}
static connected = false
static connect() {
if (data.device_id == null)
@@ -37,17 +35,16 @@ class Client {
this.socket!.on("disconnect", () => {
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 {
if (name == null || data == null) return
const re = this.events[name]?.(data)
re && callback(re)
this.events[name]?.forEach((v) => v(data))
} catch (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))) {
return new Promise((reslove) => {
setTimeout(async () => reslove(await this.invoke(method, args, timeout)), 500)
@@ -67,8 +64,11 @@ class Client {
const re = await this.invoke("User.auth", {
access_token: token
}, timeout)
if (re.code == 200)
if (re.code == 200) {
await this.updateCachedProfile()
document.cookie = 'token=' + token
document.cookie = 'device_id=' + data.device_id
}
return re
}
static async updateCachedProfile() {
@@ -76,11 +76,18 @@ class Client {
token: data.access_token
})).data as unknown as User
}
static on(eventName: ClientEvent, func: (data: UnknownObject) => UnknownObject | void) {
this.events[eventName] = func
static on(eventName: ClientEvent, func: (data: unknown) => void) {
if (this.events[eventName] == null)
this.events[eventName] = []
if (this.events[eventName].indexOf(func) == -1)
this.events[eventName].push(func)
}
static off(eventName: ClientEvent) {
delete this.events[eventName]
static off(eventName: ClientEvent, func: (data: unknown) => void) {
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="description" id="ErrorDialog_Message"></span>
</mdui-dialog>
<input style="display: none;" id="copy_to_clipboard_fallback"></input>
<script nomodule>
alert('很抱歉, 此应用无法在较旧的浏览器运行, 请使用基于 Chromium 89+ 的浏览器(内核)使用 :(')

View File

@@ -18,8 +18,9 @@ import UserProfileDialog from "./dialog/UserProfileDialog.tsx"
import ContactsList from "./main/ContactsList.tsx"
import RecentsList from "./main/RecentsList.tsx"
import useAsyncEffect from "./useAsyncEffect.ts"
import ChatInfoDialog from "./dialog/ChatInfoDialog.tsx";
import Chat from "../api/client_data/Chat.ts";
import ChatInfoDialog from "./dialog/ChatInfoDialog.tsx"
import Chat from "../api/client_data/Chat.ts"
import AddContactDialog from './dialog/AddContactDialog.tsx'
declare global {
namespace React {
@@ -57,6 +58,8 @@ export default function App() {
userProfileDialogRef.current!.open = true
})
const addContactDialogRef = React.useRef<Dialog>(null)
const chatInfoDialogRef = React.useRef<Dialog>(null)
const [chatInfo, setChatInfo] = React.useState(null as unknown as Chat)
@@ -121,13 +124,16 @@ export default function App() {
}}
chat={chatInfo} />
<AddContactDialog
addContactDialogRef={addContactDialogRef} />
<mdui-navigation-rail contained value="Recents" ref={navigationRailRef}>
<mdui-button-icon slot="top">
<Avatar src={myUserProfileCache?.avatar} text={myUserProfileCache?.nickname} avatarRef={openMyUserProfileDialogButtonRef} />
</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="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-navigation-rail>
@@ -148,9 +154,10 @@ export default function App() {
setRecentsList={setRecentsList} />
}
{
// 联系人列表
// 對話列表
<ContactsList
setChatInfo={setChatInfo}
addContactDialogRef={addContactDialogRef as any}
chatInfoDialogRef={chatInfoDialogRef as any}
display={navigationItemSelected == "Contacts"} />
}
@@ -168,12 +175,13 @@ export default function App() {
textAlign: 'center',
alignSelf: 'center',
}}>
...
...
</div>
}
{
isShowChatFragment && <ChatFragment
target={currentChatId} />
target={currentChatId}
key={currentChatId} />
}
</div>
</div>

View File

@@ -19,6 +19,7 @@ import RecentsList from "./main/RecentsList.tsx"
import useAsyncEffect from "./useAsyncEffect.ts"
import ChatInfoDialog from "./dialog/ChatInfoDialog.tsx"
import Chat from "../api/client_data/Chat.ts"
import AddContactDialog from './dialog/AddContactDialog.tsx'
declare global {
namespace React {
@@ -56,6 +57,8 @@ export default function AppMobile() {
userProfileDialogRef.current!.open = true
})
const addContactDialogRef = React.useRef<Dialog>(null)
const chatInfoDialogRef = React.useRef<Dialog>(null)
const [chatInfo, setChatInfo] = React.useState(null as unknown as Chat)
@@ -109,6 +112,7 @@ export default function AppMobile() {
<ChatFragment
showReturnButton={true}
onReturnButtonClicked={() => setIsShowChatFragment(false)}
key={currentChatId}
target={currentChatId} />
</div>
</mdui-dialog>
@@ -139,6 +143,9 @@ export default function AppMobile() {
}}
chat={chatInfo} />
<AddContactDialog
addContactDialogRef={addContactDialogRef} />
<mdui-top-app-bar style={{
position: 'sticky',
marginTop: '3px',
@@ -149,7 +156,7 @@ export default function AppMobile() {
<mdui-top-app-bar-title>{
({
Recents: "最近對話",
Contacts: "聯絡人"
Contacts: "所有對話"
})[navigationItemSelected]
}</mdui-top-app-bar-title>
<div style={{
@@ -181,9 +188,10 @@ export default function AppMobile() {
setRecentsList={setRecentsList} />
}
{
// 联系人列表
// 對話列表
<ContactsList
setChatInfo={setChatInfo}
addContactDialogRef={addContactDialogRef as any}
chatInfoDialogRef={chatInfoDialogRef as any}
display={navigationItemSelected == "Contacts"} />
}
@@ -193,7 +201,7 @@ export default function AppMobile() {
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="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>
</div>
)

View File

@@ -100,7 +100,7 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
chat: string
msg: Message
}
Client.on('Client.onMessage', (data: unknown) => {
function callback(data: unknown) {
const { chat, msg } = (data as OnMessageData)
if (target == chat) {
setMessagesList(messagesList.concat([msg]))
@@ -110,9 +110,11 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
behavior: "smooth",
}), 100)
}
})
}
Client.on('Client.onMessage', callback)
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,
registerDialogRef
}: Refs) {
const loginButtonRef: React.MutableRefObject<Button | null> = React.useRef(null)
const registerButtonRef: React.MutableRefObject<Button | null> = React.useRef(null)
const loginButtonRef = React.useRef<Button>(null)
const registerButtonRef = React.useRef<Button>(null)
useEventListener(registerButtonRef, 'click', () => registerDialogRef.current!.open = true)
useEventListener(loginButtonRef, 'click', async () => {
const account = loginInputAccountRef.current!.value
@@ -41,11 +41,11 @@ export default function LoginDialog({
return (
<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={{
height: "10px",
}}></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={loginButtonRef}></mdui-button>

View File

@@ -23,8 +23,8 @@ export default function RegisterDialog({
registerInputPasswordRef,
registerDialogRef
}: Refs) {
const registerBackButtonRef: React.MutableRefObject<Button | null> = React.useRef(null)
const doRegisterButtonRef: React.MutableRefObject<Button | null> = React.useRef(null)
const registerBackButtonRef = React.useRef<Button>(null)
const doRegisterButtonRef = React.useRef<Button>(null)
useEventListener(registerBackButtonRef, 'click', () => registerDialogRef.current!.open = false)
useEventListener(doRegisterButtonRef, 'click', async () => {
const username = registerInputUserNameRef.current!.value
@@ -50,15 +50,15 @@ export default function RegisterDialog({
return (
<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={{
height: "10px",
}}></div>
<mdui-text-field label="昵稱" ref={registerInputNickNameRef}></mdui-text-field>
<mdui-text-field label="昵稱" ref={registerInputNickNameRef as any}></mdui-text-field>
<div style={{
height: "10px",
}}></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={doRegisterButtonRef}></mdui-button>

View File

@@ -1,5 +1,5 @@
import * as React from 'react'
import { Button, Dialog, TextField } from "mdui"
import { Button, Dialog, TextField, dialog } from "mdui"
import useEventListener from "../useEventListener.ts"
import { checkApiSuccessOrSncakbar, snackbar } from "../snackbar.ts"
import Client from "../../api/Client.ts"
@@ -63,7 +63,6 @@ export default function UserProfileDialog({
</div>
<mdui-divider style={{
marginTop: "10px",
marginBottom: "10px",
}}></mdui-divider>
<mdui-list>
@@ -71,8 +70,35 @@ export default function UserProfileDialog({
{
isMySelf && <>
<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="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>
@@ -102,10 +128,14 @@ export default function UserProfileDialog({
</div>
<mdui-divider style={{
marginTop: "10px",
marginBottom: "10px",
}}></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={async () => {

View File

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

View File

@@ -1,6 +1,6 @@
{
"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",
"build": "cd ./client && deno task build"
},
@@ -8,6 +8,7 @@
"chalk": "npm:chalk@5.4.1",
"file-type": "npm:file-type@21.0.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 User from "../data/User.ts"
import Token from "./Token.ts"
import TokenManager from './TokenManager.ts'
import * as SocketIo from "socket.io"
export default abstract class BaseApi {
@@ -24,12 +25,7 @@ export default abstract class BaseApi {
return false
}
checkToken(token: Token, deviceId: string) {
if (token.expired_time < Date.now()) return false
if (!User.findById(token.author)) return false
if (deviceId != null)
if (token.device_id != deviceId)
return false
return true
return TokenManager.checkToken(token, deviceId)
}
registerEvent(name: CallMethod, func: EventCallbackFunction) {
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 users: string[] = []
if (chat.bean.type == 'private') {
users.push(token.author as string)
}
const users: string[] = UserChatLinker.getChatMembers(chat.bean.id)
for (const user of users) {
if (ApiManager.checkUserIsOnline(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')
}
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(
Buffer.from(token, 'hex')
).toString()) as Token
} catch(e) {
return {} as Token
}
}
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
}
/**
* 嚴格檢驗令牌: 時間, 用戶, (設備 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: "登錄令牌失效",
code: 401,
}
if (!User.findById(access_token.author)) return {
if (!access_token.author || !User.findById(access_token.author)) return {
msg: "賬號不存在",
code: 401,
}
@@ -68,7 +68,7 @@ export default class UserApi extends BaseApi {
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 {
msg: "賬號或密碼錯誤",
code: 400,
@@ -226,7 +226,7 @@ export default class UserApi extends BaseApi {
})
// 添加聯絡人
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: "參數缺失",
code: 400,
}
@@ -237,8 +237,20 @@ export default class UserApi extends BaseApi {
msg: "令牌無效",
}
const user = User.findById(token.author)
user!.addContact(args.contact_chat_id as string)
const user = User.findById(token.author) as User
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 {
msg: "成功",

View File

@@ -88,13 +88,16 @@ export default class Chat {
userIds.forEach((v) => UserChatLinker.unlinkUserAndChat(v, this.bean.id))
}
getAnotherUserForPrivate(userMySelf: User) {
const user_a_id = this.getMembersList()[0]
const user_b_id = this.getMembersList()[0]
const members = this.getMembersList()
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, 不需要再指定對方用戶
if (user_a_id == userMySelf.bean.id)
return User.findById(user_b_id as string)
if (user_b_id == userMySelf.bean.id)
return userMySelf
return User.findById(user_a_id as string)
return null
}

View File

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

View File

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

View File

@@ -11,21 +11,38 @@ import process from "node:process"
import chalk from "chalk"
import child_process from "node:child_process"
import FileManager from "./data/FileManager.ts"
import TokenManager from "./api/TokenManager.ts"
import UserChatLinker from "./data/UserChatLinker.ts"
import path from "node:path"
import cookieParser from 'cookie-parser'
const app = express()
app.use('/', express.static(config.data_path + '/page_compiled'))
app.use(cookieParser())
app.get('/uploaded_files/:hash', (req, res) => {
const hash = req.params.hash as string
res.setHeader('Content-Type', 'text/plain')
if (hash == null) {
res.sendStatus(404)
res.send("404 Not Found")
res.send("404 Not Found", 404)
return
}
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) {
res.sendStatus(404)
res.send("404 Not Found")
res.send("404 Not Found", 404)
return
}