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.
@@ -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-B2dOrakb.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-DYJPnabS.css">
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": "2.0.17",
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"
@@ -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
- const app = createConfiguredApp(uploadsDir);
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,
@@ -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.users = new Map();
7
- this.groupName = '';
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 existing = this.users.get(username) || {};
20
- this.users.set(username, {
21
- socketId,
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.users.get(username);
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
- setOffline(username) {
44
- const user = this.users.get(username);
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
- listUsers() {
55
- return [...this.users.entries()].map(([username, user]) => ({
56
- sender: username,
57
- nickname: user.nickname || '',
58
- ip: user.ip || '',
59
- status: user.status
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.groupName = groupName || '';
155
+ this.setRoomName(DEFAULT_ROOM_ID, groupName);
65
156
  }
66
157
 
67
158
  getGroupName() {
68
- return this.groupName;
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
+ };
@@ -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));
@@ -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 loginUrl = `${homeUrl}/login?receiver=${req.query.receiver || 'group_chat'}`;
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 targetUrl = `${homeUrl}/chat?sender=${req.query.sender}&receiver=${receiver}`;
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
+ };