redmine-context 1.0.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/LICENSE +21 -0
- package/README.md +449 -0
- package/dist/bundle/index.d.ts +5 -0
- package/dist/bundle/index.js +5 -0
- package/dist/bundle/json.d.ts +90 -0
- package/dist/bundle/json.js +266 -0
- package/dist/bundle/markdown.d.ts +75 -0
- package/dist/bundle/markdown.js +294 -0
- package/dist/bundle/search-list.d.ts +43 -0
- package/dist/bundle/search-list.js +53 -0
- package/dist/bundle/stable-stringify.d.ts +26 -0
- package/dist/bundle/stable-stringify.js +50 -0
- package/dist/cache/contract.d.ts +157 -0
- package/dist/cache/contract.js +0 -0
- package/dist/cache/disk-index.d.ts +82 -0
- package/dist/cache/disk-index.js +220 -0
- package/dist/cache/disk.d.ts +133 -0
- package/dist/cache/disk.js +313 -0
- package/dist/cache/gc.d.ts +78 -0
- package/dist/cache/gc.js +123 -0
- package/dist/cache/get-or-compute.d.ts +36 -0
- package/dist/cache/get-or-compute.js +52 -0
- package/dist/cache/index.d.ts +9 -0
- package/dist/cache/index.js +8 -0
- package/dist/cache/keys.d.ts +76 -0
- package/dist/cache/keys.js +78 -0
- package/dist/cache/memory.d.ts +48 -0
- package/dist/cache/memory.js +110 -0
- package/dist/cache-first.d.ts +127 -0
- package/dist/cache-first.js +227 -0
- package/dist/client/errors.d.ts +33 -0
- package/dist/client/errors.js +49 -0
- package/dist/client/http.d.ts +110 -0
- package/dist/client/http.js +207 -0
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.js +5 -0
- package/dist/client/issues.d.ts +71 -0
- package/dist/client/issues.js +100 -0
- package/dist/client/search.d.ts +58 -0
- package/dist/client/search.js +81 -0
- package/dist/config/credentials.d.ts +247 -0
- package/dist/config/credentials.js +427 -0
- package/dist/config/doctor.d.ts +123 -0
- package/dist/config/doctor.js +260 -0
- package/dist/config/index.d.ts +6 -0
- package/dist/config/index.js +6 -0
- package/dist/config/keyring.d.ts +96 -0
- package/dist/config/keyring.js +158 -0
- package/dist/config/login.d.ts +97 -0
- package/dist/config/login.js +189 -0
- package/dist/config/settings.d.ts +94 -0
- package/dist/config/settings.js +140 -0
- package/dist/contract.d.ts +173 -0
- package/dist/contract.js +27 -0
- package/dist/core.d.ts +1 -0
- package/dist/core.js +8 -0
- package/dist/extract/audio-extractor.d.ts +105 -0
- package/dist/extract/audio-extractor.js +156 -0
- package/dist/extract/audio.d.ts +126 -0
- package/dist/extract/audio.js +184 -0
- package/dist/extract/dispatcher.d.ts +132 -0
- package/dist/extract/dispatcher.js +115 -0
- package/dist/extract/download.d.ts +111 -0
- package/dist/extract/download.js +261 -0
- package/dist/extract/duration.d.ts +106 -0
- package/dist/extract/duration.js +148 -0
- package/dist/extract/ffmpeg.d.ts +56 -0
- package/dist/extract/ffmpeg.js +95 -0
- package/dist/extract/gguf.d.ts +137 -0
- package/dist/extract/gguf.js +215 -0
- package/dist/extract/index.d.ts +19 -0
- package/dist/extract/index.js +19 -0
- package/dist/extract/magic.d.ts +80 -0
- package/dist/extract/magic.js +282 -0
- package/dist/extract/ooxml.d.ts +131 -0
- package/dist/extract/ooxml.js +336 -0
- package/dist/extract/pdf.d.ts +147 -0
- package/dist/extract/pdf.js +322 -0
- package/dist/extract/queue.d.ts +167 -0
- package/dist/extract/queue.js +217 -0
- package/dist/extract/subprocess.d.ts +145 -0
- package/dist/extract/subprocess.js +181 -0
- package/dist/extract/tesseract.d.ts +153 -0
- package/dist/extract/tesseract.js +321 -0
- package/dist/extract/video-extractor.d.ts +84 -0
- package/dist/extract/video-extractor.js +89 -0
- package/dist/extract/video.d.ts +198 -0
- package/dist/extract/video.js +418 -0
- package/dist/extract/which.d.ts +63 -0
- package/dist/extract/which.js +72 -0
- package/dist/extract/whisper-extract.d.ts +211 -0
- package/dist/extract/whisper-extract.js +323 -0
- package/dist/extract/whisper.d.ts +50 -0
- package/dist/extract/whisper.js +67 -0
- package/dist/extract/zip.d.ts +50 -0
- package/dist/extract/zip.js +165 -0
- package/dist/extract-issue-attachments.d.ts +82 -0
- package/dist/extract-issue-attachments.js +156 -0
- package/dist/fetch-attachment-text.d.ts +113 -0
- package/dist/fetch-attachment-text.js +155 -0
- package/dist/fetch-issue-bundle.d.ts +81 -0
- package/dist/fetch-issue-bundle.js +93 -0
- package/dist/fetch-issue-search.d.ts +74 -0
- package/dist/fetch-issue-search.js +120 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +67 -0
- package/dist/normalize/collections.d.ts +52 -0
- package/dist/normalize/collections.js +170 -0
- package/dist/normalize/helpers.d.ts +35 -0
- package/dist/normalize/helpers.js +59 -0
- package/dist/normalize/index.d.ts +2 -0
- package/dist/normalize/index.js +2 -0
- package/dist/normalize/issue.d.ts +34 -0
- package/dist/normalize/issue.js +154 -0
- package/dist/surfaces/cli/commands.d.ts +61 -0
- package/dist/surfaces/cli/commands.js +262 -0
- package/dist/surfaces/cli/main.d.ts +32 -0
- package/dist/surfaces/cli/main.js +210 -0
- package/dist/surfaces/cli/prompts.d.ts +66 -0
- package/dist/surfaces/cli/prompts.js +147 -0
- package/dist/surfaces/cli/tty.d.ts +27 -0
- package/dist/surfaces/cli/tty.js +35 -0
- package/dist/surfaces/cli/types.d.ts +39 -0
- package/dist/surfaces/cli/types.js +7 -0
- package/dist/surfaces/mcp/server.d.ts +171 -0
- package/dist/surfaces/mcp/server.js +427 -0
- package/dist/surfaces/tui/app.d.ts +55 -0
- package/dist/surfaces/tui/app.js +180 -0
- package/dist/surfaces/tui/attachment-status.d.ts +79 -0
- package/dist/surfaces/tui/attachment-status.js +113 -0
- package/dist/surfaces/tui/components/breadcrumb.d.ts +7 -0
- package/dist/surfaces/tui/components/breadcrumb.js +31 -0
- package/dist/surfaces/tui/components/gradient-text.d.ts +23 -0
- package/dist/surfaces/tui/components/gradient-text.js +75 -0
- package/dist/surfaces/tui/components/scroll-view.d.ts +25 -0
- package/dist/surfaces/tui/components/scroll-view.js +77 -0
- package/dist/surfaces/tui/components/spinner.d.ts +12 -0
- package/dist/surfaces/tui/components/spinner.js +39 -0
- package/dist/surfaces/tui/components/text-input.d.ts +45 -0
- package/dist/surfaces/tui/components/text-input.js +114 -0
- package/dist/surfaces/tui/format-file-size.d.ts +24 -0
- package/dist/surfaces/tui/format-file-size.js +43 -0
- package/dist/surfaces/tui/glyphs.d.ts +47 -0
- package/dist/surfaces/tui/glyphs.js +84 -0
- package/dist/surfaces/tui/hooks/use-auth-guard.d.ts +39 -0
- package/dist/surfaces/tui/hooks/use-auth-guard.js +135 -0
- package/dist/surfaces/tui/hooks/use-doctor-status.d.ts +64 -0
- package/dist/surfaces/tui/hooks/use-doctor-status.js +123 -0
- package/dist/surfaces/tui/hooks/use-escape-interceptor.d.ts +25 -0
- package/dist/surfaces/tui/hooks/use-escape-interceptor.js +65 -0
- package/dist/surfaces/tui/hooks/use-exit-guard.d.ts +18 -0
- package/dist/surfaces/tui/hooks/use-exit-guard.js +66 -0
- package/dist/surfaces/tui/hooks/use-export-bundle.d.ts +62 -0
- package/dist/surfaces/tui/hooks/use-export-bundle.js +100 -0
- package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +61 -0
- package/dist/surfaces/tui/hooks/use-issue-detail.js +132 -0
- package/dist/surfaces/tui/hooks/use-issue-search.d.ts +71 -0
- package/dist/surfaces/tui/hooks/use-issue-search.js +168 -0
- package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +44 -0
- package/dist/surfaces/tui/hooks/use-list-navigation.js +82 -0
- package/dist/surfaces/tui/hooks/use-media-binaries.d.ts +24 -0
- package/dist/surfaces/tui/hooks/use-media-binaries.js +44 -0
- package/dist/surfaces/tui/hooks/use-my-issues.d.ts +66 -0
- package/dist/surfaces/tui/hooks/use-my-issues.js +151 -0
- package/dist/surfaces/tui/hooks/use-onboarding-callbacks.d.ts +11 -0
- package/dist/surfaces/tui/hooks/use-onboarding-callbacks.js +104 -0
- package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +52 -0
- package/dist/surfaces/tui/hooks/use-terminal-width.js +90 -0
- package/dist/surfaces/tui/index.d.ts +50 -0
- package/dist/surfaces/tui/index.js +158 -0
- package/dist/surfaces/tui/instance.d.ts +37 -0
- package/dist/surfaces/tui/instance.js +36 -0
- package/dist/surfaces/tui/job-registry.d.ts +113 -0
- package/dist/surfaces/tui/job-registry.js +123 -0
- package/dist/surfaces/tui/job-status.d.ts +45 -0
- package/dist/surfaces/tui/job-status.js +81 -0
- package/dist/surfaces/tui/navigation.d.ts +74 -0
- package/dist/surfaces/tui/navigation.js +87 -0
- package/dist/surfaces/tui/palettes.d.ts +38 -0
- package/dist/surfaces/tui/palettes.js +244 -0
- package/dist/surfaces/tui/screen.d.ts +30 -0
- package/dist/surfaces/tui/screen.js +51 -0
- package/dist/surfaces/tui/screens/about.d.ts +2 -0
- package/dist/surfaces/tui/screens/about.js +35 -0
- package/dist/surfaces/tui/screens/appearance.d.ts +2 -0
- package/dist/surfaces/tui/screens/appearance.js +74 -0
- package/dist/surfaces/tui/screens/config.d.ts +2 -0
- package/dist/surfaces/tui/screens/config.js +82 -0
- package/dist/surfaces/tui/screens/doctor.d.ts +2 -0
- package/dist/surfaces/tui/screens/doctor.js +109 -0
- package/dist/surfaces/tui/screens/export.d.ts +2 -0
- package/dist/surfaces/tui/screens/export.js +168 -0
- package/dist/surfaces/tui/screens/home-selection.d.ts +70 -0
- package/dist/surfaces/tui/screens/home-selection.js +80 -0
- package/dist/surfaces/tui/screens/home.d.ts +2 -0
- package/dist/surfaces/tui/screens/home.js +200 -0
- package/dist/surfaces/tui/screens/issue-detail.d.ts +6 -0
- package/dist/surfaces/tui/screens/issue-detail.js +182 -0
- package/dist/surfaces/tui/screens/jobs.d.ts +7 -0
- package/dist/surfaces/tui/screens/jobs.js +89 -0
- package/dist/surfaces/tui/screens/loaded-issue-context.d.ts +49 -0
- package/dist/surfaces/tui/screens/loaded-issue-context.js +57 -0
- package/dist/surfaces/tui/screens/onboarding/api-key.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/api-key.js +69 -0
- package/dist/surfaces/tui/screens/onboarding/login.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/login.js +50 -0
- package/dist/surfaces/tui/screens/onboarding/mode.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/mode.js +42 -0
- package/dist/surfaces/tui/screens/onboarding/onboarding-context.d.ts +221 -0
- package/dist/surfaces/tui/screens/onboarding/onboarding-context.js +131 -0
- package/dist/surfaces/tui/screens/onboarding/success.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/success.js +41 -0
- package/dist/surfaces/tui/screens/onboarding/url.d.ts +24 -0
- package/dist/surfaces/tui/screens/onboarding/url.js +84 -0
- package/dist/surfaces/tui/screens/onboarding/validating.d.ts +1 -0
- package/dist/surfaces/tui/screens/onboarding/validating.js +86 -0
- package/dist/surfaces/tui/screens/welcome.d.ts +6 -0
- package/dist/surfaces/tui/screens/welcome.js +95 -0
- package/dist/surfaces/tui/status-color.d.ts +21 -0
- package/dist/surfaces/tui/status-color.js +23 -0
- package/dist/surfaces/tui/symbols.d.ts +231 -0
- package/dist/surfaces/tui/symbols.js +14 -0
- package/dist/surfaces/tui/terminal-colors.d.ts +29 -0
- package/dist/surfaces/tui/terminal-colors.js +39 -0
- package/dist/surfaces/tui/theme.d.ts +156 -0
- package/dist/surfaces/tui/theme.js +86 -0
- package/dist/surfaces/tui/truncate.d.ts +31 -0
- package/dist/surfaces/tui/truncate.js +81 -0
- package/package.json +93 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download do modelo GGUF do whisper.cpp com verificação de integridade
|
|
3
|
+
* (M4-02, #58, ADR-002).
|
|
4
|
+
*
|
|
5
|
+
* A transcrição local (ADR-002) exige um modelo `.gguf`, artefato independente do
|
|
6
|
+
* binário `whisper-cli` ({@link findWhisper}). Este módulo baixa esse modelo de
|
|
7
|
+
* forma AUDITÁVEL e SEGURA, respeitando a política híbrida de binários do ADR-002:
|
|
8
|
+
*
|
|
9
|
+
* - CONSTANTES PINADAS: URL HTTPS oficial ({@link GGUF_MODEL_URL}) e SHA-256
|
|
10
|
+
* esperado ({@link GGUF_MODEL_SHA256}) ficam no código, não em config remota — a
|
|
11
|
+
* integridade não depende de o servidor "dizer" qual é o hash certo.
|
|
12
|
+
* - SÓ HTTPS: qualquer URL `http://` é RECUSADA antes de tocar a rede (defesa
|
|
13
|
+
* contra downgrade/MITM em ambiente corporativo).
|
|
14
|
+
* - CHECKSUM OBRIGATÓRIO: o conteúdo é gravado num `.part`, seu SHA-256 é comparado
|
|
15
|
+
* ao pinado e só então renomeado para o destino; divergência → o `.part` é
|
|
16
|
+
* DESCARTADO e um erro claro (esperado vs. obtido) é lançado.
|
|
17
|
+
* - RETOMADA VIA HTTP RANGE (M4-03, #59): se um `.part` de N bytes já existe, o
|
|
18
|
+
* request pede `Range: bytes=N-` e ANEXA os bytes recebidos (`206 Partial Content`),
|
|
19
|
+
* evitando rebaixar do zero. O checksum é sempre do arquivo COMPLETO. Fallbacks
|
|
20
|
+
* graciosos: servidor que IGNORA o Range (`200 OK` com o corpo inteiro) recomeça
|
|
21
|
+
* do zero sem corromper; `.part` inservível (`416`) é descartado e o download
|
|
22
|
+
* reinicia limpo.
|
|
23
|
+
* - NUNCA EM MCP HEADLESS: o download é OPT-IN INTERATIVO (`--download-binaries`,
|
|
24
|
+
* ADR-002). Quando a chamada sinaliza ambiente MCP/headless ({@link DownloadGgufOptions.headless}),
|
|
25
|
+
* a função RECUSA antes de qualquer IO, com erro acionável — download silencioso
|
|
26
|
+
* de executáveis/modelos é vetor de supply chain.
|
|
27
|
+
*
|
|
28
|
+
* Todas as dependências de efeito colateral (fetch/fs) são INJETÁVEIS
|
|
29
|
+
* ({@link GgufDeps}) para tornar os testes herméticos — sem rede nem FS real.
|
|
30
|
+
*/
|
|
31
|
+
import { createHash } from 'node:crypto';
|
|
32
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
import { whisperModelDir } from './whisper.js';
|
|
35
|
+
/**
|
|
36
|
+
* Modelo GGUF default: `ggml-tiny` (multilíngue, ~78 MB) do repositório oficial
|
|
37
|
+
* `ggerganov/whisper.cpp` no Hugging Face. Escolhido por ser o MENOR modelo
|
|
38
|
+
* multilíngue oficial — mantém o auto-detect de idioma do ADR-002 com o menor
|
|
39
|
+
* custo de download para o primeiro contato. Modelos maiores (base/small) são
|
|
40
|
+
* evolução futura configurável; a MECÂNICA de download+verificação é idêntica.
|
|
41
|
+
*/
|
|
42
|
+
export const GGUF_MODEL_NAME = 'ggml-tiny.bin';
|
|
43
|
+
/** URL HTTPS oficial e fixa do {@link GGUF_MODEL_NAME} (Hugging Face resolve). */
|
|
44
|
+
export const GGUF_MODEL_URL = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin';
|
|
45
|
+
/**
|
|
46
|
+
* SHA-256 hex esperado do {@link GGUF_MODEL_NAME}. É a ÂNCORA de integridade:
|
|
47
|
+
* o arquivo baixado só é aceito se seu hash bater exatamente com esta constante.
|
|
48
|
+
*/
|
|
49
|
+
export const GGUF_MODEL_SHA256 = 'be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21';
|
|
50
|
+
/** Sufixo do arquivo parcial durante a escrita, antes do rename para o destino. */
|
|
51
|
+
const PART_SUFFIX = '.part';
|
|
52
|
+
/**
|
|
53
|
+
* Erro tipado de qualquer recusa/falha do download do modelo GGUF. O campo
|
|
54
|
+
* {@link GgufDownloadError.code} permite ao chamador (CLI/doctor) reagir de forma
|
|
55
|
+
* específica sem casar strings de mensagem.
|
|
56
|
+
*/
|
|
57
|
+
export class GgufDownloadError extends Error {
|
|
58
|
+
/** Categoria da falha — ver {@link GgufDownloadFailure}. */
|
|
59
|
+
code;
|
|
60
|
+
constructor(code, message) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = new.target.name;
|
|
63
|
+
this.code = code;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Constrói as dependências reais de produção: `fetch` global e `node:fs/promises`
|
|
68
|
+
* (`mkdir` recursivo, `rm` com `force`). Isolado para manter {@link downloadGgufModel}
|
|
69
|
+
* testável sem tocar rede/FS.
|
|
70
|
+
*
|
|
71
|
+
* @returns Deps ligadas ao ambiente real.
|
|
72
|
+
*/
|
|
73
|
+
export function defaultGgufDeps() {
|
|
74
|
+
return {
|
|
75
|
+
fetch: (url, init) => fetch(url, init),
|
|
76
|
+
mkdir: async (dir) => {
|
|
77
|
+
await mkdir(dir, { recursive: true });
|
|
78
|
+
},
|
|
79
|
+
writeFile: (path, data) => writeFile(path, data),
|
|
80
|
+
// flag 'a': cria o arquivo se ausente e sempre anexa ao fim (retomada).
|
|
81
|
+
appendFile: (path, data) => writeFile(path, data, { flag: 'a' }),
|
|
82
|
+
readPart: async (path) => new Uint8Array(await readFile(path)),
|
|
83
|
+
// stat inexistente (ENOENT) ⇒ sem `.part` ⇒ download novo (undefined).
|
|
84
|
+
statPart: async (path) => {
|
|
85
|
+
try {
|
|
86
|
+
return (await stat(path)).size;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
rename: (from, to) => rename(from, to),
|
|
93
|
+
rm: (path) => rm(path, { force: true }),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Baixa o modelo GGUF do whisper.cpp e o grava no cache local, verificando o
|
|
98
|
+
* SHA-256 pinado antes de considerá-lo válido (ADR-002, M4-02).
|
|
99
|
+
*
|
|
100
|
+
* Ordem dos portões (todos exercitados por testes): (1) guard headless — recusa
|
|
101
|
+
* antes de qualquer IO; (2) só HTTPS — recusa `http://` sem tocar a rede; (3)
|
|
102
|
+
* RETOMADA — se um `.part` de N bytes já existe, pede `Range: bytes=N-` e ANEXA o
|
|
103
|
+
* `206`; se o servidor ignora o Range (`200`) recomeça do zero, e um `.part`
|
|
104
|
+
* inservível (`416`) é descartado antes de reiniciar limpo; (4) compara o SHA-256
|
|
105
|
+
* do arquivo COMPLETO ao esperado — se bater, renomeia atomicamente para o destino
|
|
106
|
+
* e devolve o caminho; se não, DESCARTA o `.part` e lança {@link GgufDownloadError}
|
|
107
|
+
* com esperado vs. obtido.
|
|
108
|
+
*
|
|
109
|
+
* @param options - Destino, flag headless, origem/checksum e deps — ver {@link DownloadGgufOptions}.
|
|
110
|
+
* @returns Caminho absoluto do modelo `.gguf`/`.bin` gravado e verificado.
|
|
111
|
+
* @throws {GgufDownloadError} `headless` (ambiente MCP), `insecure-url` (não-HTTPS),
|
|
112
|
+
* `http-error` (resposta não-2xx) ou `checksum-mismatch` (integridade divergente).
|
|
113
|
+
* @example
|
|
114
|
+
* const modelPath = await downloadGgufModel({ headless: false });
|
|
115
|
+
* logger.info(`modelo pronto em ${modelPath}`);
|
|
116
|
+
*/
|
|
117
|
+
export async function downloadGgufModel(options = {}) {
|
|
118
|
+
const headless = options.headless ?? false;
|
|
119
|
+
const url = options.url ?? GGUF_MODEL_URL;
|
|
120
|
+
const expected = (options.sha256 ?? GGUF_MODEL_SHA256).toLowerCase();
|
|
121
|
+
const destDir = options.destDir ?? whisperModelDir();
|
|
122
|
+
const deps = options.deps ?? defaultGgufDeps();
|
|
123
|
+
// Portão 1: opt-in interativo (ADR-002). Nunca baixa em MCP/headless.
|
|
124
|
+
if (headless) {
|
|
125
|
+
throw new GgufDownloadError('headless', 'Download do modelo GGUF é opt-in interativo (--download-binaries) e não pode ' +
|
|
126
|
+
'rodar em ambiente MCP/headless (ADR-002). Execute a CLI interativamente para baixá-lo.');
|
|
127
|
+
}
|
|
128
|
+
// Portão 2: só HTTPS — bloqueia downgrade/MITM antes de qualquer rede. Uma URL
|
|
129
|
+
// malformada é tratada como insegura (não escapa como TypeError cru).
|
|
130
|
+
let protocol;
|
|
131
|
+
try {
|
|
132
|
+
protocol = new URL(url).protocol;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
throw new GgufDownloadError('insecure-url', `Download recusado: URL do modelo GGUF inválida ('${url}'). Apenas HTTPS é permitido.`);
|
|
136
|
+
}
|
|
137
|
+
if (protocol !== 'https:') {
|
|
138
|
+
throw new GgufDownloadError('insecure-url', `Download recusado: apenas HTTPS é permitido para o modelo GGUF (protocolo recebido: '${protocol}').`);
|
|
139
|
+
}
|
|
140
|
+
const finalPath = join(destDir, GGUF_MODEL_NAME);
|
|
141
|
+
const partPath = `${finalPath}${PART_SUFFIX}`;
|
|
142
|
+
// Portão 3: retomada. Um `.part` de N>0 bytes vira o ponto de partida do Range;
|
|
143
|
+
// ausente/vazio ⇒ download novo (sem cabeçalho Range).
|
|
144
|
+
const existingSize = await deps.statPart(partPath);
|
|
145
|
+
let rangeStart = existingSize !== undefined && existingSize > 0 ? existingSize : 0;
|
|
146
|
+
let response = await requestModel(deps, url, rangeStart);
|
|
147
|
+
// `.part` inservível para o servidor (ex.: já completo/maior): 416. Descarta o
|
|
148
|
+
// parcial e reinicia limpo UMA vez, para não travar num parcial envenenado.
|
|
149
|
+
if (response.status === 416 && rangeStart > 0) {
|
|
150
|
+
await deps.rm(partPath).catch(() => undefined);
|
|
151
|
+
rangeStart = 0;
|
|
152
|
+
response = await requestModel(deps, url, 0);
|
|
153
|
+
}
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
throw new GgufDownloadError('http-error', `Falha ao baixar o modelo GGUF: resposta HTTP ${response.status}.`);
|
|
156
|
+
}
|
|
157
|
+
const body = new Uint8Array(await response.arrayBuffer());
|
|
158
|
+
await deps.mkdir(destDir);
|
|
159
|
+
try {
|
|
160
|
+
// Só é retomada de verdade se pedimos Range E o servidor honrou com 206. Caso
|
|
161
|
+
// contrário (200 com o corpo inteiro), o `.part` é sobrescrito do zero — nunca
|
|
162
|
+
// anexamos a um corpo completo, o que corromperia o arquivo.
|
|
163
|
+
let complete;
|
|
164
|
+
if (rangeStart > 0 && response.status === 206) {
|
|
165
|
+
const previous = await deps.readPart(partPath);
|
|
166
|
+
await deps.appendFile(partPath, body);
|
|
167
|
+
complete = concatBytes(previous, body);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
await deps.writeFile(partPath, body);
|
|
171
|
+
complete = body;
|
|
172
|
+
}
|
|
173
|
+
// Portão 4: integridade do arquivo COMPLETO. Só o hash pinado valida o
|
|
174
|
+
// conteúdo — nunca o servidor.
|
|
175
|
+
const actual = createHash('sha256').update(complete).digest('hex');
|
|
176
|
+
if (actual !== expected) {
|
|
177
|
+
throw new GgufDownloadError('checksum-mismatch', `Integridade do modelo GGUF divergente: esperado ${expected}, obtido ${actual}. Arquivo descartado.`);
|
|
178
|
+
}
|
|
179
|
+
await deps.rename(partPath, finalPath);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
// Descarta o `.part` em QUALQUER falha (escrita parcial, checksum ou rename) —
|
|
183
|
+
// nunca deixa lixo nem um destino final corrompido. `rm` é force, idempotente.
|
|
184
|
+
await deps.rm(partPath).catch(() => undefined);
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
return finalPath;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Faz o request do modelo, anexando `Range: bytes=<from>-` quando `from > 0` para
|
|
191
|
+
* retomar de um `.part` parcial. Isolado para manter {@link downloadGgufModel}
|
|
192
|
+
* legível e testar o cabeçalho de retomada de forma direta.
|
|
193
|
+
*
|
|
194
|
+
* @param deps - Dependências injetáveis (usa apenas `fetch`).
|
|
195
|
+
* @param url - URL HTTPS já validada do modelo.
|
|
196
|
+
* @param from - Offset de retomada em bytes; `0` faz um GET normal (sem Range).
|
|
197
|
+
* @returns A resposta HTTP (200 no download novo/fallback; 206 na retomada honrada).
|
|
198
|
+
*/
|
|
199
|
+
function requestModel(deps, url, from) {
|
|
200
|
+
return from > 0 ? deps.fetch(url, { headers: { Range: `bytes=${from}-` } }) : deps.fetch(url);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Concatena `previous` (bytes já no `.part`) com `appended` (bytes do `206`) para
|
|
204
|
+
* compor o arquivo COMPLETO sobre o qual o SHA-256 é verificado.
|
|
205
|
+
*
|
|
206
|
+
* @param previous - Bytes já presentes no `.part` antes da retomada.
|
|
207
|
+
* @param appended - Bytes recebidos no corpo do `206 Partial Content`.
|
|
208
|
+
* @returns Buffer com o conteúdo completo (`previous` seguido de `appended`).
|
|
209
|
+
*/
|
|
210
|
+
function concatBytes(previous, appended) {
|
|
211
|
+
const out = new Uint8Array(previous.length + appended.length);
|
|
212
|
+
out.set(previous);
|
|
213
|
+
out.set(appended, previous.length);
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const MODULE_NAME: "extract";
|
|
2
|
+
export { downloadAttachment, isSkipped, safeExtension, type DownloadAttachmentOptions, type DownloadResult, type SkippedDownload, } from './download.js';
|
|
3
|
+
export { detectMime, detectMimeFromFile, mimeForExtension, MAGIC_SAMPLE_SIZE, } from './magic.js';
|
|
4
|
+
export { dispatchExtraction, ExtractorRegistry, type DispatchOptions, type ExtractOptions, type Extractor, } from './dispatcher.js';
|
|
5
|
+
export { defaultConcurrency, runQueue, type JobContext, type QueueEvent, type QueueJob, type QueueJobStatus, type RunQueueOptions, } from './queue.js';
|
|
6
|
+
export { createDefaultRegistry, createTesseractExtractor, detectTesseractVersion, findTesseract, TesseractExtractor, TESSERACT_MIMES, type TesseractExtractorOptions, type TesseractLocation, } from './tesseract.js';
|
|
7
|
+
export { createPdfExtractor, detectPdftotextVersion, findPdftotext, PdfExtractor, PDF_MIMES, type PdfExtractorOptions, type PdftotextLocation, } from './pdf.js';
|
|
8
|
+
export { findExecutable, isExecutable, type ConventionalDirs, type ExecutableLocation, type FindExecutableDeps, } from './which.js';
|
|
9
|
+
export { runWithWatchdog, sanitizedEnv, SubprocessTimeoutError, type ExecFileLike, type SanitizedEnvOptions, type WatchdogChild, type WatchdogDeps, type WatchdogInvocation, } from './subprocess.js';
|
|
10
|
+
export { detectFfmpegVersion, findFfmpeg, findFfprobe, type FfmpegLocation, type FfprobeLocation, } from './ffmpeg.js';
|
|
11
|
+
export { audioTempDir, convertAudioToWav, type ConvertAudioToWavOptions, type FfmpegInvocation, type FfmpegRunner, type WavConversionFailure, type WavConversionResult, type WavConversionSuccess, } from './audio.js';
|
|
12
|
+
export { findWhisper, whisperModelDir, type WhisperLocation } from './whisper.js';
|
|
13
|
+
export { createWhisperExtractor, parseWhisperOutput, WhisperExtractor, WHISPER_MIMES, type CreateWhisperExtractorOptions, type WhisperExtractorOptions, type WhisperInvocation, type WhisperRunner, } from './whisper-extract.js';
|
|
14
|
+
export { AudioExtractor, AUDIO_MIMES, createAudioExtractor, type AudioExtractorOptions, } from './audio-extractor.js';
|
|
15
|
+
export { createVideoExtractor, VideoExtractor, type VideoExtractorOptions, type VideoPipelineOverrides, } from './video-extractor.js';
|
|
16
|
+
export { createOoxmlExtractor, OoxmlExtractor, OOXML_MIMES } from './ooxml.js';
|
|
17
|
+
export { parseZip, type ZipArchive, type ZipEntry } from './zip.js';
|
|
18
|
+
export { convertVideoToWav, extractVideoKeyframe, extractVideoTranscript, parseFfprobeDuration, probeVideoDuration, VIDEO_MIMES, type DurationProbeOk, type DurationProbeResult, type DurationProbeUnavailable, type ExtractKeyframeOptions, type ExtractVideoTranscriptOptions, type FfprobeInvocation, type FfprobeRunner, type KeyframeFailure, type KeyframeResult, type KeyframeSuccess, type ProbeVideoDurationOptions, } from './video.js';
|
|
19
|
+
export { downloadGgufModel, defaultGgufDeps, GgufDownloadError, GGUF_MODEL_NAME, GGUF_MODEL_URL, GGUF_MODEL_SHA256, type DownloadGgufOptions, type GgufDeps, type GgufFetchInit, type GgufDownloadFailure, } from './gguf.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const MODULE_NAME = 'extract';
|
|
2
|
+
export { downloadAttachment, isSkipped, safeExtension, } from './download.js';
|
|
3
|
+
export { detectMime, detectMimeFromFile, mimeForExtension, MAGIC_SAMPLE_SIZE, } from './magic.js';
|
|
4
|
+
export { dispatchExtraction, ExtractorRegistry, } from './dispatcher.js';
|
|
5
|
+
export { defaultConcurrency, runQueue, } from './queue.js';
|
|
6
|
+
export { createDefaultRegistry, createTesseractExtractor, detectTesseractVersion, findTesseract, TesseractExtractor, TESSERACT_MIMES, } from './tesseract.js';
|
|
7
|
+
export { createPdfExtractor, detectPdftotextVersion, findPdftotext, PdfExtractor, PDF_MIMES, } from './pdf.js';
|
|
8
|
+
export { findExecutable, isExecutable, } from './which.js';
|
|
9
|
+
export { runWithWatchdog, sanitizedEnv, SubprocessTimeoutError, } from './subprocess.js';
|
|
10
|
+
export { detectFfmpegVersion, findFfmpeg, findFfprobe, } from './ffmpeg.js';
|
|
11
|
+
export { audioTempDir, convertAudioToWav, } from './audio.js';
|
|
12
|
+
export { findWhisper, whisperModelDir } from './whisper.js';
|
|
13
|
+
export { createWhisperExtractor, parseWhisperOutput, WhisperExtractor, WHISPER_MIMES, } from './whisper-extract.js';
|
|
14
|
+
export { AudioExtractor, AUDIO_MIMES, createAudioExtractor, } from './audio-extractor.js';
|
|
15
|
+
export { createVideoExtractor, VideoExtractor, } from './video-extractor.js';
|
|
16
|
+
export { createOoxmlExtractor, OoxmlExtractor, OOXML_MIMES } from './ooxml.js';
|
|
17
|
+
export { parseZip } from './zip.js';
|
|
18
|
+
export { convertVideoToWav, extractVideoKeyframe, extractVideoTranscript, parseFfprobeDuration, probeVideoDuration, VIDEO_MIMES, } from './video.js';
|
|
19
|
+
export { downloadGgufModel, defaultGgufDeps, GgufDownloadError, GGUF_MODEL_NAME, GGUF_MODEL_URL, GGUF_MODEL_SHA256, } from './gguf.js';
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detecção de MIME REAL por magic bytes (M3-08, ADR-005).
|
|
3
|
+
*
|
|
4
|
+
* SEGURANÇA: o tipo do anexo é decidido SEMPRE pelos primeiros bytes do arquivo
|
|
5
|
+
* baixado — NUNCA pela extensão do filename nem pelo `Content-Type` reportado
|
|
6
|
+
* pelo Redmine (ambos são atacáveis/mentíveis). Um `.png` que na verdade é um
|
|
7
|
+
* executável, ou um `Content-Type: image/png` sobre um zip, são detectados pelo
|
|
8
|
+
* conteúdo. Sem dependência nova: assinaturas mínimas próprias, tabeladas abaixo.
|
|
9
|
+
*
|
|
10
|
+
* Tabela de assinaturas (offset em bytes → sequência hex):
|
|
11
|
+
*
|
|
12
|
+
* MIME | offset | bytes (hex) | origem
|
|
13
|
+
* --------------------|--------|--------------------------------|------------------
|
|
14
|
+
* image/png | 0 | 89 50 4E 47 0D 0A 1A 0A | PNG signature
|
|
15
|
+
* image/jpeg | 0 | FF D8 FF | JFIF/EXIF SOI
|
|
16
|
+
* image/gif | 0 | 47 49 46 38 (GIF8) | GIF87a/GIF89a
|
|
17
|
+
* image/webp | 0 + 8 | 52 49 46 46 (RIFF) + 57 45 42 50| RIFF container
|
|
18
|
+
* application/pdf | 0 | 25 50 44 46 (%PDF) | PDF header
|
|
19
|
+
* application/zip | 0 | 50 4B 03 04 (PK\x03\x04) | ZIP local header (docx/xlsx/pptx)
|
|
20
|
+
* audio/wav | 0 + 8 | 52 49 46 46 (RIFF) + 57 41 56 45| RIFF/WAVE container
|
|
21
|
+
* video/x-msvideo | 0 + 8 | 52 49 46 46 (RIFF) + 41 56 49 20| RIFF/AVI container
|
|
22
|
+
* audio/ogg | 0 | 4F 67 67 53 (OggS) | Ogg container
|
|
23
|
+
* video/webm | 0 | 1A 45 DF A3 (EBML) | Matroska/WebM (EBML)
|
|
24
|
+
* audio/mpeg | 0 | 49 44 33 (ID3) ou FF Ex sync | MP3 (tag ID3 ou frame sync)
|
|
25
|
+
*
|
|
26
|
+
* CONTÊINERES ISO-BMFF (MP4/MOV/M4A, M4-14/#73): a box `ftyp` fica no offset 4
|
|
27
|
+
* (bytes 4-8 == 'ftyp'); a MARCA (brand, bytes 8-12) decide áudio vs vídeo. Brands
|
|
28
|
+
* de áudio (`M4A `, `M4B `) → `audio/mp4`; qualquer outra (`isom`, `mp42`, `qt `,
|
|
29
|
+
* `avc1`, …) → `video/mp4`. Isso roteia o mesmo container para o extrator certo
|
|
30
|
+
* (whisper direto para áudio; pipeline de vídeo → áudio → whisper para vídeo).
|
|
31
|
+
*
|
|
32
|
+
* Fallback: se nenhuma assinatura casar e a amostra for "texto plausível"
|
|
33
|
+
* (sem byte NUL e maioria de bytes imprimíveis), classifica como `text/plain`.
|
|
34
|
+
* Caso contrário, retorna `undefined` (desconhecido/binário sem extrator).
|
|
35
|
+
*
|
|
36
|
+
* DOCX/XLSX/PPTX são contêineres ZIP: a detecção por magic bytes os reporta como
|
|
37
|
+
* `application/zip` — a diferenciação fina (inspecionar `[Content_Types].xml`)
|
|
38
|
+
* fica para o extrator específico, fora do escopo do dispatcher.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* Tamanho da amostra lida do início do arquivo. Cobre a maior assinatura
|
|
42
|
+
* (webp precisa de 12 bytes) com folga e dá base estatística ao teste de texto.
|
|
43
|
+
*/
|
|
44
|
+
export declare const MAGIC_SAMPLE_SIZE = 512;
|
|
45
|
+
/**
|
|
46
|
+
* Detecta o MIME REAL de uma amostra de bytes via a tabela de assinaturas, com
|
|
47
|
+
* fallback para `text/plain` quando a amostra é texto plausível.
|
|
48
|
+
*
|
|
49
|
+
* @param bytes - Amostra do início do arquivo (idealmente {@link MAGIC_SAMPLE_SIZE}).
|
|
50
|
+
* @returns O MIME detectado, ou `undefined` se desconhecido/binário sem assinatura.
|
|
51
|
+
* @example
|
|
52
|
+
* detectMime(new Uint8Array([0x25, 0x50, 0x44, 0x46])); // 'application/pdf'
|
|
53
|
+
* detectMime(new Uint8Array([])); // undefined
|
|
54
|
+
*/
|
|
55
|
+
export declare function detectMime(bytes: Uint8Array): string | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* Lê o prefixo de um arquivo e detecta seu MIME REAL por magic bytes.
|
|
58
|
+
*
|
|
59
|
+
* Lê no máximo {@link MAGIC_SAMPLE_SIZE} bytes (não carrega o arquivo inteiro),
|
|
60
|
+
* então fecha o descritor. Arquivos vazios ou muito curtos resultam em
|
|
61
|
+
* `undefined` — o dispatcher os trata como `unsupported`, sem erro fatal.
|
|
62
|
+
*
|
|
63
|
+
* @param filePath - Caminho absoluto do arquivo baixado.
|
|
64
|
+
* @returns O MIME detectado, ou `undefined` se desconhecido.
|
|
65
|
+
* @throws {Error} Se o arquivo não puder ser aberto/lido (ex.: não existe).
|
|
66
|
+
*/
|
|
67
|
+
export declare function detectMimeFromFile(filePath: string): Promise<string | undefined>;
|
|
68
|
+
/**
|
|
69
|
+
* Deriva o MIME que a EXTENSÃO de um filename declara — usado somente para o
|
|
70
|
+
* aviso de mismatch (o magic byte é a fonte de verdade). Retorna `undefined`
|
|
71
|
+
* para extensões ausentes ou não mapeadas (nesse caso não há mismatch a avisar).
|
|
72
|
+
*
|
|
73
|
+
* @param filename - Nome de arquivo reportado pelo Redmine (não confiável).
|
|
74
|
+
* @returns O MIME declarado pela extensão, ou `undefined`.
|
|
75
|
+
* @example
|
|
76
|
+
* mimeForExtension('photo.PNG'); // 'image/png'
|
|
77
|
+
* mimeForExtension('report.pdf'); // 'application/pdf'
|
|
78
|
+
* mimeForExtension('noext'); // undefined
|
|
79
|
+
*/
|
|
80
|
+
export declare function mimeForExtension(filename: string): string | undefined;
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detecção de MIME REAL por magic bytes (M3-08, ADR-005).
|
|
3
|
+
*
|
|
4
|
+
* SEGURANÇA: o tipo do anexo é decidido SEMPRE pelos primeiros bytes do arquivo
|
|
5
|
+
* baixado — NUNCA pela extensão do filename nem pelo `Content-Type` reportado
|
|
6
|
+
* pelo Redmine (ambos são atacáveis/mentíveis). Um `.png` que na verdade é um
|
|
7
|
+
* executável, ou um `Content-Type: image/png` sobre um zip, são detectados pelo
|
|
8
|
+
* conteúdo. Sem dependência nova: assinaturas mínimas próprias, tabeladas abaixo.
|
|
9
|
+
*
|
|
10
|
+
* Tabela de assinaturas (offset em bytes → sequência hex):
|
|
11
|
+
*
|
|
12
|
+
* MIME | offset | bytes (hex) | origem
|
|
13
|
+
* --------------------|--------|--------------------------------|------------------
|
|
14
|
+
* image/png | 0 | 89 50 4E 47 0D 0A 1A 0A | PNG signature
|
|
15
|
+
* image/jpeg | 0 | FF D8 FF | JFIF/EXIF SOI
|
|
16
|
+
* image/gif | 0 | 47 49 46 38 (GIF8) | GIF87a/GIF89a
|
|
17
|
+
* image/webp | 0 + 8 | 52 49 46 46 (RIFF) + 57 45 42 50| RIFF container
|
|
18
|
+
* application/pdf | 0 | 25 50 44 46 (%PDF) | PDF header
|
|
19
|
+
* application/zip | 0 | 50 4B 03 04 (PK\x03\x04) | ZIP local header (docx/xlsx/pptx)
|
|
20
|
+
* audio/wav | 0 + 8 | 52 49 46 46 (RIFF) + 57 41 56 45| RIFF/WAVE container
|
|
21
|
+
* video/x-msvideo | 0 + 8 | 52 49 46 46 (RIFF) + 41 56 49 20| RIFF/AVI container
|
|
22
|
+
* audio/ogg | 0 | 4F 67 67 53 (OggS) | Ogg container
|
|
23
|
+
* video/webm | 0 | 1A 45 DF A3 (EBML) | Matroska/WebM (EBML)
|
|
24
|
+
* audio/mpeg | 0 | 49 44 33 (ID3) ou FF Ex sync | MP3 (tag ID3 ou frame sync)
|
|
25
|
+
*
|
|
26
|
+
* CONTÊINERES ISO-BMFF (MP4/MOV/M4A, M4-14/#73): a box `ftyp` fica no offset 4
|
|
27
|
+
* (bytes 4-8 == 'ftyp'); a MARCA (brand, bytes 8-12) decide áudio vs vídeo. Brands
|
|
28
|
+
* de áudio (`M4A `, `M4B `) → `audio/mp4`; qualquer outra (`isom`, `mp42`, `qt `,
|
|
29
|
+
* `avc1`, …) → `video/mp4`. Isso roteia o mesmo container para o extrator certo
|
|
30
|
+
* (whisper direto para áudio; pipeline de vídeo → áudio → whisper para vídeo).
|
|
31
|
+
*
|
|
32
|
+
* Fallback: se nenhuma assinatura casar e a amostra for "texto plausível"
|
|
33
|
+
* (sem byte NUL e maioria de bytes imprimíveis), classifica como `text/plain`.
|
|
34
|
+
* Caso contrário, retorna `undefined` (desconhecido/binário sem extrator).
|
|
35
|
+
*
|
|
36
|
+
* DOCX/XLSX/PPTX são contêineres ZIP: a detecção por magic bytes os reporta como
|
|
37
|
+
* `application/zip` — a diferenciação fina (inspecionar `[Content_Types].xml`)
|
|
38
|
+
* fica para o extrator específico, fora do escopo do dispatcher.
|
|
39
|
+
*/
|
|
40
|
+
import { open } from 'node:fs/promises';
|
|
41
|
+
/**
|
|
42
|
+
* Tamanho da amostra lida do início do arquivo. Cobre a maior assinatura
|
|
43
|
+
* (webp precisa de 12 bytes) com folga e dá base estatística ao teste de texto.
|
|
44
|
+
*/
|
|
45
|
+
export const MAGIC_SAMPLE_SIZE = 512;
|
|
46
|
+
/** Razão mínima de bytes imprimíveis para classificar a amostra como texto. */
|
|
47
|
+
const TEXT_PRINTABLE_RATIO = 0.95;
|
|
48
|
+
/** Byte NUL — sua presença descarta imediatamente a hipótese de texto. */
|
|
49
|
+
const NUL_BYTE = 0x00;
|
|
50
|
+
/**
|
|
51
|
+
* Tabela de assinaturas, avaliada em ordem. A primeira que casar vence — as
|
|
52
|
+
* assinaturas são disjuntas (prefixos distintos), então a ordem não é ambígua.
|
|
53
|
+
*/
|
|
54
|
+
const SIGNATURES = [
|
|
55
|
+
{ mime: 'image/png', segments: [{ offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }] },
|
|
56
|
+
{ mime: 'image/jpeg', segments: [{ offset: 0, bytes: [0xff, 0xd8, 0xff] }] },
|
|
57
|
+
{ mime: 'image/gif', segments: [{ offset: 0, bytes: [0x47, 0x49, 0x46, 0x38] }] },
|
|
58
|
+
{
|
|
59
|
+
mime: 'image/webp',
|
|
60
|
+
segments: [
|
|
61
|
+
{ offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] },
|
|
62
|
+
{ offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
{ mime: 'application/pdf', segments: [{ offset: 0, bytes: [0x25, 0x50, 0x44, 0x46] }] },
|
|
66
|
+
{ mime: 'application/zip', segments: [{ offset: 0, bytes: [0x50, 0x4b, 0x03, 0x04] }] },
|
|
67
|
+
// RIFF/WAVE (áudio) e RIFF/AVI (vídeo): mesmo prefixo RIFF do webp, distinguidos
|
|
68
|
+
// pelo subtipo no offset 8 (segmento dupla-checado, como o webp).
|
|
69
|
+
{
|
|
70
|
+
mime: 'audio/wav',
|
|
71
|
+
segments: [
|
|
72
|
+
{ offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] },
|
|
73
|
+
{ offset: 8, bytes: [0x57, 0x41, 0x56, 0x45] },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
mime: 'video/x-msvideo',
|
|
78
|
+
segments: [
|
|
79
|
+
{ offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] },
|
|
80
|
+
{ offset: 8, bytes: [0x41, 0x56, 0x49, 0x20] },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
// Ogg (áudio) — o whisper transcreve a faixa; classificamos como áudio (ADR-005).
|
|
84
|
+
{ mime: 'audio/ogg', segments: [{ offset: 0, bytes: [0x4f, 0x67, 0x67, 0x53] }] },
|
|
85
|
+
// EBML (Matroska/WebM): compartilham a assinatura; roteamos ao pipeline de vídeo,
|
|
86
|
+
// que extrai a faixa de áudio (webm/mkv só-áudio ainda transcreve; keyframe degrada).
|
|
87
|
+
{ mime: 'video/webm', segments: [{ offset: 0, bytes: [0x1a, 0x45, 0xdf, 0xa3] }] },
|
|
88
|
+
// MP3 com tag ID3v2 no início (o frame sync puro é tratado à parte, ver detectMime).
|
|
89
|
+
{ mime: 'audio/mpeg', segments: [{ offset: 0, bytes: [0x49, 0x44, 0x33] }] },
|
|
90
|
+
];
|
|
91
|
+
/** Segmento da box `ftyp` de um contêiner ISO-BMFF (MP4/MOV/M4A): offset 4. */
|
|
92
|
+
const FTYP_SEGMENT = { offset: 4, bytes: [0x66, 0x74, 0x79, 0x70] };
|
|
93
|
+
/**
|
|
94
|
+
* Brands (marca no offset 8-12) de ISO-BMFF que classificam o container como
|
|
95
|
+
* ÁUDIO: `M4A ` e `M4B `. Qualquer outra brand é tratada como vídeo (`video/mp4`).
|
|
96
|
+
*/
|
|
97
|
+
const AUDIO_FTYP_BRANDS = [
|
|
98
|
+
[0x4d, 0x34, 0x41, 0x20], // 'M4A '
|
|
99
|
+
[0x4d, 0x34, 0x42, 0x20], // 'M4B '
|
|
100
|
+
];
|
|
101
|
+
/**
|
|
102
|
+
* Detecta o MIME de um contêiner ISO-BMFF (MP4/MOV/M4A) pela box `ftyp` (offset 4)
|
|
103
|
+
* e sua brand (offset 8). Brands de áudio → `audio/mp4`; demais → `video/mp4`.
|
|
104
|
+
*
|
|
105
|
+
* @param bytes - Amostra do início do arquivo.
|
|
106
|
+
* @returns `audio/mp4` | `video/mp4` se for ISO-BMFF; `undefined` caso contrário.
|
|
107
|
+
*/
|
|
108
|
+
function detectFtypMime(bytes) {
|
|
109
|
+
if (!matchesSegment(bytes, FTYP_SEGMENT)) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
const isAudio = AUDIO_FTYP_BRANDS.some((brand) => matchesSegment(bytes, { offset: 8, bytes: brand }));
|
|
113
|
+
return isAudio ? 'audio/mp4' : 'video/mp4';
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* `true` se a amostra começa com um MPEG audio frame sync (MP3 sem tag ID3): o
|
|
117
|
+
* primeiro byte é `0xFF` e os 3 bits mais altos do segundo byte são `1` (máscara
|
|
118
|
+
* `0xE0`). Distingue-se do JPEG (`FF D8`), cujo `0xD8 & 0xE0 = 0xC0`, e do ftyp.
|
|
119
|
+
*
|
|
120
|
+
* @param bytes - Amostra do início do arquivo.
|
|
121
|
+
* @returns `true` se casar o frame sync de MP3.
|
|
122
|
+
*/
|
|
123
|
+
function isMp3FrameSync(bytes) {
|
|
124
|
+
const b0 = bytes[0];
|
|
125
|
+
const b1 = bytes[1];
|
|
126
|
+
if (b0 === undefined || b1 === undefined) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
return b0 === 0xff && (b1 & 0xe0) === 0xe0;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Mapa extensão → MIME esperado. USADO APENAS para detectar mismatch entre a
|
|
133
|
+
* extensão declarada e o magic byte real (o magic byte sempre vence, ver
|
|
134
|
+
* dispatcher). NUNCA é fonte de verdade para roteamento.
|
|
135
|
+
*/
|
|
136
|
+
const EXTENSION_MIME = {
|
|
137
|
+
png: 'image/png',
|
|
138
|
+
jpg: 'image/jpeg',
|
|
139
|
+
jpeg: 'image/jpeg',
|
|
140
|
+
gif: 'image/gif',
|
|
141
|
+
webp: 'image/webp',
|
|
142
|
+
pdf: 'application/pdf',
|
|
143
|
+
zip: 'application/zip',
|
|
144
|
+
docx: 'application/zip',
|
|
145
|
+
xlsx: 'application/zip',
|
|
146
|
+
pptx: 'application/zip',
|
|
147
|
+
txt: 'text/plain',
|
|
148
|
+
md: 'text/plain',
|
|
149
|
+
log: 'text/plain',
|
|
150
|
+
csv: 'text/plain',
|
|
151
|
+
// Contêineres de áudio/vídeo (M4-14/#73): mapeados para o MIME que o `detectMime`
|
|
152
|
+
// produz para o mesmo conteúdo, minimizando avisos de mismatch espúrios. O `mov`
|
|
153
|
+
// é declarado como `video/mp4` porque a brand `qt ` é classificada como vídeo.
|
|
154
|
+
mp4: 'video/mp4',
|
|
155
|
+
m4v: 'video/mp4',
|
|
156
|
+
mov: 'video/mp4',
|
|
157
|
+
webm: 'video/webm',
|
|
158
|
+
mkv: 'video/webm',
|
|
159
|
+
avi: 'video/x-msvideo',
|
|
160
|
+
m4a: 'audio/mp4',
|
|
161
|
+
m4b: 'audio/mp4',
|
|
162
|
+
mp3: 'audio/mpeg',
|
|
163
|
+
wav: 'audio/wav',
|
|
164
|
+
ogg: 'audio/ogg',
|
|
165
|
+
oga: 'audio/ogg',
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* Verifica se um único segmento de assinatura casa com a amostra.
|
|
169
|
+
*
|
|
170
|
+
* @param bytes - Amostra lida do início do arquivo.
|
|
171
|
+
* @param segment - Segmento (offset + bytes esperados).
|
|
172
|
+
* @returns `true` se todos os bytes do segmento casam na amostra.
|
|
173
|
+
*/
|
|
174
|
+
function matchesSegment(bytes, segment) {
|
|
175
|
+
if (segment.offset + segment.bytes.length > bytes.length) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
for (let i = 0; i < segment.bytes.length; i += 1) {
|
|
179
|
+
if (bytes[segment.offset + i] !== segment.bytes[i]) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Heurística de "texto plausível": sem byte NUL e com ao menos
|
|
187
|
+
* {@link TEXT_PRINTABLE_RATIO} de bytes imprimíveis (ASCII visível, espaços em
|
|
188
|
+
* branco comuns ou bytes ≥ 0x80, que cobrem sequências UTF-8). Uma amostra
|
|
189
|
+
* vazia NÃO é texto (retorna `false`).
|
|
190
|
+
*
|
|
191
|
+
* @param bytes - Amostra lida do início do arquivo.
|
|
192
|
+
* @returns `true` se a amostra parece texto legível.
|
|
193
|
+
*/
|
|
194
|
+
function isPlausibleText(bytes) {
|
|
195
|
+
if (bytes.length === 0) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
let printable = 0;
|
|
199
|
+
for (const byte of bytes) {
|
|
200
|
+
if (byte === NUL_BYTE) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
// Reason: tab/LF/CR + ASCII visível (0x20–0x7E) + alto (≥0x80 UTF-8) contam
|
|
204
|
+
// como imprimíveis; demais bytes de controle derrubam a razão.
|
|
205
|
+
const isWhitespace = byte === 0x09 || byte === 0x0a || byte === 0x0d;
|
|
206
|
+
const isVisibleAscii = byte >= 0x20 && byte <= 0x7e;
|
|
207
|
+
const isHighByte = byte >= 0x80;
|
|
208
|
+
if (isWhitespace || isVisibleAscii || isHighByte) {
|
|
209
|
+
printable += 1;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return printable / bytes.length >= TEXT_PRINTABLE_RATIO;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Detecta o MIME REAL de uma amostra de bytes via a tabela de assinaturas, com
|
|
216
|
+
* fallback para `text/plain` quando a amostra é texto plausível.
|
|
217
|
+
*
|
|
218
|
+
* @param bytes - Amostra do início do arquivo (idealmente {@link MAGIC_SAMPLE_SIZE}).
|
|
219
|
+
* @returns O MIME detectado, ou `undefined` se desconhecido/binário sem assinatura.
|
|
220
|
+
* @example
|
|
221
|
+
* detectMime(new Uint8Array([0x25, 0x50, 0x44, 0x46])); // 'application/pdf'
|
|
222
|
+
* detectMime(new Uint8Array([])); // undefined
|
|
223
|
+
*/
|
|
224
|
+
export function detectMime(bytes) {
|
|
225
|
+
for (const signature of SIGNATURES) {
|
|
226
|
+
if (signature.segments.every((segment) => matchesSegment(bytes, segment))) {
|
|
227
|
+
return signature.mime;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Contêineres que exigem lógica além de casar bytes fixos: ISO-BMFF (a brand
|
|
231
|
+
// decide áudio vs vídeo) e MP3 sem tag ID3 (frame sync com máscara de bits).
|
|
232
|
+
const ftyp = detectFtypMime(bytes);
|
|
233
|
+
if (ftyp !== undefined) {
|
|
234
|
+
return ftyp;
|
|
235
|
+
}
|
|
236
|
+
if (isMp3FrameSync(bytes)) {
|
|
237
|
+
return 'audio/mpeg';
|
|
238
|
+
}
|
|
239
|
+
return isPlausibleText(bytes) ? 'text/plain' : undefined;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Lê o prefixo de um arquivo e detecta seu MIME REAL por magic bytes.
|
|
243
|
+
*
|
|
244
|
+
* Lê no máximo {@link MAGIC_SAMPLE_SIZE} bytes (não carrega o arquivo inteiro),
|
|
245
|
+
* então fecha o descritor. Arquivos vazios ou muito curtos resultam em
|
|
246
|
+
* `undefined` — o dispatcher os trata como `unsupported`, sem erro fatal.
|
|
247
|
+
*
|
|
248
|
+
* @param filePath - Caminho absoluto do arquivo baixado.
|
|
249
|
+
* @returns O MIME detectado, ou `undefined` se desconhecido.
|
|
250
|
+
* @throws {Error} Se o arquivo não puder ser aberto/lido (ex.: não existe).
|
|
251
|
+
*/
|
|
252
|
+
export async function detectMimeFromFile(filePath) {
|
|
253
|
+
const handle = await open(filePath, 'r');
|
|
254
|
+
try {
|
|
255
|
+
const buffer = Buffer.alloc(MAGIC_SAMPLE_SIZE);
|
|
256
|
+
const { bytesRead } = await handle.read(buffer, 0, MAGIC_SAMPLE_SIZE, 0);
|
|
257
|
+
return detectMime(buffer.subarray(0, bytesRead));
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
await handle.close();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Deriva o MIME que a EXTENSÃO de um filename declara — usado somente para o
|
|
265
|
+
* aviso de mismatch (o magic byte é a fonte de verdade). Retorna `undefined`
|
|
266
|
+
* para extensões ausentes ou não mapeadas (nesse caso não há mismatch a avisar).
|
|
267
|
+
*
|
|
268
|
+
* @param filename - Nome de arquivo reportado pelo Redmine (não confiável).
|
|
269
|
+
* @returns O MIME declarado pela extensão, ou `undefined`.
|
|
270
|
+
* @example
|
|
271
|
+
* mimeForExtension('photo.PNG'); // 'image/png'
|
|
272
|
+
* mimeForExtension('report.pdf'); // 'application/pdf'
|
|
273
|
+
* mimeForExtension('noext'); // undefined
|
|
274
|
+
*/
|
|
275
|
+
export function mimeForExtension(filename) {
|
|
276
|
+
const dot = filename.lastIndexOf('.');
|
|
277
|
+
if (dot < 0 || dot === filename.length - 1) {
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
const ext = filename.slice(dot + 1).toLowerCase();
|
|
281
|
+
return EXTENSION_MIME[ext];
|
|
282
|
+
}
|