• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

teableio / teable / 8421654220

25 Mar 2024 02:22PM CUT coverage: 79.934% (+53.8%) from 26.087%
8421654220

Pull #495

github

web-flow
Merge 4faeebea5 into 1869c986d
Pull Request #495: chore: add licenses for non-NPM packages

3256 of 3853 branches covered (84.51%)

25152 of 31466 relevant lines covered (79.93%)

1188.29 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

75.36
/apps/nestjs-backend/src/features/attachments/plugins/local.ts
1
/* eslint-disable @typescript-eslint/naming-convention */
2✔
2
import { createReadStream, createWriteStream } from 'fs';
2✔
3
import { type Readable as ReadableStream } from 'node:stream';
2✔
4
import { join, resolve } from 'path';
2✔
5
import { BadRequestException, Injectable } from '@nestjs/common';
2✔
6
import { getRandomString } from '@teable/core';
2✔
7
import type { Request } from 'express';
2✔
8
import * as fse from 'fs-extra';
2✔
9
import sharp from 'sharp';
2✔
10
import { CacheService } from '../../../cache/cache.service';
2✔
11
import { IStorageConfig, StorageConfig } from '../../../configs/storage';
2✔
12
import { FileUtils } from '../../../utils';
2✔
13
import { Encryptor } from '../../../utils/encryptor';
2✔
14
import { getFullStorageUrl } from '../../../utils/full-storage-url';
2✔
15
import { second } from '../../../utils/second';
2✔
16
import type StorageAdapter from './adapter';
2✔
17
import type { ILocalFileUpload, IObjectMeta, IPresignParams, IRespHeaders } from './types';
2✔
18

2✔
19
interface ITokenEncryptor {
2✔
20
  expiresDate: number;
2✔
21
  respHeaders?: IRespHeaders;
2✔
22
}
2✔
23

