chattransfer 2.0.17 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frontend/dist/assets/{browser-BzA-fxZ1.js → browser-_rMkfdfG.js} +1 -1
- package/frontend/dist/assets/{index-B2dOrakb.js → index-BTJ_Fb6p.js} +43 -43
- package/frontend/dist/assets/index-IvWY8A4p.css +1 -0
- package/frontend/dist/index.html +2 -2
- package/package.json +4 -3
- package/server/cli-options.js +9 -1
- package/server/index.js +20 -4
- package/server/room-manager.js +117 -25
- package/server/routes/batch-download.js +31 -0
- package/server/routes/index.js +14 -2
- package/server/routes/qr.js +10 -2
- package/server/services/batch-download-service.js +175 -0
- package/server/services/message-history-service.js +779 -13
- package/server/socket/index.js +592 -51
- package/server/utils/network.js +11 -1
- package/server/utils/runtime-options.js +30 -0
- package/frontend/dist/assets/index-DYJPnabS.css +0 -1
package/frontend/dist/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<link rel="icon" href="/assets/logo-DQUxWwh1.png" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>ChatTransfer</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-BTJ_Fb6p.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-IvWY8A4p.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="app"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chattransfer",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "LAN instant messaging and file transfer tool",
|
|
5
5
|
"main": "server/index.js",
|
|
6
6
|
"engines": {
|
|
@@ -32,10 +32,11 @@
|
|
|
32
32
|
"node-machine-id": "^1.1.12",
|
|
33
33
|
"prebuild-install": "7.1.3",
|
|
34
34
|
"qr-image": "^3.2.0",
|
|
35
|
-
"sqlite3": "5.1.7",
|
|
36
35
|
"socket.io": "^4.8.3",
|
|
36
|
+
"sqlite3": "5.1.7",
|
|
37
37
|
"underscore": "^1.10.2",
|
|
38
|
-
"urlencode": "^1.1.0"
|
|
38
|
+
"urlencode": "^1.1.0",
|
|
39
|
+
"yazl": "^3.3.1"
|
|
39
40
|
},
|
|
40
41
|
"bin": {
|
|
41
42
|
"chattransfer": "bin/chattransfer.js"
|
package/server/cli-options.js
CHANGED
|
@@ -5,6 +5,8 @@ const CLI_OPTIONS = {
|
|
|
5
5
|
port: { type: 'string' },
|
|
6
6
|
'data-dir': { type: 'string' },
|
|
7
7
|
'public-url': { type: 'string' },
|
|
8
|
+
'room-idle-retention-days': { type: 'string' },
|
|
9
|
+
'public-room-retention-days': { type: 'string' },
|
|
8
10
|
'no-browser': { type: 'boolean', default: false },
|
|
9
11
|
version: { type: 'boolean', default: false },
|
|
10
12
|
help: { type: 'boolean', default: false }
|
|
@@ -33,6 +35,8 @@ function toStartOptions(cliValues, env = process.env) {
|
|
|
33
35
|
port: cliValues.port,
|
|
34
36
|
dataDir: cliValues['data-dir'],
|
|
35
37
|
publicUrl: cliValues['public-url'],
|
|
38
|
+
roomIdleRetentionDays: cliValues['room-idle-retention-days'],
|
|
39
|
+
publicRoomRetentionDays: cliValues['public-room-retention-days'],
|
|
36
40
|
openBrowser: cliValues['no-browser'] ? false : undefined
|
|
37
41
|
}, env);
|
|
38
42
|
}
|
|
@@ -49,6 +53,8 @@ function getHelpText() {
|
|
|
49
53
|
' --port <port> Listen port (default: 55556)',
|
|
50
54
|
' --data-dir <path> Runtime data directory',
|
|
51
55
|
' --public-url <url> Public URL used by QR codes and links',
|
|
56
|
+
' --room-idle-retention-days <days> Delete idle rooms after N days (default: 7, 0 disables)',
|
|
57
|
+
' --public-room-retention-days <days> Clear Public Lobby messages after N days (default: 7, 0 disables)',
|
|
52
58
|
' --no-browser Do not open the local browser',
|
|
53
59
|
' --version Print the version',
|
|
54
60
|
' --help Print this help',
|
|
@@ -58,7 +64,9 @@ function getHelpText() {
|
|
|
58
64
|
' CHATTRANSFER_PORT',
|
|
59
65
|
' CHATTRANSFER_DATA_DIR',
|
|
60
66
|
' CHATTRANSFER_PUBLIC_URL',
|
|
61
|
-
' CHATTRANSFER_NO_BROWSER'
|
|
67
|
+
' CHATTRANSFER_NO_BROWSER',
|
|
68
|
+
' CHATTRANSFER_ROOM_IDLE_RETENTION_DAYS',
|
|
69
|
+
' CHATTRANSFER_PUBLIC_ROOM_RETENTION_DAYS'
|
|
62
70
|
].join('\n');
|
|
63
71
|
}
|
|
64
72
|
|
package/server/index.js
CHANGED
|
@@ -7,6 +7,7 @@ const { getCachePath, getConfigFile, getDefaultCachePath } = require('./config')
|
|
|
7
7
|
const registerRoutes = require('./routes/index');
|
|
8
8
|
const registerSocket = require('./socket/index');
|
|
9
9
|
const { MessageHistoryService } = require('./services/message-history-service');
|
|
10
|
+
const { BatchDownloadService } = require('./services/batch-download-service');
|
|
10
11
|
const { trackEvent } = require('./telemetry/compat');
|
|
11
12
|
const {
|
|
12
13
|
getDatabaseFile,
|
|
@@ -26,7 +27,7 @@ function createApp() {
|
|
|
26
27
|
return app;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
function createConfiguredApp(cachePath) {
|
|
30
|
+
function createConfiguredApp(cachePath, historyService = null, batchDownloadService = null) {
|
|
30
31
|
const app = createApp();
|
|
31
32
|
const safeCachePath = cachePath
|
|
32
33
|
? { path: path.resolve(cachePath), changed: false }
|
|
@@ -43,7 +44,7 @@ function createConfiguredApp(cachePath) {
|
|
|
43
44
|
console.warn(`[Cache] fallback to default: ${safeCachePath.path}`);
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
registerRoutes(app, safeCachePath.path);
|
|
47
|
+
registerRoutes(app, safeCachePath.path, historyService, batchDownloadService);
|
|
47
48
|
return app;
|
|
48
49
|
}
|
|
49
50
|
|
|
@@ -115,6 +116,7 @@ async function start(options = {}) {
|
|
|
115
116
|
cachePath: uploadsDir,
|
|
116
117
|
databasePath: usesIndependentDatabaseFile() ? getDatabaseFile() : undefined
|
|
117
118
|
});
|
|
119
|
+
const batchDownloadService = new BatchDownloadService({ historyService });
|
|
118
120
|
|
|
119
121
|
try {
|
|
120
122
|
await historyService.init();
|
|
@@ -123,16 +125,29 @@ async function start(options = {}) {
|
|
|
123
125
|
return;
|
|
124
126
|
}
|
|
125
127
|
|
|
126
|
-
|
|
128
|
+
historyService.configureIdleRetention(runtimeOptions.roomIdleRetentionDays);
|
|
129
|
+
const publicRoomRetentionDays = runtimeOptions.publicRoomRetentionDays;
|
|
130
|
+
if (Number.isFinite(publicRoomRetentionDays) && publicRoomRetentionDays > 0) {
|
|
131
|
+
historyService.startPublicRoomCleanup({ retentionDays: publicRoomRetentionDays });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const app = createConfiguredApp(uploadsDir, historyService, batchDownloadService);
|
|
127
135
|
const server = http.createServer(app);
|
|
128
136
|
|
|
129
|
-
registerSocket(server, historyService
|
|
137
|
+
const io = registerSocket(server, historyService, batchDownloadService, {
|
|
138
|
+
roomIdleRetentionDays: runtimeOptions.roomIdleRetentionDays
|
|
139
|
+
});
|
|
140
|
+
await io.idleCleanupReady;
|
|
130
141
|
|
|
131
142
|
await new Promise((resolve, reject) => {
|
|
132
143
|
server.once('error', reject);
|
|
133
144
|
server.listen(port, runtimeOptions.host, resolve);
|
|
134
145
|
});
|
|
135
146
|
|
|
147
|
+
server.once('close', () => {
|
|
148
|
+
historyService.stopPublicRoomCleanup();
|
|
149
|
+
});
|
|
150
|
+
|
|
136
151
|
const homeUrl = runtimeOptions.publicUrl || `http://${clientIpAddress}:${port}`;
|
|
137
152
|
|
|
138
153
|
console.log(`ChatTransfer v${pkg.version}`);
|
|
@@ -141,6 +156,7 @@ async function start(options = {}) {
|
|
|
141
156
|
console.log(`CacheDir: ${uploadsDir}`);
|
|
142
157
|
console.log(`Runtime: ${getRuntimeName()}`);
|
|
143
158
|
console.log(`DataDir: ${path.dirname(getConfigFile())}`);
|
|
159
|
+
console.log(`Retention: idle-room=${runtimeOptions.roomIdleRetentionDays}d public-messages=${runtimeOptions.publicRoomRetentionDays}d`);
|
|
144
160
|
|
|
145
161
|
trackEvent('app_start', {
|
|
146
162
|
local_url: homeUrl,
|
package/server/room-manager.js
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
const ONLINE = 'ONLINE';
|
|
2
2
|
const OFFLINE = 'OFFLINE';
|
|
3
|
+
const DEFAULT_ROOM_ID = 'group_chat';
|
|
3
4
|
|
|
4
5
|
class RoomManager {
|
|
5
6
|
constructor() {
|
|
6
|
-
this.
|
|
7
|
-
this.
|
|
7
|
+
this.rooms = new Map();
|
|
8
|
+
this.roomNames = new Map();
|
|
9
|
+
this.ensureRoom(DEFAULT_ROOM_ID);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
ensureRoom(roomId = DEFAULT_ROOM_ID) {
|
|
13
|
+
if (!this.rooms.has(roomId)) {
|
|
14
|
+
this.rooms.set(roomId, { users: new Map() });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return this.rooms.get(roomId);
|
|
8
18
|
}
|
|
9
19
|
|
|
10
20
|
getOnline() {
|
|
@@ -15,22 +25,55 @@ class RoomManager {
|
|
|
15
25
|
return OFFLINE;
|
|
16
26
|
}
|
|
17
27
|
|
|
18
|
-
setOnline({ username, socketId, nickname = '', ip = '' }) {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
28
|
+
setOnline({ roomId = DEFAULT_ROOM_ID, username, socketId, nickname = '', ip = '' }) {
|
|
29
|
+
const room = this.ensureRoom(roomId);
|
|
30
|
+
const existing = room.users.get(username) || {
|
|
31
|
+
socketIds: new Set(),
|
|
32
|
+
status: OFFLINE,
|
|
33
|
+
nickname: '',
|
|
34
|
+
ip: '',
|
|
35
|
+
lastSeenAt: null,
|
|
36
|
+
onlineSince: null
|
|
37
|
+
};
|
|
38
|
+
const socketIds = new Set(existing.socketIds || []);
|
|
39
|
+
if (socketId) socketIds.add(socketId);
|
|
40
|
+
const wasOnline = existing.status === ONLINE && socketIds.size > 0;
|
|
41
|
+
const user = {
|
|
42
|
+
...existing,
|
|
43
|
+
socketIds,
|
|
22
44
|
status: ONLINE,
|
|
23
45
|
nickname: nickname || existing.nickname || '',
|
|
24
|
-
ip
|
|
25
|
-
|
|
46
|
+
ip: ip || existing.ip || '',
|
|
47
|
+
onlineSince: wasOnline ? existing.onlineSince : Date.now()
|
|
48
|
+
};
|
|
49
|
+
room.users.set(username, user);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
user: this.getPublicUser(roomId, username),
|
|
53
|
+
becameOnline: !wasOnline
|
|
54
|
+
};
|
|
26
55
|
}
|
|
27
56
|
|
|
28
|
-
getUser(username) {
|
|
29
|
-
return this.users.get(username);
|
|
57
|
+
getUser(roomId = DEFAULT_ROOM_ID, username) {
|
|
58
|
+
return this.ensureRoom(roomId).users.get(username);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
getPublicUser(roomId = DEFAULT_ROOM_ID, username) {
|
|
62
|
+
const user = this.ensureRoom(roomId).users.get(username);
|
|
63
|
+
if (!user) return null;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
sender: username,
|
|
67
|
+
nickname: user.nickname || '',
|
|
68
|
+
ip: user.ip || '',
|
|
69
|
+
status: user.status,
|
|
70
|
+
lastSeenAt: user.lastSeenAt || null,
|
|
71
|
+
onlineSince: user.onlineSince || null
|
|
72
|
+
};
|
|
30
73
|
}
|
|
31
74
|
|
|
32
|
-
setNickname(username, nickname) {
|
|
33
|
-
const user = this.
|
|
75
|
+
setNickname({ roomId = DEFAULT_ROOM_ID, username, nickname = '' }) {
|
|
76
|
+
const user = this.getUser(roomId, username);
|
|
34
77
|
|
|
35
78
|
if (!user) {
|
|
36
79
|
return false;
|
|
@@ -40,36 +83,85 @@ class RoomManager {
|
|
|
40
83
|
return true;
|
|
41
84
|
}
|
|
42
85
|
|
|
43
|
-
|
|
44
|
-
const user = this.
|
|
86
|
+
removeSocket({ roomId = DEFAULT_ROOM_ID, username, socketId }) {
|
|
87
|
+
const user = this.getUser(roomId, username);
|
|
45
88
|
|
|
46
89
|
if (!user) {
|
|
47
|
-
return false;
|
|
90
|
+
return { user: null, hasConnections: false };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
user.socketIds.delete(socketId);
|
|
94
|
+
return {
|
|
95
|
+
user: this.getPublicUser(roomId, username),
|
|
96
|
+
hasConnections: user.socketIds.size > 0
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
markOffline({ roomId = DEFAULT_ROOM_ID, username }) {
|
|
101
|
+
const user = this.getUser(roomId, username);
|
|
102
|
+
|
|
103
|
+
if (!user) {
|
|
104
|
+
return { user: null, becameOffline: false };
|
|
48
105
|
}
|
|
49
106
|
|
|
107
|
+
const becameOffline = user.status !== OFFLINE;
|
|
108
|
+
user.socketIds.clear();
|
|
50
109
|
user.status = OFFLINE;
|
|
110
|
+
user.onlineSince = null;
|
|
111
|
+
user.lastSeenAt = Date.now();
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
user: this.getPublicUser(roomId, username),
|
|
115
|
+
becameOffline
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
setOffline({ roomId = DEFAULT_ROOM_ID, username, socketId = null }) {
|
|
120
|
+
const user = this.getUser(roomId, username);
|
|
121
|
+
|
|
122
|
+
if (!user) {
|
|
123
|
+
return { user: null, becameOffline: false, hasConnections: false };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (socketId) user.socketIds.delete(socketId);
|
|
127
|
+
const hasConnections = user.socketIds.size > 0;
|
|
128
|
+
|
|
129
|
+
return hasConnections
|
|
130
|
+
? { user: this.getPublicUser(roomId, username), becameOffline: false, hasConnections }
|
|
131
|
+
: { ...this.markOffline({ roomId, username }), hasConnections: false };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
listUsers(roomId = DEFAULT_ROOM_ID) {
|
|
135
|
+
const room = this.ensureRoom(roomId);
|
|
136
|
+
return [...room.users.keys()].map((username) => this.getPublicUser(roomId, username));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
removeRoom(roomId = DEFAULT_ROOM_ID) {
|
|
140
|
+
if (roomId === DEFAULT_ROOM_ID) return false;
|
|
141
|
+
this.rooms.delete(roomId);
|
|
142
|
+
this.roomNames.delete(roomId);
|
|
51
143
|
return true;
|
|
52
144
|
}
|
|
53
145
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}));
|
|
146
|
+
setRoomName(roomId = DEFAULT_ROOM_ID, name = '') {
|
|
147
|
+
this.roomNames.set(roomId, name || '');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
getRoomName(roomId = DEFAULT_ROOM_ID) {
|
|
151
|
+
return this.roomNames.get(roomId) || '';
|
|
61
152
|
}
|
|
62
153
|
|
|
63
154
|
setGroupName(groupName) {
|
|
64
|
-
this.
|
|
155
|
+
this.setRoomName(DEFAULT_ROOM_ID, groupName);
|
|
65
156
|
}
|
|
66
157
|
|
|
67
158
|
getGroupName() {
|
|
68
|
-
return this.
|
|
159
|
+
return this.getRoomName(DEFAULT_ROOM_ID);
|
|
69
160
|
}
|
|
70
161
|
}
|
|
71
162
|
|
|
72
163
|
module.exports = {
|
|
73
164
|
RoomManager,
|
|
74
|
-
USER_STATUS: [ONLINE, OFFLINE]
|
|
165
|
+
USER_STATUS: [ONLINE, OFFLINE],
|
|
166
|
+
DEFAULT_ROOM_ID
|
|
75
167
|
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
|
|
3
|
+
module.exports = function registerBatchDownloadRoutes(batchDownloadService) {
|
|
4
|
+
if (!batchDownloadService) {
|
|
5
|
+
throw new Error('batchDownloadService is required');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const router = express.Router();
|
|
9
|
+
|
|
10
|
+
router.get('/batch-download/:token', (req, res) => {
|
|
11
|
+
const record = batchDownloadService.consumeDownloadToken(req.params.token);
|
|
12
|
+
|
|
13
|
+
if (!record) {
|
|
14
|
+
return res.status(410).send({
|
|
15
|
+
code: -1,
|
|
16
|
+
message: 'Download link expired or already used'
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!batchDownloadService.streamArchive(res, record)) {
|
|
21
|
+
return res.status(404).send({
|
|
22
|
+
code: -1,
|
|
23
|
+
message: 'No downloadable attachment files'
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return undefined;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return router;
|
|
31
|
+
};
|
package/server/routes/index.js
CHANGED
|
@@ -4,11 +4,20 @@ const { trackEvent } = require('../telemetry/compat');
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const fileUpload = require('express-fileupload');
|
|
6
6
|
const { getCachePath } = require('../config');
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
const { BatchDownloadService } = require('../services/batch-download-service');
|
|
8
|
+
|
|
9
|
+
module.exports = function registerRoutes(
|
|
10
|
+
app,
|
|
11
|
+
configuredUploadsDir = getCachePath(),
|
|
12
|
+
historyService = null,
|
|
13
|
+
batchDownloadService = null
|
|
14
|
+
) {
|
|
9
15
|
const uploadsDir = path.resolve(configuredUploadsDir);
|
|
10
16
|
const filesDir = path.join(uploadsDir, 'files');
|
|
11
17
|
const tempDir = path.join(uploadsDir, 'tmp');
|
|
18
|
+
const sharedBatchDownloadService = batchDownloadService || (
|
|
19
|
+
historyService ? new BatchDownloadService({ historyService }) : null
|
|
20
|
+
);
|
|
12
21
|
|
|
13
22
|
if (!fs.existsSync(filesDir)) {
|
|
14
23
|
fs.mkdirSync(filesDir, { recursive: true });
|
|
@@ -31,6 +40,9 @@ module.exports = function registerRoutes(app, configuredUploadsDir = getCachePat
|
|
|
31
40
|
app.use(require('./session'));
|
|
32
41
|
app.use(require('./qr'));
|
|
33
42
|
app.use(require('./files')(uploadsDir));
|
|
43
|
+
if (sharedBatchDownloadService) {
|
|
44
|
+
app.use(require('./batch-download')(sharedBatchDownloadService));
|
|
45
|
+
}
|
|
34
46
|
|
|
35
47
|
app.use(express.static(path.join(__dirname, '../../frontend/dist')));
|
|
36
48
|
app.use('/files', express.static(filesDir));
|
package/server/routes/qr.js
CHANGED
|
@@ -37,7 +37,11 @@ router.get('/getIndexInfo', (req, res) => {
|
|
|
37
37
|
|
|
38
38
|
router.get('/getIndexInfo2', (req, res) => {
|
|
39
39
|
const homeUrl = getRequestBaseUrl(req);
|
|
40
|
-
const
|
|
40
|
+
const roomId = req.query.roomId;
|
|
41
|
+
const roomCode = req.query.code;
|
|
42
|
+
const loginUrl = roomId && roomId !== 'group_chat'
|
|
43
|
+
? `${homeUrl}/login?roomId=${encodeURIComponent(roomId)}&code=${encodeURIComponent(roomCode || '')}`
|
|
44
|
+
: `${homeUrl}/login?receiver=${req.query.receiver || 'group_chat'}`;
|
|
41
45
|
|
|
42
46
|
res.send({
|
|
43
47
|
code: 0,
|
|
@@ -51,7 +55,11 @@ router.get('/getIndexInfo2', (req, res) => {
|
|
|
51
55
|
router.get('/scanLogin', (req, res) => {
|
|
52
56
|
const homeUrl = getRequestBaseUrl(req);
|
|
53
57
|
const receiver = req.query.receiver || 'group_chat';
|
|
54
|
-
const
|
|
58
|
+
const roomId = req.query.roomId;
|
|
59
|
+
const roomCode = req.query.code;
|
|
60
|
+
const targetUrl = roomId && roomId !== 'group_chat'
|
|
61
|
+
? `${homeUrl}/chat?sender=${req.query.sender}&roomId=${encodeURIComponent(roomId)}&code=${encodeURIComponent(roomCode || '')}`
|
|
62
|
+
: `${homeUrl}/chat?sender=${req.query.sender}&receiver=${receiver}`;
|
|
55
63
|
|
|
56
64
|
res.send({
|
|
57
65
|
code: 0,
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const yazl = require('yazl');
|
|
5
|
+
|
|
6
|
+
const DOWNLOAD_TOKEN_TTL = 10 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
function sanitizeZipEntryName(name) {
|
|
9
|
+
const safeName = String(name || 'download')
|
|
10
|
+
.replace(/[\u0000-\u001f\u007f]/g, '')
|
|
11
|
+
.replace(/[\\/:*?"<>|]/g, '_')
|
|
12
|
+
.replace(/^\.+/, '_')
|
|
13
|
+
.trim();
|
|
14
|
+
|
|
15
|
+
return safeName || 'download';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function buildUniqueZipEntryName(fileName, usedNames) {
|
|
19
|
+
const baseName = sanitizeZipEntryName(fileName);
|
|
20
|
+
const extension = path.extname(baseName);
|
|
21
|
+
const nameWithoutExtension = extension ? baseName.slice(0, -extension.length) : baseName;
|
|
22
|
+
let candidate = baseName;
|
|
23
|
+
let index = 1;
|
|
24
|
+
|
|
25
|
+
while (usedNames.has(candidate.toLowerCase())) {
|
|
26
|
+
index += 1;
|
|
27
|
+
candidate = `${nameWithoutExtension} (${index})${extension}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
usedNames.add(candidate.toLowerCase());
|
|
31
|
+
return candidate;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildManifest(attachments, missingAttachments = []) {
|
|
35
|
+
const lines = ['ChatTransfer batch download manifest', ''];
|
|
36
|
+
|
|
37
|
+
attachments.forEach((attachment, index) => {
|
|
38
|
+
lines.push(`${index + 1}. ${attachment.zipEntryName}`);
|
|
39
|
+
lines.push(` Message ID: ${attachment.msgId}`);
|
|
40
|
+
lines.push(` Time: ${attachment.createTime}`);
|
|
41
|
+
lines.push(` Sender: ${attachment.senderNickname || attachment.sender}`);
|
|
42
|
+
lines.push(` Size: ${Number(attachment.fileSize) || 0} bytes`);
|
|
43
|
+
lines.push('');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (missingAttachments.length > 0) {
|
|
47
|
+
lines.push('Missing files:', '');
|
|
48
|
+
missingAttachments.forEach((attachment) => {
|
|
49
|
+
lines.push(`- ${attachment.fileName || attachment.msgId}`);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return lines.join('\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class BatchDownloadService {
|
|
57
|
+
constructor({ historyService }) {
|
|
58
|
+
if (!historyService) throw new Error('historyService is required');
|
|
59
|
+
this.historyService = historyService;
|
|
60
|
+
this.tokens = new Map();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async createDownloadToken({ requester, roomId, chatType, targetUser, messageIds }) {
|
|
64
|
+
const attachments = await this.historyService.getBatchAttachments({
|
|
65
|
+
requester,
|
|
66
|
+
roomId,
|
|
67
|
+
chatType,
|
|
68
|
+
targetUser,
|
|
69
|
+
messageIds
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const token = crypto.randomBytes(24).toString('hex');
|
|
73
|
+
this.tokens.set(token, {
|
|
74
|
+
attachments,
|
|
75
|
+
roomId: roomId || null,
|
|
76
|
+
expiresAt: Date.now() + DOWNLOAD_TOKEN_TTL
|
|
77
|
+
});
|
|
78
|
+
this.cleanupTokens();
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
token,
|
|
82
|
+
downloadUrl: `/batch-download/${token}`,
|
|
83
|
+
expiresIn: DOWNLOAD_TOKEN_TTL
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
consumeDownloadToken(token) {
|
|
88
|
+
if (typeof token !== 'string') return null;
|
|
89
|
+
|
|
90
|
+
const record = this.tokens.get(token);
|
|
91
|
+
if (!record) return null;
|
|
92
|
+
this.tokens.delete(token);
|
|
93
|
+
|
|
94
|
+
if (Date.now() > record.expiresAt) {
|
|
95
|
+
this.cleanupTokens();
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return record;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
cleanupTokens() {
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
for (const [token, record] of this.tokens.entries()) {
|
|
105
|
+
if (now > record.expiresAt) this.tokens.delete(token);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
revokeRoom(roomId) {
|
|
110
|
+
if (!roomId) return 0;
|
|
111
|
+
|
|
112
|
+
let revoked = 0;
|
|
113
|
+
for (const [token, record] of this.tokens.entries()) {
|
|
114
|
+
if (record.roomId !== roomId) continue;
|
|
115
|
+
this.tokens.delete(token);
|
|
116
|
+
revoked += 1;
|
|
117
|
+
}
|
|
118
|
+
return revoked;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
streamArchive(res, record) {
|
|
122
|
+
const usedNames = new Set();
|
|
123
|
+
const availableAttachments = record.attachments
|
|
124
|
+
.map((attachment) => ({
|
|
125
|
+
...attachment,
|
|
126
|
+
zipEntryName: buildUniqueZipEntryName(attachment.fileName, usedNames)
|
|
127
|
+
}))
|
|
128
|
+
.filter((attachment) => {
|
|
129
|
+
try {
|
|
130
|
+
return fs.statSync(attachment.physicalPath).isFile();
|
|
131
|
+
} catch (error) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const missingAttachments = record.attachments.filter((attachment) => (
|
|
137
|
+
!availableAttachments.some((item) => item.msgId === attachment.msgId)
|
|
138
|
+
));
|
|
139
|
+
|
|
140
|
+
if (availableAttachments.length === 0) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const archive = new yazl.ZipFile();
|
|
145
|
+
const zipFileName = `ChatTransfer-${new Date().toISOString().replace(/[:.]/g, '-')}.zip`;
|
|
146
|
+
res.status(200);
|
|
147
|
+
res.setHeader('Content-Type', 'application/zip');
|
|
148
|
+
res.setHeader('Content-Disposition', `attachment; filename="${zipFileName}"`);
|
|
149
|
+
archive.outputStream.pipe(res);
|
|
150
|
+
archive.outputStream.once('error', (error) => {
|
|
151
|
+
console.error('[Batch download] archive failed:', error.message);
|
|
152
|
+
res.destroy(error);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
availableAttachments.forEach((attachment) => {
|
|
156
|
+
const isAlreadyCompressed = /^(image|video)\//.test(attachment.fileType || '');
|
|
157
|
+
archive.addFile(attachment.physicalPath, attachment.zipEntryName, {
|
|
158
|
+
compress: !isAlreadyCompressed
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
archive.addBuffer(
|
|
162
|
+
Buffer.from(buildManifest(availableAttachments, missingAttachments), 'utf8'),
|
|
163
|
+
'manifest.txt'
|
|
164
|
+
);
|
|
165
|
+
archive.end();
|
|
166
|
+
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = {
|
|
172
|
+
BatchDownloadService,
|
|
173
|
+
buildUniqueZipEntryName,
|
|
174
|
+
sanitizeZipEntryName
|
|
175
|
+
};
|