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

alkem-io / server / #9160

28 Jan 2025 03:04PM UTC coverage: 14.318%. First build
#9160

Pull #4889

travis-ci

Pull Request #4889: fix default avatar generation

84 of 4992 branches covered (1.68%)

Branch coverage included in aggregate %.

2 of 8 new or added lines in 3 files covered. (25.0%)

2299 of 11651 relevant lines covered (19.73%)

7.22 hits per line

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

17.02
/src/domain/common/visual/visual.service.ts
1
import { LogContext } from '@common/enums';
55✔
2
import { AuthorizationPolicyType } from '@common/enums/authorization.policy.type';
55✔
3
import { VisualType } from '@common/enums/visual.type';
55✔
4
import {
55✔
5
  EntityNotFoundException,
6
  ValidationException,
7
} from '@common/exceptions';
8
import { StorageUploadFailedException } from '@common/exceptions/storage/storage.upload.failed.exception';
55✔
9
import { streamToBuffer } from '@common/utils';
10
import { AuthorizationPolicy } from '@domain/common/authorization-policy';
11
import { CreateVisualInput } from '@domain/common/visual/dto/visual.dto.create';
55✔
12
import { UpdateVisualInput } from '@domain/common/visual/dto/visual.dto.update';
55✔
13
import { IDocument } from '@domain/storage/document/document.interface';
55✔
14
import { DocumentService } from '@domain/storage/document/document.service';
55✔
15
import { IStorageBucket } from '@domain/storage/storage-bucket/storage.bucket.interface';
16
import { StorageBucketService } from '@domain/storage/storage-bucket/storage.bucket.service';
17
import { Inject, Injectable, LoggerService } from '@nestjs/common';
18
import { ConfigService } from '@nestjs/config';
19
import { InjectRepository } from '@nestjs/typeorm';
20
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
55✔
21
import { Readable } from 'stream';
55✔
22
import { FindOneOptions, Repository } from 'typeorm';
55✔
23
import { AuthorizationPolicyService } from '../authorization-policy/authorization.policy.service';
55✔
24
import { DeleteVisualInput } from './dto/visual.dto.delete';
55✔
25
import {
55✔
26
  DEFAULT_VISUAL_CONSTRAINTS,
27
  VISUAL_ALLOWED_TYPES,
28
} from './visual.constraints';
55✔
29
import { Visual } from './visual.entity';
30
import { IVisual } from './visual.interface';
×
31

