Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,23 @@ export interface EmailConfig {
to: string
}

export type PathStyle = 'posix' | 'win32'

export interface RemotePathMapping {
name?: string
remotePrefix: string
localPrefix: string
remoteStyle: PathStyle
localStyle: PathStyle
}

export interface Config {
milky: MilkyConfig
satori: SatoriConfig
ob11: OB11Config
webui: WebUIConfig
email?: EmailConfig
remotePathMappings: RemotePathMapping[]
// onlyLocalhost: boolean
enableLocalFile2Url?: boolean // 开启后,本地文件路径图片会转成http链接, 语音会转成base64
log?: boolean
Expand Down
1 change: 1 addition & 0 deletions src/common/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './file'
export * from './pathMapping'
export * from './misc'
export * from './misc'
export { getVideoInfo } from './video'
Expand Down
149 changes: 149 additions & 0 deletions src/common/utils/pathMapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import path from 'node:path'
import { PathStyle, RemotePathMapping } from '@/common/types'

type PathModule = typeof path.posix

const pathModules: Record<PathStyle, PathModule> = {
posix: path.posix,
win32: path.win32,
}

export interface NormalizedRemotePathMapping extends RemotePathMapping {
remotePrefix: string
localPrefix: string
}

interface Direction {
sourcePrefix: string
sourceStyle: PathStyle
targetPrefix: string
targetStyle: PathStyle
}

export interface RemotePathMapper {
remotePathToLocal(filePath: string): string
localPathToRemote(filePath: string): string
}

export function normalizeRemotePathMappings(mappings: readonly RemotePathMapping[] = []): NormalizedRemotePathMapping[] {
return mappings.map(mapping => {
const remoteStyle = mapping.remoteStyle
const localStyle = mapping.localStyle

return {
...mapping,
remoteStyle,
localStyle,
remotePrefix: normalizePrefix(mapping.remotePrefix, remoteStyle, 'remotePrefix'),
localPrefix: normalizePrefix(mapping.localPrefix, localStyle, 'localPrefix'),
}
})
}

export function createRemotePathMapper(mappings: readonly RemotePathMapping[] = []): RemotePathMapper {
const normalizedMappings = normalizeRemotePathMappings(mappings)
const remoteToLocalDirections = normalizedMappings.map(mapping => ({
sourcePrefix: mapping.remotePrefix,
sourceStyle: mapping.remoteStyle,
targetPrefix: mapping.localPrefix,
targetStyle: mapping.localStyle,
}))
const localToRemoteDirections = normalizedMappings.map(mapping => ({
sourcePrefix: mapping.localPrefix,
sourceStyle: mapping.localStyle,
targetPrefix: mapping.remotePrefix,
targetStyle: mapping.remoteStyle,
}))

return {
remotePathToLocal(filePath: string) {
return mapPath(filePath, remoteToLocalDirections)
},
localPathToRemote(filePath: string) {
return mapPath(filePath, localToRemoteDirections)
},
}
}

export function mapRemotePathToLocal(filePath: string, mappings: readonly RemotePathMapping[] = []): string {
return createRemotePathMapper(mappings).remotePathToLocal(filePath)
}

export function mapLocalPathToRemote(filePath: string, mappings: readonly RemotePathMapping[] = []): string {
return createRemotePathMapper(mappings).localPathToRemote(filePath)
}

function normalizePrefix(prefix: string, style: PathStyle, fieldName: string) {
if (!prefix) {
throw new Error(`remote path mapping ${fieldName} must not be empty`)
}

const pathModule = pathModules[style]
const normalized = stripTrailingSeparators(pathModule.normalize(prefix), style)
if (!pathModule.isAbsolute(normalized)) {
throw new Error(`remote path mapping ${fieldName} must be an absolute ${style} path: ${prefix}`)
}
return normalized
}

function mapPath(filePath: string, directions: Direction[]) {
let matched: Direction | undefined
let normalizedSourcePath = ''

for (const direction of directions) {
const pathModule = pathModules[direction.sourceStyle]
const candidate = pathModule.normalize(filePath)
if (!pathModule.isAbsolute(candidate)) {
continue
}
if (!isPrefixMatch(candidate, direction.sourcePrefix, direction.sourceStyle)) {
continue
}
if (!matched || direction.sourcePrefix.length > matched.sourcePrefix.length) {
matched = direction
normalizedSourcePath = candidate
}
}

if (!matched) {
return filePath
}

const rest = normalizedSourcePath.slice(matched.sourcePrefix.length)
const restParts = splitPathRest(rest, matched.sourceStyle)
return pathModules[matched.targetStyle].normalize(pathModules[matched.targetStyle].join(matched.targetPrefix, ...restParts))
}

function isPrefixMatch(filePath: string, prefix: string, style: PathStyle) {
const normalizedFilePath = style === 'win32' ? filePath.toLowerCase() : filePath
const normalizedPrefix = style === 'win32' ? prefix.toLowerCase() : prefix
if (normalizedFilePath === normalizedPrefix) {
return true
}

const root = pathModules[style].parse(prefix).root
if (prefix === root) {
return normalizedFilePath.startsWith(normalizedPrefix)
}

return normalizedFilePath.startsWith(normalizedPrefix + pathModules[style].sep)
}

function splitPathRest(rest: string, style: PathStyle) {
if (!rest) {
return []
}
if (style === 'win32') {
return rest.split(/[\\/]+/).filter(Boolean)
}
return rest.split('/').filter(Boolean)
}

function stripTrailingSeparators(input: string, style: PathStyle) {
const root = pathModules[style].parse(input).root
let output = input
while (output.length > root.length && output.endsWith(pathModules[style].sep)) {
output = output.slice(0, -1)
}
return output
}
1 change: 1 addition & 0 deletions src/main/config/defaultConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const defaultConfig: Config = {
milky: milkyDefault,
satori: satoriDefault,
ob11: ob11Default,
remotePathMappings: [],
enableLocalFile2Url: false,
log: true,
autoDeleteFile: false,
Expand Down
1 change: 1 addition & 0 deletions src/main/config/default_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
}
]
},
"remotePathMappings": [],
"enableLocalFile2Url": false,
"log": true,
"autoDeleteFile": false,
Expand Down
69 changes: 49 additions & 20 deletions src/ntqqapi/api/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { selfInfo } from '@/common/globalVars'
import { FlashFileListItem, FlashFileSetInfo } from '@/ntqqapi/types/flashfile'
import { HighwayHttpSession, HighwayTcpSession } from '../helper/highway'
import { Media } from '../proto'
import { RemotePathMapping } from '@/common/types'
import { createRemotePathMapper, RemotePathMapper } from '@/common/utils/pathMapping'

declare module 'cordis' {
interface Context {
Expand All @@ -22,13 +24,30 @@ declare module 'cordis' {
}

export class NTQQFileApi extends Service {
static inject = ['logger', 'pmhq']
static inject = ['logger', 'pmhq', 'config']

rkeyManager: RkeyManager
private remotePathMapper: RemotePathMapper = createRemotePathMapper()

constructor(protected ctx: Context) {
super(ctx, 'ntFileApi')
this.rkeyManager = new RkeyManager(ctx, 'https://llob.linyuchen.net/rkey')
this.setRemotePathMappings(ctx.config.get().remotePathMappings)
ctx.on('llob/config-updated', input => {
this.setRemotePathMappings(input.remotePathMappings)
})
}

setRemotePathMappings(mappings: readonly RemotePathMapping[] = []) {
this.remotePathMapper = createRemotePathMapper(mappings)
}

remotePathToLocal(filePath: string) {
return this.remotePathMapper.remotePathToLocal(filePath)
}

localPathToRemote(filePath: string) {
return this.remotePathMapper.localPathToRemote(filePath)
}

async getVideoUrl(fileUuid: string, isGroup: boolean) {
Expand Down Expand Up @@ -75,11 +94,13 @@ export class NTQQFileApi extends Service {
fileName += ext ? '.' + ext : ''
}
const mediaPath = await this.getRichMediaFilePath(fileMd5, fileName, elementType, elementSubType)
await copyFile(filePath, mediaPath)
const localMediaPath = this.remotePathToLocal(mediaPath)
await copyFile(filePath, localMediaPath)
return {
md5: fileMd5,
fileName,
path: mediaPath,
localPath: localMediaPath,
}
}

Expand Down Expand Up @@ -274,16 +295,18 @@ export class NTQQFileApi extends Service {
}

async uploadGroupVideo(groupCode: string, filePath: string, thumbPath: string) {
const result = await this.ctx.pmhq.getGroupVideoUploadInfo(groupCode, filePath, thumbPath)
const localFilePath = this.remotePathToLocal(filePath)
const localThumbPath = this.remotePathToLocal(thumbPath)
const result = await this.ctx.pmhq.getGroupVideoUploadInfo(groupCode, localFilePath, localThumbPath)
const highwaySession = await this.ctx.pmhq.getHighwaySession()
const maxBlockSize = 1024 * 1024
if (result.ext.uKey) {
const { index } = result.ext.msgInfoBody[0]
result.ext.hash.fileSha1 = await calculateSha1StreamBytes(filePath)
result.ext.hash.fileSha1 = await calculateSha1StreamBytes(localFilePath)
const trans = {
uin: selfInfo.uin,
cmd: 1005,
readable: createReadStream(filePath, { highWaterMark: maxBlockSize }),
readable: createReadStream(localFilePath, { highWaterMark: maxBlockSize }),
sum: Buffer.from(index.info.md5HexStr, 'hex'),
size: index.info.fileSize,
ticket: highwaySession.sigSession,
Expand All @@ -299,11 +322,11 @@ export class NTQQFileApi extends Service {
}
if (result.subExt.uKey) {
const { index } = result.subExt.msgInfoBody[1]
result.subExt.hash.fileSha1 = await calculateSha1StreamBytes(thumbPath)
result.subExt.hash.fileSha1 = await calculateSha1StreamBytes(localThumbPath)
const trans = {
uin: selfInfo.uin,
cmd: 1006,
readable: createReadStream(thumbPath, { highWaterMark: maxBlockSize }),
readable: createReadStream(localThumbPath, { highWaterMark: maxBlockSize }),
sum: Buffer.from(index.info.md5HexStr, 'hex'),
size: index.info.fileSize,
ticket: highwaySession.sigSession,
Expand All @@ -324,16 +347,18 @@ export class NTQQFileApi extends Service {
}

async uploadC2CVideo(peerUid: string, filePath: string, thumbPath: string) {
const result = await this.ctx.pmhq.getC2CVideoUploadInfo(peerUid, filePath, thumbPath)
const localFilePath = this.remotePathToLocal(filePath)
const localThumbPath = this.remotePathToLocal(thumbPath)
const result = await this.ctx.pmhq.getC2CVideoUploadInfo(peerUid, localFilePath, localThumbPath)
const highwaySession = await this.ctx.pmhq.getHighwaySession()
const maxBlockSize = 1024 * 1024
if (result.ext.uKey) {
const { index } = result.ext.msgInfoBody[0]
result.ext.hash.fileSha1 = await calculateSha1StreamBytes(filePath)
result.ext.hash.fileSha1 = await calculateSha1StreamBytes(localFilePath)
const trans = {
uin: selfInfo.uin,
cmd: 1001,
readable: createReadStream(filePath, { highWaterMark: maxBlockSize }),
readable: createReadStream(localFilePath, { highWaterMark: maxBlockSize }),
sum: Buffer.from(index.info.md5HexStr, 'hex'),
size: index.info.fileSize,
ticket: highwaySession.sigSession,
Expand All @@ -349,11 +374,11 @@ export class NTQQFileApi extends Service {
}
if (result.subExt.uKey) {
const { index } = result.subExt.msgInfoBody[1]
result.subExt.hash.fileSha1 = await calculateSha1StreamBytes(thumbPath)
result.subExt.hash.fileSha1 = await calculateSha1StreamBytes(localThumbPath)
const trans = {
uin: selfInfo.uin,
cmd: 1002,
readable: createReadStream(thumbPath, { highWaterMark: maxBlockSize }),
readable: createReadStream(localThumbPath, { highWaterMark: maxBlockSize }),
sum: Buffer.from(index.info.md5HexStr, 'hex'),
size: index.info.fileSize,
ticket: highwaySession.sigSession,
Expand All @@ -374,7 +399,8 @@ export class NTQQFileApi extends Service {
}

async uploadGroupFile(groupCode: string, filePath: string, fileName: string, parentFolderId = '/') {
const result = await this.ctx.pmhq.getGroupFileUploadInfo(groupCode, filePath, fileName, parentFolderId)
const localFilePath = this.remotePathToLocal(filePath)
const result = await this.ctx.pmhq.getGroupFileUploadInfo(groupCode, localFilePath, fileName, parentFolderId)
if (!result.fileExist) {
const highwaySession = await this.ctx.pmhq.getHighwaySession()
const ext = Media.FileUploadExt.encode({
Expand Down Expand Up @@ -418,7 +444,7 @@ export class NTQQFileApi extends Service {
const trans = {
uin: selfInfo.uin,
cmd: 71,
readable: createReadStream(filePath, { highWaterMark: maxBlockSize }),
readable: createReadStream(localFilePath, { highWaterMark: maxBlockSize }),
sum: result.md5,
size: result.fileSize,
ticket: highwaySession.sigSession,
Expand All @@ -439,7 +465,8 @@ export class NTQQFileApi extends Service {
}

async uploadC2CFile(peerUid: string, filePath: string, fileName: string) {
const result = await this.ctx.pmhq.getC2CFileUploadInfo(peerUid, filePath, fileName)
const localFilePath = this.remotePathToLocal(filePath)
const result = await this.ctx.pmhq.getC2CFileUploadInfo(peerUid, localFilePath, fileName)
const highwaySession = await this.ctx.pmhq.getHighwaySession()
const ext = Media.FileUploadExt.encode({
unknown1: 100,
Expand Down Expand Up @@ -483,7 +510,7 @@ export class NTQQFileApi extends Service {
const trans = {
uin: selfInfo.uin,
cmd: 95,
readable: createReadStream(filePath, { highWaterMark: maxBlockSize }),
readable: createReadStream(localFilePath, { highWaterMark: maxBlockSize }),
sum: result.md5CheckSum,
size: result.fileSize,
ticket: highwaySession.sigSession,
Expand All @@ -504,15 +531,16 @@ export class NTQQFileApi extends Service {
}

async uploadGroupImage(groupCode: string, filePath: string) {
const result = await this.ctx.pmhq.getGroupImageUploadInfo(groupCode, filePath)
const localFilePath = this.remotePathToLocal(filePath)
const result = await this.ctx.pmhq.getGroupImageUploadInfo(groupCode, localFilePath)
const highwaySession = await this.ctx.pmhq.getHighwaySession()
const maxBlockSize = 1024 * 1024
if (result.ext.uKey) {
const { index } = result.ext.msgInfoBody[0]
const trans = {
uin: selfInfo.uin,
cmd: 1004,
readable: createReadStream(filePath, { highWaterMark: maxBlockSize }),
readable: createReadStream(localFilePath, { highWaterMark: maxBlockSize }),
sum: Buffer.from(index.info.md5HexStr, 'hex'),
size: index.info.fileSize,
ticket: highwaySession.sigSession,
Expand All @@ -533,15 +561,16 @@ export class NTQQFileApi extends Service {
}

async uploadC2CImage(peerUid: string, filePath: string) {
const result = await this.ctx.pmhq.getC2CImageUploadInfo(peerUid, filePath)
const localFilePath = this.remotePathToLocal(filePath)
const result = await this.ctx.pmhq.getC2CImageUploadInfo(peerUid, localFilePath)
const highwaySession = await this.ctx.pmhq.getHighwaySession()
const maxBlockSize = 1024 * 1024
if (result.ext.uKey) {
const { index } = result.ext.msgInfoBody[0]
const trans = {
uin: selfInfo.uin,
cmd: 1003,
readable: createReadStream(filePath, { highWaterMark: maxBlockSize }),
readable: createReadStream(localFilePath, { highWaterMark: maxBlockSize }),
sum: Buffer.from(index.info.md5HexStr, 'hex'),
size: index.info.fileSize,
ticket: highwaySession.sigSession,
Expand Down
Loading