chattransfer 2.0.16 → 2.0.18

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.
@@ -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-CbD6gmvL.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-CAVS2mBK.css">
8
+ <script type="module" crossorigin src="/assets/index-PrmIL31V.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-DDcqIagG.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": "2.0.16",
3
+ "version": "2.0.18",
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/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,10 +125,10 @@ async function start(options = {}) {
123
125
  return;
124
126
  }
125
127
 
126
- const app = createConfiguredApp(uploadsDir);
128
+ const app = createConfiguredApp(uploadsDir, historyService, batchDownloadService);
127
129
  const server = http.createServer(app);
128
130
 
129
- registerSocket(server, historyService);
131
+ registerSocket(server, historyService, batchDownloadService);
130
132
 
131
133
  await new Promise((resolve, reject) => {
132
134
  server.once('error', reject);
@@ -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
+ };
@@ -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
- module.exports = function registerRoutes(app, configuredUploadsDir = getCachePath()) {
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));
@@ -35,6 +35,7 @@ module.exports = (configuredUploadsDir = getCachePath()) => {
35
35
  message: 'ok',
36
36
  data: {
37
37
  version: require('../../package.json').version,
38
+ copyright: '© suncx and ChatTransfer Contributors',
38
39
  deviceId: 'anonymous'
39
40
  }
40
41
  });
@@ -0,0 +1,161 @@
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, chatType, targetUser, messageIds }) {
64
+ const attachments = await this.historyService.getBatchAttachments({
65
+ requester,
66
+ chatType,
67
+ targetUser,
68
+ messageIds
69
+ });
70
+
71
+ const token = crypto.randomBytes(24).toString('hex');
72
+ this.tokens.set(token, {
73
+ attachments,
74
+ expiresAt: Date.now() + DOWNLOAD_TOKEN_TTL
75
+ });
76
+ this.cleanupTokens();
77
+
78
+ return {
79
+ token,
80
+ downloadUrl: `/batch-download/${token}`,
81
+ expiresIn: DOWNLOAD_TOKEN_TTL
82
+ };
83
+ }
84
+
85
+ consumeDownloadToken(token) {
86
+ if (typeof token !== 'string') return null;
87
+
88
+ const record = this.tokens.get(token);
89
+ if (!record) return null;
90
+ this.tokens.delete(token);
91
+
92
+ if (Date.now() > record.expiresAt) {
93
+ this.cleanupTokens();
94
+ return null;
95
+ }
96
+
97
+ return record;
98
+ }
99
+
100
+ cleanupTokens() {
101
+ const now = Date.now();
102
+ for (const [token, record] of this.tokens.entries()) {
103
+ if (now > record.expiresAt) this.tokens.delete(token);
104
+ }
105
+ }
106
+
107
+ streamArchive(res, record) {
108
+ const usedNames = new Set();
109
+ const availableAttachments = record.attachments
110
+ .map((attachment) => ({
111
+ ...attachment,
112
+ zipEntryName: buildUniqueZipEntryName(attachment.fileName, usedNames)
113
+ }))
114
+ .filter((attachment) => {
115
+ try {
116
+ return fs.statSync(attachment.physicalPath).isFile();
117
+ } catch (error) {
118
+ return false;
119
+ }
120
+ });
121
+
122
+ const missingAttachments = record.attachments.filter((attachment) => (
123
+ !availableAttachments.some((item) => item.msgId === attachment.msgId)
124
+ ));
125
+
126
+ if (availableAttachments.length === 0) {
127
+ return false;
128
+ }
129
+
130
+ const archive = new yazl.ZipFile();
131
+ const zipFileName = `ChatTransfer-${new Date().toISOString().replace(/[:.]/g, '-')}.zip`;
132
+ res.status(200);
133
+ res.setHeader('Content-Type', 'application/zip');
134
+ res.setHeader('Content-Disposition', `attachment; filename="${zipFileName}"`);
135
+ archive.outputStream.pipe(res);
136
+ archive.outputStream.once('error', (error) => {
137
+ console.error('[Batch download] archive failed:', error.message);
138
+ res.destroy(error);
139
+ });
140
+
141
+ availableAttachments.forEach((attachment) => {
142
+ const isAlreadyCompressed = /^(image|video)\//.test(attachment.fileType || '');
143
+ archive.addFile(attachment.physicalPath, attachment.zipEntryName, {
144
+ compress: !isAlreadyCompressed
145
+ });
146
+ });
147
+ archive.addBuffer(
148
+ Buffer.from(buildManifest(availableAttachments, missingAttachments), 'utf8'),
149
+ 'manifest.txt'
150
+ );
151
+ archive.end();
152
+
153
+ return true;
154
+ }
155
+ }
156
+
157
+ module.exports = {
158
+ BatchDownloadService,
159
+ buildUniqueZipEntryName,
160
+ sanitizeZipEntryName
161
+ };
@@ -5,6 +5,8 @@ const sqlite3 = require('sqlite3');
5
5
 