2✔
24
@Injectable()
2✔
25
export class LocalStorage implements StorageAdapter {
2✔
26
  path: string;
64✔
27
  storageDir: string;
64✔
28
  temporaryDir = resolve(process.cwd(), '.temporary');
64✔
29
  expireTokenEncryptor: Encryptor<ITokenEncryptor>;
64✔
30
  readPath = '/api/attachments/read';
64✔
31

64✔
32
  constructor(
64✔
33
    @StorageConfig() readonly config: IStorageConfig,
64✔
34
    private readonly cacheService: CacheService
64✔
35
  ) {
64✔
36
    this.expireTokenEncryptor = new Encryptor(this.config.encryption);
64✔
37
    this.path = this.config.local.path;
64✔
38
    this.storageDir = resolve(process.cwd(), this.path);
64✔
39

64✔
40
    fse.ensureDir(this.temporaryDir);
64✔
41
    fse.ensureDir(this.storageDir);
64✔
42
  }
64✔
43

64✔
44
  private getUploadUrl(token: string) {
64✔
45
    return `/api/attachments/upload/${token}`;
8✔
46
  }
8✔
47

64✔
48
  private deleteFile(filePath: string) {
64✔
49
    if (fse.existsSync(filePath)) {
×
50
      fse.unlinkSync(filePath);
×
51
    }
×
52
  }
×
53

64✔
54
  private getUrl(bucket: string, path: string, params: ITokenEncryptor) {
64✔
55
    const token = this.expireTokenEncryptor.encrypt(params);
18✔
56
    return `${join(this.readPath, bucket, path)}?token=${token}`;
18✔
57
  }
18✔
58

64✔
59
  parsePath(path: string) {
64✔
60
    const parts = path.split('/');
18✔
61
    return {
18✔
62
      bucket: parts[0],
18✔
63
      token: parts[parts.length - 1],
18✔
64
    };
18✔
65
  }
18✔
66

64✔
67
  async presigned(_bucket: string, dir: string, params: IPresignParams) {
64✔
68
    const { contentType, contentLength, hash } = params;
8✔
69
    const token = getRandomString(12);
8✔
70
    const filename = hash ?? token;
8✔
71
    const expiresIn = params?.expiresIn ?? second(this.config.tokenExpireIn);
8✔
72
    await this.cacheService.set(
8✔
73
      `attachment:local-signature:${token}`,
8✔
74
      {
8✔
75
        expiresDate: Math.floor(Date.now() / 1000) + expiresIn,
8✔
76
        contentLength,
8✔
77
        contentType,
8✔
78
      },
8✔
79
      expiresIn
8✔
80
    );
8✔
81

8✔
82
    const path = join(dir, filename);
8✔
83
    return {
8✔
84
      token,
8✔
85
      path,
8✔
86
      url: this.getUploadUrl(token),
8✔
87
      uploadMethod: 'PUT',
8✔
88
      requestHeaders: {
8✔
89
        'Content-Type': contentType,
8✔
90
        'Content-Length': contentLength,
8✔
91
      },
8✔
92
    };
8✔
93
  }
8✔
94

64✔
95
  async validateToken(token: string, file: ILocalFileUpload) {
64✔
96
    const validateMeta = await this.cacheService.get(`attachment:local-signature:${token}`);
8✔
97
    if (!validateMeta) {
8!
98
      throw new BadRequestException('Invalid token');
×
99
    }
×
100
    const { expiresDate, contentLength, contentType } = validateMeta;
8✔
101

8✔
102
    const { size, mimetype } = file;
8✔
103
    if (Math.floor(Date.now() / 1000) > expiresDate) {
8!
104
      throw new BadRequestException('Token has expired');
×
105
    }
×
106
    if (contentLength && contentLength !== size) {
8!
107
      throw new BadRequestException('Size mismatch');
×
108
    }
×
109
    if (mimetype && mimetype !== contentType) {
8!
110
      throw new BadRequestException(`Not allow upload ${mimetype} file`);
×
111
    }
×
112
  }
8✔
113

64✔
114
  async saveTemporaryFile(req: Request) {
64✔
115
    const name = getRandomString(12);
8✔
116
    const path = resolve(this.temporaryDir, name);
8✔
117
    let size = 0;
8✔
118
    return new Promise<ILocalFileUpload>((resolve, reject) => {
8✔
119
      try {
8✔
120
        const fileStream = createWriteStream(path);
8✔
121
        req.on('data', (chunk) => {
8✔
122
          fileStream.write(chunk);
8✔
123
          size += chunk.length;
8✔
124
        });
8✔
125

8✔
126
        req.on('end', () => {
8✔
127
          fileStream.end();
8✔
128
          resolve({
8✔
129
            size,
8✔
130
            mimetype: req.headers['content-type'] as string,
8✔
131
            path,
8✔
132
          });
8✔
133
        });
8✔
134
        req.on('error', (err) => {
8✔
135
          this.deleteFile(path);
×
136
          reject(err.message);
×
137
        });
×
138
        fileStream.on('error', (err) => {
8✔
139
          this.deleteFile(path);
×
140
          reject(err.message);
×
141
        });
×
142
      } catch (error) {
8!
143
        this.deleteFile(path);
×
144
        reject(error);
×
145
      }
×
146
    });
8✔
147
  }
8✔
148

64✔
149
  async save(filePath: string, rename: string) {
64✔
150
    const distPath = resolve(this.storageDir);
13✔
151
    const newFilePath = resolve(distPath, rename);
13✔
152
    await fse.copy(filePath, newFilePath);
13✔
153
    await fse.remove(filePath);
13✔
154
    return join(this.path, rename);
13✔
155
  }
13✔
156

64✔
157
  read(path: string) {
64✔
158
    return createReadStream(resolve(this.storageDir, path));
18✔
159
  }
18✔
160

64✔
161
  getLastModifiedTime(path: string) {
64✔
162
    const url = resolve(this.storageDir, path);
18✔
163
    if (!fse.existsSync(url)) {
18!
164
      return;
×
165
    }
×
166
    return fse.statSync(url).mtimeMs;
18✔
167
  }
18✔
168

64✔
169
  async getFileMate(path: string) {
64✔
170
    const info = await sharp(path).metadata();
×
171
    return {
×
172
      width: info.width,
×
173
      height: info.height,
×
174
    };
×
175
  }
×
176

64✔
177
  async getObjectMeta(bucket: string, path: string, token: string): Promise<IObjectMeta> {
64✔
178
    const uploadCache = await this.cacheService.get(`attachment:upload:${token}`);
8✔
179
    if (!uploadCache) {
8!
180
      throw new BadRequestException(`Invalid token: ${token}`);
×
181
    }
×
182
    const { mimetype, hash, size } = uploadCache;
8✔
183

8✔
184
    const meta = {
8✔
185
      hash,
8✔
186
      mimetype,
8✔
187
      size,
8✔
188
      url: this.getUrl(bucket, path, {
8✔
189
        respHeaders: { 'Content-Type': mimetype },
8✔
190
        expiresDate: -1,
8✔
191
      }),
8✔
192
    };
8✔
193

8✔
194
    if (!mimetype?.startsWith('image/')) {
8✔
195
      return meta;
8✔
196
    }
8!
197
    return {
×
198
      ...meta,
×
199
      ...(await this.getFileMate(resolve(this.storageDir, bucket, path))),
×
200
    };
×
201
  }
×
202

64✔
203
  async getPreviewUrl(
64✔
204
    bucket: string,
10✔
205
    path: string,
10✔
206
    expiresIn: number = second(this.config.urlExpireIn),
10✔
207
    respHeaders?: IRespHeaders
10✔
208
  ): Promise<string> {
10✔
209
    const url = this.getUrl(bucket, path, {
10✔
210
      expiresDate: Math.floor(Date.now() / 1000) + expiresIn,
10✔
211
      respHeaders,
10✔
212
    });
10✔
213
    return getFullStorageUrl(url);
10✔
214
  }
10✔
215

64✔
216
  verifyReadToken(token: string) {
64✔
217
    try {
18✔
218
      const { expiresDate, respHeaders } = this.expireTokenEncryptor.decrypt(token);
18✔
219
      if (expiresDate > 0 && Math.floor(Date.now() / 1000) > expiresDate) {
18!
220
        throw new BadRequestException('Token has expired');
×
221
      }
×
222
      return { respHeaders };
18✔
223
    } catch (error) {
18!
224
      throw new BadRequestException('Invalid token');
×
225
    }
×
226
  }
18✔
227

64✔
228
  async uploadFileWidthPath(
64✔
229
    bucket: string,
×
230
    path: string,
×
231
    filePath: string,
×
232
    _metadata: Record<string, unknown>
×
233
  ) {
×
234
    const hash = await FileUtils.getHash(filePath);
×
235
    await this.save(filePath, join(bucket, path));
×
236
    return {
×
237
      hash,
×
238
      url: join(this.readPath, bucket, path),
×
239
    };
×
240
  }
×
241

64✔
242
  async uploadFile(
64✔
243
    bucket: string,
5✔
244
    path: string,
5✔
245
    stream: Buffer | ReadableStream,
5✔
246
    _metadata?: Record<string, unknown>
5✔
247
  ) {
5✔
248
    const name = getRandomString(12);
5✔
249
    const temPath = resolve(this.temporaryDir, name);
5✔
250
    if (stream instanceof Buffer) {
5✔
251
      await fse.writeFile(temPath, stream);
5✔
252
    } else {
5!
253
      await new Promise<void>((resolve, reject) => {
×
254
        const writer = createWriteStream(temPath);
×
255
        stream.pipe(writer);
×
256
        stream.on('end', function () {
×
257
          writer.end();
×
258
          writer.close();
×
259
          resolve();
×
260
        });
×
261
        stream.on('error', (err) => {
×
262
          writer.end();
×
263
          writer.close();
×
264
          this.deleteFile(path);
×
265
          reject(err);
×
266
        });
×
267
      });
×
268
    }
×
269
    const hash = await FileUtils.getHash(temPath);
5✔
270
    await this.save(temPath, join(bucket, path));
5✔
271
    return {
5✔
272
      hash,
5✔
273
      url: join(this.readPath, bucket, path),
5✔
274
    };
5✔
275
  }
5✔
276
}
64✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc