first commit

This commit is contained in:
root
2026-03-31 21:04:10 +08:00
commit 8aac33bf65
15 changed files with 3056 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
'use strict';
const crypto = require('node:crypto');
const fs = require('node:fs');
const https = require('node:https');
// --- Argument parsing ---
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i += 2) {
const key = argv[i].replace(/^--/, '');
const val = argv[i + 1];
if (!val || val.startsWith('--')) {
console.error(`Missing value for --${key}`);
process.exit(1);
}
args[key] = val;
}
return args;
}
const REQUIRED = ['file', 'secret-id', 'secret-key', 'token', 'bucket', 'region', 'cos-key'];
// --- Crypto helpers ---
function hmacSha1(key, data) {
return crypto.createHmac('sha1', key).update(data).digest('hex');
}
function sha1(data) {
return crypto.createHash('sha1').update(data).digest('hex');
}
// --- COS Authorization header (PUT Object) ---
// Reference: https://cloud.tencent.com/document/product/436/7778
function buildAuthorization({ secretId, secretKey, method, pathname, headers, startTime, expiredTime }) {
const keyTime = `${startTime};${expiredTime}`;
// 1. SignKey = HMAC-SHA1(SecretKey, KeyTime)
const signKey = hmacSha1(secretKey, keyTime);
// 2. HttpString = method\npathname\nparams\nheaders\n
// For PUT, no query params; headers we sign: host, content-length
const headerKeys = Object.keys(headers).sort();
const httpHeaders = headerKeys.map((k) => `${k.toLowerCase()}=${encodeURIComponent(headers[k])}`).join('&');
const httpString = `${method.toLowerCase()}\n${pathname}\n\n${httpHeaders}\n`;
// 3. StringToSign = sha1\nKeyTime\nSHA1(HttpString)\n
const stringToSign = `sha1\n${keyTime}\n${sha1(httpString)}\n`;
// 4. Signature = HMAC-SHA1(SignKey, StringToSign)
const signature = hmacSha1(signKey, stringToSign);
// 5. Build Authorization
const headerList = headerKeys.map((k) => k.toLowerCase()).join(';');
return [
`q-sign-algorithm=sha1`,
`q-ak=${secretId}`,
`q-sign-time=${keyTime}`,
`q-key-time=${keyTime}`,
`q-header-list=${headerList}`,
`q-url-param-list=`,
`q-signature=${signature}`,
].join('&');
}
// --- Upload via PUT Object ---
function upload(args) {
const secretId = args['secret-id'];
const secretKey = args['secret-key'];
const { token } = args;
const { bucket } = args;
const { region } = args;
const cosKey = args['cos-key'];
const filePath = args.file;
const startTime = args['start-time'] || String(Math.floor(Date.now() / 1000));
const expiredTime = args['expired-time'] || String(Math.floor(Date.now() / 1000) + 3600);
const fileContent = fs.readFileSync(filePath);
const hostname = `${bucket}.cos.${region}.myqcloud.com`;
const pathname = `/${cosKey}`;
// Headers to sign
const signHeaders = {
'content-length': String(fileContent.length),
host: hostname,
};
const authorization = buildAuthorization({
secretId,
secretKey,
method: 'PUT',
pathname,
headers: signHeaders,
startTime,
expiredTime,
});
// Use the actual file content type if provided, otherwise fall back to octet-stream
const contentType = args['content-type'] || 'application/octet-stream';
const options = {
hostname,
port: 443,
path: pathname,
method: 'PUT',
headers: {
'Content-Type': contentType,
'Content-Length': fileContent.length,
Authorization: authorization,
'x-cos-security-token': token,
},
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => (body += chunk));
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
console.log(`Upload successful (HTTP ${res.statusCode})`);
process.exit(0);
} else {
console.error(`COS upload failed (HTTP ${res.statusCode}): ${body}`);
process.exit(1);
}
});
});
req.on('error', (err) => {
console.error(`COS upload error: ${err.message}`);
process.exit(1);
});
req.write(fileContent);
req.end();
}
// --- Main ---
function main() {
const args = parseArgs(process.argv);
const missing = REQUIRED.filter((k) => !args[k]);
if (missing.length) {
console.error(`Missing required arguments: ${missing.map((k) => `--${k}`).join(', ')}`);
console.error(
`Usage: node cos-upload.cjs --file <path> --secret-id <sid> --secret-key <skey> --token <token> --bucket <bucket> --region <region> --cos-key <key> [--content-type <mime>] [--start-time <ts>] [--expired-time <ts>]`,
);
process.exit(1);
}
const filePath = args.file;
if (!fs.existsSync(filePath)) {
console.error(`File not found: ${filePath}`);
process.exit(1);
}
upload(args);
}
main();
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env node
'use strict';
/**
* Preflight check for uploading a file to IMA Knowledge Base.
*
* Validates file type, size, and extracts all metadata needed for
* create_media and add_knowledge API calls.
*
* Resolution priority:
* 1. If --content-type is provided and recognized → use it (content-type rules over extension)
* 2. If --content-type is unrecognized, fall back to extension
* 3. If no --content-type, use extension
* 4. If neither can resolve → fail
*
* Usage:
* node preflight-check.cjs --file /path/to/report.pdf
* node preflight-check.cjs --file /path/to/downloaded_file --content-type application/pdf
*
* Output (JSON, always to stdout):
*
* Pass:
* {
* "pass": true,
* "file_path": "/absolute/path/to/report.pdf",
* "file_name": "report.pdf",
* "file_ext": "pdf",
* "file_size": 123456,
* "media_type": 1,
* "content_type": "application/pdf"
* }
*
* Pass (no extension, content_type provided):
* {
* "pass": true,
* "file_path": "/absolute/path/to/downloaded_file",
* "file_name": "downloaded_file",
* "file_ext": "",
* "file_size": 123456,
* "media_type": 1,
* "content_type": "application/pdf"
* }
*
* Fail:
* {
* "pass": false,
* "file_path": "/absolute/path/to/video.mp4",
* "file_name": "video.mp4",
* "file_ext": "mp4",
* "reason": "Video files (.mp4) are not supported. ..."
* }
*
* Exit codes:
* 0 = pass — file is ready for upload
* 1 = fail — file rejected (unsupported type, over size limit, etc.)
* 2 = error — file not found, usage error, etc.
*/
const fs = require('node:fs');
const path = require('node:path');
// ─── Extension → media_type + content_type ──────────────────────────────────
const EXT_MAP = {
pdf: { media_type: 1, content_type: 'application/pdf' },
doc: { media_type: 3, content_type: 'application/msword' },
docx: { media_type: 3, content_type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' },
ppt: { media_type: 4, content_type: 'application/vnd.ms-powerpoint' },
pptx: { media_type: 4, content_type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' },
xls: { media_type: 5, content_type: 'application/vnd.ms-excel' },
xlsx: { media_type: 5, content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
csv: { media_type: 5, content_type: 'text/csv' },
md: { media_type: 7, content_type: 'text/markdown' },
markdown: { media_type: 7, content_type: 'text/markdown' },
png: { media_type: 9, content_type: 'image/png' },
jpg: { media_type: 9, content_type: 'image/jpeg' },
jpeg: { media_type: 9, content_type: 'image/jpeg' },
webp: { media_type: 9, content_type: 'image/webp' },
txt: { media_type: 13, content_type: 'text/plain' },
xmind: { media_type: 14, content_type: 'application/x-xmind' },
mp3: { media_type: 15, content_type: 'audio/mpeg' },
m4a: { media_type: 15, content_type: 'audio/x-m4a' },
wav: { media_type: 15, content_type: 'audio/wav' },
aac: { media_type: 15, content_type: 'audio/aac' },
};
// ─── Content-Type → media_type (reverse lookup) ─────────────────────────────
const CONTENT_TYPE_MAP = {};
for (const [, value] of Object.entries(EXT_MAP)) {
// First entry wins — keeps the canonical content_type per media_type
if (!CONTENT_TYPE_MAP[value.content_type]) {
CONTENT_TYPE_MAP[value.content_type] = value.media_type;
}
}
// Extra aliases not covered by EXT_MAP
Object.assign(CONTENT_TYPE_MAP, {
'text/x-markdown': 7,
'application/md': 7,
'application/markdown': 7,
'application/vnd.xmind.workbook': 14,
'application/zip': 14, // xmind can be zip
});
// ─── Size limits by media_type (bytes) ──────────────────────────────────────
const MB = 1024 * 1024;
const SIZE_LIMITS = {
5: 10 * MB, // Excel / CSV
7: 10 * MB, // Markdown
13: 10 * MB, // TXT
14: 10 * MB, // Xmind
9: 30 * MB, // Image
};
const DEFAULT_SIZE_LIMIT = 200 * MB; // PDF, Word, PPT, Audio, etc.
// ─── Explicitly unsupported extensions ──────────────────────────────────────
const UNSUPPORTED_VIDEO_EXT = new Set(['mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv', 'webm', 'm4v', 'rmvb', 'rm', '3gp']);
const UNSUPPORTED_VIDEO_CT = new Set([
'video/mp4',
'video/x-msvideo',
'video/quicktime',
'video/x-matroska',
'video/x-ms-wmv',
'video/x-flv',
'video/webm',
]);
// ─── Helpers ────────────────────────────────────────────────────────────────
function fail(result) {
console.log(JSON.stringify({ pass: false, ...result }));
process.exit(1);
}
function formatSize(bytes) {
if (bytes < MB) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / MB).toFixed(1)} MB`;
}
// ─── Argument parsing ───────────────────────────────────────────────────────
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i++) {
if (argv[i].startsWith('--') && i + 1 < argv.length) {
args[argv[i].replace(/^--/, '')] = argv[i + 1];
i += 1;
}
}
return args;
}
// ─── Main ───────────────────────────────────────────────────────────────────
const args = parseArgs(process.argv);
if (!args.file) {
console.error('Usage: node preflight-check.cjs --file <path> [--content-type <mime>]');
process.exit(2);
}
const filePath = path.resolve(args.file);
const fileName = path.basename(filePath);
const extMatch = fileName.match(/\.([^.]+)$/);
const ext = extMatch ? extMatch[1].toLowerCase() : '';
const inputContentType = args['content-type'] || '';
const base = { file_path: filePath, file_name: fileName, file_ext: ext };
// 1. Check file exists
let stat;
try {
stat = fs.statSync(filePath);
} catch (err) {
if (err.code === 'ENOENT') {
console.error(`File not found: ${filePath}`);
process.exit(2);
}
throw err;
}
// 2. Check not an unsupported video type (by ext or content-type)
if (UNSUPPORTED_VIDEO_EXT.has(ext)) {
fail({ ...base, reason: `Video files (.${ext}) are not supported. Only supported in IMA desktop app.` });
}
if (UNSUPPORTED_VIDEO_CT.has(inputContentType)) {
fail({ ...base, reason: `Video files (${inputContentType}) are not supported. Only supported in IMA desktop app.` });
}
// 3. Resolve media_type and content_type
// Priority: content-type first, then fall back to extension
let mediaType = null;
let contentType = null;
const ctMediaType = inputContentType ? CONTENT_TYPE_MAP[inputContentType] : undefined;
const extMapping = ext ? EXT_MAP[ext] : undefined;
if (ctMediaType != null) {
// Content-type recognized — always wins
mediaType = ctMediaType;
contentType = inputContentType;
} else if (inputContentType) {
// Content-type provided but unrecognized — try extension fallback
if (extMapping) {
mediaType = extMapping.media_type;
contentType = extMapping.content_type;
} else {
fail({
...base,
reason: `Unrecognized content type ${inputContentType}${ext ? ` and file extension .${ext}` : ''}. This file type is not supported.`,
});
}
} else {
// No content-type provided — fall back to extension
if (extMapping) {
mediaType = extMapping.media_type;
contentType = extMapping.content_type;
} else if (ext) {
fail({ ...base, reason: `Unrecognized file extension .${ext}. This file type is not supported.` });
} else {
fail({ ...base, reason: 'File has no extension and no --content-type provided. Cannot determine file type.' });
}
}
// 4. Check file size
const fileSize = stat.size;
const sizeLimit = SIZE_LIMITS[mediaType] || DEFAULT_SIZE_LIMIT;
if (fileSize > sizeLimit) {
fail({
...base,
file_size: fileSize,
media_type: mediaType,
content_type: contentType,
reason: `File size ${formatSize(fileSize)} exceeds the ${formatSize(sizeLimit)} limit for this file type.`,
});
}
// 5. All checks passed
console.log(
JSON.stringify({
pass: true,
...base,
file_size: fileSize,
media_type: mediaType,
content_type: contentType,
}),
);
process.exit(0);