6
6
  const DEFAULT_LIMIT = 100;
7
7
  const MAX_LIMIT = 200;
8
+ const MAX_BATCH_ATTACHMENT_COUNT = 200;
9
+ const MAX_BATCH_ATTACHMENT_TOTAL_SIZE = 2 * 1024 * 1024 * 1024;
8
10
  const GROUP_SESSION_ID = 'group';
9
11
 
10
12
  class MessageHistoryService {
@@ -147,6 +149,82 @@ class MessageHistoryService {
147
149
  };
148
150
  }
149
151
 
152
+ async queryAttachments({ requester, chatType, targetUser }) {
153
+ this.ensureOpened();
154
+
155
+ const conversation = this.getConversation({ requester, chatType, targetUser });
156
+ const rows = await this.all(
157
+ `SELECT seq, id, sender_id, sender_nickname, message_type, create_time, created_at,
158
+ file_path, file_name, file_size, file_mime_type
159
+ FROM messages
160
+ WHERE session_id = ? AND file_path IS NOT NULL
161
+ ORDER BY created_at ASC, seq ASC`,
162
+ [conversation.sessionId]
163
+ );
164
+
165
+ const attachments = [];
166
+ for (const row of rows) {
167
+ const physicalPath = this.resolveExistingFilePath(row.file_path);
168
+ if (!physicalPath) continue;
169
+ const stats = fs.statSync(physicalPath);
170
+ if (!stats.isFile()) continue;
171
+ const physicalFileName = path.posix.basename(row.file_path);
172
+
173
+ attachments.push({
174
+ msgId: row.id,
175
+ seq: row.seq,
176
+ sender: row.sender_id,
177
+ senderNickname: row.sender_nickname || row.sender_id,
178
+ msgType: row.message_type,
179
+ createTime: row.create_time,
180
+ createdAt: row.created_at,
181
+ fileName: row.file_name || physicalFileName,
182
+ fileSize: Number(row.file_size) || stats.size,
183
+ fileType: row.file_mime_type,
184
+ fileUrl: `/files/${encodeURIComponent(physicalFileName)}`,
185
+ filePath: row.file_path,
186
+ physicalPath,
187
+ available: true
188
+ });
189
+ }
190
+
191
+ return { attachments };
192
+ }
193
+
194
+ async getBatchAttachments({ requester, chatType, targetUser, messageIds }) {
195
+ this.ensureOpened();
196
+
197
+ if (!Array.isArray(messageIds) || messageIds.length === 0) {
198
+ throw new Error('No attachment selected');
199
+ }
200
+
201
+ const requestedIds = new Set(messageIds.filter((id) => typeof id === 'string' && id.length > 0));
202
+ if (requestedIds.size === 0) throw new Error('No attachment selected');
203
+ if (requestedIds.size > MAX_BATCH_ATTACHMENT_COUNT) {
204
+ throw new Error(`Batch download supports at most ${MAX_BATCH_ATTACHMENT_COUNT} files`);
205
+ }
206
+
207
+ const { attachments } = await this.queryAttachments({ requester, chatType, targetUser });
208
+ const attachmentMap = new Map(attachments.map((attachment) => [attachment.msgId, attachment]));
209
+ const selectedAttachments = [];
210
+
211
+ for (const messageId of requestedIds) {
212
+ const attachment = attachmentMap.get(messageId);
213
+ if (!attachment) throw new Error(`Attachment not found: ${messageId}`);
214
+ if (!attachment.available) throw new Error(`Attachment file missing: ${attachment.fileName}`);
215
+ selectedAttachments.push(attachment);
216
+ }
217
+
218
+ const totalSize = selectedAttachments.reduce((total, attachment) => (
219
+ total + (Number(attachment.fileSize) || 0)
220
+ ), 0);
221
+ if (totalSize > MAX_BATCH_ATTACHMENT_TOTAL_SIZE) {
222
+ throw new Error('Batch download exceeds the 2GB size limit');
223
+ }
224
+
225
+ return selectedAttachments;
226
+ }
227
+
150
228
  clearHistory({ requester, chatType, targetUser }) {
151
229
  this.ensureOpened();
152
230
 
@@ -411,6 +489,30 @@ class MessageHistoryService {
411
489
  return physicalPath;
412
490
  }
413
491
 
492
+ resolveExistingFilePath(filePath) {
493
+ const physicalPath = this.resolveFilePath(filePath);
494
+ if (!physicalPath) return null;
495
+
496
+ try {
497
+ const realCachePath = fs.realpathSync(this.cachePath);
498
+ const realFilesPath = path.join(realCachePath, 'files');
499
+ const realPhysicalPath = fs.realpathSync(physicalPath);
500
+ const relativePath = path.relative(realFilesPath, realPhysicalPath);
501
+
502
+ if (
503
+ relativePath === '' ||
504
+ relativePath.startsWith('..') ||
505
+ path.isAbsolute(relativePath)
506
+ ) {
507
+ return null;
508
+ }
509
+
510
+ return realPhysicalPath;
511
+ } catch (error) {
512
+ return null;
513
+ }
514
+ }
515
+
414
516
  isValidUserId(value) {
415
517
  return typeof value === 'string' && value.trim().length > 0 && value.length <= 256;
416
518
  }
@@ -6,10 +6,13 @@ const {
6
6
  decoratePrivateMessage
7
7
  } = require('../services/message-service');
8
8
 
9
- module.exports = function registerSocket(server, historyService) {
9
+ module.exports = function registerSocket(server, historyService, batchDownloadService) {
10
10
  if (!historyService) {
11
11
  throw new Error('historyService is required');
12
12
  }
13
+ if (!batchDownloadService) {
14
+ throw new Error('batchDownloadService is required');
15
+ }
13
16
 
14
17
  const io = socketIo(server, {
15
18
  cors: {
@@ -190,6 +193,67 @@ module.exports = function registerSocket(server, historyService) {
190
193
  }
191
194
  });
192
195
 
196
+ socket.on('attachment_query', async (params = {}, ack) => {
197
+ if (typeof ack !== 'function') return;
198
+
199
+ try {
200
+ const requester = socket.username || params.requester;
201
+ const chatType = params.chatType || (
202
+ params.targetUser === 'group_chat' ? 'group' : 'private'
203
+ );
204
+ const { attachments } = await historyService.queryAttachments({
205
+ requester,
206
+ chatType,
207
+ targetUser: params.targetUser
208
+ });
209
+
210
+ ack({
211
+ code: 0,
212
+ data: {
213
+ attachments: attachments.map((attachment) => ({
214
+ msgId: attachment.msgId,
215
+ seq: attachment.seq,
216
+ sender: attachment.sender,
217
+ senderNickname: attachment.senderNickname,
218
+ msgType: attachment.msgType,
219
+ createTime: attachment.createTime,
220
+ createdAt: attachment.createdAt,
221
+ fileName: attachment.fileName,
222
+ fileSize: attachment.fileSize,
223
+ fileType: attachment.fileType,
224
+ fileUrl: attachment.fileUrl,
225
+ available: attachment.available
226
+ }))
227
+ }
228
+ });
229
+ } catch (error) {
230
+ console.error('[Attachment] query failed:', error.message);
231
+ ack({ code: -1, message: 'Failed to load attachments' });
232
+ }
233
+ });
234
+
235
+ socket.on('attachment_download', async (params = {}, ack) => {
236
+ if (typeof ack !== 'function') return;
237
+
238
+ try {
239
+ const requester = socket.username || params.requester;
240
+ const chatType = params.chatType || (
241
+ params.targetUser === 'group_chat' ? 'group' : 'private'
242
+ );
243
+ const data = await batchDownloadService.createDownloadToken({
244
+ requester,
245
+ chatType,
246
+ targetUser: params.targetUser,
247
+ messageIds: params.messageIds
248
+ });
249
+
250
+ ack({ code: 0, data });
251
+ } catch (error) {
252
+ console.error('[Attachment] download request failed:', error.message);
253
+ ack({ code: -1, message: error.message });
254
+ }
255
+ });
256
+
193
257
  socket.on('history_clear', async (params = {}, ack) => {
194
258
  if (typeof ack !== 'function') return;
195
259