first commit
This commit is contained in:
@@ -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();
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user