Compare commits

...

18 Commits

Author SHA1 Message Date
CrescentLeaf
546f04dc0e chore: declare new Api 2025-09-08 23:18:26 +08:00
CrescentLeaf
bc11034892 feat(wip): declare Message 2025-09-08 23:18:13 +08:00
CrescentLeaf
dfe8b27a12 feat(wip): 聊天頁面的消息列表, 自己索引消息 2025-09-08 23:17:59 +08:00
CrescentLeaf
5eb7e0018a feat(untestes): setAvatar 2025-09-08 23:17:28 +08:00
CrescentLeaf
b3015084a6 feat(wip): sendMessage getMessageHistory 2025-09-08 23:17:05 +08:00
CrescentLeaf
316fd140bc feat: BaseApi 兩個 Token 檢查方法 2025-09-08 23:16:41 +08:00
CrescentLeaf
3cb9bcc148 chore: Token.ts 單獨成類 2025-09-08 23:16:17 +08:00
CrescentLeaf
4ca3bd44da fix: missing File.getHash 2025-09-08 22:46:31 +08:00
CrescentLeaf
39c1473c57 chore: fuck lint and make it happy 2025-09-08 22:45:46 +08:00
CrescentLeaf
3c3beebfc5 fix: wrong Crypto->E<-S 2025-09-08 22:44:53 +08:00
CrescentLeaf
9b3a24e37a chore: make lint unhappy 2025-09-08 21:33:38 +08:00
CrescentLeaf
182236964b chore: 更加豐富的顔色! 2025-09-08 21:31:17 +08:00
CrescentLeaf
a3920f9084 fix: 訪問令牌失效判定邏輯錯誤 2025-09-08 21:31:01 +08:00
CrescentLeaf
45aef8204a fix: CryptoES -> CryptoJS 2025-09-08 21:26:09 +08:00
CrescentLeaf
e2c385b559 fix: token not stored after login 2025-09-08 21:22:22 +08:00
CrescentLeaf
4a942f1e77 chore: remove unused window exportion 2025-09-08 21:20:53 +08:00
CrescentLeaf
fb541849b4 fix: LocalDataStorage 2025-09-08 21:18:58 +08:00
CrescentLeaf
9e92fad8fa chore: colorful console.log :) 2025-09-08 20:19:36 +08:00
19 changed files with 136 additions and 105 deletions

View File

@@ -1,10 +1,11 @@
import * as CryptoES from 'crypto-es'
// @ts-types="npm:@types/crypto-js"
import * as CryptoJS from 'crypto-js'
const dataIsEmpty = !localStorage.tws_data || localStorage.tws_data == ''
const aes = {
enc: (m: string, k: string) => CryptoES.AES.encrypt(m, k).toString(CryptoES.HexFormatter),
dec: (m: string, k: string) => CryptoES.AES.decrypt(m, k).toString(CryptoES.Utf8),
enc: (data: string, key: string) => CryptoJS.AES.encrypt(data, key).toString(),
dec: (data: string, key: string) => CryptoJS.AES.decrypt(data, key).toString(CryptoJS.enc.Utf8),
}
const key = location.host + '_TWS_姐姐'
@@ -20,7 +21,7 @@ const _data_cached = JSON.parse(_dec)
declare global {
interface Window {
data: {
apply: () => void
apply(): void
access_token?: string
}
}
@@ -28,17 +29,18 @@ declare global {
// deno-lint-ignore no-window
(window.data == null) && (window.data = new Proxy({
apply() {
localStorage.tws_data = aes.enc(JSON.stringify(_data_cached), key)
}
apply() {}
}, {
get(_obj, k) {
if (k == '_cached') return _data_cached
if (k == 'apply') return () => localStorage.tws_data = aes.enc(JSON.stringify(_data_cached), key)
return _data_cached[k]
},
set(_obj, k, v) {
if (k == '_cached') return false
_data_cached[k] = v
return true
},
}
}))
// deno-lint-ignore no-window

View File

@@ -0,0 +1,5 @@
export default class Message {
declare id: number
declare text: string
declare user_id: string
}

View File

@@ -22,7 +22,7 @@
"mdui": "npm:mdui@2.1.4",
"split.js": "npm:split.js@1.3.2",
"crypto-es": "npm:crypto-es@3.1.0",
"crypto-js": "npm:crypto-js@4.2.0",
"socket.io-client": "npm:socket.io-client@4.8.1"
}
}

