feat: Add profile images
This commit is contained in:
+51
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
isAnimatedGif,
|
||||
isAnimatedWebp
|
||||
} from './profile-avatar-image.service';
|
||||
|
||||
describe('profile-avatar image animation detection', () => {
|
||||
it('detects animated gifs with multiple frames', () => {
|
||||
const animatedGif = new Uint8Array([
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
|
||||
0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00,
|
||||
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00,
|
||||
0x3B
|
||||
]).buffer;
|
||||
|
||||
expect(isAnimatedGif(animatedGif)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mark single-frame gifs as animated', () => {
|
||||
const staticGif = new Uint8Array([
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
|
||||
0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00,
|
||||
0x3B
|
||||
]).buffer;
|
||||
|
||||
expect(isAnimatedGif(staticGif)).toBe(false);
|
||||
});
|
||||
|
||||
it('detects animated webp files from the VP8X animation flag', () => {
|
||||
const animatedWebp = new Uint8Array([
|
||||
0x52, 0x49, 0x46, 0x46, 0x16, 0x00, 0x00, 0x00,
|
||||
0x57, 0x45, 0x42, 0x50,
|
||||
0x56, 0x50, 0x38, 0x58, 0x0A, 0x00, 0x00, 0x00,
|
||||
0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||
]).buffer;
|
||||
|
||||
expect(isAnimatedWebp(animatedWebp)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mark static webp files as animated', () => {
|
||||
const staticWebp = new Uint8Array([
|
||||
0x52, 0x49, 0x46, 0x46, 0x16, 0x00, 0x00, 0x00,
|
||||
0x57, 0x45, 0x42, 0x50,
|
||||
0x56, 0x50, 0x38, 0x58, 0x0A, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||
]).buffer;
|
||||
|
||||
expect(isAnimatedWebp(staticWebp)).toBe(false);
|
||||
});
|
||||
});
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
PROFILE_AVATAR_ALLOWED_MIME_TYPES,
|
||||
PROFILE_AVATAR_OUTPUT_SIZE,
|
||||
ProfileAvatarTransform,
|
||||
EditableProfileAvatarSource,
|
||||
ProcessedProfileAvatar,
|
||||
clampProfileAvatarTransform,
|
||||
PROFILE_AVATAR_EDITOR_FRAME_SIZE,
|
||||
resolveProfileAvatarBaseScale
|
||||
} from '../../domain/profile-avatar.models';
|
||||
|
||||
const PROFILE_AVATAR_OUTPUT_MIME = 'image/webp';
|
||||
const PROFILE_AVATAR_OUTPUT_QUALITY = 0.92;
|
||||
|
||||
export function isAnimatedGif(buffer: ArrayBuffer): boolean {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
if (bytes.length < 13 || readAscii(bytes, 0, 6) !== 'GIF87a' && readAscii(bytes, 0, 6) !== 'GIF89a') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let offset = 13;
|
||||
|
||||
if ((bytes[10] & 0x80) !== 0) {
|
||||
offset += 3 * (2 ** ((bytes[10] & 0x07) + 1));
|
||||
}
|
||||
|
||||
let frameCount = 0;
|
||||
|
||||
while (offset < bytes.length) {
|
||||
const blockType = bytes[offset];
|
||||
|
||||
if (blockType === 0x3B) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (blockType === 0x21) {
|
||||
offset += 2;
|
||||
|
||||
while (offset < bytes.length) {
|
||||
const blockSize = bytes[offset++];
|
||||
|
||||
if (blockSize === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
offset += blockSize;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blockType !== 0x2C || offset + 10 > bytes.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
frameCount++;
|
||||
|
||||
if (frameCount > 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const packedFields = bytes[offset + 9];
|
||||
|
||||
offset += 10;
|
||||
|
||||
if ((packedFields & 0x80) !== 0) {
|
||||
offset += 3 * (2 ** ((packedFields & 0x07) + 1));
|
||||
}
|
||||
|
||||
offset += 1;
|
||||
|
||||
while (offset < bytes.length) {
|
||||
const blockSize = bytes[offset++];
|
||||
|
||||
if (blockSize === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
offset += blockSize;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isAnimatedWebp(buffer: ArrayBuffer): boolean {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
if (bytes.length < 16 || readAscii(bytes, 0, 4) !== 'RIFF' || readAscii(bytes, 8, 4) !== 'WEBP') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let offset = 12;
|
||||
|
||||
while (offset + 8 <= bytes.length) {
|
||||
const chunkType = readAscii(bytes, offset, 4);
|
||||
const chunkSize = readUint32LittleEndian(bytes, offset + 4);
|
||||
|
||||
if (chunkType === 'ANIM' || chunkType === 'ANMF') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (chunkType === 'VP8X' && offset + 9 <= bytes.length) {
|
||||
const featureFlags = bytes[offset + 8];
|
||||
|
||||
if ((featureFlags & 0x02) !== 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
offset += 8 + chunkSize + (chunkSize % 2);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProfileAvatarImageService {
|
||||
validateFile(file: File): string | null {
|
||||
const mimeType = file.type.toLowerCase();
|
||||
const normalizedName = file.name.toLowerCase();
|
||||
const isAllowedMime = PROFILE_AVATAR_ALLOWED_MIME_TYPES.includes(mimeType as typeof PROFILE_AVATAR_ALLOWED_MIME_TYPES[number]);
|
||||
const isAllowedExtension = normalizedName.endsWith('.webp')
|
||||
|| normalizedName.endsWith('.gif')
|
||||
|| normalizedName.endsWith('.jpg')
|
||||
|| normalizedName.endsWith('.jpeg');
|
||||
|
||||
if (!isAllowedExtension || (mimeType && !isAllowedMime)) {
|
||||
return 'Invalid file type. Use WebP, GIF, JPG, or JPEG.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async prepareEditableSource(file: File): Promise<EditableProfileAvatarSource> {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const mime = this.resolveSourceMime(file);
|
||||
|
||||
try {
|
||||
const [image, preservesAnimation] = await Promise.all([this.loadImage(objectUrl), this.detectAnimatedSource(file, mime)]);
|
||||
|
||||
return {
|
||||
file,
|
||||
objectUrl,
|
||||
mime,
|
||||
name: file.name,
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
preservesAnimation
|
||||
};
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
releaseEditableSource(source: EditableProfileAvatarSource | null | undefined): void {
|
||||
if (!source?.objectUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(source.objectUrl);
|
||||
}
|
||||
|
||||
async processEditableSource(
|
||||
source: EditableProfileAvatarSource,
|
||||
transform: ProfileAvatarTransform
|
||||
): Promise<ProcessedProfileAvatar> {
|
||||
if (source.preservesAnimation) {
|
||||
return this.processAnimatedSource(source);
|
||||
}
|
||||
|
||||
const image = await this.loadImage(source.objectUrl);
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
canvas.width = PROFILE_AVATAR_OUTPUT_SIZE;
|
||||
canvas.height = PROFILE_AVATAR_OUTPUT_SIZE;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Canvas not supported');
|
||||
}
|
||||
|
||||
const clampedTransform = clampProfileAvatarTransform(source, transform);
|
||||
const previewScale = resolveProfileAvatarBaseScale(source, PROFILE_AVATAR_EDITOR_FRAME_SIZE) * clampedTransform.zoom;
|
||||
const renderRatio = PROFILE_AVATAR_OUTPUT_SIZE / PROFILE_AVATAR_EDITOR_FRAME_SIZE;
|
||||
const drawWidth = image.naturalWidth * previewScale * renderRatio;
|
||||
const drawHeight = image.naturalHeight * previewScale * renderRatio;
|
||||
const drawX = (PROFILE_AVATAR_OUTPUT_SIZE - drawWidth) / 2 + clampedTransform.offsetX * renderRatio;
|
||||
const drawY = (PROFILE_AVATAR_OUTPUT_SIZE - drawHeight) / 2 + clampedTransform.offsetY * renderRatio;
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||||
|
||||
const renderedBlob = await this.canvasToBlob(canvas, PROFILE_AVATAR_OUTPUT_MIME, PROFILE_AVATAR_OUTPUT_QUALITY);
|
||||
const compressedBlob = renderedBlob;
|
||||
const updatedAt = Date.now();
|
||||
const dataUrl = await this.readBlobAsDataUrl(compressedBlob);
|
||||
const hash = await this.computeHash(compressedBlob);
|
||||
|
||||
return {
|
||||
blob: compressedBlob,
|
||||
base64: dataUrl.split(',', 2)[1] ?? '',
|
||||
avatarUrl: dataUrl,
|
||||
avatarHash: hash,
|
||||
avatarMime: compressedBlob.type || PROFILE_AVATAR_OUTPUT_MIME,
|
||||
avatarUpdatedAt: updatedAt,
|
||||
width: PROFILE_AVATAR_OUTPUT_SIZE,
|
||||
height: PROFILE_AVATAR_OUTPUT_SIZE
|
||||
};
|
||||
}
|
||||
|
||||
private async processAnimatedSource(source: EditableProfileAvatarSource): Promise<ProcessedProfileAvatar> {
|
||||
const updatedAt = Date.now();
|
||||
const dataUrl = await this.readBlobAsDataUrl(source.file);
|
||||
const hash = await this.computeHash(source.file);
|
||||
|
||||
return {
|
||||
blob: source.file,
|
||||
base64: dataUrl.split(',', 2)[1] ?? '',
|
||||
avatarUrl: dataUrl,
|
||||
avatarHash: hash,
|
||||
avatarMime: source.mime || source.file.type || PROFILE_AVATAR_OUTPUT_MIME,
|
||||
avatarUpdatedAt: updatedAt,
|
||||
width: source.width,
|
||||
height: source.height
|
||||
};
|
||||
}
|
||||
|
||||
private async detectAnimatedSource(file: File, mime: string): Promise<boolean> {
|
||||
if (mime !== 'image/gif' && mime !== 'image/webp') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const buffer = await file.arrayBuffer();
|
||||
|
||||
return mime === 'image/gif'
|
||||
? isAnimatedGif(buffer)
|
||||
: isAnimatedWebp(buffer);
|
||||
}
|
||||
|
||||
private resolveSourceMime(file: File): string {
|
||||
const mimeType = file.type.toLowerCase();
|
||||
|
||||
if (mimeType === 'image/jpg') {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
if (mimeType) {
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
const normalizedName = file.name.toLowerCase();
|
||||
|
||||
if (normalizedName.endsWith('.gif')) {
|
||||
return 'image/gif';
|
||||
}
|
||||
|
||||
if (normalizedName.endsWith('.jpg') || normalizedName.endsWith('.jpeg')) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
if (normalizedName.endsWith('.webp')) {
|
||||
return 'image/webp';
|
||||
}
|
||||
|
||||
return PROFILE_AVATAR_OUTPUT_MIME;
|
||||
}
|
||||
|
||||
private async computeHash(blob: Blob): Promise<string> {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const digest = await crypto.subtle.digest('SHA-256', buffer);
|
||||
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((value) => value.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
private canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('Failed to render avatar image'));
|
||||
}, type, quality);
|
||||
});
|
||||
}
|
||||
|
||||
private readBlobAsDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('Failed to encode avatar image'));
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Failed to read avatar image'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
private loadImage(url: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('Failed to load avatar image'));
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function readAscii(bytes: Uint8Array, offset: number, length: number): string {
|
||||
return String.fromCharCode(...bytes.slice(offset, offset + length));
|
||||
}
|
||||
|
||||
function readUint32LittleEndian(bytes: Uint8Array, offset: number): number {
|
||||
return bytes[offset]
|
||||
| (bytes[offset + 1] << 8)
|
||||
| (bytes[offset + 2] << 16)
|
||||
| (bytes[offset + 3] << 24);
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import {
|
||||
ProcessedProfileAvatar,
|
||||
resolveProfileAvatarStorageFileName
|
||||
} from '../../domain/profile-avatar.models';
|
||||
|
||||
const LEGACY_PROFILE_FILE_NAMES = [
|
||||
'profile.webp',
|
||||
'profile.gif',
|
||||
'profile.jpg',
|
||||
'profile.jpeg',
|
||||
'profile.png'
|
||||
];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProfileAvatarStorageService {
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
async persistProcessedAvatar(
|
||||
user: Pick<User, 'id' | 'username' | 'displayName'>,
|
||||
avatar: Pick<ProcessedProfileAvatar, 'base64' | 'avatarMime'>
|
||||
): Promise<void> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
const appDataPath = await electronApi.getAppDataPath();
|
||||
const usernameSegment = this.sanitizePathSegment(user.username || user.displayName || user.id || 'user');
|
||||
const directoryPath = `${appDataPath}/user/${usernameSegment}/profile`;
|
||||
const targetFileName = resolveProfileAvatarStorageFileName(avatar.avatarMime);
|
||||
|
||||
await electronApi.ensureDir(directoryPath);
|
||||
|
||||
for (const fileName of LEGACY_PROFILE_FILE_NAMES) {
|
||||
const filePath = `${directoryPath}/${fileName}`;
|
||||
|
||||
if (fileName !== targetFileName && await electronApi.fileExists(filePath)) {
|
||||
await electronApi.deleteFile(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
await electronApi.writeFile(`${directoryPath}/${targetFileName}`, avatar.base64);
|
||||
}
|
||||
|
||||
private sanitizePathSegment(value: string): string {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
|
||||
return normalized || 'user';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user