Add media verification addon with C2PA/ProofMode support

Introduces a new zammad-addon-media-verify package that uses the proofmode
Ruby gem (built from proofmode-rust) to verify media attachments on tickets
for C2PA content credentials and ProofMode cryptographic proofs.

The addon runs as a Zammad scheduled job that:
- Scans incoming ticket articles for media attachments (images, video, audio, PDFs, ZIPs)
- Calls proofmode check_files() to verify C2PA manifests, PGP signatures,
  OpenTimestamps, and EXIF metadata
- Posts a human-readable verification report as an internal note on the ticket
- Tracks checked articles via preferences to avoid duplicate processing

Also restores the zammad-addon-common package (previously removed in repo cleanup)
to share build tooling (ZPM builder and migration generator) between addon packages,
keeping things DRY. The link addon now imports from common instead of inlining these.

Docker integration:
- Dockerfile updated to install proofmode gem from docker/zammad/gems/
- setup.rb updated to handle MediaVerify package lifecycle

https://claude.ai/code/session_01GJYbRCFFJCJDAEcEVbD36N
This commit is contained in:
Claude 2026-02-15 13:56:57 +00:00
parent c40d7d056e
commit 33375c9221
No known key found for this signature in database
22 changed files with 761 additions and 2821 deletions

View file

@ -0,0 +1,19 @@
{
"name": "@link-stack/zammad-addon-common",
"version": "3.5.0-beta.1",
"description": "Shared build tooling for Zammad addon packages.",
"exports": {
"./build": "./src/build.ts",
"./migrate": "./src/migrate.ts"
},
"devDependencies": {
"@types/node": "^24.7.0",
"glob": "^11.0.3",
"typescript": "^5"
},
"dependencies": {
"glob": "^11.0.3"
},
"author": "",
"license": "AGPL-3.0-or-later"
}

View file

@ -0,0 +1,88 @@
import { promises as fs } from "fs";
import { glob } from "glob";
import path from "path";
import os from "os";
const log = (msg: string, data?: Record<string, any>) => {
console.log(JSON.stringify({ msg, ...data, timestamp: new Date().toISOString() }));
};
const packageFile = async (actualPath: string): Promise<any> => {
log('Packaging file', { actualPath });
const packagePath = actualPath.slice(4);
const data = await fs.readFile(actualPath, "utf-8");
const content = Buffer.from(data, "utf-8").toString("base64");
const fileStats = await fs.stat(actualPath);
const permission = parseInt(
(fileStats.mode & 0o777).toString(8).slice(-3),
10,
);
return {
location: packagePath,
permission,
encode: "base64",
content,
};
};
const packageFiles = async () => {
const packagedFiles: any[] = [];
const ignoredPatterns = [
/\.gitkeep/,
/Gemfile/,
/Gemfile.lock/,
/\.ruby-version/,
];
const processDir = async (dir: string) => {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await processDir(entryPath);
} else if (entry.isFile()) {
if (!ignoredPatterns.some((pattern) => pattern.test(entry.name))) {
packagedFiles.push(await packageFile(entryPath));
}
}
}
};
await processDir("./src/");
return packagedFiles;
};
export const createZPM = async ({
name,
displayName,
version,
}: Record<string, string>) => {
const files = await packageFiles();
const skeleton = {
name: displayName,
version,
vendor: "Center for Digital Resilience",
license: "AGPL-v3+",
url: `https://gitlab.com/digiresilience/link/link-stack/packages/${name}`,
buildhost: os.hostname(),
builddate: new Date().toISOString(),
files,
};
const pkg = JSON.stringify(skeleton, null, 2);
try {
const oldFiles = await glob(`../../docker/zammad/addons/${name}-v*.zpm`, {});
for (const file of oldFiles) {
await fs.unlink(file);
log('File was deleted', { file });
}
} catch (err) {
log('Error removing old addon files', { error: String(err) });
}
await fs.writeFile(
`../../docker/zammad/addons/${name}-v${version}.zpm`,
pkg,
"utf-8",
);
};

View file

@ -0,0 +1,43 @@
import { promises as fs } from "fs";
import path from "path";
const underscore = (str: string) => {
return str
.replace(/([a-z\d])([A-Z])/g, "$1_$2")
.replace(/([A-Z]+)([A-Z][a-z\d]+)/g, "$1_$2")
.toLowerCase();
}
const camelize = (str: string): string => {
const camelizedStr = str.replace(/_([a-z])/g, (g) => g[1].toUpperCase());
return camelizedStr.charAt(0).toUpperCase() + camelizedStr.slice(1);
}
export const createMigration = async ({ displayName }: Record<string, string>) => {
const rawName: string = await new Promise((resolve) => {
process.stdin.setEncoding("utf-8");
process.stdout.write("Enter migration name: ");
process.stdin.once("data", (data: string) => {
resolve(data.trim());
});
});
const migrationBaseName = `${displayName}_${underscore(rawName)}`;
const migrationName = camelize(migrationBaseName);
const migrationTemplate = `class MIGRATION_NAME < ActiveRecord::Migration[5.2]
def self.up
# add your code here
end
def self.down
# add your code here
end
end`;
const contents = migrationTemplate.replace("MIGRATION_NAME", migrationName);
const time = new Date().toISOString().replace(/[-:.]/g, "").slice(0, 14);
const migrationFileName = `${time}_${migrationBaseName}.rb`;
const addonDir = path.join("src", "db", "addon", displayName);
await fs.mkdir(addonDir, { recursive: true });
await fs.writeFile(path.join(addonDir, migrationFileName), contents);
}

View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}