View File

@@ -4,6 +4,8 @@ import MessageContainer from "./MessageContainer.jsx"
import * as React from 'react'
export default function ChatFragment({ ...props } = {}) {
const messageList = React.useState([])
return (
<div style={{
width: '100%',
@@ -31,11 +33,6 @@ export default function ChatFragment({ ...props } = {}) {
<mdui-button variant="text">加載更多</mdui-button>
</div>
<MessageContainer>
<Message
nickName="Fey"
avatar="https://www.court-records.net/mugshot/aa6-004-maya.png">
Test
</Message>
</MessageContainer>
{
// 输入框

View File

@@ -4,7 +4,7 @@ import useEventListener from "../useEventListener.ts"
import { checkApiSuccessOrSncakbar } from "../snackbar.ts"
import Client from "../../api/Client.ts"
import * as CryptoES from 'crypto-es'
import * as CryptoJS from 'crypto-js'
import data from "../../Data.ts";
interface Refs {
@@ -29,12 +29,13 @@ export default function LoginDialog({
const re = await Client.invoke("User.login", {
account: account,
password: CryptoES.SHA256(password).toString(CryptoES.Hex),
password: CryptoJS.SHA256(password).toString(CryptoJS.enc.Hex),
})
if (checkApiSuccessOrSncakbar(re, "登錄失敗")) return
data.access_token = re.data!.access_token as string
data.apply()
location.reload()
})
return (

View File

@@ -4,7 +4,7 @@ import useEventListener from "../useEventListener.ts";
import Client from "../../api/Client.ts";
import { checkApiSuccessOrSncakbar, snackbar } from "../snackbar.ts";
import * as CryptoES from 'crypto-es'
import * as CryptoJS from 'crypto-js'
interface Refs {
loginInputAccountRef: React.MutableRefObject<TextField | null>
@@ -31,7 +31,7 @@ export default function RegisterDialog({
const re = await Client.invoke("User.register", {
username: username,
nickname: registerInputNickNameRef.current!.value,
password: CryptoES.SHA256(registerInputPasswordRef.current!.value).toString(CryptoES.Hex),
password: CryptoJS.SHA256(registerInputPasswordRef.current!.value).toString(CryptoJS.enc.Hex),
})
if (checkApiSuccessOrSncakbar(re, "注冊失敗")) return

View File

@@ -1,7 +1,12 @@
export type CallMethod =
"User.auth" |
"User.register" |
"User.login"
"User.login" |
"User.setAvatar" |
"Chat.sendMessage" |
"Chat.getMessageHistory"
export type ClientEvent =
"Client.onMessage"

View File

@@ -5,6 +5,7 @@ import ApiCallbackMessage from "./ApiCallbackMessage.ts"
import EventCallbackFunction from "../typedef/EventCallbackFunction.ts"
import BaseApi from "./BaseApi.ts"
import DataWrongError from "./DataWrongError.ts";
import chalk from "chalk";
export default class ApiManager {
static httpServer: HttpServerLike
@@ -32,16 +33,22 @@ export default class ApiManager {
static initEvents() {
const io = this.socketIoServer
io.on('connection', (socket) => {
socket.on("The_White_Silk", (name: string, args: { [key: string]: unknown }, callback: (ret: ApiCallbackMessage) => void) => {
socket.on("The_White_Silk", (name: string, args: { [key: string]: unknown }, callback_: (ret: ApiCallbackMessage) => void) => {
function callback(ret: ApiCallbackMessage) {
console.log(chalk.blue('[發]') + ` ${socket.request.socket.remoteAddress} <- ${ret.code == 200 ? chalk.green(ret.msg) : chalk.red(ret.msg)} [${ret.code}]${ ret.data ? (' <extras: ' + JSON.stringify(ret.data) + '>') : ''}`)
return callback_(ret)
}
try {
if (name == null || args == null) return callback({
msg: "Invalid request.",
code: 400
})
console.log(chalk.red('[收]') + ` ${socket.request.socket.remoteAddress} -> ${chalk.yellow(name)} <args: ${JSON.stringify(args)}>`)
return callback(this.event_listeners[name]?.(args))
} catch (e) {
const err = e as Error
console.log(chalk.yellow('[壞]') + ` ${err.message} (${err.stack})`)
try {
callback({
code: err instanceof DataWrongError ? 400 : 500,

View File

@@ -1,6 +1,8 @@
import EventCallbackFunction from "../typedef/EventCallbackFunction.ts"
import ApiManager from "./ApiManager.ts"
import { CallMethod } from './ApiDeclare.ts'
import User from "../data/User.ts"
import Token from "./Token.ts"
export default abstract class BaseApi {
abstract getName(): string
@@ -20,6 +22,16 @@ export default abstract class BaseApi {
return true
return false
}
checkUserToken(user: User, token: Token) {
if (!this.checkToken(token)) return false
if (token.author != user.bean.id) return false
return true
}
checkToken(token: Token) {
if (token.expired_time < Date.now()) return false
if (!User.findById(token.author)) return false
return true
}
registerEvent(name: CallMethod, func: EventCallbackFunction) {
if (!name.startsWith(this.getName() + ".")) throw Error("注冊的事件應該與接口集合命名空間相匹配: " + name)
ApiManager.addEventListener(name, func)

24
server/api/ChatApi.ts Normal file
View File

@@ -0,0 +1,24 @@
import User from "../data/User.ts"
import BaseApi from "./BaseApi.ts"
export default class UserApi extends BaseApi {
override getName(): string {
return "Chat"
}
override onInit(): void {
this.registerEvent("Chat.sendMessage", (args) => {
return {
code: 501,
msg: "未實現",
}
})
this.registerEvent("Chat.getMessageHistory", (args) => {
return {
code: 501,
msg: "未實現",
}
})
}
}

6
server/api/Token.ts Normal file
View File

@@ -0,0 +1,6 @@
export default interface Token {
author: string
auth: string
made_time: number
expired_time: number
}

View File

@@ -1,14 +1,8 @@
import { Buffer } from "node:buffer";
import { Buffer } from "node:buffer"
import config from "../config.ts"
import User from "../data/User.ts"
import crypto from 'node:crypto'
interface Token {
author: string
auth: string
made_time: number
expired_time: number
}
import Token from "./Token.ts"
function normalizeKey(key: string, keyLength = 32) {
const hash = crypto.createHash('sha256')

View File

@@ -1,3 +1,4 @@
import { Buffer } from "node:buffer";
import User from "../data/User.ts";
import BaseApi from "./BaseApi.ts"
import TokenManager from "./TokenManager.ts";
@@ -16,7 +17,7 @@ export default class UserApi extends BaseApi {
try {
const access_token = TokenManager.decode(args.access_token as string)
if (access_token.expired_time > Date.now()) return {
if (access_token.expired_time < Date.now()) return {
msg: "登錄令牌失效",
code: 401,
}
@@ -91,5 +92,30 @@ export default class UserApi extends BaseApi {
},
}
})
// 更新頭像
this.registerEvent("User.setAvatar", (args) => {
if (this.checkArgsMissing(args, ['avatar', 'token'])) return {
msg: "參數缺失",
code: 400,
}
if (!(args.avatar instanceof Buffer)) return {
msg: "參數不合法",
code: 400,
}
const token = TokenManager.decode(args.token as string)
if (!this.checkToken(token)) return {
code: 401,
msg: "令牌無效",
}
const avatar: Buffer = args.avatar as Buffer
const user = User.findById(token.author)
user!.setAvatar(avatar)
return {
msg: "成功",
code: 200,
}
})
}
}

View File

@@ -4,6 +4,8 @@ import path from 'node:path'
import config from '../config.ts'
import ChatBean from './ChatBean.ts'
import { SQLInputValue } from "node:sqlite"
import chalk from "chalk"
/**
* Chat.ts - Wrapper and manager
@@ -25,12 +27,12 @@ export default class Chat {
return db
}
private static findAllByCondition(condition: string, ...args: unknown[]): ChatBean[] {
return database.prepare(`SELECT * FROM ${Chat.table_name} WHERE ${condition}`).all(...args)
private static findAllBeansByCondition(condition: string, ...args: SQLInputValue[]): ChatBean[] {
return this.database.prepare(`SELECT * FROM ${Chat.table_name} WHERE ${condition}`).all(...args) as unknown as ChatBean[]
}
static findById(id: string): Chat {
const beans = Chat.findAllBeansByCondition('id = ?', id)
const beans = this.findAllBeansByCondition('id = ?', id)
if (beans.length == 0)
throw new Error(`找不到 id 为 ${id} 的 Chat`)
else if (beans.length > 1)
@@ -42,41 +44,8 @@ export default class Chat {
constructor(bean: ChatBean) {
this.bean = bean
}
private setAttr(key: string, value: unknown): void {
User.database.prepare(`UPDATE ${Chat.table_name} SET ${key} = ? WHERE id = ?`).run(value, this.bean.id)
private setAttr(key: string, value: SQLInputValue): void {
Chat.database.prepare(`UPDATE ${Chat.table_name} SET ${key} = ? WHERE id = ?`).run(value, this.bean.id)
this.bean[key] = value
}
getSettings(): Chat.Settings {
return new Chat.Settings(this, JSON.parse(this.bean.settings))
}
static Settings = class {
declare bean: Chat.SettingsBean
declare chat: Chat
constructor(chat: Chat, bean: Chat.SettingsBean) {
this.bean = bean
this.chat = chat
for (const i of [
]) {
this["set" + i.substring(0, 1).toUpperCase() + i.substring(1)] = (v: unknown) => {
this.set(i, v)
}
}
}
set(key: string, value: unknown) {
this.bean[key] = value
}
get(key: string) {
return this.bean[key]
}
apply() {
this.chat.setAttr("settings", JSON.stringify(this.bean))
}
}
static SettingsBean = class {
}
}

View File

@@ -1,4 +1,6 @@
export default class ChatBean {
declare id: string
declare settings: string
[key: string]: unknown
}

View File

@@ -55,6 +55,9 @@ class File {
getLastUsedTime() {
return this.bean.last_used_time
}
getHash() {
return this.bean.hash
}
readSync() {
this.setAttr("last_used_time", Date.now())
return fs_sync.readFileSync(this.getFilePath())

7
server/data/Message.ts Normal file
View File

@@ -0,0 +1,7 @@
export default class Message {
declare id: number
declare text: string
declare user_id: string
[key: string]: unknown
}

View File

@@ -12,6 +12,8 @@ import FileManager from './FileManager.ts'
import { SQLInputValue } from "node:sqlite";
import DataWrongError from "../api/DataWrongError.ts";
type UserBeanKey = keyof UserBean
/**
* User.ts - Wrapper and manager
* Wrap with UserBean to directly update database
@@ -100,7 +102,7 @@ export default class User {
this.bean = bean
}
/* 一切的基础都是 count ID */
private setAttr(key: string, value: unknown) {
private setAttr(key: string, value: SQLInputValue) {
User.database.prepare(`UPDATE ${User.table_name} SET ${key} = ? WHERE count = ?`).run(value, this.bean.count)
this.bean[key] = value
}
@@ -123,42 +125,9 @@ export default class User {
this.setAttr("password", password)
}
getAvatar(): Buffer | null {
return FileManager.findByHash(this.bean.avatar_file_hash)?.readSync()
return this.bean.avatar_file_hash != null ? FileManager.findByHash(this.bean.avatar_file_hash)?.readSync() : null
}
setAvatar(avatar: Buffer) {
this.setAttr("avatar_file_hash", FileManager.uploadFile(`avatar_user_${this.bean.count}`, avatar).getHash())
}
getSettings() {
return new User.Settings(this, JSON.parse(this.bean.settings))
}
static Settings = class {
declare bean: User.SettingsBean
declare user: User
constructor(user: User, bean: User.SettingsBean) {
this.bean = bean
this.user = user
for (const i of [
]) {
this["set" + i.substring(0, 1).toUpperCase() + i.substring(1)] = (v: unknown) => {
this.set(i, v)
}
}
}
set(key: string, value: unknown) {
this.bean[key] = value
}
get(key: string) {
return this.bean[key]
}
apply() {
this.user.setAttr("settings", JSON.stringify(this.bean))
}
}
static SettingsBean = class {
async setAvatar(avatar: Buffer) {
this.setAttr("avatar_file_hash", (await FileManager.uploadFile(`avatar_user_${this.bean.count}`, avatar)).getHash())
}
}

View File

@@ -7,4 +7,6 @@ export default class UserBean {
declare nickname: string
declare avatar_file_hash: string | null
declare settings: string
[key: string]: unknown
}