@canonmsg/backend-contracts 8.1.1 → 8.2.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.
package/dist/cjs/index.js CHANGED
@@ -17,6 +17,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./diffRedaction.js"), exports);
18
18
  __exportStar(require("./environment.js"), exports);
19
19
  __exportStar(require("./media.js"), exports);
20
+ __exportStar(require("./media-url.js"), exports);
20
21
  __exportStar(require("./message.js"), exports);
21
22
  __exportStar(require("./messageText.js"), exports);
22
23
  __exportStar(require("./runtimeCardFields.js"), exports);
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MEDIA_ORIGINS = void 0;
4
+ // Generated by scripts/sync-runtime-endpoints.mjs from canonical endpoints and storage policies.
5
+ exports.MEDIA_ORIGINS = {
6
+ "canonmail-dev.firebasestorage.app": "https://dev.api.canonmail.com",
7
+ "canonmail-prod.firebasestorage.app": "https://api.canonmail.com"
8
+ };
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildFirebaseMediaUrl = buildFirebaseMediaUrl;
4
+ exports.buildMediaDownloadUrl = buildMediaDownloadUrl;
5
+ exports.parseMediaStoragePath = parseMediaStoragePath;
6
+ exports.canonMediaDownloadUrl = canonMediaDownloadUrl;
7
+ const media_js_1 = require("./media.js");
8
+ const media_origins_generated_js_1 = require("./media-origins.generated.js");
9
+ function buildFirebaseMediaUrl(bucket, storagePath, token) {
10
+ return `https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent(bucket)}/o/${encodeURIComponent(storagePath)}?alt=media&token=${encodeURIComponent(token)}`;
11
+ }
12
+ function validMediaPath(path) {
13
+ const parts = path.split('/');
14
+ return parts.length === 4 && parts[0] === 'media'
15
+ && parts.every((part) => Boolean(part) && part !== '.' && part !== '..' && !/[\u0000-\u001f\u007f\\]/.test(part));
16
+ }
17
+ function buildMediaDownloadUrl(bucket, storagePath, token) {
18
+ const origin = media_origins_generated_js_1.MEDIA_ORIGINS[bucket];
19
+ if (!origin || !validMediaPath(storagePath))
20
+ return buildFirebaseMediaUrl(bucket, storagePath, token);
21
+ return `${origin}/files/${storagePath.split('/').slice(1).map(encodeURIComponent).join('/')}?token=${encodeURIComponent(token)}`;
22
+ }
23
+ /** Accept historical Firebase URLs and Canon links, only for the expected bucket. */
24
+ function parseMediaStoragePath(value, bucket) {
25
+ if (typeof value !== 'string')
26
+ return null;
27
+ try {
28
+ const url = new URL(value);
29
+ if (url.protocol !== 'https:' || url.username || url.password)
30
+ return null;
31
+ let path;
32
+ const prefix = `/v0/b/${bucket}/o/`;
33
+ if (url.origin === 'https://firebasestorage.googleapis.com' && url.pathname.startsWith(prefix)) {
34
+ path = decodeURIComponent(url.pathname.slice(prefix.length));
35
+ }
36
+ else if (url.origin === media_origins_generated_js_1.MEDIA_ORIGINS[bucket] && url.pathname.startsWith('/files/')) {
37
+ path = `media/${decodeURIComponent(url.pathname.slice('/files/'.length))}`;
38
+ }
39
+ else
40
+ return null;
41
+ return validMediaPath(path) ? path : null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ /** Presentation upgrade for existing messages without rewriting their stored URLs. */
48
+ function canonMediaDownloadUrl(value, downloadFileName) {
49
+ for (const bucket of Object.keys(media_origins_generated_js_1.MEDIA_ORIGINS)) {
50
+ const path = parseMediaStoragePath(value, bucket);
51
+ if (path) {
52
+ const token = new URL(value).searchParams.get('token');
53
+ if (token) {
54
+ const url = new URL(buildMediaDownloadUrl(bucket, path, token));
55
+ if (downloadFileName !== undefined)
56
+ url.searchParams.set('download', (0, media_js_1.sanitizeDownloadFileName)(downloadFileName));
57
+ return url.href;
58
+ }
59
+ }
60
+ }
61
+ return value;
62
+ }
package/dist/cjs/media.js CHANGED
@@ -1,11 +1,56 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MEDIA_CACHE_CONTROL = void 0;
4
+ exports.sanitizeDownloadFileName = sanitizeDownloadFileName;
5
+ exports.buildMediaContentDisposition = buildMediaContentDisposition;
6
+ exports.processedVideoFileName = processedVideoFileName;
3
7
  exports.inferMediaAttachmentKind = inferMediaAttachmentKind;
4
8
  exports.getStoredFileExtension = getStoredFileExtension;
5
9
  exports.normalizeStoredAttachments = normalizeStoredAttachments;
6
10
  exports.getMessageAttachments = getMessageAttachments;
7
11
  exports.getPrimaryAttachment = getPrimaryAttachment;
8
12
  exports.describeAttachment = describeAttachment;
13
+ /** Allow private conditional caching, but recheck access before every reuse. */
14
+ exports.MEDIA_CACHE_CONTROL = 'private, max-age=0, must-revalidate';
15
+ /** Preserve Unicode and the extension while removing unsafe filesystem characters. */
16
+ function sanitizeDownloadFileName(value, fallback = 'attachment') {
17
+ const baseName = typeof value === 'string'
18
+ ? value.trim().split(/[\\/]/).filter(Boolean).pop() ?? ''
19
+ : '';
20
+ let name = Array.from(baseName, (char) => /^[\ud800-\udfff]$/.test(char) ? '-' : char).join('').normalize('NFC')
21
+ .replace(/[\u0000-\u001f\u007f<>:"/\\|?*\u202a-\u202e\u2066-\u2069]+/g, '-')
22
+ .replace(/\s+/g, ' ')
23
+ .replace(/^[. -]+|[. -]+$/g, '');
24
+ if (!name)
25
+ return sanitizeDownloadFileName(fallback, 'attachment');
26
+ if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name))
27
+ name = `_${name}`;
28
+ const extension = name.match(/\.[a-z0-9]{1,16}$/i)?.[0] ?? '';
29
+ const stem = extension ? name.slice(0, -extension.length) : name;
30
+ // Stay below filesystem byte limits as well as the display-length limit.
31
+ let safeStem = '';
32
+ let bytes = extension.length;
33
+ for (const char of Array.from(stem).slice(0, 120 - extension.length)) {
34
+ bytes += encodeURIComponent(char).replace(/%[0-9A-F]{2}|./g, '_').length;
35
+ if (bytes > 240)
36
+ break;
37
+ safeStem += char;
38
+ }
39
+ return `${safeStem.replace(/[. -]+$/g, '')}${extension}`;
40
+ }
41
+ /** RFC 6266: readable ASCII fallback plus the exact UTF-8 filename. */
42
+ function buildMediaContentDisposition(input) {
43
+ const fileName = sanitizeDownloadFileName(input.fileName, input.fallbackFileName);
44
+ const asciiName = fileName.replace(/[^\x20-\x7e]|%/g, '_');
45
+ const encodedName = encodeURIComponent(fileName).replace(/['()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
46
+ return `${input.disposition ?? 'attachment'}; filename="${asciiName}"`
47
+ + (fileName === asciiName ? '' : `; filename*=UTF-8''${encodedName}`);
48
+ }
49
+ /** The processor changes the container to MP4; keep its name consistent everywhere. */
50
+ function processedVideoFileName(value) {
51
+ const stem = typeof value === 'string' ? value.trim().replace(/\.[a-z0-9]{1,16}$/i, '') : '';
52
+ return sanitizeDownloadFileName(`${stem || 'video'}.mp4`, 'video.mp4');
53
+ }
9
54
  function inferMediaAttachmentKind(mimeType) {
10
55
  if (mimeType.startsWith('image/'))
11
56
  return 'image';
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './diffRedaction.js';
2
2
  export * from './environment.js';
3
3
  export * from './media.js';
4
+ export * from './media-url.js';
4
5
  export * from './message.js';
5
6
  export * from './messageText.js';
6
7
  export * from './runtimeCardFields.js';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './diffRedaction.js';
2
2
  export * from './environment.js';
3
3
  export * from './media.js';
4
+ export * from './media-url.js';
4
5
  export * from './message.js';
5
6
  export * from './messageText.js';
6
7
  export * from './runtimeCardFields.js';
@@ -0,0 +1 @@
1
+ export declare const MEDIA_ORIGINS: Readonly<Record<string, string>>;
@@ -0,0 +1,5 @@
1
+ // Generated by scripts/sync-runtime-endpoints.mjs from canonical endpoints and storage policies.
2
+ export const MEDIA_ORIGINS = {
3
+ "canonmail-dev.firebasestorage.app": "https://dev.api.canonmail.com",
4
+ "canonmail-prod.firebasestorage.app": "https://api.canonmail.com"
5
+ };
@@ -0,0 +1,6 @@
1
+ export declare function buildFirebaseMediaUrl(bucket: string, storagePath: string, token: string): string;
2
+ export declare function buildMediaDownloadUrl(bucket: string, storagePath: string, token: string): string;
3
+ /** Accept historical Firebase URLs and Canon links, only for the expected bucket. */
4
+ export declare function parseMediaStoragePath(value: unknown, bucket: string): string | null;
5
+ /** Presentation upgrade for existing messages without rewriting their stored URLs. */
6
+ export declare function canonMediaDownloadUrl(value: string, downloadFileName?: string): string;
@@ -0,0 +1,56 @@
1
+ import { sanitizeDownloadFileName } from './media.js';
2
+ import { MEDIA_ORIGINS } from './media-origins.generated.js';
3
+ export function buildFirebaseMediaUrl(bucket, storagePath, token) {
4
+ return `https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent(bucket)}/o/${encodeURIComponent(storagePath)}?alt=media&token=${encodeURIComponent(token)}`;
5
+ }
6
+ function validMediaPath(path) {
7
+ const parts = path.split('/');
8
+ return parts.length === 4 && parts[0] === 'media'
9
+ && parts.every((part) => Boolean(part) && part !== '.' && part !== '..' && !/[\u0000-\u001f\u007f\\]/.test(part));
10
+ }
11
+ export function buildMediaDownloadUrl(bucket, storagePath, token) {
12
+ const origin = MEDIA_ORIGINS[bucket];
13
+ if (!origin || !validMediaPath(storagePath))
14
+ return buildFirebaseMediaUrl(bucket, storagePath, token);
15
+ return `${origin}/files/${storagePath.split('/').slice(1).map(encodeURIComponent).join('/')}?token=${encodeURIComponent(token)}`;
16
+ }
17
+ /** Accept historical Firebase URLs and Canon links, only for the expected bucket. */
18
+ export function parseMediaStoragePath(value, bucket) {
19
+ if (typeof value !== 'string')
20
+ return null;
21
+ try {
22
+ const url = new URL(value);
23
+ if (url.protocol !== 'https:' || url.username || url.password)
24
+ return null;
25
+ let path;
26
+ const prefix = `/v0/b/${bucket}/o/`;
27
+ if (url.origin === 'https://firebasestorage.googleapis.com' && url.pathname.startsWith(prefix)) {
28
+ path = decodeURIComponent(url.pathname.slice(prefix.length));
29
+ }
30
+ else if (url.origin === MEDIA_ORIGINS[bucket] && url.pathname.startsWith('/files/')) {
31
+ path = `media/${decodeURIComponent(url.pathname.slice('/files/'.length))}`;
32
+ }
33
+ else
34
+ return null;
35
+ return validMediaPath(path) ? path : null;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /** Presentation upgrade for existing messages without rewriting their stored URLs. */
42
+ export function canonMediaDownloadUrl(value, downloadFileName) {
43
+ for (const bucket of Object.keys(MEDIA_ORIGINS)) {
44
+ const path = parseMediaStoragePath(value, bucket);
45
+ if (path) {
46
+ const token = new URL(value).searchParams.get('token');
47
+ if (token) {
48
+ const url = new URL(buildMediaDownloadUrl(bucket, path, token));
49
+ if (downloadFileName !== undefined)
50
+ url.searchParams.set('download', sanitizeDownloadFileName(downloadFileName));
51
+ return url.href;
52
+ }
53
+ }
54
+ }
55
+ return value;
56
+ }
package/dist/media.d.ts CHANGED
@@ -1,4 +1,16 @@
1
1
  export type MediaAttachmentKind = 'image' | 'audio' | 'video' | 'file';
2
+ /** Allow private conditional caching, but recheck access before every reuse. */
3
+ export declare const MEDIA_CACHE_CONTROL = "private, max-age=0, must-revalidate";
4
+ /** Preserve Unicode and the extension while removing unsafe filesystem characters. */
5
+ export declare function sanitizeDownloadFileName(value: unknown, fallback?: string): string;
6
+ /** RFC 6266: readable ASCII fallback plus the exact UTF-8 filename. */
7
+ export declare function buildMediaContentDisposition(input: {
8
+ fileName?: unknown;
9
+ fallbackFileName: string;
10
+ disposition?: 'inline' | 'attachment';
11
+ }): string;
12
+ /** The processor changes the container to MP4; keep its name consistent everywhere. */
13
+ export declare function processedVideoFileName(value: unknown): string;
2
14
  /** Lifecycle for newly uploaded videos that are normalized server-side. */
3
15
  export type VideoProcessingStatus = 'processing' | 'ready' | 'failed';
4
16
  /** Stable, display-safe reasons for a terminal video processing failure. */
@@ -7,9 +19,9 @@ export interface MediaAttachment {
7
19
  kind: MediaAttachmentKind;
8
20
  url: string;
9
21
  /**
10
- * Server-issued identity for a finalized resumable upload. The send path
22
+ * Server-issued identity for a finalized upload. The send path
11
23
  * uses this to retain temporary canonical media atomically with the message.
12
- * Legacy/base64 uploads intentionally omit it.
24
+ * Older attachments and forwarded copies may omit it.
13
25
  */
14
26
  uploadId?: string;
15
27
  mimeType?: string;
package/dist/media.js CHANGED
@@ -1,3 +1,44 @@
1
+ /** Allow private conditional caching, but recheck access before every reuse. */
2
+ export const MEDIA_CACHE_CONTROL = 'private, max-age=0, must-revalidate';
3
+ /** Preserve Unicode and the extension while removing unsafe filesystem characters. */
4
+ export function sanitizeDownloadFileName(value, fallback = 'attachment') {
5
+ const baseName = typeof value === 'string'
6
+ ? value.trim().split(/[\\/]/).filter(Boolean).pop() ?? ''
7
+ : '';
8
+ let name = Array.from(baseName, (char) => /^[\ud800-\udfff]$/.test(char) ? '-' : char).join('').normalize('NFC')
9
+ .replace(/[\u0000-\u001f\u007f<>:"/\\|?*\u202a-\u202e\u2066-\u2069]+/g, '-')
10
+ .replace(/\s+/g, ' ')
11
+ .replace(/^[. -]+|[. -]+$/g, '');
12
+ if (!name)
13
+ return sanitizeDownloadFileName(fallback, 'attachment');
14
+ if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name))
15
+ name = `_${name}`;
16
+ const extension = name.match(/\.[a-z0-9]{1,16}$/i)?.[0] ?? '';
17
+ const stem = extension ? name.slice(0, -extension.length) : name;
18
+ // Stay below filesystem byte limits as well as the display-length limit.
19
+ let safeStem = '';
20
+ let bytes = extension.length;
21
+ for (const char of Array.from(stem).slice(0, 120 - extension.length)) {
22
+ bytes += encodeURIComponent(char).replace(/%[0-9A-F]{2}|./g, '_').length;
23
+ if (bytes > 240)
24
+ break;
25
+ safeStem += char;
26
+ }
27
+ return `${safeStem.replace(/[. -]+$/g, '')}${extension}`;
28
+ }
29
+ /** RFC 6266: readable ASCII fallback plus the exact UTF-8 filename. */
30
+ export function buildMediaContentDisposition(input) {
31
+ const fileName = sanitizeDownloadFileName(input.fileName, input.fallbackFileName);
32
+ const asciiName = fileName.replace(/[^\x20-\x7e]|%/g, '_');
33
+ const encodedName = encodeURIComponent(fileName).replace(/['()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
34
+ return `${input.disposition ?? 'attachment'}; filename="${asciiName}"`
35
+ + (fileName === asciiName ? '' : `; filename*=UTF-8''${encodedName}`);
36
+ }
37
+ /** The processor changes the container to MP4; keep its name consistent everywhere. */
38
+ export function processedVideoFileName(value) {
39
+ const stem = typeof value === 'string' ? value.trim().replace(/\.[a-z0-9]{1,16}$/i, '') : '';
40
+ return sanitizeDownloadFileName(`${stem || 'video'}.mp4`, 'video.mp4');
41
+ }
1
42
  export function inferMediaAttachmentKind(mimeType) {
2
43
  if (mimeType.startsWith('image/'))
3
44
  return 'image';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/backend-contracts",
3
- "version": "8.1.1",
3
+ "version": "8.2.0",
4
4
  "description": "Canon backend contract helpers shared by Functions and stream-service",
5
5
  "type": "module",
6
6
  "main": "dist/cjs/index.js",