import { path as pathModule, CallExpressionShape, FileSystem, Plugin, type Source, type Record, type Project, crypto, path, type Factory, type AssociateRecordsShape, type LazyValue, gunzipSync, ObjectShape, ts, gzipSync, isLazyValue, hasAssociatedRecords, unloadBuilder, } from '@servicenow/sdk-build-core' import { CHUNK_SIZE, chunkData, generateId } from './static-content-plugin' export async function sha256(message: Buffer) { const hashBuffer = await crypto.subtle.digest('SHA-256', new Uint8Array(message)) const hashArray = Array.from(new Uint8Array(hashBuffer)) return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') } const ImageAttachmentTypes = new Map([ ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.png', 'image/png'], ['.bmp', 'image/bmp'], ['.gif', 'image/gif'], ['.ico', 'image/ico'], ['.svg', 'image/svg+xml'], ]) function getExtension(contentType: string) { switch (contentType) { case 'image/jpeg': return '.jpg' case 'image/png': return '.png' case 'image/bmp': return '.bmp' case 'image/gif': return '.gif' case 'image/vnd.microsoft.icon': case 'image/x-icon': case 'image/ico': return '.ico' case 'image/svg': case 'image/svg+xml': return '.svg' default: return '' } } type BaseAttachmentProperties = { average_image_color: '' chunk_size_bytes: unknown compressed: boolean content_type: string | undefined hash: string image_height: '' image_width: '' size_bytes: unknown size_compressed: unknown data: string[] } export class NowAttachShape extends CallExpressionShape implements AssociateRecordsShape, LazyValue { private readonly baseProperties: BaseAttachmentProperties constructor({ source, path, baseProperties, }: { source: Source; path: string; baseProperties: BaseAttachmentProperties }) { super({ source, callee: 'Now.attach', args: [path] }) this.baseProperties = baseProperties } evaluate(parentRecord: Record, field: string) { return generateId(parentRecord.getId().getValue(), 'sys_attachment', this.baseProperties.hash, field) } getPath(): string { return this.getArgument(0).asString().getValue() } getBaseAttachmentProperties() { return this.baseProperties } getExtension() { return getExtension(this.getBaseAttachmentProperties().content_type ?? '') } async getBufferData() { const buffers = await Promise.all(this.getBaseAttachmentProperties().data.map((d) => Buffer.from(d, 'base64'))) const combinedBuffer = Buffer.concat(buffers) // Use gunzipSync for browser compatibility - async gunzip spawns a Web Worker that fails in the IDE return this.baseProperties.compressed ? gunzipSync(combinedBuffer) : combinedBuffer } async writeBufferTo(fs: FileSystem, absolutePath: string) { const bufferData = await this.getBufferData() fs.mkdirSync(path.dirname(absolutePath), { recursive: true }) if (!FileSystem.existsSync(fs, absolutePath) || !fs.readFileSync(absolutePath).equals(bufferData)) { fs.writeFileSync(absolutePath, bufferData, { encoding: 'binary' }) } } async writeToPath(fs: FileSystem, project: Project, relativePathWithoutExtension: string) { const extension = getExtension(this.getBaseAttachmentProperties().content_type ?? '') const relativePath = `${relativePathWithoutExtension}${extension}` const absolutePath = project.resolvePath(relativePath) await this.writeBufferTo(fs, absolutePath) return relativePath } async createAssociatedRecords({ parentRecord, factory, field, }: { parentRecord: Record factory: Factory field: string }) { const attachment = await factory.createRecord({ source: parentRecord.getSource(), table: 'sys_attachment', properties: { sys_id: generateId(parentRecord.getId().getValue(), 'sys_attachment', this.baseProperties.hash, field), average_image_color: this.baseProperties.average_image_color, chunk_size_bytes: this.baseProperties.chunk_size_bytes, compressed: this.baseProperties.compressed, content_type: this.baseProperties.content_type, hash: this.baseProperties.hash, image_height: this.baseProperties.image_height, image_width: this.baseProperties.image_width, size_bytes: this.baseProperties.size_bytes, size_compressed: this.baseProperties.size_compressed, file_name: field, table_name: `ZZ_YY${parentRecord.getTable()}`, table_sys_id: parentRecord.getId().getValue(), }, }) const attachmentDocs = await Promise.all( this.baseProperties.data.map((data, i) => factory.createRecord({ source: parentRecord.getSource(), table: 'sys_attachment_doc', properties: { sys_id: generateId(parentRecord.getId().getValue(), 'sys_attachment_doc', field, data), data, length: data.length, position: i, sys_attachment: attachment.getId().getValue(), }, }) ) ) return [attachment, ...attachmentDocs] } override equals(value: E): boolean { return value instanceof NowAttachShape && value.baseProperties.hash === this.baseProperties.hash } static async create(source: Source, fs: FileSystem, filePath: string) { const buffer = (await fs.readFileSync(filePath)) as Buffer const hash = await sha256(buffer) const gzipped = gzipSync(buffer) const compressedFileData = Buffer.from(gzipped) // The first sys_attachment_doc (position=0), contains only the // first 10 bytes of the gzip header, which are base64-encoded into 16 characters. // For more info on the gzip header: https://www.ietf.org/rfc/rfc1952.txt const gzipHeader = compressedFileData.subarray(0, 10).toString('base64') const remainingCompressedData = chunkData(compressedFileData.subarray(10).toString('base64')) return new NowAttachShape({ source, path: filePath, baseProperties: { average_image_color: '', chunk_size_bytes: CHUNK_SIZE, compressed: true, content_type: ImageAttachmentTypes.get(path.extname(filePath).toLowerCase()), hash, image_height: '', image_width: '', size_bytes: buffer.length, size_compressed: gzipped.length, data: [gzipHeader, ...remainingCompressedData], }, }) } static fromAttachmentRecord(parentUpdateName: string, attachment: Record, attachmentDocs: Record[]) { const field = attachment.get('file_name').getValue() const extension = getExtension(attachment.get('content_type').toString().getValue() || '') const relativePath = `.${path.sep}${parentUpdateName}_${field}${extension}` return new NowAttachShape({ source: attachment, path: relativePath, baseProperties: { average_image_color: '', chunk_size_bytes: attachment.get('chunk_size_bytes').getValue(), compressed: attachment.get('compressed').toBoolean().getValue(), content_type: attachment.get('content_type').toString().getValue() || undefined, hash: attachment.get('hash').toString().getValue(), image_height: '', image_width: '', size_bytes: attachment.get('size_bytes').getValue(), size_compressed: attachment.get('size_compressed').getValue(), data: attachmentDocs.map((doc) => doc.get('data').toString().getValue()), }, }) } } export const NowAttachPlugin = Plugin.create({ name: 'NowAttachPlugin', noTelemetry: true, records: { '*': { async toFile(record, { database, factory }) { const entries = record.entries() const hasAttachments = entries.some(([, shape]) => isLazyValue(shape)) || Object.keys(record.properties()).some((field) => hasAssociatedRecords(record.get(field))) if (!hasAttachments) { return { success: false } } const recordBuilder = unloadBuilder() const builder = recordBuilder.record(record) entries .sort(([a], [b]) => a.localeCompare(b)) // Sort keys to make outputs more deterministic .forEach(([prop, shape]) => { if (isLazyValue(shape)) { builder.field(prop, shape.evaluate(record, prop)) } else { builder.field(prop, shape) } }) for (const field in record.properties()) { const shape = record.get(field) if (hasAssociatedRecords(shape)) { for (const rec of await shape.createAssociatedRecords({ parentRecord: record, factory, field, })) { const claimBuilder = recordBuilder.record(rec) rec.entries() .sort(([a], [b]) => a.localeCompare(b)) // Sort keys to make outputs more deterministic .forEach(([prop, shape]) => claimBuilder.field(prop, shape)) } } } const updateName = record.get('sys_update_name').asString() const claims = database .query('sys_claim') .filter((claim) => claim.get('metadata_update_name').equals(updateName)) for (const claim of claims) { const claimBuilder = recordBuilder.record(claim) claim .entries() .sort(([a], [b]) => a.localeCompare(b)) // Sort keys to make outputs more deterministic .forEach(([prop, shape]) => claimBuilder.field(prop, shape)) } return { success: true, value: { source: record, name: `${updateName.getValue()}.xml`, category: record.getInstallCategory(), content: recordBuilder.end(), }, } }, }, sys_attachment: { toShape: async (record) => { const fileName = record.get('file_name').getValue() const hash = record.get('hash').getValue() const extension = getExtension(record.get('content_type').toString().getValue() || '') return { success: true, value: new CallExpressionShape({ source: record, args: [`${fileName}_${hash}${extension}`], callee: 'Now.attach', }), } }, }, }, shapes: [ { shape: CallExpressionShape, fileTypes: ['fluent'], async toSubclass(callExpression, { diagnostics, project, fs }) { if (callExpression.getCallee() !== 'Now.attach') { return { success: false } } const arg = callExpression.getArgument(0) if (!arg.isString()) { diagnostics.error(arg, 'Now.attach() must have a string argument') return { success: false } } const path = arg.getValue() if (!/^\.\.?\/.*$/.test(path.trim()) && path.includes('/')) { diagnostics.error(arg, 'Now.attach() argument must be a relative path') return { success: false } } const absolutePath = pathModule.resolve(pathModule.dirname(callExpression.getOriginalFilePath()), path) if (pathModule.relative(project.getRootDir(), absolutePath).startsWith('..')) { diagnostics.error(arg, `File path is not within project: ${absolutePath}`) return { success: false } } if (!FileSystem.existsSync(fs, absolutePath)) { diagnostics.error(arg, 'File not found') return { success: false } } const attachment = await NowAttachShape.create(callExpression.getSource(), fs, absolutePath) return { success: true, value: attachment, } }, }, { shape: NowAttachShape, async commit(shape, target, { transform, fs }) { const targetResult = await transform.toShape(target) if (!targetResult.success) { return { success: false } } const { value: targetShape } = targetResult let absolutePath: string if ( targetShape.is(NowAttachShape) && path.extname(targetShape.getPath()) === path.extname(shape.getPath()) ) { absolutePath = targetShape.getPath() } else { absolutePath = path.resolve(path.dirname(target.getSourceFile().getFilePath()), shape.getPath()) target.replaceWithText(shape.getCode()) } await shape.writeBufferTo(fs, absolutePath) return { success: true } }, }, // The following is janky. The problem is, in the case of a transform where // the ObjectLiteralExpression for a plugin CallExpression exists, but doesn't // have the field yet (and will because it's coming from an incoming XML file), // the commit code which saves the actual file (like say a .jpg file) is never // called. Rather, the field is populated via getCode() instead which doesn't // actually save the attached file. This commit is called before the basic-syntax-plugin's // ObjectShape commiter, so we populate the Now.attach field so that the NowAttachPlugin's // commit functiong gets called. { shape: ObjectShape, async commit(shape, target) { if (!ts.Node.isObjectLiteralExpression(target)) { return { success: false } } const attachShapes = shape .entries({ resolve: false }) .filter(([name, value]) => value.is(NowAttachShape) && !target.getProperty(name)) .map(([name, shape]) => { return { name: ObjectShape.quotePropertyNameIfNeeded(name), initializer: shape.getCode(), kind: ts.StructureKind.PropertyAssignment, } }) as ts.PropertyAssignmentStructure[] if (attachShapes.length > 0) { target.addPropertyAssignments(attachShapes) } return { success: false } }, }, ], })