×
32
@Injectable()
×
33
export class VisualService {
34
  constructor(
×
35
    private authorizationPolicyService: AuthorizationPolicyService,
36
    private documentService: DocumentService,
37
    private storageBucketService: StorageBucketService,
38
    @InjectRepository(Visual)
39
    private visualRepository: Repository<Visual>,
40
    @Inject(WINSTON_MODULE_NEST_PROVIDER)
41
    private readonly logger: LoggerService,
×
42
    private configService: ConfigService
43
  ) {}
×
44

45
  public createVisual(
46
    visualInput: CreateVisualInput,
×
47
    initialUri?: string
48
  ): IVisual {
49
    if (!visualInput.name) {
50
      throw new ValidationException(
×
51
        'Visual type (name) must be provided when creating a visual.',
×
52
        LogContext.COMMUNITY
53
      );
54
    }
×
55
    const visual: IVisual = Visual.create({
56
      ...visualInput,
57
      uri: initialUri ?? '',
58
    });
×
59

×
60
    visual.authorization = new AuthorizationPolicy(
×
61
      AuthorizationPolicyType.VISUAL
×
62
    );
63

64
    if (initialUri) {
×
65
      visual.uri = initialUri;
66
    }
67

68
    return visual;
×
69
  }
×
70

71
  async updateVisual(visualData: UpdateVisualInput): Promise<IVisual> {
×
72
    const visual = await this.getVisualOrFail(visualData.visualID);
×
73
    visual.uri = visualData.uri;
74
    if (visualData.alternativeText !== undefined) {
×
75
      visual.alternativeText = visualData.alternativeText;
×
76
    }
×
77

78
    return await this.visualRepository.save(visual);
79
  }
80

81
  async deleteVisual(deleteData: DeleteVisualInput): Promise<IVisual> {
82
    const visualID = deleteData.ID;
83
    const visual = await this.getVisualOrFail(visualID);
84

85
    if (visual.authorization)
86
      await this.authorizationPolicyService.delete(visual.authorization);
87

88
    const { id } = visual;
89
    const result = await this.visualRepository.remove(visual as Visual);
90
    return {
×
91
      ...result,
92
      id,
×
93
    };
×
94
  }
95

96
  async uploadImageOnVisual(
97
    visual: IVisual,
98
    storageBucket: IStorageBucket,
×
99
    readStream: Readable,
100
    fileName: string,
×
101
    mimetype: string,
×
102
    userID: string
×
103
  ): Promise<IDocument | never> {
×
104
    this.validateMimeType(visual, mimetype);
105

106
    if (!readStream)
107
      throw new ValidationException(
×
108
        'Readstream should be defined!',
109
        LogContext.DOCUMENT
×
110
      );
111

112
    const startTime = Date.now();
113
    try {
114
      const streamTimeoutMs = this.configService.get<number>(
115
        'storage.file.stream_timeout_ms',
116
        { infer: true }
117
      )!;
×
118
      const buffer = await streamToBuffer(readStream, streamTimeoutMs);
×
119
      this.logger.verbose?.(
×
120
        {
121
          message: 'Stream buffered',
×
122
          fileName,
123
          durationMs: Date.now() - startTime,
124
          sizeBytes: buffer.length,
125
        },
×
126
        LogContext.STORAGE_BUCKET
127
      );
×
128

129
      const documentForVisual = await this.documentService.getDocumentFromURL(
130
        visual.uri
131
      );
132

133
      // file-service-go canonicalizes image bytes (physical EXIF
134
      // rotation, profile-preserving metadata strip) and reports
135
      // post-rotation pixel dimensions on the response — populated
136
      // here as transient `imageWidth`/`imageHeight` on the returned
137
      // document. Validation against visual-type constraints (CARD,
138
      // AVATAR, etc.) happens AFTER upload, using those dims. On
139
      // dim-mismatch we delete the just-uploaded doc to avoid
140
      // orphaning. Pre-upload local decoding is gone — file-service-go
141
      // is the single source of truth for canonical pixel dimensions.
142
      const newDocument =
143
        await this.storageBucketService.uploadFileAsDocumentFromBuffer(
144
          storageBucket.id,
×
145
          buffer,
146
          fileName,
147
          mimetype,
148
          userID
×
149
        );
×
150

151
      try {
152
        this.validateImageDimensionsOnVisual(visual, newDocument);
153
      } catch (validationError) {
×
154
        await this.documentService
155
          .deleteDocument({ ID: newDocument.id })
156
          .catch(cleanupError => {
157
            this.logger.error?.(
×
158
              {
159
                message:
160
                  'Failed to delete just-uploaded document after dimension validation failure',
161
                visualId: visual.id,
×
162
                documentId: newDocument.id,
163
                cleanupError: String(cleanupError),
164
              },
165
              cleanupError instanceof Error ? (cleanupError.stack ?? '') : '',
×
166
              LogContext.STORAGE_BUCKET
×
167
            );
168
          });
169
        throw validationError;
170
      }
171

172
      // Delete the old document
173
      if (
174
        documentForVisual &&
×
175
        newDocument.externalID != documentForVisual?.externalID
×
176
      ) {
177
        await this.documentService.deleteDocument({
178
          ID: documentForVisual.id,
179
        });
180
      }
181
      this.logger.verbose?.(
182
        {
×
183
          message: 'Visual upload completed',
×
184
          fileName,
185
          visualId: visual.id,
186
          durationMs: Date.now() - startTime,
187
        },
188
        LogContext.STORAGE_BUCKET
189
      );
190
      return newDocument;
×
191
    } catch (error: any) {
192
      if (error instanceof StorageUploadFailedException) {
193
        throw error;
194
      }
195
      throw new StorageUploadFailedException(
196
        'Upload on visual failed!',
197
        LogContext.STORAGE_BUCKET,
198
        {
199
          message: error.message,
200
          fileName,
×
201
          visualID: visual.id,
202
          originalException: error,
203
        }
204
      );
205
    }
206
  }
207

208
  async getVisualOrFail(
209
    visualID: string,
210
    options?: FindOneOptions<Visual>
×
211
  ): Promise<IVisual> {
212
    const visual = await this.visualRepository.findOne({
213
      where: { id: visualID },
214
      ...options,
215
    });
216
    if (!visual)
217
      throw new EntityNotFoundException(
218
        `Not able to locate visual with the specified ID: ${visualID}`,
219
        LogContext.SPACES
NEW
220
      );
×
221
    return visual;
222
  }
223

224
  async saveVisual(visual: IVisual): Promise<IVisual> {
225
    return await this.visualRepository.save(visual);
×
226
  }
227

228
  /**
229
   * Validate post-rotation pixel dimensions on a freshly-uploaded
230
   * document against the visual's allowed range. Reads dims from the
231
   * document's transient `imageWidth`/`imageHeight` fields populated by
232
   * `uploadFileAsDocumentFromBuffer` from file-service-go's response —
233
   * no local decode. Throws ValidationException if dims are missing
234
   * (non-image content reaching here is a contract bug; validateMimeType
235
   * runs upstream) or out of range.
236
   */
237
  public validateImageDimensionsOnVisual(
238
    visual: IVisual,
239
    document: IDocument
240
  ): void {
241
    const { imageWidth, imageHeight } = document;
242
    if (imageWidth === undefined || imageHeight === undefined) {
243
      throw new ValidationException(
244
        'Image dimensions missing from upload response — cannot validate against visual constraints',
245
        LogContext.STORAGE_BUCKET,
246
        {
247
          visualId: visual.id,
248
          documentId: document.id,
249
          mimeType: document.mimeType,
250
        }
251
      );
252
    }
253
    this.validateImageWidth(visual, imageWidth);
254
    this.validateImageHeight(visual, imageHeight);
255
  }
256

257
  public validateMimeType(visual: IVisual, mimeType: string) {
258
    // Check both the stored allowedTypes on the entity AND the current
259
    // DEFAULT_VISUAL_CONSTRAINTS. This ensures existing Visual entities
260
    // with stale allowedTypes (missing HEIC/HEIF) still accept new formats
261
    // without requiring a database migration.
262
    const currentDefaults =
263
      DEFAULT_VISUAL_CONSTRAINTS[
264
        visual.name as keyof typeof DEFAULT_VISUAL_CONSTRAINTS
265
      ];
266
    const allowedByDefaults = currentDefaults
267
      ? (currentDefaults.allowedTypes as readonly string[])
268
      : VISUAL_ALLOWED_TYPES;
269

270
    if (
271
      !visual.allowedTypes.includes(mimeType) &&
272
      !allowedByDefaults.includes(mimeType)
273
    ) {
274
      throw new ValidationException(
275
        'Image upload type not in allowed mime types',
276
        LogContext.COMMUNITY,
277
        {
278
          mimeType,
279
          allowedTypes: [...visual.allowedTypes],
280
        }
281
      );
282
    }
283
  }
284

285
  public validateImageWidth(visual: IVisual, imageWidth: number) {
286
    if (imageWidth < visual.minWidth || imageWidth > visual.maxWidth)
287
      throw new ValidationException(
288
        `Upload image has a width resolution of '${imageWidth}' which is not in the allowed range of ${visual.minWidth} - ${visual.maxWidth} pixels!`,
289
        LogContext.COMMUNITY
290
      );
291
  }
292

293
  public validateImageHeight(visual: IVisual, imageHeight: number) {
294
    if (imageHeight < visual.minHeight || imageHeight > visual.maxHeight)
295
      throw new ValidationException(
296
        `Upload image has a height resolution of '${imageHeight}' which is not in the allowed range of ${visual.minHeight} - ${visual.maxHeight} pixels!`,
297
        LogContext.COMMUNITY
298
      );
299
  }
300

301
  public createVisualBanner(uri?: string): IVisual {
302
    return this.createVisual(
303
      {
304
        name: VisualType.BANNER,
305
        ...DEFAULT_VISUAL_CONSTRAINTS[VisualType.BANNER],
306
      },
307
      uri
308
    );
309
  }
310

311
  public createVisualWhiteboardPreview(uri?: string): IVisual {
312
    return this.createVisual(
313
      {
314
        name: VisualType.WHITEBOARD_PREVIEW,
315
        ...DEFAULT_VISUAL_CONSTRAINTS[VisualType.WHITEBOARD_PREVIEW],
316
      },
317
      uri
318
    );
319
  }
320

321
  public createVisualCard(uri?: string): IVisual {
322
    return this.createVisual(
323
      {
324
        name: VisualType.CARD,
325
        ...DEFAULT_VISUAL_CONSTRAINTS[VisualType.CARD],
326
      },
327
      uri
328
    );
329
  }
330

331
  public createVisualBannerWide(uri?: string): IVisual {
332
    return this.createVisual(
333
      {
334
        name: VisualType.BANNER_WIDE,
335
        ...DEFAULT_VISUAL_CONSTRAINTS[VisualType.BANNER_WIDE],
336
      },
337
      uri
338
    );
339
  }
340

341
  public createVisualAvatar(uri?: string): IVisual {
342
    return this.createVisual(
343
      {
344
        name: VisualType.AVATAR,
345
        ...DEFAULT_VISUAL_CONSTRAINTS[VisualType.AVATAR],
346
      },
347
      uri
348
    );
349
  }
350

351
  public createVisualMediaGalleryImage(uri?: string): IVisual {
352
    return this.createVisual(
353
      {
354
        name: VisualType.MEDIA_GALLERY_IMAGE,
355
        ...DEFAULT_VISUAL_CONSTRAINTS[VisualType.MEDIA_GALLERY_IMAGE],
356
      },
357
      uri
358
    );
359
  }
360

361
  public createVisualMediaGalleryVideo(uri?: string): IVisual {
362
    return this.createVisual(
363
      {
364
        name: VisualType.MEDIA_GALLERY_VIDEO,
365
        ...DEFAULT_VISUAL_CONSTRAINTS[VisualType.MEDIA_GALLERY_VIDEO],
366
      },
367
      uri
368
    );
369
  }
370
}
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

© 2026 Coveralls, Inc