Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97f940f261 | ||
|
|
282161d297 | ||
|
|
b9bef85bc7 | ||
|
|
354caaf8f0 | ||
|
|
682ba8b943 | ||
|
|
45d535bdb7 | ||
|
|
1457caa453 | ||
|
|
c5c94a9956 |
No files matched your search
@@ -2,6 +2,53 @@
|
|||||||
|
|
||||||
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
||||||
|
|
||||||
|
## [0.3.2] - 2026-10-18
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
- **Travamento ao clicar em um vídeo no app para Linux.** O AppImage traz o WebKitGTK sem os plugins de mídia do GStreamer: ao criar um `<video>` ou `<audio>` o processo da página morria e a janela ficava congelada. Agora o app para Linux nunca cria esses elementos; vídeo e áudio têm o botão "Abrir no player do sistema", que copia o arquivo para uma pasta temporária do usuário e o abre no player padrão (inclusive quando o Guará roda como administrador). No navegador (`guara ui`) e nos demais sistemas o vídeo continua tocando dentro do app, mas só na tela grande, nunca no painel pequeno.
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
- Clicar na pré-visualização do painel de detalhes abre a visualização grande (imagem, PDF, documento e demais tipos), agora maior (até 98% da largura e 95% da altura da janela).
|
||||||
|
|
||||||
|
## [0.3.1] - 2026-10-18
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
- Filtros "Ocultar arquivos pequenos (menos de 30 KB)" e "Mais de 1 megapixel" no explorador, para separar fotos de ícones e miniaturas quando há milhares de imagens.
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
- Abrir uma sessão salva falhava por inteiro quando a origem (por exemplo `/dev/sdb`) não estava acessível, e a mensagem de erro era vaga. Agora a sessão sempre abre: dá para navegar, exportar e gerar relatório, e o botão "Escolher a origem…" reconecta o disco ou uma imagem para voltar a pré-visualizar e recuperar.
|
||||||
|
- Mensagens claras quando o arquivo de sessão não pode ser lido (por exemplo, criado como administrador em `/root`) ou não é uma sessão válida.
|
||||||
|
- Teste automático garante que sessões gravadas pela v0.1.0 continuam abrindo.
|
||||||
|
- Carving de PNG: texto ASCII logo após um PNG truncado era lido como um bloco de ~1,9 GB e gerava "fotos" gigantes de confiança baixa. Agora o bloco precisa ser plausível (tipo válido, tamanho razoável, dentro do disco) e o arquivo termina no último bloco válido.
|
||||||
|
- Retomar uma varredura de sessão sem a origem disponível agora avisa na hora, em vez de falhar depois.
|
||||||
|
- Rodando como administrador (sudo/pkexec), as pastas sugeridas passam a ser as do usuário que abriu o Guará e tudo que ele grava (sessões, CSV, relatórios, imagens e arquivos recuperados, com as pastas criadas) fica com o dono e as permissões desse usuário, e não do root.
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-10-18
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
- **Explorador de arquivos:** grade de miniaturas e lista com rolagem virtual (milhares de itens), agrupamento por dia/mês, navegação lateral por tipo, data da foto, pasta, câmera e confiança, seleção com Ctrl/Shift/teclado, painel de detalhes e visualização rápida em tela cheia.
|
||||||
|
- **Miniaturas rápidas** geradas no núcleo (JPEG com redução na decodificação, PNG, GIF, BMP, WebP), com orientação EXIF e uso da miniatura embutida quando a imagem está danificada.
|
||||||
|
- **Metadados:** EXIF (câmera, lente, datas, GPS), PDF, Office/OpenDocument e MP4/MOV; datas de criação, modificação e acesso do NTFS, FAT e exFAT.
|
||||||
|
- **Perícia:** hashes MD5, SHA-1 e SHA-256, `guara hash`, `guara search` (texto no disco inteiro em UTF-8 e UTF-16), `guara export` (CSV), `guara report` (relatório HTML com cadeia de custódia) e detecção de duplicadas. Tudo também na interface gráfica.
|
||||||
|
- Nova identidade visual: logo de pixels que se juntam num lobo-guará de silhueta lisa.
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
- A barra inferior e o painel de progresso não são mais recriados a cada atualização.
|
||||||
|
- Textos sem travessões.
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-10-18
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
- **Pré-visualização completa:** imagem (zoom), vídeo (MP4 e outros), áudio, PDF (pdf.js incluso, funciona offline), texto, Word/Excel/PowerPoint/OpenDocument, conteúdo de ZIP e hexadecimal; tela cheia com navegação por teclado (↑ ↓ ← → Espaço Esc). O conteúdo é lido sob demanda com suporte a Range, sem gravar em disco.
|
||||||
|
- Seletor de tema **Auto / Claro / Escuro**; a escolha é salva e vale também no app reiniciado como administrador.
|
||||||
|
- Nova identidade visual em verde-azulado, com logo de pixels que se juntam até formar um lobo-guará.
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
- Reiniciar como administrador no Linux: no AppImage o executável fica num ponto de montagem inacessível ao root; agora usa o próprio `.AppImage`, mostra o motivo se falhar e fecha o app antigo só quando o novo abre.
|
||||||
|
- Barra de ações inferior piscava a cada atualização; agora é criada uma vez e só atualiza o texto. O painel de progresso também deixou de ser recriado a cada ciclo.
|
||||||
|
- FAT32 em mídias pequenas (menos de 65 525 clusters) não era reconhecido.
|
||||||
|
- O tema não acompanhava a escolha ao abrir como root (o WebView do root usava outro tema do sistema).
|
||||||
|
|
||||||
## [0.1.0] - 2026-10-18
|
## [0.1.0] - 2026-10-18
|
||||||
|
|
||||||
### Adicionado
|
### Adicionado
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 3
|
version = 3
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -58,6 +64,12 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.2"
|
version = "2.13.2"
|
||||||
@@ -82,6 +94,18 @@ dependencies = [
|
|||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytemuck"
|
||||||
|
version = "1.25.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -140,6 +164,12 @@ version = "1.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "color_quant"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -155,6 +185,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -214,6 +253,26 @@ version = "2.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fdeflate"
|
||||||
|
version = "0.3.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||||
|
dependencies = [
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide 0.9.1",
|
||||||
|
"zlib-rs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.7"
|
version = "0.14.7"
|
||||||
@@ -246,9 +305,19 @@ dependencies = [
|
|||||||
"r-efi",
|
"r-efi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gif"
|
||||||
|
version = "0.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||||
|
dependencies = [
|
||||||
|
"color_quant",
|
||||||
|
"weezl",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guara-cli"
|
name = "guara-cli"
|
||||||
version = "0.1.0"
|
version = "0.3.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
@@ -260,14 +329,21 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guara-core"
|
name = "guara-core"
|
||||||
version = "0.1.0"
|
version = "0.3.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"flate2",
|
||||||
|
"image",
|
||||||
|
"jpeg-decoder",
|
||||||
|
"jpeg-encoder",
|
||||||
"libc",
|
"libc",
|
||||||
|
"md-5",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
|
"tiny_http",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -283,6 +359,32 @@ version = "1.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image"
|
||||||
|
version = "0.25.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"byteorder-lite",
|
||||||
|
"color_quant",
|
||||||
|
"gif",
|
||||||
|
"image-webp",
|
||||||
|
"moxcms",
|
||||||
|
"num-traits",
|
||||||
|
"png",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image-webp"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder-lite",
|
||||||
|
"quick-error",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
@@ -295,6 +397,18 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jpeg-decoder"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jpeg-encoder"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b454d911ac55068f53495488d8ccd0646eaa540c033a28ee15b07838afafb01f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.191"
|
version = "0.2.191"
|
||||||
@@ -313,12 +427,52 @@ version = "0.4.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "md-5"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.3"
|
version = "2.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "moxcms"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"pxfm",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.31.3"
|
version = "0.31.3"
|
||||||
@@ -331,6 +485,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2"
|
name = "objc2"
|
||||||
version = "0.6.5"
|
version = "0.6.5"
|
||||||
@@ -358,6 +521,19 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "png"
|
||||||
|
version = "0.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"crc32fast",
|
||||||
|
"fdeflate",
|
||||||
|
"flate2",
|
||||||
|
"miniz_oxide 0.8.9",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.107"
|
version = "1.0.107"
|
||||||
@@ -367,6 +543,18 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.30"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-error"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
@@ -438,6 +626,17 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -449,6 +648,12 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -541,6 +746,12 @@ version = "0.11.1+wasi-snapshot-preview1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "weezl"
|
||||||
|
version = "0.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -629,6 +840,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zlib-rs"
|
||||||
|
version = "0.6.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/guara-core", "crates/guara-cli"]
|
members = ["crates/guara-core", "crates/guara-cli"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.3.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://github.com/lucasrguerra/guara"
|
repository = "https://github.com/lucasrguerra/guara"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<p align="center"><img src="apps/gui/ui/guara.svg" width="96" alt="Guará"></p>
|
<p align="center"><img src="apps/gui/ui/guara-logo.svg" width="360" alt="Guará"></p>
|
||||||
<h1 align="center">Guará</h1>
|
<h1 align="center">Guará</h1>
|
||||||
<p align="center">Recuperação de arquivos livre e gratuita para HD, SSD e pendrive.<br>Terminal e interface gráfica · Windows, Linux e macOS · somente leitura na origem.</p>
|
<p align="center">Recuperação de arquivos livre e gratuita para HD, SSD e pendrive.<br>Terminal e interface gráfica · Windows, Linux e macOS · somente leitura na origem.</p>
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ O Guará junta, numa única varredura, o que ferramentas como Recuva, PhotoRec,
|
|||||||
- A origem é aberta **somente para leitura**. Nada no núcleo escreve no dispositivo recuperado.
|
- A origem é aberta **somente para leitura**. Nada no núcleo escreve no dispositivo recuperado.
|
||||||
- A recuperação **recusa destinos no mesmo disco** da origem (Linux/macOS).
|
- A recuperação **recusa destinos no mesmo disco** da origem (Linux/macOS).
|
||||||
- **Imagem forense** (`guara image`) tolerante a setores ruins, com mapa de erros e SHA-256: trabalhe sempre na cópia de um disco com defeito.
|
- **Imagem forense** (`guara image`) tolerante a setores ruins, com mapa de erros e SHA-256: trabalhe sempre na cópia de um disco com defeito.
|
||||||
- Cada recuperação grava `guara-manifesto.json` com o **SHA-256** de todos os arquivos.
|
- Cada recuperação grava `guara-manifesto.json` com **MD5, SHA-1 e SHA-256** de todos os arquivos.
|
||||||
- **Volumes cifrados** (BitLocker, LUKS) são **detectados e avisados**; a v1 não desbloqueia nem varre dentro deles.
|
- **Volumes cifrados** (BitLocker, LUKS) são **detectados e avisados**; a v1 não desbloqueia nem varre dentro deles.
|
||||||
|
|
||||||
## Instalação
|
## Instalação
|
||||||
@@ -34,7 +34,7 @@ Cada arquivo tem um `.sha256` ao lado. Compilar do código: `cargo build --relea
|
|||||||
|
|
||||||
> Os binários ainda **não são assinados**: o Windows SmartScreen e o Gatekeeper do macOS vão avisar. Confira o SHA-256.
|
> Os binários ainda **não são assinados**: o Windows SmartScreen e o Gatekeeper do macOS vão avisar. Confira o SHA-256.
|
||||||
|
|
||||||
Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux/macOS).
|
Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux/macOS). Mesmo como root, o Guará grava sessões, relatórios e arquivos recuperados com o dono e as permissões do usuário que o abriu.
|
||||||
|
|
||||||
## Uso (terminal)
|
## Uso (terminal)
|
||||||
|
|
||||||
@@ -48,6 +48,10 @@ guara scan disco.img --recover /mnt/outro-disco/Recuperados
|
|||||||
guara recover s.json /mnt/outro-disco/Recuperados --category image
|
guara recover s.json /mnt/outro-disco/Recuperados --category image
|
||||||
guara resume s.json # continua após Ctrl+C
|
guara resume s.json # continua após Ctrl+C
|
||||||
sudo guara image /dev/sdb /mnt/outro-disco/sdb.img # imagem com mapa de erros
|
sudo guara image /dev/sdb /mnt/outro-disco/sdb.img # imagem com mapa de erros
|
||||||
|
sudo guara hash /dev/sdb # MD5, SHA-1 e SHA-256 do dispositivo
|
||||||
|
sudo guara search /dev/sdb "contrato" "cpf" # texto em todo o disco (UTF-8 e UTF-16)
|
||||||
|
guara export s.json achados.csv --category image # planilha dos achados
|
||||||
|
guara report s.json relatorio.html --case 2026-001 --examiner "Nome" --hash-source
|
||||||
guara ui # interface no navegador (127.0.0.1 + token)
|
guara ui # interface no navegador (127.0.0.1 + token)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -55,7 +59,28 @@ guara ui # interface no navegador (127.0.0.1 + t
|
|||||||
|
|
||||||
## Interface gráfica
|
## Interface gráfica
|
||||||
|
|
||||||
App desktop (Tauri) com barra lateral de dispositivos, mapa do disco, resultados ao vivo, filtros por tipo, busca, pré-visualização de imagens, confiança por arquivo, pausar/continuar e salvar sessão. Tema claro/escuro automático e layout responsivo. O mesmo front-end roda em `guara ui`.
|
App desktop (Tauri) no estilo de um gerenciador de arquivos, pensado para quando o Guará acha milhares de itens:
|
||||||
|
|
||||||
|
- **Grade de miniaturas** (gerada pelo núcleo, rápida mesmo com milhares de fotos) ou **lista**, com controle de tamanho, agrupamento por dia ou mês e rolagem virtual que aguenta dezenas de milhares de arquivos.
|
||||||
|
- **Navegação lateral** por tipo, **data real da foto (EXIF)**, pasta, **câmera**, confiança, com localização GPS e ocultando duplicadas.
|
||||||
|
- **Seleção como no Finder/Explorer:** clique, Ctrl/Cmd, Shift, setas, Ctrl+A. Espaço, Enter ou duplo clique abrem a **visualização rápida** em tela cheia com navegação por ← →.
|
||||||
|
- **Painel de detalhes** com pré-visualização, datas (tirada, modificada, criada, acessada) e metadados.
|
||||||
|
- Pausar/continuar, salvar sessão, tema **Auto / Claro / Escuro** (lembrado também ao reiniciar como administrador) e layout responsivo. O mesmo front-end roda em `guara ui`.
|
||||||
|
|
||||||
|
**Pré-visualização antes de recuperar** (lida direto do disco, sem gravar nada): imagens, **vídeo (MP4/MOV/WebM)** e áudio com player, **PDF** página a página, texto, documentos Word/Excel/PowerPoint/OpenDocument (texto extraído), conteúdo de ZIP e visão hexadecimal para o resto. Quando um arquivo não abre, o aviso ajuda a julgar se ele está danificado. A reprodução de vídeo depende dos codecs do seu sistema (no Linux, instale os plugins `gstreamer1.0-libav` e `gstreamer1.0-plugins-good`).
|
||||||
|
|
||||||
|
## Recursos periciais
|
||||||
|
|
||||||
|
Pensados para quem precisa documentar o que fez:
|
||||||
|
|
||||||
|
- **Hashes MD5, SHA-1 e SHA-256** de cada arquivo recuperado, da imagem forense e do dispositivo inteiro (`guara hash`).
|
||||||
|
- **Metadados:** EXIF (câmera, lente, número de série, data, **GPS**), informações de PDF (autor, programa, datas), propriedades de documentos Office e OpenDocument (autor, última modificação por, empresa) e dados de vídeo MP4/MOV (data de gravação, duração, resolução).
|
||||||
|
- **Linha do tempo:** datas de criação, modificação e acesso lidas do NTFS, FAT e exFAT.
|
||||||
|
- **Pesquisa de texto no disco inteiro** (`guara search`), inclusive espaço livre e fora de partições, em UTF-8 e UTF-16, mostrando posição, partição e contexto.
|
||||||
|
- **Exportação CSV** (abre no Excel) e **relatório HTML** pronto para imprimir (`guara report`), com identificação do caso, origem, partições, resumo, hashes e lista dos arquivos.
|
||||||
|
- **Detecção de duplicadas** por assinatura de conteúdo.
|
||||||
|
|
||||||
|
Escopo: o Guará trabalha com **mídias de armazenamento** (HD, SSD, pendrive, cartões e imagens de disco). Ele **não** desbloqueia celulares nem contorna senhas ou travas de segurança, e não substitui ferramentas de extração de dispositivos móveis.
|
||||||
|
|
||||||
## Limites conhecidos (v0.1)
|
## Limites conhecidos (v0.1)
|
||||||
|
|
||||||
@@ -68,7 +93,7 @@ Seja realista sobre o que dá para esperar:
|
|||||||
- **exFAT:** coberto por teste com imagem sintética; falta validar em mais mídias reais.
|
- **exFAT:** coberto por teste com imagem sintética; falta validar em mais mídias reais.
|
||||||
- **Cifragem:** somente detecção.
|
- **Cifragem:** somente detecção.
|
||||||
- **Windows/macOS:** o código compila e é testado em CI, mas os testes com imagens reais rodam só no Linux. Relatos de mídias reais são muito bem-vindos.
|
- **Windows/macOS:** o código compila e é testado em CI, mas os testes com imagens reais rodam só no Linux. Relatos de mídias reais são muito bem-vindos.
|
||||||
- **GUI:** no Linux/macOS o app reinicia inteiro como administrador (via `pkexec`/`osascript`) em vez de usar um processo auxiliar separado — melhoria planejada.
|
- **GUI:** no Linux/macOS o app reinicia inteiro como administrador (via `pkexec`/`osascript`) em vez de usar um processo auxiliar separado (melhoria planejada).
|
||||||
|
|
||||||
## Desenvolvimento
|
## Desenvolvimento
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
# Segurança
|
# Segurança
|
||||||
|
|
||||||
Guará abre dispositivos em modo somente leitura e processa dados potencialmente corrompidos ou maliciosos (sistemas de arquivos e arquivos forjados). Vulnerabilidades — corrupção de memória em parsers, escrita indevida na origem, escape de caminho na recuperação — devem ser reportadas em privado pela aba **Security → Report a vulnerability** do repositório no GitHub. Não abra issue pública antes da correção.
|
Guará abre dispositivos em modo somente leitura e processa dados potencialmente corrompidos ou maliciosos (sistemas de arquivos e arquivos forjados). Vulnerabilidades (corrupção de memória em parsers, escrita indevida na origem, escape de caminho na recuperação) devem ser reportadas em privado pela aba **Security → Report a vulnerability** do repositório no GitHub. Não abra issue pública antes da correção.
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "guara-gui"
|
name = "guara-gui"
|
||||||
description = "Guará: interface gráfica de recuperação de arquivos"
|
description = "Guará: interface gráfica de recuperação de arquivos"
|
||||||
version = "0.1.0"
|
version = "0.3.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://github.com/lucasrguerra/guara"
|
repository = "https://github.com/lucasrguerra/guara"
|
||||||
@@ -20,6 +20,9 @@ tauri-plugin-dialog = "2"
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
guara-core = { path = "../../../crates/guara-core" }
|
guara-core = { path = "../../../crates/guara-core" }
|
||||||
|
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 455 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 455 KiB |
@@ -16,40 +16,166 @@ async fn rpc(state: tauri::State<'_, State>, method: String, params: serde_json:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Reabre o app com privilégios de administrador (Linux/macOS). No Windows o
|
/// Reabre o app com privilégios de administrador (Linux/macOS). No Windows o
|
||||||
/// manifesto já exige elevação.
|
/// manifesto já exige elevação. O app novo encerra este quando abre; se a
|
||||||
|
/// autorização falhar, a janela continua aqui e a interface mostra o motivo.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn relaunch_elevated() -> Result<(), String> {
|
fn relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
{
|
||||||
|
use tauri::Emitter;
|
||||||
|
let exe = elevated_exe()?;
|
||||||
|
let cfg = guara_core::settings::config_dir();
|
||||||
|
let log = std::env::temp_dir().join(format!("guara-elevate-{}.log", std::process::id()));
|
||||||
|
let _ = std::fs::write(&log, b"");
|
||||||
|
let out = std::fs::OpenOptions::new().append(true).open(&log).map_err(|e| e.to_string())?;
|
||||||
|
let err = out.try_clone().map_err(|e| e.to_string())?;
|
||||||
|
let pid = std::process::id().to_string();
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
let mut cmd = {
|
||||||
let mut cmd = std::process::Command::new("pkexec");
|
let mut c = std::process::Command::new("pkexec");
|
||||||
cmd.arg("env");
|
c.arg("/usr/bin/env");
|
||||||
for k in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"] {
|
for k in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "GDK_BACKEND"] {
|
||||||
if let Ok(v) = std::env::var(k) {
|
if let Ok(v) = std::env::var(k) {
|
||||||
cmd.arg(format!("{k}={v}"));
|
c.arg(format!("{k}={v}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cmd.arg(exe);
|
c.arg(format!("GUARA_CONFIG_DIR={}", cfg.display()));
|
||||||
cmd.spawn().map_err(|e| format!("pkexec indisponível: {e}"))?;
|
c.arg(format!("GUARA_REPLACE_PID={pid}"));
|
||||||
std::process::exit(0);
|
c.arg(&exe);
|
||||||
}
|
c
|
||||||
|
};
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
let mut cmd = {
|
||||||
let script = format!("do shell script \"'{}' &> /dev/null &\" with administrator privileges", exe.display());
|
let shell = format!(
|
||||||
std::process::Command::new("osascript").args(["-e", &script]).spawn().map_err(|e| e.to_string())?;
|
"GUARA_CONFIG_DIR='{}' GUARA_REPLACE_PID={} '{}' >/dev/null 2>&1 &",
|
||||||
std::process::exit(0);
|
cfg.display(),
|
||||||
|
pid,
|
||||||
|
exe.display()
|
||||||
|
);
|
||||||
|
let script = format!("do shell script \"{}\" with administrator privileges", shell.replace('\\', "\\\\").replace('"', "\\\""));
|
||||||
|
let mut c = std::process::Command::new("osascript");
|
||||||
|
c.args(["-e", &script]);
|
||||||
|
c
|
||||||
|
};
|
||||||
|
cmd.stdout(out).stderr(err);
|
||||||
|
let mut child = cmd.spawn().map_err(|e| format!("não foi possível pedir privilégios: {e}"))?;
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
// No Linux o pkexec só termina com sucesso quando o app elevado é fechado (e o app
|
||||||
|
// elevado já terá encerrado este processo). Qualquer falha antes disso vira aviso.
|
||||||
|
let status = child.wait();
|
||||||
|
if matches!(&status, Ok(s) if s.success()) {
|
||||||
|
let _ = std::fs::remove_file(&log);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let detail = std::fs::read_to_string(&log).unwrap_or_default();
|
||||||
|
let msg = match status.ok().and_then(|s| s.code()) {
|
||||||
|
Some(126) => "Autorização cancelada.".to_string(),
|
||||||
|
Some(127) => "Não foi possível autenticar como administrador.".to_string(),
|
||||||
|
_ => format!("Não foi possível reiniciar como administrador. {}", detail.lines().last().unwrap_or("").trim()),
|
||||||
|
};
|
||||||
|
let _ = app.emit("elevate-failed", msg);
|
||||||
|
let _ = std::fs::remove_file(&log);
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
let _ = exe;
|
let _ = app;
|
||||||
Err("o Guará já roda como administrador no Windows".into())
|
Err("o Guará já roda como administrador no Windows".into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Caminho que o root consegue executar. No AppImage, `current_exe` aponta para
|
||||||
|
/// dentro de um ponto de montagem FUSE do usuário, inacessível ao root: usa o
|
||||||
|
/// próprio arquivo `.AppImage` (variável `APPIMAGE`).
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
fn elevated_exe() -> Result<std::path::PathBuf, String> {
|
||||||
|
if let Some(p) = std::env::var_os("APPIMAGE").map(std::path::PathBuf::from).filter(|p| p.is_file()) {
|
||||||
|
return Ok(p);
|
||||||
|
}
|
||||||
|
std::env::current_exe().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve os arquivos recuperados para pré-visualização (`guara://localhost/file/<job>/<id>`),
|
||||||
|
/// com suporte a Range para vídeo/áudio/PDF, lendo direto da origem.
|
||||||
|
fn serve_file(eng: &Engine, req: &tauri::http::Request<Vec<u8>>) -> tauri::http::Response<Vec<u8>> {
|
||||||
|
use tauri::http::Response;
|
||||||
|
if let Some(rest) = req.uri().path().strip_prefix("/thumb/") {
|
||||||
|
let mut it = rest.split('/');
|
||||||
|
let (job, id) = (it.next().unwrap_or(""), it.next().and_then(|s| s.parse::<u64>().ok()));
|
||||||
|
let size = req
|
||||||
|
.uri()
|
||||||
|
.query()
|
||||||
|
.and_then(|q| q.split('&').find_map(|kv| kv.strip_prefix("s=")))
|
||||||
|
.and_then(|v| v.parse::<u32>().ok())
|
||||||
|
.unwrap_or(256);
|
||||||
|
let err = |code: u16| Response::builder().status(code).header("Access-Control-Allow-Origin", "*").body(Vec::new()).unwrap();
|
||||||
|
let Some(id) = id else { return err(400) };
|
||||||
|
return match eng.thumb(job, id, size) {
|
||||||
|
Ok(b) => Response::builder()
|
||||||
|
.status(200)
|
||||||
|
.header("Content-Type", "image/jpeg")
|
||||||
|
.header("Cache-Control", "private, max-age=3600")
|
||||||
|
.header("Access-Control-Allow-Origin", "*")
|
||||||
|
.body(b.as_ref().clone())
|
||||||
|
.unwrap_or_else(|_| err(500)),
|
||||||
|
Err(code) => err(code),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const CAP: u64 = 8 * 1024 * 1024;
|
||||||
|
let mut it = req.uri().path().trim_start_matches("/file/").split('/');
|
||||||
|
let (job, id) = (it.next().unwrap_or(""), it.next().and_then(|s| s.parse::<u64>().ok()));
|
||||||
|
let err = |code: u16| Response::builder().status(code).header("Access-Control-Allow-Origin", "*").body(Vec::new()).unwrap();
|
||||||
|
let Some(id) = id else { return err(400) };
|
||||||
|
let range = req.headers().get("range").and_then(|v| v.to_str().ok());
|
||||||
|
let slice = match eng.file_slice(job, id, range) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(code) => return err(code),
|
||||||
|
};
|
||||||
|
let total = slice.total;
|
||||||
|
let start = slice.start;
|
||||||
|
let want = slice.len();
|
||||||
|
let mut headers = slice.headers();
|
||||||
|
let partial = slice.partial || want > CAP;
|
||||||
|
let Ok(data) = slice.read_all(CAP) else { return err(500) };
|
||||||
|
let mut b = Response::builder().status(if partial { 206 } else { 200 });
|
||||||
|
headers.retain(|(k, _)| *k != "Content-Range");
|
||||||
|
for (k, v) in &headers {
|
||||||
|
b = b.header(*k, v);
|
||||||
|
}
|
||||||
|
if partial && !data.is_empty() {
|
||||||
|
b = b.header("Content-Range", format!("bytes {}-{}/{}", start, start + data.len() as u64 - 1, total));
|
||||||
|
}
|
||||||
|
b.body(data).unwrap_or_else(|_| err(500))
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
let engine = Arc::new(Engine::new());
|
||||||
|
let for_protocol = engine.clone();
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.manage(State(Arc::new(Engine::new())))
|
.manage(State(engine))
|
||||||
|
.register_asynchronous_uri_scheme_protocol("guara", move |_ctx, request, responder| {
|
||||||
|
let eng = for_protocol.clone();
|
||||||
|
std::thread::spawn(move || responder.respond(serve_file(&eng, &request)));
|
||||||
|
})
|
||||||
|
.setup(|_app| {
|
||||||
|
// Quando abrimos elevados a pedido do app normal, ele fecha o app antigo.
|
||||||
|
#[cfg(unix)]
|
||||||
|
if let Some(pid) = std::env::var("GUARA_REPLACE_PID").ok().and_then(|p| p.parse::<i32>().ok()) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(1200));
|
||||||
|
// SAFETY: só envia SIGTERM ao processo anterior do próprio usuário.
|
||||||
|
unsafe {
|
||||||
|
if libc::geteuid() == 0 {
|
||||||
|
libc::kill(pid, libc::SIGTERM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.invoke_handler(tauri::generate_handler![rpc, relaunch_elevated])
|
.invoke_handler(tauri::generate_handler![rpc, relaunch_elevated])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("erro ao iniciar o Guará");
|
.expect("erro ao iniciar o Guará");
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Guara",
|
"productName": "Guara",
|
||||||
"version": "0.1.0",
|
"version": "0.3.2",
|
||||||
"identifier": "dev.guara.app",
|
"identifier": "dev.guara.app",
|
||||||
"build": { "frontendDist": "../ui" },
|
"build": {
|
||||||
|
"frontendDist": "../ui"
|
||||||
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"withGlobalTauri": true,
|
"withGlobalTauri": true,
|
||||||
"windows": [
|
"windows": [
|
||||||
@@ -16,7 +18,9 @@
|
|||||||
"resizable": true
|
"resizable": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": { "csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" }
|
"security": {
|
||||||
|
"csp": "default-src 'self'; img-src 'self' data: blob: guara: http://guara.localhost; media-src 'self' blob: guara: http://guara.localhost; connect-src 'self' ipc: http://ipc.localhost guara: http://guara.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; frame-src 'none'; object-src 'none'"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
@@ -24,6 +28,12 @@
|
|||||||
"category": "Utility",
|
"category": "Utility",
|
||||||
"shortDescription": "Recuperação de arquivos",
|
"shortDescription": "Recuperação de arquivos",
|
||||||
"longDescription": "Guará recupera arquivos apagados e partições perdidas de HD, SSD e pendrive, sempre em modo somente leitura na origem.",
|
"longDescription": "Guará recupera arquivos apagados e partições perdidas de HD, SSD e pendrive, sempre em modo somente leitura na origem.",
|
||||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"]
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,10 +7,10 @@
|
|||||||
--text-2: #6e6e73;
|
--text-2: #6e6e73;
|
||||||
--text-3: #8e8e93;
|
--text-3: #8e8e93;
|
||||||
--line: rgba(0, 0, 0, 0.09);
|
--line: rgba(0, 0, 0, 0.09);
|
||||||
--accent: #d9541e;
|
--accent: #0f766e;
|
||||||
--accent-2: #ff8a3d;
|
--accent-2: #14b8a6;
|
||||||
--accent-ink: #ffffff;
|
--accent-ink: #ffffff;
|
||||||
--accent-soft: rgba(217, 84, 30, 0.1);
|
--accent-soft: rgba(15, 118, 110, 0.11);
|
||||||
--ok: #1a8f3c;
|
--ok: #1a8f3c;
|
||||||
--warn: #b26a00;
|
--warn: #b26a00;
|
||||||
--bad: #d12b2b;
|
--bad: #d12b2b;
|
||||||
@@ -34,10 +34,10 @@
|
|||||||
--text-2: #a1a1a6;
|
--text-2: #a1a1a6;
|
||||||
--text-3: #7d7d82;
|
--text-3: #7d7d82;
|
||||||
--line: rgba(255, 255, 255, 0.1);
|
--line: rgba(255, 255, 255, 0.1);
|
||||||
--accent: #ff7a3d;
|
--accent: #2dd4bf;
|
||||||
--accent-2: #ff9d5c;
|
--accent-2: #5eead4;
|
||||||
--accent-ink: #1a0b03;
|
--accent-ink: #042f2e;
|
||||||
--accent-soft: rgba(255, 122, 61, 0.15);
|
--accent-soft: rgba(45, 212, 191, 0.15);
|
||||||
--ok: #32d74b;
|
--ok: #32d74b;
|
||||||
--warn: #ffb340;
|
--warn: #ffb340;
|
||||||
--bad: #ff6961;
|
--bad: #ff6961;
|
||||||
@@ -48,6 +48,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg: #000000;
|
||||||
|
--surface: #1c1c1e;
|
||||||
|
--surface-2: #232326;
|
||||||
|
--side: rgba(28, 28, 30, 0.8);
|
||||||
|
--text: #f5f5f7;
|
||||||
|
--text-2: #a1a1a6;
|
||||||
|
--text-3: #7d7d82;
|
||||||
|
--line: rgba(255, 255, 255, 0.1);
|
||||||
|
--accent: #2dd4bf;
|
||||||
|
--accent-2: #5eead4;
|
||||||
|
--accent-ink: #042f2e;
|
||||||
|
--accent-soft: rgba(45, 212, 191, 0.15);
|
||||||
|
--ok: #32d74b;
|
||||||
|
--warn: #ffb340;
|
||||||
|
--bad: #ff6961;
|
||||||
|
--ok-soft: rgba(50, 215, 75, 0.15);
|
||||||
|
--warn-soft: rgba(255, 179, 64, 0.15);
|
||||||
|
--bad-soft: rgba(255, 105, 97, 0.15);
|
||||||
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
:root[data-theme="light"] { color-scheme: light; }
|
||||||
|
:root[data-theme="dark"] { color-scheme: dark; }
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body { height: 100%; }
|
html, body { height: 100%; }
|
||||||
body {
|
body {
|
||||||
@@ -211,7 +237,8 @@ input[type="checkbox"] { accent-color: var(--accent); width: 17px; height: 17px;
|
|||||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; font-size: 13.5px; }
|
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; font-size: 13.5px; }
|
||||||
.kv dt { color: var(--text-3); } .kv dd { margin: 0; overflow-wrap: anywhere; }
|
.kv dt { color: var(--text-3); } .kv dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
.actionbar { position: fixed; left: var(--side-w); right: 0; bottom: 0; padding: 14px 24px calc(14px + env(safe-area-inset-bottom)); display: flex; gap: 12px; align-items: center; justify-content: center; background: var(--side); backdrop-filter: saturate(180%) blur(24px); -webkit-backdrop-filter: saturate(180%) blur(24px); border-top: 1px solid var(--line); z-index: 5; animation: rise .3s; }
|
.actionbar { position: fixed; left: var(--side-w); right: 0; bottom: 0; padding: 14px 24px calc(14px + env(safe-area-inset-bottom)); display: flex; gap: 12px; align-items: center; justify-content: center; background: var(--side); backdrop-filter: saturate(180%) blur(24px); -webkit-backdrop-filter: saturate(180%) blur(24px); border-top: 1px solid var(--line); z-index: 5; }
|
||||||
|
.actionbar.enter { animation: rise .3s; }
|
||||||
.actionbar .info { color: var(--text-2); font-size: 14px; }
|
.actionbar .info { color: var(--text-2); font-size: 14px; }
|
||||||
|
|
||||||
dialog { border: 0; border-radius: 22px; padding: 26px; width: min(520px, calc(100vw - 32px)); background: var(--surface); color: var(--text); box-shadow: 0 30px 80px rgba(0, 0, 0, .35); }
|
dialog { border: 0; border-radius: 22px; padding: 26px; width: min(520px, calc(100vw - 32px)); background: var(--surface); color: var(--text); box-shadow: 0 30px 80px rgba(0, 0, 0, .35); }
|
||||||
@@ -255,3 +282,181 @@ code, .mono { overflow-wrap: anywhere; font-family: var(--mono); font-size: .92e
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
* { animation: none !important; transition: none !important; }
|
* { animation: none !important; transition: none !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ tema */
|
||||||
|
.theme-switch { display: inline-flex; padding: 2px; border-radius: 999px; background: color-mix(in srgb, var(--text) 8%, transparent); }
|
||||||
|
.theme-switch button { border: 0; background: transparent; cursor: pointer; padding: 4px 10px; border-radius: 999px; font-size: 12px; color: var(--text-2); }
|
||||||
|
.theme-switch button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, .18); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ pré-visualização */
|
||||||
|
.viewer { position: relative; min-height: 160px; max-height: 52vh; overflow: auto; border-radius: 12px; background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--line); display: flex; flex-direction: column; align-items: center; }
|
||||||
|
.viewer.large { max-height: none; height: 100%; min-height: 0; }
|
||||||
|
.viewer > * { max-width: 100%; }
|
||||||
|
.pv-img { display: block; max-width: 100%; max-height: 46vh; object-fit: contain; margin: auto; }
|
||||||
|
.viewer.large .pv-img { max-height: 100%; }
|
||||||
|
.viewer.zoomable .pv-img { cursor: zoom-in; }
|
||||||
|
.viewer.actual .pv-img { max-width: none; max-height: none; cursor: zoom-out; }
|
||||||
|
.viewer.actual { align-items: flex-start; }
|
||||||
|
.pv-video { width: 100%; max-height: 46vh; background: #000; border-radius: 12px; }
|
||||||
|
.viewer.large .pv-video { max-height: 100%; height: 100%; }
|
||||||
|
.pv-audio { width: calc(100% - 24px); margin: auto 12px; }
|
||||||
|
.pv-note { width: 100%; padding: 8px 12px; font-size: 12.5px; color: var(--text-2); text-align: center; }
|
||||||
|
.pv-note.bad { text-align: left; color: var(--text); background: var(--warn-soft); border-radius: 10px; margin: 8px; width: calc(100% - 16px); font-size: 13.5px; }
|
||||||
|
.pv-loading { height: 160px; display: grid; place-items: center; width: 100%; }
|
||||||
|
.pv-loading i { width: 26px; height: 26px; border-radius: 50%; border: 3px solid var(--line); border-top-color: var(--accent); animation: spin .8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.pv-text, .pv-hex { margin: 0; padding: 12px 14px; width: 100%; font: 12.5px/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text); text-align: left; }
|
||||||
|
.pv-hex { white-space: pre; overflow-x: auto; overflow-wrap: normal; font-size: 12px; }
|
||||||
|
.pv-hex .o { color: var(--text-3); } .pv-hex .a { color: var(--accent); }
|
||||||
|
.pv-page { width: 100%; background: var(--surface); padding: 18px 22px; font-family: Georgia, "Times New Roman", serif; font-size: 14px; line-height: 1.6; text-align: left; }
|
||||||
|
.pv-page p { margin: 0 0 .8em; white-space: pre-wrap; } .pv-page h4 { margin: 1em 0 .4em; font-family: var(--font); font-size: 16px; }
|
||||||
|
.pv-sheet { width: 100%; overflow: auto; text-align: left; }
|
||||||
|
.pv-sheet table { border-collapse: collapse; font-size: 12.5px; min-width: 100%; }
|
||||||
|
.pv-sheet th, .pv-sheet td { border: 1px solid var(--line); padding: 4px 8px; white-space: nowrap; max-width: 240px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.pv-sheet th { background: var(--surface); color: var(--text-3); font-weight: 500; position: sticky; top: 0; }
|
||||||
|
.pv-slide { width: calc(100% - 20px); margin: 10px; padding: 14px 16px; background: var(--surface); border-radius: 10px; box-shadow: 0 0 0 1px var(--line); text-align: left; }
|
||||||
|
.pv-slide small { color: var(--text-3); } .pv-slide h4 { margin: 4px 0 6px; font-size: 16px; } .pv-slide p { margin: 0 0 4px; font-size: 13.5px; }
|
||||||
|
.pv-zip { list-style: none; margin: 0; padding: 4px 0; width: 100%; text-align: left; font-size: 13px; }
|
||||||
|
.pv-zip li { display: flex; justify-content: space-between; gap: 12px; padding: 5px 14px; border-bottom: 1px solid var(--line); }
|
||||||
|
.pv-zip li span { overflow-wrap: anywhere; } .pv-zip small { color: var(--text-3); flex: none; }
|
||||||
|
.pv-pdfbar { position: sticky; top: 0; z-index: 2; display: flex; gap: 10px; align-items: center; justify-content: center; padding: 6px; width: 100%; background: var(--side); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
|
||||||
|
.pv-pdfbar .pg { font-size: 13px; color: var(--text-2); min-width: 110px; text-align: center; }
|
||||||
|
.pv-pdfwrap { padding: 8px; } .pv-canvas { display: block; background: #fff; box-shadow: 0 2px 14px rgba(0, 0, 0, .25); }
|
||||||
|
|
||||||
|
dialog.lightbox { width: min(1760px, 98vw); height: min(95vh, 1100px); padding: 0; display: none; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; }
|
||||||
|
dialog.lightbox[open] { display: grid; }
|
||||||
|
.lb-head, .lb-foot { display: flex; gap: 12px; align-items: center; padding: 12px 16px; }
|
||||||
|
.lb-head { border-bottom: 1px solid var(--line); } .lb-foot { border-top: 1px solid var(--line); flex-wrap: wrap; justify-content: space-between; }
|
||||||
|
.lb-head .ttl { flex: 1; min-width: 0; } .lb-head .ttl b { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .lb-head .ttl small { color: var(--text-2); }
|
||||||
|
.lb-body { padding: 12px; min-height: 0; }
|
||||||
|
|
||||||
|
.hero-logo { width: min(440px, 80%); height: auto; display: block; margin: 0 0 22px -4px; }
|
||||||
|
.pill.accent { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ explorador de arquivos */
|
||||||
|
.main.fixed { overflow: hidden; }
|
||||||
|
.page.wide { max-width: none; padding: 14px 18px 0; }
|
||||||
|
.page.fill { height: 100%; display: flex; flex-direction: column; }
|
||||||
|
.xp-top { display: flex; gap: 16px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 8px; }
|
||||||
|
.xp-top h1 { font-size: 24px; }
|
||||||
|
.xp-top .sub { font-size: 12.5px; }
|
||||||
|
.xp-host { flex: 1; min-height: 0; padding-bottom: 12px; }
|
||||||
|
.progress-card.slim { padding: 14px 18px; border-radius: 16px; gap: 10px; margin-bottom: 10px; }
|
||||||
|
.stats.compact { gap: 22px; }
|
||||||
|
.stats.compact .stat b { font-size: 20px; }
|
||||||
|
.done-bar { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; padding: 8px 14px; border-radius: 12px; background: var(--accent-soft); margin-bottom: 10px; font-size: 13.5px; }
|
||||||
|
.done-bar .grow { flex: 1; }
|
||||||
|
|
||||||
|
.xp { position: relative; display: grid; grid-template-columns: 236px minmax(0, 1fr) 340px; grid-template-rows: auto minmax(0, 1fr); gap: 10px 14px; height: 100%; }
|
||||||
|
.xp-bar { grid-column: 1 / -1; display: flex; gap: 10px; align-items: center; min-width: 0; }
|
||||||
|
.xp-crumb { display: flex; align-items: baseline; gap: 8px; min-width: 0; flex: 1; white-space: nowrap; overflow: hidden; }
|
||||||
|
.xp-crumb b { font-size: 15px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.xp-crumb small { color: var(--text-3); font-variant-numeric: tabular-nums; }
|
||||||
|
.xp-q { flex: 0 1 300px !important; min-width: 120px; }
|
||||||
|
.xp-bar .theme-switch button { display: inline-grid; place-items: center; padding: 5px 9px; }
|
||||||
|
.xp-bar input[type="range"] { width: 110px; accent-color: var(--accent); }
|
||||||
|
.xp-navbtn { display: none; }
|
||||||
|
|
||||||
|
.xp-nav { overflow-y: auto; border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); padding: 8px 6px 14px; min-height: 0; }
|
||||||
|
.xp-sec { padding: 4px 0 6px; }
|
||||||
|
.xp-sec h4 { margin: 8px 10px 4px; font-size: 11.5px; letter-spacing: .02em; color: var(--text-3); font-weight: 600; }
|
||||||
|
.xp-node { display: flex; align-items: center; gap: 4px; width: 100%; border: 0; background: transparent; cursor: pointer; text-align: left; padding: 6px 8px 6px calc(8px + var(--d, 0) * 14px); border-radius: 9px; font-size: 13.5px; color: var(--text); }
|
||||||
|
.xp-node:hover { background: color-mix(in srgb, var(--text) 6%, transparent); }
|
||||||
|
.xp-node.on { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||||
|
.xp-node .l { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.xp-node small { color: var(--text-3); font-variant-numeric: tabular-nums; font-weight: 400; }
|
||||||
|
.xp-node .tw { display: inline-grid; place-items: center; width: 18px; height: 18px; border-radius: 5px; flex: none; transition: transform .15s; }
|
||||||
|
.xp-node .tw:hover { background: color-mix(in srgb, var(--text) 10%, transparent); }
|
||||||
|
|
||||||
|
.xp-main { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); overflow: hidden; }
|
||||||
|
.xp-chips { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; padding: 0 12px; }
|
||||||
|
.xp-chips:not(:empty) { padding: 10px 12px 2px; }
|
||||||
|
.xp-chips .chip { display: inline-flex; gap: 6px; align-items: center; background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||||
|
.xp-scroll { position: relative; flex: 1; min-height: 0; overflow-y: auto; outline: none; }
|
||||||
|
.xp-sizer { position: relative; width: 100%; }
|
||||||
|
.xp-row { position: absolute; left: 0; right: 0; display: flex; gap: 10px; padding: 0 12px; box-sizing: border-box; }
|
||||||
|
.xp-row.head { align-items: flex-end; }
|
||||||
|
.xp-head { display: flex; align-items: baseline; gap: 10px; width: 100%; padding: 0 2px 6px; border-bottom: 1px solid var(--line); }
|
||||||
|
.xp-head b::first-letter { text-transform: uppercase; }
|
||||||
|
.xp-head b { font-size: 14px; } .xp-head small { color: var(--text-3); } .xp-hsel { margin-left: auto; border: 0; background: transparent; color: var(--accent); cursor: pointer; font-size: 12.5px; opacity: 0; }
|
||||||
|
.xp-head:hover .xp-hsel, .xp-hsel:focus-visible { opacity: 1; }
|
||||||
|
|
||||||
|
.xp-tile { position: relative; flex: none; border-radius: 12px; cursor: pointer; user-select: none; -webkit-user-select: none; }
|
||||||
|
.xp-thumb { position: relative; border-radius: 12px; overflow: hidden; background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--line); }
|
||||||
|
.xp-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; background: var(--surface-2); }
|
||||||
|
.xp-tile[aria-selected="true"] .xp-thumb { box-shadow: 0 0 0 3px var(--accent); }
|
||||||
|
.xp-tile[data-cur="1"]:focus-visible .xp-thumb, .xp-tile[data-cur="1"] .xp-thumb { outline: 2px solid color-mix(in srgb, var(--accent) 50%, transparent); outline-offset: 2px; }
|
||||||
|
.xp-ico { width: 100%; height: 100%; display: grid; place-content: center; text-align: center; gap: 2px; color: #fff; }
|
||||||
|
.xp-ico span { font-size: 34px; line-height: 1; } .xp-ico b { font-size: 12px; letter-spacing: .04em; opacity: .9; }
|
||||||
|
.c-document { background: linear-gradient(145deg, #3b82f6, #1d4ed8); } .c-video { background: linear-gradient(145deg, #8b5cf6, #5b21b6); }
|
||||||
|
.c-audio { background: linear-gradient(145deg, #ec4899, #9d174d); } .c-archive { background: linear-gradient(145deg, #f59e0b, #b45309); }
|
||||||
|
.c-database { background: linear-gradient(145deg, #14b8a6, #0f766e); } .c-other { background: linear-gradient(145deg, #64748b, #334155); } .c-image { background: linear-gradient(145deg, #0ea5e9, #0369a1); }
|
||||||
|
.c-bad { background: linear-gradient(145deg, #6b7280, #374151); }
|
||||||
|
.xp-chk { position: absolute; top: 7px; left: 7px; width: 22px; height: 22px; border-radius: 50%; background: rgba(0, 0, 0, .35); box-shadow: inset 0 0 0 2px #fff; opacity: 0; transition: opacity .12s; backdrop-filter: blur(4px); }
|
||||||
|
.xp-tile:hover .xp-chk, .xp-tile[aria-selected="true"] .xp-chk, .xp-lrow:hover .xp-chk, .xp-lrow[aria-selected="true"] .xp-chk { opacity: 1; }
|
||||||
|
.xp-tile[aria-selected="true"] .xp-chk, .xp-lrow[aria-selected="true"] .xp-chk { background: var(--accent) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 12.5l4 4 8-9'/%3E%3C/svg%3E") center / 14px no-repeat; }
|
||||||
|
.xp-dot { position: absolute; right: 7px; bottom: 7px; width: 10px; height: 10px; border-radius: 50%; box-shadow: 0 0 0 2px rgba(255, 255, 255, .85); }
|
||||||
|
.xp-dot.ok { background: var(--ok); } .xp-dot.warn { background: var(--warn); } .xp-dot.bad { background: var(--bad); }
|
||||||
|
.xp-gps { position: absolute; left: 7px; bottom: 6px; color: #fff; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .6)); }
|
||||||
|
.xp-cap { padding: 5px 2px 0; min-width: 0; }
|
||||||
|
.xp-cap b { display: block; font-size: 12.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.xp-cap small { display: block; font-size: 11.5px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.xp-lrow { position: relative; display: grid; grid-template-columns: 26px 40px minmax(0, 1fr) 132px 76px 58px; gap: 12px; align-items: center; width: 100%; height: 50px; padding: 0 10px; border-radius: 10px; cursor: pointer; user-select: none; }
|
||||||
|
.xp-lrow:hover { background: var(--surface-2); }
|
||||||
|
.xp-lrow[aria-selected="true"] { background: var(--accent-soft); }
|
||||||
|
.xp-lrow .xp-chk { position: static; opacity: .0; }
|
||||||
|
.xp-lth { width: 40px; height: 40px; border-radius: 8px; overflow: hidden; background: var(--surface-2); display: grid; place-items: center; }
|
||||||
|
.xp-lth img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.xp-lth .xp-ico span { font-size: 18px; } .xp-lth .xp-ico b { display: none; }
|
||||||
|
.xp-lico { font-size: 18px; width: 100%; height: 100%; display: grid; place-items: center; color: #fff; }
|
||||||
|
.xp-lnm { min-width: 0; } .xp-lnm b { display: block; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .xp-lnm small { display: block; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.xp-lcol { color: var(--text-2); font-size: 13px; white-space: nowrap; } .xp-lcol.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.xp-actions { display: flex; gap: 10px; align-items: center; justify-content: center; flex-wrap: wrap; padding: 10px 14px; border-top: 1px solid var(--line); background: var(--surface); }
|
||||||
|
.xp-actions .info { color: var(--text-2); font-size: 13.5px; margin-right: auto; }
|
||||||
|
|
||||||
|
.xp-insp { overflow-y: auto; border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); padding: 14px; display: flex; flex-direction: column; gap: 10px; min-height: 0; }
|
||||||
|
.xp-insp .viewer { max-height: 38vh; min-height: 150px; }
|
||||||
|
.xp-ititle { display: flex; gap: 8px; align-items: flex-start; justify-content: space-between; } .xp-ititle b { overflow-wrap: anywhere; font-size: 15px; }
|
||||||
|
.xp-meta h5 { margin: 6px 0 6px; font-size: 12px; color: var(--text-3); font-weight: 600; }
|
||||||
|
.xp-multi { display: grid; gap: 12px; padding: 20px 4px; text-align: center; } .xp-multi b { font-size: 18px; }
|
||||||
|
.xp-insp-x { display: none; align-self: flex-end; }
|
||||||
|
.xp-veil { display: none; }
|
||||||
|
.kv dd { overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
@media (max-width: 1280px) { .xp { grid-template-columns: 208px minmax(0, 1fr) 300px; } }
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.xp { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.xp-bar { flex-wrap: wrap; }
|
||||||
|
.xp-navbtn { display: inline-grid; }
|
||||||
|
.xp-nav { position: fixed; inset: 0 auto 0 0; width: min(86vw, 310px); z-index: 40; border-radius: 0 var(--radius) var(--radius) 0; transform: translateX(-104%); transition: transform .26s cubic-bezier(.2, .8, .2, 1); }
|
||||||
|
.xp.show-nav .xp-nav { transform: none; }
|
||||||
|
.xp-insp { position: fixed; left: 0; right: 0; bottom: 0; max-height: 80vh; z-index: 40; border-radius: 20px 20px 0 0; transform: translateY(104%); transition: transform .26s cubic-bezier(.2, .8, .2, 1); padding-bottom: calc(14px + env(safe-area-inset-bottom)); }
|
||||||
|
.xp.show-insp .xp-insp { transform: none; }
|
||||||
|
.xp-insp-x { display: inline-grid; }
|
||||||
|
.xp-veil { position: fixed; inset: 0; z-index: 30; background: rgba(0, 0, 0, .32); opacity: 0; pointer-events: none; transition: opacity .2s; display: block; }
|
||||||
|
.xp.show-nav .xp-veil, .xp.show-insp .xp-veil { opacity: 1; pointer-events: auto; }
|
||||||
|
.xp-q { flex: 1 1 140px !important; }
|
||||||
|
.xp-crumb { flex: 1 1 100%; order: -1; }
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.page.wide { padding: 10px 10px 0; }
|
||||||
|
.xp-lrow { grid-template-columns: 26px 40px minmax(0, 1fr) 64px; } .xp-lrow .xp-lcol { display: none; }
|
||||||
|
.xp-bar input[type="range"] { display: none !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fld { display: grid; gap: 4px; margin: 10px 0; font-size: 13px; color: var(--text-2); }
|
||||||
|
.fld textarea { resize: vertical; font: inherit; }
|
||||||
|
.hits { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
|
||||||
|
.hit { display: grid; grid-template-columns: 130px 80px 90px minmax(0, 1fr); gap: 12px; padding: 9px 14px; border-bottom: 1px solid var(--line); font-size: 13px; align-items: start; }
|
||||||
|
.hit.head { background: var(--surface-2); color: var(--text-3); font-size: 12px; }
|
||||||
|
.hit .ctx { font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; color: var(--text-2); }
|
||||||
|
.hit mark { background: var(--accent-soft); color: var(--accent); padding: 0 2px; border-radius: 3px; font-weight: 600; }
|
||||||
|
@media (max-width: 860px) { .hit { grid-template-columns: 100px 1fr; } .hit span:nth-child(2), .hit span:nth-child(3) { display: none; } }
|
||||||
|
|
||||||
|
.xp-insp #xpPv { cursor: zoom-in; }
|
||||||
|
.pv-card { width: 100%; min-height: 220px; display: grid; place-content: center; justify-items: center; gap: 10px; padding: 24px; text-align: center; }
|
||||||
|
.pv-card b { overflow-wrap: anywhere; font-size: 15px; }
|
||||||
|
.pv-bigicon { font-size: 52px; line-height: 1; }
|
||||||
|
.pv-why { max-width: 38ch; font-size: 12.5px; color: var(--text-2); }
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// Guará — interface. Fala com o núcleo por `rpc(método, parâmetros)`:
|
// Guará: interface. Fala com o núcleo por `rpc(método, parâmetros)`:
|
||||||
// • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/<método> com token.
|
// • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/<método> com token.
|
||||||
|
|
||||||
|
import { createExplorer } from './explorer.js';
|
||||||
|
|
||||||
const tauri = window.__TAURI__;
|
const tauri = window.__TAURI__;
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
let token = params.get('token');
|
let token = params.get('token');
|
||||||
@@ -21,6 +23,33 @@ async function rpc(method, p = {}) {
|
|||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isWin = /Windows/i.test(navigator.userAgent);
|
||||||
|
const fileBase = tauri ? (isWin ? 'http://guara.localhost' : 'guara://localhost') : '';
|
||||||
|
const thumbUrl = (job, id, s = 256) => `${fileBase}/thumb/${job}/${id}?s=${s}` + (tauri ? '' : `&t=${encodeURIComponent(token || '')}`);
|
||||||
|
const fileUrl = (job, id) => `${fileBase}/file/${job}/${id}` + (tauri ? '' : `?t=${encodeURIComponent(token || '')}`);
|
||||||
|
|
||||||
|
// Atualiza o HTML só quando mudou (evita piscar e perder o foco/hover).
|
||||||
|
function setHTML(el, html) {
|
||||||
|
if (!el || el._h === html) return false;
|
||||||
|
el._h = html;
|
||||||
|
el.innerHTML = html;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ tema
|
||||||
|
function applyTheme(t) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (t === 'light' || t === 'dark') root.dataset.theme = t; else delete root.dataset.theme;
|
||||||
|
document.querySelectorAll('[data-theme-pick]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.themePick === (t || 'auto'))));
|
||||||
|
}
|
||||||
|
try { applyTheme(localStorage.getItem('guara-theme') || 'auto'); } catch { applyTheme('auto'); }
|
||||||
|
document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async () => {
|
||||||
|
const t = b.dataset.themePick;
|
||||||
|
applyTheme(t);
|
||||||
|
try { localStorage.setItem('guara-theme', t); } catch { /* sem armazenamento */ }
|
||||||
|
try { await rpc('settings_set', { theme: t }); } catch { /* segue só com o armazenamento local */ }
|
||||||
|
}));
|
||||||
|
|
||||||
// ------------------------------------------------------------------ utilidades
|
// ------------------------------------------------------------------ utilidades
|
||||||
const $ = (s, el = document) => el.querySelector(s);
|
const $ = (s, el = document) => el.querySelector(s);
|
||||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
@@ -35,7 +64,7 @@ const dur = (s) => {
|
|||||||
s = Math.round(s);
|
s = Math.round(s);
|
||||||
return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}min ${s % 60}s` : `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}min`;
|
return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}min ${s % 60}s` : `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}min`;
|
||||||
};
|
};
|
||||||
const date = (t) => (t ? new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'medium', timeStyle: 'short' }) : '—');
|
const date = (t) => (t ? new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'medium', timeStyle: 'short' }) : 'sem data');
|
||||||
const CATS = [
|
const CATS = [
|
||||||
['image', 'Fotos'], ['document', 'Documentos'], ['video', 'Vídeos'], ['audio', 'Áudio'],
|
['image', 'Fotos'], ['document', 'Documentos'], ['video', 'Vídeos'], ['audio', 'Áudio'],
|
||||||
['archive', 'Compactados'], ['database', 'Bancos de dados'], ['other', 'Outros'],
|
['archive', 'Compactados'], ['database', 'Bancos de dados'], ['other', 'Outros'],
|
||||||
@@ -75,6 +104,23 @@ function ask({ title, text, value, placeholder, ok = 'OK', pick }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function askForm({ title, text, fields, ok = 'OK' }) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const d = $('#dlg');
|
||||||
|
d.innerHTML = `<form method="dialog"><h2>${esc(title)}</h2><p>${text || ''}</p>
|
||||||
|
${fields.map((f) => `<label class="fld"><span>${esc(f.label)}</span>${f.multiline ? `<textarea class="input" name="${f.name}" rows="3" placeholder="${esc(f.placeholder || '')}">${esc(f.value || '')}</textarea>` : `<input class="input" name="${f.name}" value="${esc(f.value || '')}" placeholder="${esc(f.placeholder || '')}" autocomplete="off">`}</label>`).join('')}
|
||||||
|
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">${esc(ok)}</button></menu></form>`;
|
||||||
|
d.onclose = () => {
|
||||||
|
if (d.returnValue !== 'ok') return resolve(null);
|
||||||
|
const out = {};
|
||||||
|
d.querySelectorAll('[name]').forEach((el) => (out[el.name] = el.value.trim()));
|
||||||
|
resolve(out);
|
||||||
|
};
|
||||||
|
d.showModal();
|
||||||
|
d.querySelector('[name]')?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function info({ title, html }) {
|
function info({ title, html }) {
|
||||||
const d = $('#dlg');
|
const d = $('#dlg');
|
||||||
d.innerHTML = `<form method="dialog"><h2>${esc(title)}</h2>${html}<menu><button class="btn primary" value="ok">Entendi</button></menu></form>`;
|
d.innerHTML = `<form method="dialog"><h2>${esc(title)}</h2>${html}<menu><button class="btn primary" value="ok">Entendi</button></menu></form>`;
|
||||||
@@ -125,7 +171,7 @@ function renderSide() {
|
|||||||
dl.innerHTML = S.devices.map((d, i) => `
|
dl.innerHTML = S.devices.map((d, i) => `
|
||||||
<li><button class="dev" data-i="${i}" aria-current="${S.device?.path === d.path}">
|
<li><button class="dev" data-i="${i}" aria-current="${S.device?.path === d.path}">
|
||||||
<span class="ico">${icon(d.kind)}</span>
|
<span class="ico">${icon(d.kind)}</span>
|
||||||
<span><span class="t">${esc(d.name)}</span><span class="s">${esc(d.kind)} · ${d.size ? human(d.size) : '—'}${d.readable ? '' : ' · requer admin'}</span></span>
|
<span><span class="t">${esc(d.name)}</span><span class="s">${esc(d.kind)} · ${d.size ? human(d.size) : '?'}${d.readable ? '' : ' · requer admin'}</span></span>
|
||||||
</button></li>`).join('');
|
</button></li>`).join('');
|
||||||
dl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => { openDevice(S.devices[+b.dataset.i]); setMenu(false); }));
|
dl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => { openDevice(S.devices[+b.dataset.i]); setMenu(false); }));
|
||||||
const sl = $('#sessionList');
|
const sl = $('#sessionList');
|
||||||
@@ -164,17 +210,18 @@ $('#loadSessionBtn').onclick = async () => {
|
|||||||
const s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() };
|
const s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() };
|
||||||
S.sessions.unshift(s);
|
S.sessions.unshift(s);
|
||||||
openJob(s);
|
openJob(s);
|
||||||
} catch (e) { toast(e.message); }
|
} catch (e) { info({ title: 'Não foi possível abrir a sessão', html: `<p>${esc(e.message)}</p>` }); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// ------------------------------------------------------------------ início
|
// ------------------------------------------------------------------ início
|
||||||
function renderHome() {
|
function renderHome() {
|
||||||
|
main.classList.remove('fixed');
|
||||||
stopPoll();
|
stopPoll();
|
||||||
S.device = null; S.job = null;
|
S.device = null; S.job = null;
|
||||||
renderSide();
|
renderSide();
|
||||||
main.innerHTML = `<div class="page">
|
main.innerHTML = `<div class="page">
|
||||||
<section class="hero"><h1>Encontre o que você achou que tinha perdido.</h1>
|
<section class="hero"><img class="hero-logo" src="guara-logo.svg" alt="Guará"><h1>Encontre o que você achou que tinha perdido.</h1>
|
||||||
<p>Escolha um disco, SSD ou pendrive ao lado. O Guará lê tudo — arquivos apagados, partições perdidas e espaço não particionado — sem escrever nada no disco de origem.</p></section>
|
<p>Escolha um disco, SSD ou pendrive ao lado. O Guará lê tudo: arquivos apagados, partições perdidas e espaço não particionado, sem escrever nada no disco de origem.</p></section>
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
<div class="card"><h3>Uma varredura, tudo junto</h3><p>Metadados do sistema de arquivos e busca por assinatura na mesma passada, sem duplicatas.</p></div>
|
<div class="card"><h3>Uma varredura, tudo junto</h3><p>Metadados do sistema de arquivos e busca por assinatura na mesma passada, sem duplicatas.</p></div>
|
||||||
<div class="card"><h3>Confiança por arquivo</h3><p>Cada achado mostra a chance de abrir corretamente: alta, média ou baixa.</p></div>
|
<div class="card"><h3>Confiança por arquivo</h3><p>Cada achado mostra a chance de abrir corretamente: alta, média ou baixa.</p></div>
|
||||||
@@ -186,6 +233,7 @@ function renderHome() {
|
|||||||
|
|
||||||
// ------------------------------------------------------------------ dispositivo
|
// ------------------------------------------------------------------ dispositivo
|
||||||
async function openDevice(d) {
|
async function openDevice(d) {
|
||||||
|
main.classList.remove('fixed');
|
||||||
stopPoll();
|
stopPoll();
|
||||||
S.device = d; S.job = null; S.layout = null;
|
S.device = d; S.job = null; S.layout = null;
|
||||||
renderSide();
|
renderSide();
|
||||||
@@ -224,7 +272,7 @@ function renderDevice(err) {
|
|||||||
const notes = [];
|
const notes = [];
|
||||||
if (!d.readable) notes.push(['bad', 'Este disco exige permissão de administrador. ' + (tauri ? '<button class="btn sm" id="elevate" style="margin-left:6px">Reiniciar como administrador</button>' : 'Feche e rode de novo com <code>sudo guara ui</code> (Linux/macOS) ou como administrador (Windows).')]);
|
if (!d.readable) notes.push(['bad', 'Este disco exige permissão de administrador. ' + (tauri ? '<button class="btn sm" id="elevate" style="margin-left:6px">Reiniciar como administrador</button>' : 'Feche e rode de novo com <code>sudo guara ui</code> (Linux/macOS) ou como administrador (Windows).')]);
|
||||||
if (err) notes.push(['bad', esc(err)]);
|
if (err) notes.push(['bad', esc(err)]);
|
||||||
if (d.mounted_at?.length) notes.push(['warn', `Este disco está em uso (montado em <code>${esc(d.mounted_at.join(', '))}</code>). O Guará só lê, mas evite gravar nele enquanto recupera — qualquer escrita pode sobrescrever o que você quer salvar.`]);
|
if (d.mounted_at?.length) notes.push(['warn', `Este disco está em uso (montado em <code>${esc(d.mounted_at.join(', '))}</code>). O Guará só lê, mas evite gravar nele enquanto recupera, porque qualquer escrita pode sobrescrever o que você quer salvar.`]);
|
||||||
if (d.trim) notes.push(['warn', 'Este SSD usa TRIM: o sistema avisa o disco quais blocos foram apagados e o próprio SSD pode zerá-los. Arquivos apagados há bastante tempo tendem a não ser recuperáveis.']);
|
if (d.trim) notes.push(['warn', 'Este SSD usa TRIM: o sistema avisa o disco quais blocos foram apagados e o próprio SSD pode zerá-los. Arquivos apagados há bastante tempo tendem a não ser recuperáveis.']);
|
||||||
(l?.warnings || []).forEach((w) => notes.push(['warn', esc(w)]));
|
(l?.warnings || []).forEach((w) => notes.push(['warn', esc(w)]));
|
||||||
|
|
||||||
@@ -244,6 +292,14 @@ function renderDevice(err) {
|
|||||||
<div class="mode"><h3>Criar imagem do disco</h3><p>Copia o disco inteiro para um arquivo, tolerando setores ruins. Ideal para discos com defeito: você trabalha na cópia.</p>
|
<div class="mode"><h3>Criar imagem do disco</h3><p>Copia o disco inteiro para um arquivo, tolerando setores ruins. Ideal para discos com defeito: você trabalha na cópia.</p>
|
||||||
<button class="btn lg" id="goImage" ${d.readable ? '' : 'disabled'}>Criar imagem…</button></div>
|
<button class="btn lg" id="goImage" ${d.readable ? '' : 'disabled'}>Criar imagem…</button></div>
|
||||||
</div>
|
</div>
|
||||||
|
<h2 style="margin-top:26px">Ferramentas de perícia</h2>
|
||||||
|
<div class="modes">
|
||||||
|
<div class="mode"><h3>Pesquisar texto no disco</h3><p>Procura palavras em todo o disco, inclusive em espaço livre e fora de partições (UTF-8 e UTF-16). Útil quando não há estrutura de arquivo.</p>
|
||||||
|
<input class="input" id="srchTerms" placeholder="termos separados por vírgula" aria-label="Termos a pesquisar" style="margin-bottom:8px">
|
||||||
|
<button class="btn lg" id="goSearch" ${d.readable ? '' : 'disabled'}>Pesquisar</button></div>
|
||||||
|
<div class="mode"><h3>Hashes da origem</h3><p>Calcula MD5, SHA-1 e SHA-256 do disco inteiro para registrar a integridade e a cadeia de custódia no relatório.</p>
|
||||||
|
<button class="btn lg" id="goHash" ${d.readable ? '' : 'disabled'}>Calcular hashes</button></div>
|
||||||
|
</div>
|
||||||
<details class="adv"><summary>Opções avançadas</summary>
|
<details class="adv"><summary>Opções avançadas</summary>
|
||||||
<label class="check"><input type="checkbox" id="optAlloc"><span><b>Incluir áreas ainda em uso</b><br><span class="sub">Também procura cópias antigas dentro de arquivos existentes. Bem mais lento.</span></span></label>
|
<label class="check"><input type="checkbox" id="optAlloc"><span><b>Incluir áreas ainda em uso</b><br><span class="sub">Também procura cópias antigas dentro de arquivos existentes. Bem mais lento.</span></span></label>
|
||||||
<div class="row" id="optCats">${CATS.map(([k, n]) => `<label class="check"><input type="checkbox" value="${k}"> ${n}</label>`).join('')}</div>
|
<div class="row" id="optCats">${CATS.map(([k, n]) => `<label class="check"><input type="checkbox" value="${k}"> ${n}</label>`).join('')}</div>
|
||||||
@@ -259,6 +315,9 @@ function renderDevice(err) {
|
|||||||
$('#goDeep')?.addEventListener('click', () => startScan(opts('deep')));
|
$('#goDeep')?.addEventListener('click', () => startScan(opts('deep')));
|
||||||
$('#goQuick')?.addEventListener('click', () => startScan(opts('quick')));
|
$('#goQuick')?.addEventListener('click', () => startScan(opts('quick')));
|
||||||
$('#goImage')?.addEventListener('click', startImage);
|
$('#goImage')?.addEventListener('click', startImage);
|
||||||
|
$('#goSearch')?.addEventListener('click', startSearch);
|
||||||
|
$('#srchTerms')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') startSearch(); });
|
||||||
|
$('#goHash')?.addEventListener('click', startHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startScan(options) {
|
async function startScan(options) {
|
||||||
@@ -284,77 +343,96 @@ async function startImage() {
|
|||||||
} catch (e) { toast(e.message); }
|
} catch (e) { toast(e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startHash() {
|
||||||
|
try {
|
||||||
|
const { job } = await rpc('hash_start', { path: S.device.path });
|
||||||
|
watchAux(job, 'Calculando hashes da origem', (st) => {
|
||||||
|
const r = st.report || {};
|
||||||
|
S.sourceHashes = { ...r, path: S.device.path };
|
||||||
|
return `<p>Hashes do conteúdo inteiro de <code>${esc(S.device.path)}</code>:</p><dl class="kv"><dt>MD5</dt><dd class="mono">${esc(r.md5)}</dd><dt>SHA-1</dt><dd class="mono">${esc(r.sha1)}</dd><dt>SHA-256</dt><dd class="mono">${esc(r.sha256)}</dd></dl><p class="sub">Eles serão incluídos nos relatórios gerados nesta sessão.</p>`;
|
||||||
|
});
|
||||||
|
} catch (e) { toast(e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSearch() {
|
||||||
|
const terms = ($('#srchTerms')?.value || '').split(',').map((t) => t.trim()).filter(Boolean);
|
||||||
|
if (!terms.length) { toast('Digite pelo menos um termo.'); $('#srchTerms')?.focus(); return; }
|
||||||
|
try {
|
||||||
|
const { job } = await rpc('search_start', { path: S.device.path, terms, ignore_case: true, utf16: true });
|
||||||
|
openSearch(job, terms);
|
||||||
|
} catch (e) { toast(e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSearch(job, terms) {
|
||||||
|
stopPoll();
|
||||||
|
EX?.destroy(); EX = null;
|
||||||
|
S.job = null;
|
||||||
|
main.classList.remove('fixed');
|
||||||
|
main.innerHTML = `<div class="page"><button class="back" id="backBtn">‹ ${esc(S.device?.name || 'Dispositivos')}</button>
|
||||||
|
<h1 id="shTitle">Procurando…</h1><div class="sub">Termos: ${terms.map((t) => `<code>${esc(t)}</code>`).join(', ')}</div>
|
||||||
|
<div id="shProg" style="margin:14px 0"></div><div class="hits" id="hits"></div></div>`;
|
||||||
|
$('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome());
|
||||||
|
const termRe = new RegExp(terms.map((t) => esc(t).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'gi');
|
||||||
|
const tick2 = async () => {
|
||||||
|
let st, res;
|
||||||
|
try { st = await rpc('status', { job }); res = await rpc('search_results', { job, offset: 0, limit: 1000 }); } catch { return false; }
|
||||||
|
const run = st.status === 'running';
|
||||||
|
const p = st.progress || {};
|
||||||
|
const pct = p.total ? Math.floor((p.done / p.total) * 100) : 0;
|
||||||
|
$('#shTitle').textContent = run ? 'Procurando…' : `${res.total.toLocaleString('pt-BR')} ocorrência${res.total === 1 ? '' : 's'}`;
|
||||||
|
setHTML($('#shProg'), run ? `<div class="bar"><i style="width:${pct}%"></i></div><div class="sub" style="margin-top:6px">${pct}% · ${res.total.toLocaleString('pt-BR')} até agora</div><button class="btn sm danger" data-stop="1" style="margin-top:8px">Parar</button>` : st.status === 'error' ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : '');
|
||||||
|
setHTML($('#hits'), res.items.length ? `<div class="hit head"><span>Posição</span><span>Partição</span><span>Codificação</span><span>Contexto</span></div>` + res.items.map((h) => `<div class="hit"><span class="mono" title="${h.offset} bytes (0x${h.offset.toString(16)})">${h.offset.toLocaleString('pt-BR')}</span><span>${h.partition ? '#' + h.partition : 'fora'}</span><span>${esc(h.encoding)}</span><span class="ctx">${esc(h.context).replace(termRe, (m) => `<mark>${m}</mark>`)}</span></div>`).join('') : (run ? '' : '<div class="empty"><h3>Nada encontrado</h3><p>Esses termos não aparecem em nenhum ponto do disco (nem em UTF-8 nem em UTF-16).</p></div>'));
|
||||||
|
return run;
|
||||||
|
};
|
||||||
|
$('#shProg').onclick = (e) => { if (e.target.dataset.stop) rpc('job_stop', { job }); };
|
||||||
|
(async () => { while (await tick2()) await new Promise((r) => setTimeout(r, 700)); })();
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ varredura
|
// ------------------------------------------------------------------ varredura
|
||||||
|
let EX = null; // explorador de arquivos (grade, navegação, detalhes)
|
||||||
|
|
||||||
function openJob(s) {
|
function openJob(s) {
|
||||||
stopPoll();
|
stopPoll();
|
||||||
|
EX?.destroy();
|
||||||
|
EX = null;
|
||||||
S.job = s.job;
|
S.job = s.job;
|
||||||
S.filters = { category: null, minConfidence: null, query: '', sort: 'confidence' };
|
S.status = null;
|
||||||
S.items = []; S.total = 0; S.selected = new Set(); S.allFiltered = false; S.current = null; S.status = null;
|
|
||||||
renderSide();
|
renderSide();
|
||||||
renderScan();
|
renderScan();
|
||||||
tick();
|
tick();
|
||||||
S.poll = setInterval(tick, 500);
|
S.poll = setInterval(tick, 500);
|
||||||
main.scrollTop = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPoll() { clearInterval(S.poll); S.poll = null; }
|
function stopPoll() { clearInterval(S.poll); S.poll = null; }
|
||||||
|
|
||||||
let lastFound = -1, lastFilterKey = '';
|
|
||||||
async function tick() {
|
async function tick() {
|
||||||
if (!S.job) return;
|
if (!S.job) return;
|
||||||
let st;
|
let st;
|
||||||
try { st = await rpc('status', { job: S.job }); } catch (e) { stopPoll(); toast(e.message); return; }
|
try { st = await rpc('status', { job: S.job }); } catch (e) { stopPoll(); toast(e.message); return; }
|
||||||
S.status = st;
|
S.status = st;
|
||||||
const key = JSON.stringify(S.filters);
|
|
||||||
if (st.found !== lastFound || key !== lastFilterKey) {
|
|
||||||
lastFound = st.found; lastFilterKey = key;
|
|
||||||
await loadResults(true);
|
|
||||||
}
|
|
||||||
paintStatus();
|
paintStatus();
|
||||||
if (st.status !== 'running') {
|
EX?.onStatus(st);
|
||||||
|
// continua consultando enquanto a varredura roda ou as datas/EXIF ainda estão sendo lidas
|
||||||
|
if (st.status !== 'running' && !st.enriching) {
|
||||||
stopPoll();
|
stopPoll();
|
||||||
if (st.status === 'error') toast(st.error);
|
if (st.status === 'error') toast(st.error);
|
||||||
await loadResults(true);
|
} else if (!S.poll) {
|
||||||
paintStatus();
|
S.poll = setInterval(tick, 700);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadResults(reset) {
|
|
||||||
const f = S.filters;
|
|
||||||
const query = { sort: f.sort, offset: reset ? 0 : S.items.length, limit: 200 };
|
|
||||||
if (f.category) query.category = f.category;
|
|
||||||
if (f.minConfidence) query.min_confidence = f.minConfidence;
|
|
||||||
if (f.query) query.query = f.query;
|
|
||||||
const r = await rpc('results', { job: S.job, query });
|
|
||||||
S.items = reset ? r.items : S.items.concat(r.items);
|
|
||||||
S.total = r.total;
|
|
||||||
paintList();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderScan() {
|
function renderScan() {
|
||||||
main.innerHTML = `<div class="page">
|
main.classList.add('fixed');
|
||||||
<button class="back" id="backBtn">‹ Dispositivos</button>
|
main.innerHTML = `<div class="page wide fill">
|
||||||
<div class="dev-head"><div><h1 id="scanTitle">Procurando…</h1><div class="sub" id="scanSub"></div></div></div>
|
<div class="xp-top"><button class="back" id="backBtn">‹ Dispositivos</button><div><h1 id="scanTitle">Procurando…</h1><div class="sub" id="scanSub"></div></div></div>
|
||||||
<div id="progressArea"></div>
|
<div id="progressArea"></div>
|
||||||
<div id="warnArea"></div>
|
<div id="warnArea"></div>
|
||||||
<div class="toolbar">
|
<div class="xp-host" id="xpHost"></div>
|
||||||
<div class="chips" id="chips" role="group" aria-label="Filtrar por tipo"></div>
|
</div>`;
|
||||||
</div>
|
|
||||||
<div class="toolbar" style="margin-top:0">
|
|
||||||
<input class="input" id="q" type="search" placeholder="Buscar por nome ou pasta" value="${esc(S.filters.query)}" aria-label="Buscar">
|
|
||||||
<select class="select" id="conf" aria-label="Confiança mínima"><option value="">Qualquer confiança</option><option value="medium">Média ou alta</option><option value="high">Só alta</option></select>
|
|
||||||
<select class="select" id="sort" aria-label="Ordenar"><option value="confidence">Mais confiáveis</option><option value="size">Maiores</option><option value="name">Nome</option></select>
|
|
||||||
</div>
|
|
||||||
<div class="split"><div class="table" id="table"></div><aside class="preview" id="preview" aria-live="polite"></aside></div>
|
|
||||||
</div><div id="barHost"></div>`;
|
|
||||||
$('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome());
|
$('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome());
|
||||||
let t;
|
$('#progressArea').onclick = onProgressClick;
|
||||||
$('#q').oninput = (e) => { clearTimeout(t); t = setTimeout(() => { S.filters.query = e.target.value; resetSel(); tick(); }, 250); };
|
EX = createExplorer({ host: $('#xpHost'), job: S.job, rpc, esc, human, fileUrl, thumbUrl, onRecover: recover, toast, noInlineMedia: !!tauri && /Linux/i.test(navigator.userAgent) });
|
||||||
$('#conf').onchange = (e) => { S.filters.minConfidence = e.target.value || null; resetSel(); tick(); };
|
|
||||||
$('#sort').onchange = (e) => { S.filters.sort = e.target.value; tick(); };
|
|
||||||
paintPreview();
|
|
||||||
}
|
}
|
||||||
const resetSel = () => { S.selected = new Set(); S.allFiltered = false; };
|
|
||||||
|
|
||||||
function paintStatus() {
|
function paintStatus() {
|
||||||
const st = S.status;
|
const st = S.status;
|
||||||
@@ -366,122 +444,60 @@ function paintStatus() {
|
|||||||
$('#scanSub').innerHTML = `<span class="mono">${esc(st.target)}</span>`;
|
$('#scanSub').innerHTML = `<span class="mono">${esc(st.target)}</span>`;
|
||||||
const rate = p.total && st.elapsed > 1 && p.done ? p.done / st.elapsed : 0;
|
const rate = p.total && st.elapsed > 1 && p.done ? p.done / st.elapsed : 0;
|
||||||
const eta = rate && running ? dur((p.total - p.done) / rate) : null;
|
const eta = rate && running ? dur((p.total - p.done) / rate) : null;
|
||||||
$('#progressArea').innerHTML = `<div class="progress-card">
|
const enr = st.enriching ? `<span class="sub">Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString('pt-BR')}/${st.enriching.total.toLocaleString('pt-BR')}</span>` : '';
|
||||||
<div class="row spread"><div><b>${esc(p.phase || (running ? 'Iniciando' : ''))}</b>${pct != null ? ` <span class="sub">· ${pct}%</span>` : ''}</div>
|
const buttons = running ? '<button class="btn sm" data-act="pause">Pausar</button><button class="btn sm danger" data-act="stop">Parar</button>'
|
||||||
<div class="row">${running ? '<button class="btn sm" id="pauseBtn">Pausar</button><button class="btn sm danger" id="stopBtn">Parar</button>' : st.status === 'paused' ? '<button class="btn sm primary" id="resumeBtn">Continuar</button>' : ''}
|
: st.status === 'paused' ? '<button class="btn sm primary" data-act="resume">Continuar</button>' : '';
|
||||||
${!running && st.found ? '<button class="btn sm" id="saveBtn">Salvar sessão…</button>' : ''}</div></div>
|
const relink = st.source === false ? '<button class="btn sm primary" data-act="relink">Escolher a origem…</button>' : '';
|
||||||
|
const tools = relink + (!running && st.found ? '<button class="btn sm" data-act="csv">Exportar CSV</button><button class="btn sm" data-act="report">Relatório</button><button class="btn sm" data-act="save">Salvar sessão…</button>' : '');
|
||||||
|
if (running || st.status === 'paused') {
|
||||||
|
setHTML($('#progressArea'), `<div class="progress-card slim">
|
||||||
|
<div class="row spread"><div><b>${esc(p.phase || 'Iniciando')}</b>${pct != null ? ` <span class="sub">· ${pct}%</span>` : ''}</div><div class="row">${buttons}</div></div>
|
||||||
<div class="bar ${running && pct == null ? 'indet' : ''}"><i style="width:${pct ?? (running ? 35 : 100)}%"></i></div>
|
<div class="bar ${running && pct == null ? 'indet' : ''}"><i style="width:${pct ?? (running ? 35 : 100)}%"></i></div>
|
||||||
<div class="stats">
|
<div class="stats compact">
|
||||||
<div class="stat"><b>${st.found.toLocaleString('pt-BR')}</b><span>arquivos encontrados</span></div>
|
<div class="stat"><b>${st.found.toLocaleString('pt-BR')}</b><span>arquivos</span></div>
|
||||||
<div class="stat"><b>${dur(st.elapsed)}</b><span>decorrido${eta ? ' · faltam ~' + eta : ''}</span></div>
|
<div class="stat"><b>${dur(st.elapsed)}</b><span>decorrido${eta ? ' · faltam ~' + eta : ''}</span></div>
|
||||||
<div class="stat"><b style="color:var(--ok)">${st.confidence.high}</b><span>confiança alta</span></div>
|
<div class="stat"><b style="color:var(--ok)">${st.confidence.high}</b><span>alta</span></div>
|
||||||
<div class="stat"><b style="color:var(--warn)">${st.confidence.medium}</b><span>média</span></div>
|
<div class="stat"><b style="color:var(--warn)">${st.confidence.medium}</b><span>média</span></div>
|
||||||
<div class="stat"><b style="color:var(--bad)">${st.confidence.low}</b><span>baixa</span></div>
|
<div class="stat"><b style="color:var(--bad)">${st.confidence.low}</b><span>baixa</span></div>
|
||||||
</div></div>`;
|
</div></div>`);
|
||||||
$('#pauseBtn')?.addEventListener('click', async () => { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); });
|
} else {
|
||||||
$('#stopBtn')?.addEventListener('click', async () => { await rpc('scan_stop', { job: S.job }); });
|
setHTML($('#progressArea'), `<div class="done-bar"><span><b>${st.found.toLocaleString('pt-BR')}</b> arquivos em ${dur(st.elapsed)}</span>
|
||||||
$('#resumeBtn')?.addEventListener('click', async () => { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); });
|
<span class="sub">confiança alta ${st.confidence.high} · média ${st.confidence.medium} · baixa ${st.confidence.low}</span>${enr}<span class="grow"></span>${tools}</div>`);
|
||||||
$('#saveBtn')?.addEventListener('click', async () => {
|
}
|
||||||
const path = await ask({ title: 'Salvar sessão', text: 'Guarda os resultados para recuperar depois, sem varrer de novo.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-sessao.json', ok: 'Salvar' });
|
setHTML($('#warnArea'), (st.warnings || []).map((w) => `<div class="notice">${warnSvg}<div>${esc(w)}</div></div>`).join('')
|
||||||
if (path) { try { await rpc('session_save', { job: S.job, path }); toast('Sessão salva'); } catch (e) { toast(e.message); } }
|
+ (st.error ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : ''));
|
||||||
});
|
|
||||||
$('#warnArea').innerHTML = (st.warnings || []).map((w) => `<div class="notice">${warnSvg}<div>${esc(w)}</div></div>`).join('')
|
|
||||||
+ (st.error ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : '');
|
|
||||||
// chips
|
|
||||||
const total = st.found;
|
|
||||||
$('#chips').innerHTML = [`<button class="chip" data-c="" aria-pressed="${!S.filters.category}">Tudo<small>${total}</small></button>`]
|
|
||||||
.concat(CATS.filter(([k]) => st.categories[k]).map(([k, n]) => `<button class="chip" data-c="${k}" aria-pressed="${S.filters.category === k}">${n}<small>${st.categories[k]}</small></button>`)).join('');
|
|
||||||
$('#chips').querySelectorAll('.chip').forEach((c) => (c.onclick = () => { S.filters.category = c.dataset.c || null; resetSel(); tick(); }));
|
|
||||||
const sel = $('#conf'); if (sel && sel.value !== (S.filters.minConfidence || '')) sel.value = S.filters.minConfidence || '';
|
|
||||||
paintBar();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function paintList() {
|
async function onProgressClick(e) {
|
||||||
const el = $('#table');
|
const act = e.target.closest('[data-act]')?.dataset.act;
|
||||||
if (!el) return;
|
if (!act) return;
|
||||||
const st = S.status;
|
if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); }
|
||||||
if (!S.items.length) {
|
else if (act === 'stop') await rpc('scan_stop', { job: S.job });
|
||||||
el.innerHTML = `<div class="empty"><h3>${st && st.status === 'running' ? 'Procurando…' : 'Nada por aqui'}</h3><p>${st && st.status === 'running' ? 'Os arquivos aparecem aqui assim que são encontrados.' : (S.filters.category || S.filters.query || S.filters.minConfidence) ? 'Nenhum arquivo combina com os filtros.' : 'Não encontramos arquivos recuperáveis neste disco.'}</p></div>`;
|
else if (act === 'resume') { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); }
|
||||||
return;
|
else if (act === 'relink') {
|
||||||
|
const path = await ask({ title: 'Escolher a origem', text: `Esta sessão foi feita sobre <code>${esc(S.status?.target || '')}</code>. Informe o dispositivo ou a imagem do mesmo disco para pré-visualizar e recuperar.`, value: S.status?.target || '', ok: 'Usar esta origem' });
|
||||||
|
if (path) {
|
||||||
|
try { await rpc('session_relink', { job: S.job, path }); toast('Origem reconectada'); const cur = { job: S.job, target: S.status.target, label: S.sessions.find((x) => x.job === S.job)?.label || 'Sessão' }; openJob(cur); } catch (err) { info({ title: 'Não foi possível usar essa origem', html: `<p>${esc(err.message)}</p>` }); }
|
||||||
}
|
}
|
||||||
const allSel = S.items.every((i) => S.selected.has(i.id)) || S.allFiltered;
|
} else if (act === 'csv') {
|
||||||
el.innerHTML = `<div class="thead"><input type="checkbox" id="selAll" aria-label="Selecionar todos" ${allSel ? 'checked' : ''}><span>Nome</span><span style="text-align:right">Tamanho</span><span>Origem</span><span style="text-align:right">Confiança</span></div>`
|
const path = await ask({ title: 'Exportar para CSV', text: 'Grava os arquivos da vista atual (com os filtros aplicados). O arquivo abre direto no Excel.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-achados.csv', ok: 'Exportar' });
|
||||||
+ S.items.map((i) => {
|
if (path) { try { const r = await rpc('export_csv', { job: S.job, path, query: EX?.query() }); toast(`${r.rows.toLocaleString('pt-BR')} linhas gravadas`); } catch (err) { toast(err.message); } }
|
||||||
const [cl, cc] = CONF[i.confidence];
|
} else if (act === 'report') {
|
||||||
const on = S.allFiltered || S.selected.has(i.id);
|
const f = await askForm({ title: 'Relatório de recuperação', text: 'Gera um relatório HTML pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados.', ok: 'Gerar relatório', fields: [
|
||||||
return `<div class="tr" role="row" data-id="${i.id}" aria-selected="${on || S.current?.id === i.id}" tabindex="0">
|
{ name: 'path', label: 'Salvar em', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-relatorio.html' },
|
||||||
<input type="checkbox" ${on ? 'checked' : ''} aria-label="Selecionar ${esc(i.name)}">
|
{ name: 'case_id', label: 'Número do caso' }, { name: 'examiner', label: 'Responsável pelo exame' }, { name: 'organization', label: 'Organização' },
|
||||||
<div class="nm"><b>${esc(i.name)}</b><small>${esc(i.dir || (i.origin === 'carved' ? 'Encontrado por assinatura' : '—'))}</small></div>
|
{ name: 'notes', label: 'Observações', multiline: true } ] });
|
||||||
<div class="num">${human(i.size)}</div><div class="num" style="text-align:left">${i.origin === 'carved' ? 'assinatura' : esc(i.origin)}</div>
|
if (f) {
|
||||||
<div class="num"><span class="pill ${cc}">${cl}</span></div></div>`;
|
|
||||||
}).join('')
|
|
||||||
+ (S.items.length < S.total ? `<div class="more"><button class="btn" id="moreBtn">Mostrar mais (${(S.total - S.items.length).toLocaleString('pt-BR')})</button></div>` : '');
|
|
||||||
$('#selAll').onchange = (e) => {
|
|
||||||
if (e.target.checked) S.items.forEach((i) => S.selected.add(i.id)); else resetSel();
|
|
||||||
paintList(); paintBar();
|
|
||||||
};
|
|
||||||
el.querySelectorAll('.tr').forEach((row) => {
|
|
||||||
const id = +row.dataset.id;
|
|
||||||
const cb = $('input', row);
|
|
||||||
cb.onclick = (e) => { e.stopPropagation(); if (S.allFiltered) { S.allFiltered = false; S.items.forEach((i) => S.selected.add(i.id)); } cb.checked ? S.selected.add(id) : S.selected.delete(id); paintBar(); row.setAttribute('aria-selected', cb.checked); };
|
|
||||||
row.onclick = () => focusItem(S.items.find((i) => i.id === id));
|
|
||||||
row.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); focusItem(S.items.find((i) => i.id === id)); } };
|
|
||||||
});
|
|
||||||
$('#moreBtn')?.addEventListener('click', () => loadResults(false));
|
|
||||||
paintBar();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function focusItem(item) {
|
|
||||||
S.current = item;
|
|
||||||
document.querySelectorAll('.tr').forEach((r) => r.setAttribute('aria-selected', (+r.dataset.id === item.id) || S.selected.has(+r.dataset.id) || S.allFiltered));
|
|
||||||
paintPreview();
|
|
||||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(item.ext) && item.size < 8 * 1024 * 1024) {
|
|
||||||
try {
|
try {
|
||||||
const p = await rpc('preview', { job: S.job, id: item.id });
|
const hashes = S.sourceHashes && S.sourceHashes.path === (S.status?.target) ? { md5: S.sourceHashes.md5, sha1: S.sourceHashes.sha1, sha256: S.sourceHashes.sha256 } : null;
|
||||||
if (S.current?.id === item.id && p.mime) $('.thumb', $('#preview')).innerHTML = `<img alt="Pré-visualização de ${esc(item.name)}" src="data:${p.mime};base64,${p.base64}">`;
|
await rpc('export_report', { job: S.job, path: f.path, case: { case_id: f.case_id, examiner: f.examiner, organization: f.organization, notes: f.notes }, hashes });
|
||||||
} catch { /* sem prévia */ }
|
toast('Relatório gravado');
|
||||||
|
} catch (err) { toast(err.message); }
|
||||||
}
|
}
|
||||||
|
} else if (act === 'save') {
|
||||||
|
const path = await ask({ title: 'Salvar sessão', text: 'Guarda os resultados para recuperar depois, sem varrer de novo.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-sessao.json', ok: 'Salvar' });
|
||||||
|
if (path) { try { await rpc('session_save', { job: S.job, path }); toast('Sessão salva'); } catch (err) { toast(err.message); } }
|
||||||
}
|
}
|
||||||
|
|
||||||
function paintPreview() {
|
|
||||||
const el = $('#preview');
|
|
||||||
if (!el) return;
|
|
||||||
const i = S.current;
|
|
||||||
if (!i) { el.innerHTML = `<div class="thumb">Selecione um arquivo para ver os detalhes</div>`; return; }
|
|
||||||
const [cl, cc] = CONF[i.confidence];
|
|
||||||
el.innerHTML = `<div class="thumb">${esc(i.ext ? i.ext.toUpperCase() : 'ARQUIVO')}</div>
|
|
||||||
<dl class="kv"><dt>Nome</dt><dd>${esc(i.name)}</dd><dt>Pasta</dt><dd>${esc(i.dir || '—')}</dd><dt>Tamanho</dt><dd>${human(i.size)}</dd>
|
|
||||||
<dt>Modificado</dt><dd>${date(i.modified)}</dd><dt>Origem</dt><dd>${i.origin === 'carved' ? 'Assinatura de formato' : esc(i.origin.toUpperCase())}</dd>
|
|
||||||
<dt>Confiança</dt><dd><span class="pill ${cc}">${cl}</span></dd>${i.note ? `<dt>Aviso</dt><dd>${esc(i.note)}</dd>` : ''}</dl>
|
|
||||||
<div class="row"><button class="btn primary sm" id="recOne">Recuperar este arquivo</button></div>`;
|
|
||||||
$('#recOne').onclick = () => recover({ ids: [i.id] });
|
|
||||||
}
|
|
||||||
|
|
||||||
function selCount() { return S.allFiltered ? S.total : S.selected.size; }
|
|
||||||
|
|
||||||
function paintBar() {
|
|
||||||
const host = $('#barHost');
|
|
||||||
if (!host) return;
|
|
||||||
const st = S.status;
|
|
||||||
const n = selCount();
|
|
||||||
const canAll = st && st.found > 0 && st.status !== 'error';
|
|
||||||
if (!n && !canAll) { host.innerHTML = ''; return; }
|
|
||||||
const moreOffer = !S.allFiltered && S.selected.size > 0 && S.selected.size < S.total && S.items.length === S.selected.size
|
|
||||||
? `<button class="btn sm" id="selFiltered">Selecionar todos os ${S.total.toLocaleString('pt-BR')}</button>` : '';
|
|
||||||
host.innerHTML = `<div class="actionbar"><span class="info">${n ? `${n.toLocaleString('pt-BR')} selecionado(s)` : `${S.total.toLocaleString('pt-BR')} arquivo(s) na lista`}</span>${moreOffer}
|
|
||||||
${n ? '<button class="btn primary" id="recSel">Recuperar selecionados</button>' : '<button class="btn primary" id="recAll">Recuperar todos da lista</button>'}</div>`;
|
|
||||||
$('#selFiltered')?.addEventListener('click', () => { S.allFiltered = true; paintList(); });
|
|
||||||
$('#recSel')?.addEventListener('click', () => recover(S.allFiltered ? { filter: currentFilter() } : { ids: [...S.selected] }));
|
|
||||||
$('#recAll')?.addEventListener('click', () => recover({ filter: currentFilter() }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentFilter() {
|
|
||||||
const f = S.filters;
|
|
||||||
const q = { sort: f.sort, limit: 1 };
|
|
||||||
if (f.category) q.category = f.category;
|
|
||||||
if (f.minConfidence) q.min_confidence = f.minConfidence;
|
|
||||||
if (f.query) q.query = f.query;
|
|
||||||
return q;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recover(sel) {
|
async function recover(sel) {
|
||||||
@@ -540,6 +556,8 @@ async function openJobFromList(s) { openJob(s); }
|
|||||||
const v = await rpc('version');
|
const v = await rpc('version');
|
||||||
$('#versionTag').textContent = `v${v.version}`;
|
$('#versionTag').textContent = `v${v.version}`;
|
||||||
S.defaults = await rpc('defaults');
|
S.defaults = await rpc('defaults');
|
||||||
|
try { const st = await rpc('settings_get'); if (st?.theme) { applyTheme(st.theme); try { localStorage.setItem('guara-theme', st.theme); } catch { /* */ } } } catch { /* usa o local */ }
|
||||||
|
tauri?.event?.listen?.('elevate-failed', (e) => { toast(String(e.payload || 'Não foi possível reiniciar como administrador')); });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
main.innerHTML = `<div class="page"><div class="notice bad">${warnSvg}<div>Não foi possível falar com o núcleo do Guará: ${esc(e.message)}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando <code>guara ui</code> (ele inclui o token).</div></div></div>`;
|
main.innerHTML = `<div class="page"><div class="notice bad">${warnSvg}<div>Não foi possível falar com o núcleo do Guará: ${esc(e.message)}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando <code>guara ui</code> (ele inclui o token).</div></div></div>`;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,651 @@
|
|||||||
|
// Explorador de arquivos recuperados: grade de miniaturas (virtualizada, aguenta
|
||||||
|
// dezenas de milhares de itens), navegação por tipo/data/pasta/câmera, seleção
|
||||||
|
// estilo gerenciador de arquivos (Ctrl/Shift/teclado), painel de detalhes e
|
||||||
|
// visualização rápida em tela cheia.
|
||||||
|
|
||||||
|
import { renderPreview, kindOf, KIND_LABEL } from './preview.js';
|
||||||
|
|
||||||
|
const CAT = {
|
||||||
|
image: ['Fotos', '🖼'], document: ['Documentos', '📄'], video: ['Vídeos', '🎞'], audio: ['Áudio', '🎵'],
|
||||||
|
archive: ['Compactados', '🗜'], database: ['Bancos de dados', '🗄'], other: ['Outros', '📦'],
|
||||||
|
};
|
||||||
|
const CONF = { high: ['alta', 'ok'], medium: ['média', 'warn'], low: ['baixa', 'bad'] };
|
||||||
|
const MONTHS = ['janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho', 'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro'];
|
||||||
|
const SORTS = [
|
||||||
|
['date', 'Data (mais recentes)'], ['date_asc', 'Data (mais antigas)'], ['name', 'Nome'], ['size', 'Tamanho'], ['confidence', 'Confiança'],
|
||||||
|
];
|
||||||
|
const GAP = 10;
|
||||||
|
const PAD = 12;
|
||||||
|
|
||||||
|
const svg = (d, w = 18) => `<svg viewBox="0 0 24 24" width="${w}" height="${w}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
|
||||||
|
const I = {
|
||||||
|
grid: svg('<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>'),
|
||||||
|
list: svg('<path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01"/>'),
|
||||||
|
filter: svg('<path d="M4 5h16l-6 8v6l-4-2v-4z"/>'),
|
||||||
|
chev: svg('<path d="M9 6l6 6-6 6"/>', 14),
|
||||||
|
x: svg('<path d="M6 6l12 12M18 6L6 18"/>', 14),
|
||||||
|
pin: svg('<path d="M12 21s-6-5.3-6-10a6 6 0 1 1 12 0c0 4.7-6 10-6 10z"/><circle cx="12" cy="11" r="2"/>', 14),
|
||||||
|
warn: svg('<path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.01"/>', 22),
|
||||||
|
close: svg('<path d="M6 6l12 12M18 6L6 18"/>', 20),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createExplorer(deps) {
|
||||||
|
const { host, job, rpc, esc, human, fileUrl, thumbUrl, onRecover, toast } = deps;
|
||||||
|
const inlineMedia = !deps.noInlineMedia;
|
||||||
|
const openExternal = (it) => {
|
||||||
|
toast('Preparando para abrir no player do sistema…');
|
||||||
|
rpc('open_external', { job, id: it.id }).then(() => toast('Aberto no player do sistema')).catch((err) => toast(err.message));
|
||||||
|
};
|
||||||
|
const E = {
|
||||||
|
filters: { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, bigOnly: false, mp1: false, query: '' },
|
||||||
|
sort: 'date',
|
||||||
|
view: 'grid',
|
||||||
|
tile: 150,
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
sel: new Set(),
|
||||||
|
allFiltered: false,
|
||||||
|
anchor: -1,
|
||||||
|
curId: null,
|
||||||
|
facets: null,
|
||||||
|
expanded: new Set(),
|
||||||
|
rows: [],
|
||||||
|
height: 0,
|
||||||
|
rendered: new Map(),
|
||||||
|
reloadToken: 0,
|
||||||
|
lastReload: 0,
|
||||||
|
lastFound: -1,
|
||||||
|
wasEnriching: false,
|
||||||
|
status: null,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
E.view = localStorage.getItem('guara-view') || 'grid';
|
||||||
|
E.tile = +localStorage.getItem('guara-tile') || 150;
|
||||||
|
} catch { /* sem armazenamento */ }
|
||||||
|
|
||||||
|
host.innerHTML = `<div class="xp" data-view="${E.view}">
|
||||||
|
<div class="xp-bar">
|
||||||
|
<button class="icon-btn xp-navbtn" id="xpNavBtn" aria-label="Filtros">${I.filter}</button>
|
||||||
|
<div class="xp-crumb" id="xpCrumb"></div>
|
||||||
|
<input class="input xp-q" id="xpQ" type="search" placeholder="Buscar por nome, pasta ou câmera" aria-label="Buscar">
|
||||||
|
<select class="select" id="xpSort" aria-label="Ordenar">${SORTS.map(([k, n]) => `<option value="${k}">${n}</option>`).join('')}</select>
|
||||||
|
<div class="theme-switch" role="group" aria-label="Modo de exibição"><button data-view="grid" aria-label="Grade" title="Grade de miniaturas">${I.grid}</button><button data-view="list" aria-label="Lista" title="Lista">${I.list}</button></div>
|
||||||
|
<input type="range" id="xpSize" min="100" max="300" step="4" aria-label="Tamanho das miniaturas">
|
||||||
|
</div>
|
||||||
|
<aside class="xp-nav" id="xpNav" aria-label="Navegação"></aside>
|
||||||
|
<section class="xp-main">
|
||||||
|
<div class="xp-chips" id="xpChips"></div>
|
||||||
|
<div class="xp-scroll" id="xpScroll" tabindex="0" role="listbox" aria-multiselectable="true" aria-label="Arquivos encontrados"><div class="xp-sizer" id="xpSizer"></div></div>
|
||||||
|
<div class="xp-actions" id="xpActions"></div>
|
||||||
|
</section>
|
||||||
|
<aside class="xp-insp" id="xpInsp" aria-label="Detalhes"></aside>
|
||||||
|
<div class="xp-veil" id="xpVeil"></div>
|
||||||
|
</div>`;
|
||||||
|
const $ = (s) => host.querySelector(s);
|
||||||
|
const root = $('.xp'), scroll = $('#xpScroll'), sizer = $('#xpSizer'), nav = $('#xpNav'), insp = $('#xpInsp'), actions = $('#xpActions');
|
||||||
|
$('#xpSort').value = E.sort;
|
||||||
|
$('#xpSize').value = E.tile;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ dados
|
||||||
|
const toQuery = () => {
|
||||||
|
const f = E.filters;
|
||||||
|
const q = {};
|
||||||
|
if (f.category) q.category = f.category;
|
||||||
|
if (f.year) q.year = f.year;
|
||||||
|
if (f.month) q.month = f.month;
|
||||||
|
if (f.undated) q.undated = true;
|
||||||
|
if (f.folder) q.folder = f.folder;
|
||||||
|
if (f.camera) q.camera = f.camera;
|
||||||
|
if (f.origin) q.origin = f.origin;
|
||||||
|
if (f.minConf) q.min_confidence = f.minConf;
|
||||||
|
if (f.gps) q.has_gps = true;
|
||||||
|
if (f.dedupe) q.dedupe = true;
|
||||||
|
if (f.bigOnly) q.min_size = 30 * 1024;
|
||||||
|
if (f.mp1) q.min_mp = 1;
|
||||||
|
if (f.query) q.query = f.query;
|
||||||
|
return q;
|
||||||
|
};
|
||||||
|
const anyFilter = () => Object.entries(E.filters).some(([k, v]) => (k === 'query' ? !!v : !!v));
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
const token = ++E.reloadToken;
|
||||||
|
E.lastReload = Date.now();
|
||||||
|
const base = { ...toQuery(), sort: E.sort };
|
||||||
|
let items = [], total = 0;
|
||||||
|
for (;;) {
|
||||||
|
let r;
|
||||||
|
try { r = await rpc('results', { job, query: { ...base, offset: items.length, limit: 2000 } }); } catch (e) { toast(e.message); return; }
|
||||||
|
if (token !== E.reloadToken) return;
|
||||||
|
items = items.concat(r.items);
|
||||||
|
total = r.total;
|
||||||
|
if (!r.items.length || items.length >= total || items.length >= 30000) break;
|
||||||
|
if (items.length === 2000) { E.items = items; E.total = total; relayout(); paintAll(); }
|
||||||
|
}
|
||||||
|
E.items = items;
|
||||||
|
E.total = total;
|
||||||
|
relayout();
|
||||||
|
paintAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFacets() {
|
||||||
|
try { E.facets = await rpc('facets', { job }); } catch { return; }
|
||||||
|
paintNav();
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadAll = () => { reload(); loadFacets(); };
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ layout virtual
|
||||||
|
// Agrupa por dia; se houver poucos itens por dia, agrupa por mês.
|
||||||
|
let groupBy = 'day';
|
||||||
|
const dayKey = (t) => (t == null ? 'Sem data' : new Date(t * 1000).toISOString().slice(0, groupBy === 'day' ? 10 : 7));
|
||||||
|
const dayLabel = (k) => {
|
||||||
|
if (k === 'Sem data') return k;
|
||||||
|
return groupBy === 'day'
|
||||||
|
? new Date(k + 'T00:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'UTC', weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
|
: new Date(k + '-01T00:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'UTC', month: 'long', year: 'numeric' });
|
||||||
|
};
|
||||||
|
const fmtDate = (t) => (t == null ? 'sem data' : new Date(t * 1000).toLocaleString('pt-BR', { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'short' }));
|
||||||
|
const fmtDay = (t) => (t == null ? '' : new Date(t * 1000).toLocaleDateString('pt-BR', { timeZone: 'UTC', day: '2-digit', month: 'short', year: 'numeric' }));
|
||||||
|
|
||||||
|
function geometry() {
|
||||||
|
const W = Math.max(200, scroll.clientWidth - PAD * 2);
|
||||||
|
if (E.view === 'list') return { W, cols: 1, tw: W, cap: 0, rowH: 54 };
|
||||||
|
const T = E.tile;
|
||||||
|
const cols = Math.max(1, Math.floor((W + GAP) / (T + GAP)));
|
||||||
|
const tw = Math.floor((W - GAP * (cols - 1)) / cols);
|
||||||
|
const cap = tw >= 128 ? 42 : 0;
|
||||||
|
return { W, cols, tw, cap, rowH: tw + cap + GAP };
|
||||||
|
}
|
||||||
|
|
||||||
|
let G = geometry();
|
||||||
|
function relayout() {
|
||||||
|
G = geometry();
|
||||||
|
const grouped = E.view === 'grid' && (E.sort === 'date' || E.sort === 'date_asc');
|
||||||
|
if (grouped) {
|
||||||
|
groupBy = 'day';
|
||||||
|
const days = new Set(E.items.map((it) => dayKey(it.when)));
|
||||||
|
if (E.items.length / Math.max(1, days.size) < 2.5) groupBy = 'month';
|
||||||
|
}
|
||||||
|
const rows = [];
|
||||||
|
let y = PAD;
|
||||||
|
let cur = null, rowItems = [];
|
||||||
|
const flush = () => {
|
||||||
|
if (rowItems.length) { rows.push({ t: 'row', y, h: G.rowH, idx: rowItems }); y += G.rowH; rowItems = []; }
|
||||||
|
};
|
||||||
|
E.items.forEach((it, i) => {
|
||||||
|
if (grouped) {
|
||||||
|
const k = dayKey(it.when);
|
||||||
|
if (k !== cur) {
|
||||||
|
flush();
|
||||||
|
cur = k;
|
||||||
|
rows.push({ t: 'head', y, h: 40, key: k, start: i });
|
||||||
|
y += 40;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rowItems.push(i);
|
||||||
|
if (rowItems.length === G.cols) flush();
|
||||||
|
});
|
||||||
|
flush();
|
||||||
|
if (grouped) {
|
||||||
|
// contagem por grupo
|
||||||
|
for (let r = 0; r < rows.length; r++) {
|
||||||
|
if (rows[r].t !== 'head') continue;
|
||||||
|
let n = 0;
|
||||||
|
for (let i = rows[r].start; i < E.items.length && dayKey(E.items[i].when) === rows[r].key; i++) n++;
|
||||||
|
rows[r].count = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
E.rows = rows;
|
||||||
|
E.height = y + PAD;
|
||||||
|
sizer.style.height = `${E.height}px`;
|
||||||
|
for (const el of E.rendered.values()) el.remove();
|
||||||
|
E.rendered.clear();
|
||||||
|
renderWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowAt(top) {
|
||||||
|
let lo = 0, hi = E.rows.length - 1, ans = 0;
|
||||||
|
while (lo <= hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (E.rows[mid].y + E.rows[mid].h < top) lo = mid + 1; else { ans = mid; hi = mid - 1; }
|
||||||
|
}
|
||||||
|
return ans;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWindow() {
|
||||||
|
if (!E.rows.length) { for (const el of E.rendered.values()) el.remove(); E.rendered.clear(); return; }
|
||||||
|
const top = Math.max(0, scroll.scrollTop - 400), bottom = scroll.scrollTop + scroll.clientHeight + 400;
|
||||||
|
const a = rowAt(top);
|
||||||
|
let b = a;
|
||||||
|
while (b < E.rows.length && E.rows[b].y < bottom) b++;
|
||||||
|
for (const [ri, el] of E.rendered) {
|
||||||
|
if (ri < a || ri >= b) { el.remove(); E.rendered.delete(ri); }
|
||||||
|
}
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (let ri = a; ri < b; ri++) {
|
||||||
|
if (E.rendered.has(ri)) continue;
|
||||||
|
const row = E.rows[ri];
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = `xp-row ${row.t}`;
|
||||||
|
el.style.top = `${row.y}px`;
|
||||||
|
el.style.height = `${row.h}px`;
|
||||||
|
el.dataset.ri = ri;
|
||||||
|
el.innerHTML = row.t === 'head' ? headHtml(row) : row.idx.map((i) => (E.view === 'list' ? listHtml(i) : tileHtml(i))).join('');
|
||||||
|
E.rendered.set(ri, el);
|
||||||
|
frag.appendChild(el);
|
||||||
|
}
|
||||||
|
sizer.appendChild(frag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ itens
|
||||||
|
const isSel = (id) => E.allFiltered || E.sel.has(id);
|
||||||
|
|
||||||
|
function headHtml(row) {
|
||||||
|
return `<div class="xp-head"><b>${esc(dayLabel(row.key))}</b><small>${row.count} ${row.count === 1 ? 'item' : 'itens'}</small><button class="xp-hsel" data-hsel="${row.start}" data-n="${row.count}" aria-label="Selecionar este grupo">Selecionar grupo</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function thumbSrc(it, px) {
|
||||||
|
const s = px <= 200 ? 256 : px <= 330 ? 384 : 640;
|
||||||
|
return thumbUrl(job, it.id, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tileHtml(i) {
|
||||||
|
const it = E.items[i];
|
||||||
|
const [cl, cc] = CONF[it.confidence];
|
||||||
|
const on = isSel(it.id);
|
||||||
|
const [catName, catIco] = CAT[it.category] || CAT.other;
|
||||||
|
const media = it.category === 'image'
|
||||||
|
? `<img loading="lazy" decoding="async" alt="" src="${thumbSrc(it, G.tw * (window.devicePixelRatio > 1 ? 1.5 : 1))}" data-th="1">`
|
||||||
|
: `<div class="xp-ico c-${it.category}"><span>${catIco}</span><b>${esc((it.ext || '?').toUpperCase().slice(0, 5))}</b></div>`;
|
||||||
|
return `<div class="xp-tile" role="option" data-i="${i}" data-id="${it.id}" aria-selected="${on}" ${E.curId === it.id ? 'data-cur="1"' : ''} style="width:${G.tw}px;height:${G.tw + G.cap}px" title="${esc(it.name)}">
|
||||||
|
<div class="xp-thumb" style="height:${G.tw}px">${media}
|
||||||
|
<span class="xp-chk" data-chk="1" aria-hidden="true"></span>
|
||||||
|
<span class="xp-dot ${cc}" title="Confiança ${cl}"></span>
|
||||||
|
${it.gps ? `<span class="xp-gps" title="Tem localização GPS">${I.pin}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${G.cap ? `<div class="xp-cap"><b>${esc(it.name)}</b><small>${human(it.size)}${it.when != null ? ' · ' + esc(fmtDay(it.when)) : ''}</small></div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listHtml(i) {
|
||||||
|
const it = E.items[i];
|
||||||
|
const [cl, cc] = CONF[it.confidence];
|
||||||
|
const on = isSel(it.id);
|
||||||
|
const [, catIco] = CAT[it.category] || CAT.other;
|
||||||
|
const th = it.category === 'image' ? `<img loading="lazy" decoding="async" alt="" src="${thumbUrl(job, it.id, 128)}" data-th="1">` : `<span class="xp-lico c-${it.category}">${catIco}</span>`;
|
||||||
|
return `<div class="xp-lrow" role="option" data-i="${i}" data-id="${it.id}" aria-selected="${on}" ${E.curId === it.id ? 'data-cur="1"' : ''}>
|
||||||
|
<span class="xp-chk" data-chk="1" aria-hidden="true"></span><div class="xp-lth">${th}</div>
|
||||||
|
<div class="xp-lnm"><b>${esc(it.name)}</b><small>${esc(it.dir || (it.origin === 'carved' ? 'Encontrado por assinatura' : '/'))}</small></div>
|
||||||
|
<span class="xp-lcol">${esc(fmtDate(it.when))}</span><span class="xp-lcol num">${human(it.size)}</span><span class="pill ${cc}">${cl}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// imagens que falham viram um aviso (arquivo possivelmente danificado)
|
||||||
|
scroll.addEventListener('error', (e) => {
|
||||||
|
const img = e.target;
|
||||||
|
if (img.tagName !== 'IMG' || !img.dataset.th) return;
|
||||||
|
const wrap = img.closest('.xp-thumb, .xp-lth');
|
||||||
|
img.remove();
|
||||||
|
const offline = E.status && E.status.source === false;
|
||||||
|
wrap?.insertAdjacentHTML('afterbegin', offline
|
||||||
|
? '<div class="xp-ico c-image" title="Origem desconectada: sem miniatura"><span>🖼</span><b>IMG</b></div>'
|
||||||
|
: `<div class="xp-ico c-bad" title="Não foi possível gerar a miniatura (arquivo possivelmente danificado)"><span>${I.warn}</span><b>?</b></div>`);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ seleção
|
||||||
|
const selCount = () => (E.allFiltered ? E.total : E.sel.size);
|
||||||
|
const idxOf = (id) => E.items.findIndex((x) => x.id === id);
|
||||||
|
|
||||||
|
function paintSel() {
|
||||||
|
const set = (el) => {
|
||||||
|
const id = +el.dataset.id;
|
||||||
|
el.setAttribute('aria-selected', String(isSel(id)));
|
||||||
|
if (E.curId === id) el.dataset.cur = '1'; else delete el.dataset.cur;
|
||||||
|
};
|
||||||
|
scroll.querySelectorAll('.xp-tile, .xp-lrow').forEach(set);
|
||||||
|
paintActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRange(a, b) {
|
||||||
|
const [lo, hi] = a < b ? [a, b] : [b, a];
|
||||||
|
for (let i = lo; i <= hi; i++) E.sel.add(E.items[i].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCursor(i, { extend = false, toggle = false, reveal = false } = {}) {
|
||||||
|
if (i < 0 || i >= E.items.length) return;
|
||||||
|
const it = E.items[i];
|
||||||
|
if (E.allFiltered) { E.allFiltered = false; E.sel = new Set(E.items.map((x) => x.id)); }
|
||||||
|
if (extend && E.anchor >= 0) {
|
||||||
|
E.sel = new Set();
|
||||||
|
selectRange(E.anchor, i);
|
||||||
|
} else if (toggle) {
|
||||||
|
if (E.sel.has(it.id)) E.sel.delete(it.id); else E.sel.add(it.id);
|
||||||
|
E.anchor = i;
|
||||||
|
} else {
|
||||||
|
E.sel = new Set([it.id]);
|
||||||
|
E.anchor = i;
|
||||||
|
}
|
||||||
|
E.curId = it.id;
|
||||||
|
if (reveal) revealIndex(i);
|
||||||
|
paintSel();
|
||||||
|
schedInspector();
|
||||||
|
if (matchMedia('(max-width: 1100px)').matches && !extend && !toggle) root.classList.add('show-insp');
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealIndex(i) {
|
||||||
|
const ri = E.rows.findIndex((r) => r.t === 'row' && r.idx.includes(i));
|
||||||
|
if (ri < 0) return;
|
||||||
|
const r = E.rows[ri];
|
||||||
|
if (r.y < scroll.scrollTop + 4) scroll.scrollTop = r.y - 8;
|
||||||
|
else if (r.y + r.h > scroll.scrollTop + scroll.clientHeight) scroll.scrollTop = r.y + r.h - scroll.clientHeight + 8;
|
||||||
|
renderWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
scroll.addEventListener('scroll', () => renderWindow(), { passive: true });
|
||||||
|
new ResizeObserver(() => { if (Math.abs(geometry().W - G.W) > 1 || geometry().cols !== G.cols) relayout(); else renderWindow(); }).observe(scroll);
|
||||||
|
|
||||||
|
scroll.addEventListener('click', (e) => {
|
||||||
|
const hs = e.target.closest('[data-hsel]');
|
||||||
|
if (hs) {
|
||||||
|
const start = +hs.dataset.hsel, n = +hs.dataset.n;
|
||||||
|
if (E.allFiltered) { E.allFiltered = false; E.sel = new Set(); }
|
||||||
|
for (let i = start; i < start + n; i++) E.sel.add(E.items[i].id);
|
||||||
|
paintSel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const el = e.target.closest('.xp-tile, .xp-lrow');
|
||||||
|
if (!el) return;
|
||||||
|
const i = +el.dataset.i;
|
||||||
|
if (e.target.closest('[data-chk]')) setCursor(i, { toggle: true });
|
||||||
|
else setCursor(i, { extend: e.shiftKey, toggle: e.ctrlKey || e.metaKey });
|
||||||
|
});
|
||||||
|
scroll.addEventListener('dblclick', (e) => {
|
||||||
|
const el = e.target.closest('.xp-tile, .xp-lrow');
|
||||||
|
if (el) { setCursor(+el.dataset.i); openLightbox(+el.dataset.i); }
|
||||||
|
});
|
||||||
|
scroll.addEventListener('keydown', (e) => {
|
||||||
|
if (!E.items.length) return;
|
||||||
|
let i = idxOf(E.curId);
|
||||||
|
if (i < 0) i = 0;
|
||||||
|
const cols = G.cols;
|
||||||
|
const move = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: cols, ArrowUp: -cols, PageDown: cols * 4, PageUp: -cols * 4 }[e.key];
|
||||||
|
if (move != null) {
|
||||||
|
e.preventDefault();
|
||||||
|
setCursor(Math.max(0, Math.min(E.items.length - 1, idxOf(E.curId) < 0 ? 0 : i + move)), { extend: e.shiftKey, reveal: true });
|
||||||
|
} else if (e.key === 'Home') { e.preventDefault(); setCursor(0, { extend: e.shiftKey, reveal: true }); }
|
||||||
|
else if (e.key === 'End') { e.preventDefault(); setCursor(E.items.length - 1, { extend: e.shiftKey, reveal: true }); }
|
||||||
|
else if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); if (E.curId == null) setCursor(0); openLightbox(idxOf(E.curId)); }
|
||||||
|
else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') { e.preventDefault(); E.allFiltered = true; paintSel(); }
|
||||||
|
else if (e.key === 'Escape') { E.sel = new Set(); E.allFiltered = false; paintSel(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ barra de ações
|
||||||
|
function paintActions() {
|
||||||
|
const n = selCount();
|
||||||
|
const html = n
|
||||||
|
? `<span class="info"><b>${n.toLocaleString('pt-BR')}</b> selecionado${n === 1 ? '' : 's'}${E.allFiltered ? ' (todos desta vista)' : ''}</span>
|
||||||
|
<button class="btn sm" data-a="clear">Limpar seleção</button>
|
||||||
|
${!E.allFiltered && E.total > n ? `<button class="btn sm" data-a="all">Selecionar todos os ${E.total.toLocaleString('pt-BR')}</button>` : ''}
|
||||||
|
<button class="btn primary" data-a="rec">Recuperar ${n === 1 ? 'selecionado' : 'selecionados'}</button>`
|
||||||
|
: `<span class="info">${E.total.toLocaleString('pt-BR')} ${E.total === 1 ? 'item' : 'itens'} nesta vista${E.total > E.items.length ? ` (mostrando ${E.items.length.toLocaleString('pt-BR')})` : ''}</span>
|
||||||
|
${E.total ? '<button class="btn sm" data-a="all">Selecionar tudo</button><button class="btn primary" data-a="recall">Recuperar tudo desta vista</button>' : ''}`;
|
||||||
|
if (actions._h !== html) { actions._h = html; actions.innerHTML = html; }
|
||||||
|
}
|
||||||
|
actions.addEventListener('click', (e) => {
|
||||||
|
const a = e.target.closest('[data-a]')?.dataset.a;
|
||||||
|
if (a === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); }
|
||||||
|
else if (a === 'all') { E.allFiltered = true; paintSel(); }
|
||||||
|
else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] });
|
||||||
|
else if (a === 'recall') onRecover({ filter: toQuery() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ navegação lateral
|
||||||
|
function paintNav() {
|
||||||
|
const f = E.facets;
|
||||||
|
if (!f) return;
|
||||||
|
const F = E.filters;
|
||||||
|
const node = (label, count, attrs, active, depth = 0, extra = '') =>
|
||||||
|
`<button class="xp-node${active ? ' on' : ''}" ${attrs} style="--d:${depth}" aria-pressed="${!!active}">${extra}<span class="l">${label}</span><small>${count.toLocaleString('pt-BR')}</small></button>`;
|
||||||
|
const sec = (title, body) => (body ? `<div class="xp-sec"><h4>${title}</h4>${body}</div>` : '');
|
||||||
|
|
||||||
|
const cats = Object.keys(CAT).filter((k) => f.categories[k]).map((k) => node(`${CAT[k][1]} ${CAT[k][0]}`, f.categories[k], `data-f="category" data-v="${k}"`, F.category === k)).join('');
|
||||||
|
const years = f.years.map((y) => {
|
||||||
|
const open = E.expanded.has('y' + y.year) || F.year === y.year;
|
||||||
|
return node(String(y.year), y.count, `data-f="year" data-v="${y.year}"`, F.year === y.year && !F.month, 0,
|
||||||
|
`<span class="tw" data-tw="y${y.year}" aria-label="Expandir" style="${open ? 'transform:rotate(90deg)' : ''}">${I.chev}</span>`)
|
||||||
|
+ (open ? y.months.map((m) => node(MONTHS[m.month - 1], m.count, `data-f="month" data-y="${y.year}" data-v="${m.month}"`, F.year === y.year && F.month === m.month, 1)).join('') : '');
|
||||||
|
}).join('') + (f.undated ? node('Sem data', f.undated, 'data-f="undated" data-v="1"', F.undated) : '');
|
||||||
|
const folders = f.folders.filter((d) => {
|
||||||
|
// só mostra se todos os ancestrais estiverem expandidos
|
||||||
|
const parts = d.path.split('/');
|
||||||
|
for (let k = 1; k < parts.length; k++) if (!E.expanded.has('d' + parts.slice(0, k).join('/')) && !(F.folder || '').startsWith(parts.slice(0, k).join('/'))) return false;
|
||||||
|
return true;
|
||||||
|
}).map((d) => {
|
||||||
|
const hasKids = f.folders.some((o) => o.path.startsWith(d.path + '/'));
|
||||||
|
const open = E.expanded.has('d' + d.path) || (F.folder || '').startsWith(d.path + '/');
|
||||||
|
return node(esc(d.name), d.count, `data-f="folder" data-v="${esc(d.path)}"`, F.folder === d.path, d.depth,
|
||||||
|
hasKids ? `<span class="tw" data-tw="d${esc(d.path)}" style="${open ? 'transform:rotate(90deg)' : ''}">${I.chev}</span>` : '<span class="tw"></span>');
|
||||||
|
}).join('')
|
||||||
|
+ (f.root ? node('/ (raiz)', f.root, 'data-f="folder" data-v="@raiz"', F.folder === '@raiz') : '')
|
||||||
|
+ (f.carved ? node('Por assinatura', f.carved, 'data-f="folder" data-v="@carved"', F.folder === '@carved') : '');
|
||||||
|
const cams = f.cameras.map((c) => node(esc(c.name), c.count, `data-f="camera" data-v="${esc(c.name)}"`, F.camera === c.name)).join('');
|
||||||
|
const conf = ['high', 'medium', 'low'].map((k) => node(`Confiança ${CONF[k][0]}`, f.confidence[k], `data-f="minConf" data-v="${k}"`, F.minConf === k)).join('');
|
||||||
|
const extra = (f.gps ? node('Com localização (GPS)', f.gps, 'data-f="gps" data-v="1"', F.gps) : '')
|
||||||
|
+ (f.small && f.small < f.total ? node('Ocultar arquivos pequenos (menos de 30 KB)', f.total - f.small, 'data-f="bigOnly" data-v="1"', F.bigOnly) : '')
|
||||||
|
+ (f.mp1 ? node('Mais de 1 megapixel', f.mp1, 'data-f="mp1" data-v="1"', F.mp1) : '')
|
||||||
|
+ (f.duplicates ? node('Ocultar duplicadas', f.duplicates, 'data-f="dedupe" data-v="1"', F.dedupe) : '');
|
||||||
|
|
||||||
|
const html = `<div class="xp-sec">${node('Tudo', f.total, 'data-f="all"', !anyFilter())}</div>`
|
||||||
|
+ sec('Tipos', cats) + sec('Datas', years) + sec('Pastas', folders) + sec('Câmeras', cams) + sec('Confiança', conf) + sec('Outros', extra);
|
||||||
|
if (nav._h !== html) { nav._h = html; const st = nav.scrollTop; nav.innerHTML = html; nav.scrollTop = st; }
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.addEventListener('click', (e) => {
|
||||||
|
const tw = e.target.closest('[data-tw]');
|
||||||
|
if (tw) {
|
||||||
|
const k = tw.dataset.tw;
|
||||||
|
if (E.expanded.has(k)) E.expanded.delete(k); else E.expanded.add(k);
|
||||||
|
nav._h = null; paintNav();
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const b = e.target.closest('.xp-node');
|
||||||
|
if (!b) return;
|
||||||
|
const F = E.filters;
|
||||||
|
const f = b.dataset.f, v = b.dataset.v;
|
||||||
|
if (f === 'all') clearFilters(false);
|
||||||
|
else if (f === 'year') { const same = F.year === +v && !F.month; F.year = same ? null : +v; F.month = null; F.undated = false; E.expanded.add('y' + v); }
|
||||||
|
else if (f === 'month') { const same = F.year === +b.dataset.y && F.month === +v; F.year = same ? null : +b.dataset.y; F.month = same ? null : +v; F.undated = false; }
|
||||||
|
else if (f === 'undated') { F.undated = !F.undated; F.year = F.month = null; }
|
||||||
|
else if (f === 'gps') F.gps = !F.gps;
|
||||||
|
else if (f === 'dedupe') F.dedupe = !F.dedupe;
|
||||||
|
else if (f === 'bigOnly') F.bigOnly = !F.bigOnly;
|
||||||
|
else if (f === 'mp1') F.mp1 = !F.mp1;
|
||||||
|
else F[f] = F[f] === v ? null : v;
|
||||||
|
if (f === 'folder' && F.folder && !F.folder.startsWith('@')) E.expanded.add('d' + F.folder);
|
||||||
|
nav._h = null;
|
||||||
|
root.classList.remove('show-nav');
|
||||||
|
changed();
|
||||||
|
});
|
||||||
|
|
||||||
|
function clearFilters(refresh = true) {
|
||||||
|
E.filters = { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, bigOnly: false, mp1: false, query: '' };
|
||||||
|
$('#xpQ').value = '';
|
||||||
|
if (refresh) changed(); else changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
function changed() {
|
||||||
|
E.sel = new Set(); E.allFiltered = false; E.curId = null; E.anchor = -1;
|
||||||
|
scroll.scrollTop = 0;
|
||||||
|
paintCrumb(); paintChips(); paintNav(); paintInspector();
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintCrumb() {
|
||||||
|
const F = E.filters, parts = [];
|
||||||
|
if (F.category) parts.push(CAT[F.category][0]);
|
||||||
|
if (F.year) parts.push(String(F.year));
|
||||||
|
if (F.month) parts.push(MONTHS[F.month - 1]);
|
||||||
|
if (F.undated) parts.push('Sem data');
|
||||||
|
if (F.folder) parts.push(F.folder === '@carved' ? 'Por assinatura' : F.folder === '@raiz' ? 'Raiz' : F.folder.split('/').join(' › '));
|
||||||
|
if (F.camera) parts.push(F.camera);
|
||||||
|
$('#xpCrumb').innerHTML = `<b>${parts.length ? esc(parts.join(' › ')) : 'Todos os arquivos'}</b><small>${E.total.toLocaleString('pt-BR')}</small>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintChips() {
|
||||||
|
const F = E.filters, chips = [];
|
||||||
|
const add = (label, key) => chips.push(`<button class="chip" data-x="${key}">${label} ${I.x}</button>`);
|
||||||
|
if (F.category) add(CAT[F.category][0], 'category');
|
||||||
|
if (F.year) add(String(F.year), 'year');
|
||||||
|
if (F.month) add(MONTHS[F.month - 1], 'month');
|
||||||
|
if (F.undated) add('Sem data', 'undated');
|
||||||
|
if (F.folder) add(F.folder === '@carved' ? 'Por assinatura' : F.folder === '@raiz' ? 'Raiz' : esc(F.folder), 'folder');
|
||||||
|
if (F.camera) add(esc(F.camera), 'camera');
|
||||||
|
if (F.minConf) add(`Confiança ${CONF[F.minConf][0]}`, 'minConf');
|
||||||
|
if (F.gps) add('Com GPS', 'gps');
|
||||||
|
if (F.dedupe) add('Sem duplicadas', 'dedupe');
|
||||||
|
if (F.bigOnly) add('Sem arquivos pequenos', 'bigOnly');
|
||||||
|
if (F.mp1) add('Mais de 1 MP', 'mp1');
|
||||||
|
if (F.query) add(`“${esc(F.query)}”`, 'query');
|
||||||
|
$('#xpChips').innerHTML = chips.length ? chips.join('') + '<button class="btn sm" data-x="all">Limpar filtros</button>' : '';
|
||||||
|
}
|
||||||
|
$('#xpChips').addEventListener('click', (e) => {
|
||||||
|
const k = e.target.closest('[data-x]')?.dataset.x;
|
||||||
|
if (!k) return;
|
||||||
|
if (k === 'all') return clearFilters();
|
||||||
|
E.filters[k] = k === 'query' ? '' : k === 'undated' || k === 'gps' || k === 'dedupe' || k === 'bigOnly' || k === 'mp1' ? false : null;
|
||||||
|
if (k === 'year') E.filters.month = null;
|
||||||
|
if (k === 'query') $('#xpQ').value = '';
|
||||||
|
nav._h = null;
|
||||||
|
changed();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ barra superior
|
||||||
|
let qt;
|
||||||
|
$('#xpQ').addEventListener('input', (e) => { clearTimeout(qt); qt = setTimeout(() => { E.filters.query = e.target.value.trim(); changed(); }, 250); });
|
||||||
|
$('#xpSort').addEventListener('change', (e) => { E.sort = e.target.value; reload(); });
|
||||||
|
const paintView = () => {
|
||||||
|
root.dataset.view = E.view;
|
||||||
|
host.querySelectorAll('[data-view]').forEach((b) => { if (b.tagName === 'BUTTON') b.setAttribute('aria-pressed', String(b.dataset.view === E.view)); });
|
||||||
|
$('#xpSize').style.display = E.view === 'grid' ? '' : 'none';
|
||||||
|
};
|
||||||
|
host.querySelectorAll('button[data-view]').forEach((b) => (b.onclick = () => {
|
||||||
|
E.view = b.dataset.view;
|
||||||
|
try { localStorage.setItem('guara-view', E.view); } catch { /* */ }
|
||||||
|
paintView(); relayout();
|
||||||
|
}));
|
||||||
|
$('#xpSize').addEventListener('input', (e) => {
|
||||||
|
E.tile = +e.target.value;
|
||||||
|
try { localStorage.setItem('guara-tile', String(E.tile)); } catch { /* */ }
|
||||||
|
relayout();
|
||||||
|
});
|
||||||
|
$('#xpNavBtn').onclick = () => root.classList.toggle('show-nav');
|
||||||
|
$('#xpVeil').onclick = () => root.classList.remove('show-nav', 'show-insp');
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ painel de detalhes
|
||||||
|
let inspT, stopPv = null, metaToken = 0;
|
||||||
|
const schedInspector = () => { clearTimeout(inspT); inspT = setTimeout(paintInspector, 90); };
|
||||||
|
|
||||||
|
function timesHtml(it) {
|
||||||
|
const rows = [['Tirada em', it.taken], ['Modificado', it.modified], ['Criado', it.created], ['Acessado', it.accessed]].filter(([, v]) => v != null);
|
||||||
|
return rows.map(([k, v]) => `<dt>${k}</dt><dd>${esc(fmtDate(v))}</dd>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintInspector() {
|
||||||
|
stopPv?.(); stopPv = null;
|
||||||
|
const n = selCount();
|
||||||
|
const it = E.items[idxOf(E.curId)];
|
||||||
|
const close = `<button class="icon-btn xp-insp-x" data-i="close" aria-label="Fechar">${I.close}</button>`;
|
||||||
|
if (n > 1) {
|
||||||
|
const sizeSum = E.allFiltered ? null : [...E.sel].reduce((a, id) => a + (E.items[idxOf(id)]?.size || 0), 0);
|
||||||
|
insp.innerHTML = `${close}<div class="xp-multi"><b>${n.toLocaleString('pt-BR')} itens selecionados</b>${sizeSum != null ? `<span class="sub">${human(sizeSum)} no total</span>` : ''}
|
||||||
|
<div class="row"><button class="btn primary" data-i="rec">Recuperar selecionados</button><button class="btn" data-i="clear">Limpar seleção</button></div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!it) {
|
||||||
|
insp.innerHTML = `${close}<div class="xp-empty-insp"><div class="viewer"><div class="pv-note">Selecione um arquivo para pré-visualizar.<br><span class="sub">Setas navegam, Espaço abre em tela cheia.</span></div></div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [cl, cc] = CONF[it.confidence];
|
||||||
|
const url = kindOf(it) === 'image' ? thumbUrl(job, it.id, 640) : fileUrl(job, it.id);
|
||||||
|
insp.innerHTML = `${close}<div class="viewer" id="xpPv"></div>
|
||||||
|
<div class="xp-ititle"><b>${esc(it.name)}</b><span class="pill ${cc}">${cl}</span></div>
|
||||||
|
<div class="sub" style="font-size:12.5px">${esc(KIND_LABEL[kindOf(it)])} · ${human(it.size)}</div>
|
||||||
|
${it.note ? `<div class="pv-note bad" style="text-align:left">${esc(it.note)}</div>` : ''}
|
||||||
|
<dl class="kv"><dt>Pasta</dt><dd>${esc(it.dir || (it.origin === 'carved' ? 'Encontrado por assinatura' : '/'))}</dd>${timesHtml(it)}${it.camera ? `<dt>Câmera</dt><dd>${esc(it.camera)}</dd>` : ''}${it.px ? `<dt>Resolução</dt><dd>${it.px[0]} × ${it.px[1]} px</dd>` : ''}</dl>
|
||||||
|
<div class="xp-meta" id="xpMeta"></div>
|
||||||
|
<div class="row"><button class="btn sm" data-i="full">Tela cheia</button><button class="btn primary sm" data-i="rec1">Recuperar este arquivo</button></div>`;
|
||||||
|
stopPv = renderPreview($('#xpPv'), it, { url, thumb: kindOf(it) === 'image', inlineMedia, onOpen: () => openLightbox(idxOf(it.id)), onExternal: () => openExternal(it) });
|
||||||
|
// clicar na miniatura abre o modal grande
|
||||||
|
$('#xpPv').addEventListener('click', (ev) => {
|
||||||
|
if (ev.target.closest('button, a, .pv-pdfbar')) return;
|
||||||
|
openLightbox(idxOf(it.id));
|
||||||
|
});
|
||||||
|
const my = ++metaToken;
|
||||||
|
rpc('metadata', { job, id: it.id }).then((m) => {
|
||||||
|
if (my !== metaToken || !$('#xpMeta')) return;
|
||||||
|
const rows = (m.fields || []).filter(([k]) => !['Dimensões'].includes(k));
|
||||||
|
if (!rows.length) return;
|
||||||
|
$('#xpMeta').innerHTML = `<h5>Metadados${m.kind ? ` · ${esc(m.kind)}` : ''}</h5><dl class="kv">${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl>`
|
||||||
|
+ (m.gps ? `<div class="sub" style="font-size:12.5px">Coordenadas: ${m.gps[0].toFixed(5)}, ${m.gps[1].toFixed(5)}</div>` : '');
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
insp.addEventListener('click', (e) => {
|
||||||
|
const a = e.target.closest('[data-i]')?.dataset.i;
|
||||||
|
if (a === 'full') openLightbox(idxOf(E.curId));
|
||||||
|
else if (a === 'rec1') onRecover({ ids: [E.curId] });
|
||||||
|
else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] });
|
||||||
|
else if (a === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); paintInspector(); }
|
||||||
|
else if (a === 'close') root.classList.remove('show-insp');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ tela cheia
|
||||||
|
let stopLb = null;
|
||||||
|
function openLightbox(i) {
|
||||||
|
const it = E.items[i];
|
||||||
|
if (!it) return;
|
||||||
|
const d = document.querySelector('#lightbox');
|
||||||
|
const [cl] = CONF[it.confidence];
|
||||||
|
d.innerHTML = `<div class="lb-head"><button class="icon-btn" id="lbPrev" aria-label="Anterior" ${i <= 0 ? 'disabled' : ''}>‹</button><button class="icon-btn" id="lbNext" aria-label="Próximo" ${i >= E.items.length - 1 ? 'disabled' : ''}>›</button>
|
||||||
|
<div class="ttl"><b>${esc(it.name)}</b><small>${esc(KIND_LABEL[kindOf(it)])} · ${human(it.size)}${it.when != null ? ' · ' + esc(fmtDate(it.when)) : ''} · confiança ${cl} · ${i + 1} de ${E.items.length}</small></div>
|
||||||
|
<button class="icon-btn" id="lbClose" aria-label="Fechar">${I.close}</button></div>
|
||||||
|
<div class="lb-body"><div class="viewer large" id="lbHost"></div></div>
|
||||||
|
<div class="lb-foot"><span class="sub">${it.note ? esc(it.note) : '← → para navegar · Esc para fechar'}</span><button class="btn primary sm" id="lbRec">Recuperar este arquivo</button></div>`;
|
||||||
|
stopLb?.();
|
||||||
|
stopLb = renderPreview(d.querySelector('#lbHost'), it, { url: fileUrl(job, it.id), large: true, inlineMedia, onExternal: () => openExternal(it) });
|
||||||
|
const go = (n) => { const t = i + n; if (t >= 0 && t < E.items.length) { setCursor(t, { reveal: true }); openLightbox(t); } };
|
||||||
|
d.querySelector('#lbPrev').onclick = () => go(-1);
|
||||||
|
d.querySelector('#lbNext').onclick = () => go(1);
|
||||||
|
d.querySelector('#lbClose').onclick = () => d.close();
|
||||||
|
d.querySelector('#lbRec').onclick = () => { d.close(); onRecover({ ids: [it.id] }); };
|
||||||
|
d.onclose = () => { stopLb?.(); stopLb = null; scroll.focus({ preventScroll: true }); };
|
||||||
|
d.onkeydown = (e) => { if (e.key === 'ArrowLeft') go(-1); else if (e.key === 'ArrowRight') go(1); };
|
||||||
|
if (!d.open) d.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ paint geral
|
||||||
|
function paintAll() { paintCrumb(); paintChips(); paintActions(); renderWindow(); }
|
||||||
|
|
||||||
|
paintView(); paintCrumb(); paintActions(); paintInspector();
|
||||||
|
reloadAll();
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Chamado a cada atualização de status da varredura. */
|
||||||
|
onStatus(st) {
|
||||||
|
E.status = st;
|
||||||
|
const enriching = !!st.enriching;
|
||||||
|
const now = Date.now();
|
||||||
|
const foundChanged = st.found !== E.lastFound;
|
||||||
|
const justDone = st.status !== 'running' && E.running;
|
||||||
|
E.running = st.status === 'running';
|
||||||
|
if ((foundChanged && now - E.lastReload > 1500) || justDone || (E.wasEnriching && !enriching) || (enriching && now - E.lastReload > 2500)) {
|
||||||
|
E.lastFound = st.found;
|
||||||
|
reloadAll();
|
||||||
|
}
|
||||||
|
E.wasEnriching = enriching;
|
||||||
|
},
|
||||||
|
query: () => toQuery(),
|
||||||
|
destroy() { E.reloadToken++; stopPv?.(); stopLb?.(); host.innerHTML = ''; },
|
||||||
|
focus() { scroll.focus({ preventScroll: true }); },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 100" role="img" aria-label="Guará"><defs><linearGradient id="wh" gradientUnits="userSpaceOnUse" x1="58" y1="0" x2="160" y2="100"><stop offset="0" stop-color="#3BE3CC"/> <stop offset="0.45" stop-color="#12B5A8"/> <stop offset="1" stop-color="#0B6A86"/></linearGradient><mask id="wm" maskUnits="userSpaceOnUse" x="0" y="0" width="160" height="100"><g shape-rendering="crispEdges"><rect x="80" y="0" width="81" height="100" fill="#fff"/><rect x="72.38" y="34.88" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="87.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="64.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="47.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="64.88" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="52.38" width="5.24" height="5.24" fill="#fff"/><rect x="77.38" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="62.38" width="5.24" height="5.24" fill="#fff"/><rect x="77.38" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="64.88" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="47.38" width="5.24" height="5.24" fill="#fff"/><rect x="57.38" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="37.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="84.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="34.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="49.88" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="79.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="64.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="37.38" width="2.74" height="2.74" fill="#fff"/><rect x="57.38" y="47.38" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="77.38" width="2.74" height="2.74" fill="#fff"/><rect x="57.38" y="87.38" width="5.24" height="5.24" fill="#fff"/><rect x="74.88" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="57.38" y="82.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="67.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="84.88" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="57.38" y="54.88" width="5.24" height="5.24" fill="#fff"/><rect x="77.38" y="79.88" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="69.88" width="5.24" height="5.24" fill="#fff"/><rect x="77.38" y="54.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="34.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="69.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="62.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="77.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="87.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="47.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="79.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="32.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="87.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="62.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="67.38" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="77.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="67.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="39.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="32.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="82.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="Line truncated
|
||||||
|
After Width: | Height: | Size: 20 KiB |
@@ -1,17 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Guará">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Guará"><defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0f766e"/><stop offset="1" stop-color="#083344"/></linearGradient><clipPath id="ic"><rect width="128" height="128" rx="29"/></clipPath></defs><rect width="128" height="128" rx="29" fill="url(#bg)"/><g clip-path="url(#ic)"><g transform="translate(-13 18) scale(0.86)"><defs><linearGradient id="ih" gradientUnits="userSpaceOnUse" x1="58" y1="0" x2="160" y2="100"><stop offset="0" stop-color="#d9fff7"/> <stop offset="0.5" stop-color="#8df1e0"/> <stop offset="1" stop-color="#3fc8cf"/></linearGradient><mask id="im" maskUnits="userSpaceOnUse" x="0" y="0" width="160" height="100"><g shape-rendering="crispEdges"><rect x="80" y="0" width="81" height="100" fill="#fff"/><rect x="67.38" y="49.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="77.38" width="2.74" height="2.74" fill="#fff"/><rect x="57.38" y="62.38" width="5.24" height="5.24" fill="#fff"/><rect x="67.38" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="57.38" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="79.88" width="5.24" height="5.24" fill="#fff"/><rect x="74.88" y="64.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="84.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="54.88" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="84.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="67.38" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="62.38" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="52.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="37.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="79.88" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="84.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="87.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="47.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="39.88" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="62.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="67.38" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="54.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="89.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="37.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="49.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="69.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="47.38" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="57.38" width="5.24" height="5.24" fill="#fff"/><rect x="62.38" y="62.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="82.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="82.38" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="39.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="37.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="34.88" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="79.88" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="79.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="69.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="32.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="39.88" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="54.88" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="49.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="62.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="32.38" width="2.74" height="2.74" fill="#ffLine truncated
|
||||||
<defs>
|
|
||||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0" stop-color="#ff8a3d"/>
|
|
||||||
<stop offset="1" stop-color="#d9441c"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="128" height="128" rx="30" fill="url(#g)"/>
|
|
||||||
<!-- orelhas grandes e focinho longo do lobo-guará -->
|
|
||||||
<path d="M30 22 L52 50 L38 62 Z" fill="#fff"/>
|
|
||||||
<path d="M98 22 L76 50 L90 62 Z" fill="#fff"/>
|
|
||||||
<path d="M38 62 L52 50 Q64 44 76 50 L90 62 L74 96 Q64 108 54 96 Z" fill="#fff"/>
|
|
||||||
<path d="M54 80 Q64 90 74 80 L69 96 Q64 100 59 96 Z" fill="#3a1608"/>
|
|
||||||
<circle cx="53" cy="68" r="4" fill="#3a1608"/>
|
|
||||||
<circle cx="75" cy="68" r="4" fill="#3a1608"/>
|
|
||||||
<path d="M54 52 Q64 47 74 52 L70 60 Q64 56 58 60 Z" fill="#ffd2b0"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 828 B After Width: | Height: | Size: 18 KiB |
@@ -26,13 +26,14 @@
|
|||||||
<ul id="sessionList" class="list"></ul>
|
<ul id="sessionList" class="list"></ul>
|
||||||
<button class="side-action" id="loadSessionBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>Abrir sessão salva…</button>
|
<button class="side-action" id="loadSessionBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>Abrir sessão salva…</button>
|
||||||
</nav>
|
</nav>
|
||||||
<footer class="side-foot"><span id="versionTag"></span><span>Somente leitura na origem</span></footer>
|
<footer class="side-foot"><div class="theme-switch" role="group" aria-label="Tema"><button data-theme-pick="auto" aria-pressed="true">Auto</button><button data-theme-pick="light" aria-pressed="false">Claro</button><button data-theme-pick="dark" aria-pressed="false">Escuro</button></div><span id="versionTag"></span><span>Somente leitura na origem</span></footer>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="scrim" id="scrim"></div>
|
<div class="scrim" id="scrim"></div>
|
||||||
<main class="main" id="main" tabindex="-1"></main>
|
<main class="main" id="main" tabindex="-1"></main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dialog id="dlg"></dialog>
|
<dialog id="dlg"></dialog>
|
||||||
|
<dialog id="lightbox" class="lightbox" aria-label="Pré-visualização"></dialog>
|
||||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||||
<script src="app.js" type="module"></script>
|
<script src="app.js" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
// Pré-visualização de arquivos recuperados.
|
||||||
|
// O conteúdo é lido em streaming do núcleo (com Range) pela URL `url`; nada é
|
||||||
|
// gravado em disco. Tipos: imagem, vídeo, áudio, PDF (pdf.js), texto, documentos
|
||||||
|
// Office/OpenDocument (texto extraído), listagem de ZIP e visão hexadecimal.
|
||||||
|
|
||||||
|
const IMG = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'ico']);
|
||||||
|
const VID = new Set(['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ogv']);
|
||||||
|
const AUD = new Set(['mp3', 'wav', 'ogg', 'opus', 'm4a', 'aac', 'flac']);
|
||||||
|
const TXT = new Set(['txt', 'md', 'csv', 'tsv', 'json', 'xml', 'html', 'htm', 'log', 'ini', 'yml', 'yaml', 'js', 'css', 'c', 'h', 'cpp', 'py', 'rs', 'sh', 'bat', 'sql', 'toml', 'cfg', 'conf', 'srt', 'tex', 'svg']);
|
||||||
|
const OFFICE = new Set(['docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp']);
|
||||||
|
const ZIPS = new Set(['zip', 'jar', 'apk', 'epub']);
|
||||||
|
|
||||||
|
export function kindOf(item) {
|
||||||
|
const e = (item.ext || '').toLowerCase();
|
||||||
|
if (IMG.has(e)) return 'image';
|
||||||
|
if (VID.has(e)) return 'video';
|
||||||
|
if (AUD.has(e)) return 'audio';
|
||||||
|
if (e === 'pdf') return 'pdf';
|
||||||
|
if (OFFICE.has(e)) return 'office';
|
||||||
|
if (ZIPS.has(e)) return 'zip';
|
||||||
|
if (TXT.has(e)) return 'text';
|
||||||
|
return 'hex';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KIND_LABEL = {
|
||||||
|
image: 'Imagem', video: 'Vídeo', audio: 'Áudio', pdf: 'PDF', office: 'Documento (texto extraído)',
|
||||||
|
zip: 'Conteúdo do arquivo compactado', text: 'Texto', hex: 'Dados brutos (hexadecimal)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
const human = (n) => {
|
||||||
|
const u = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let i = 0, v = n;
|
||||||
|
while (v >= 1024 && i < 3) { v /= 1024; i++; }
|
||||||
|
return i ? `${v.toFixed(1)} ${u[i]}` : `${n} B`;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchRange(url, a, b) {
|
||||||
|
const r = await fetch(url, { headers: { Range: `bytes=${a}-${b}` } });
|
||||||
|
if (!r.ok) throw new Error(`leitura falhou (${r.status})`);
|
||||||
|
return new Uint8Array(await r.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ ZIP mínimo
|
||||||
|
async function zipDir(url, size) {
|
||||||
|
const tailLen = Math.min(size, 66000);
|
||||||
|
const tail = await fetchRange(url, size - tailLen, size - 1);
|
||||||
|
const dv = new DataView(tail.buffer);
|
||||||
|
let e = -1;
|
||||||
|
for (let i = tail.length - 22; i >= 0; i--) if (dv.getUint32(i, true) === 0x06054b50) { e = i; break; }
|
||||||
|
if (e < 0) throw new Error('diretório do ZIP não encontrado (arquivo possivelmente incompleto)');
|
||||||
|
const n = dv.getUint16(e + 10, true);
|
||||||
|
const cdSize = dv.getUint32(e + 12, true);
|
||||||
|
const cdOff = dv.getUint32(e + 16, true);
|
||||||
|
if (cdOff + cdSize > size) throw new Error('diretório do ZIP inconsistente');
|
||||||
|
const cd = await fetchRange(url, cdOff, cdOff + cdSize - 1);
|
||||||
|
const cv = new DataView(cd.buffer);
|
||||||
|
const out = [];
|
||||||
|
let p = 0;
|
||||||
|
for (let i = 0; i < n && p + 46 <= cd.length; i++) {
|
||||||
|
if (cv.getUint32(p, true) !== 0x02014b50) break;
|
||||||
|
const nl = cv.getUint16(p + 28, true), xl = cv.getUint16(p + 30, true), cl = cv.getUint16(p + 32, true);
|
||||||
|
out.push({
|
||||||
|
method: cv.getUint16(p + 10, true), csize: cv.getUint32(p + 20, true), usize: cv.getUint32(p + 24, true),
|
||||||
|
off: cv.getUint32(p + 42, true), name: new TextDecoder().decode(cd.subarray(p + 46, p + 46 + nl)),
|
||||||
|
});
|
||||||
|
p += 46 + nl + xl + cl;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function zipRead(url, e, cap = 8 << 20) {
|
||||||
|
if (e.usize > cap) throw new Error('parte grande demais');
|
||||||
|
const lh = await fetchRange(url, e.off, e.off + 29);
|
||||||
|
const dv = new DataView(lh.buffer);
|
||||||
|
const start = e.off + 30 + dv.getUint16(26, true) + dv.getUint16(28, true);
|
||||||
|
const data = e.csize ? await fetchRange(url, start, start + e.csize - 1) : new Uint8Array(0);
|
||||||
|
if (e.method === 0) return data;
|
||||||
|
if (e.method !== 8) throw new Error('compressão não suportada');
|
||||||
|
const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
|
||||||
|
return new Uint8Array(await new Response(stream).arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
const xml = (bytes) => new DOMParser().parseFromString(new TextDecoder().decode(bytes), 'application/xml');
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ renderizadores
|
||||||
|
function note(host, html, cls = '') {
|
||||||
|
host.insertAdjacentHTML('beforeend', `<div class="pv-note ${cls}">${html}</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function failure(host, item, err, withHex) {
|
||||||
|
host.innerHTML = '';
|
||||||
|
note(host, `<b>Não foi possível pré-visualizar.</b><br>${esc(err?.message || err || '')} O arquivo pode estar danificado, incompleto ou parcialmente sobrescrito, o que também indica que a chance de recuperação é menor.`, 'bad');
|
||||||
|
return withHex ? hex(host, item, host.dataset.url, false) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hex(host, item, url, large) {
|
||||||
|
const n = Math.min(item.size, large ? 4096 : 1024);
|
||||||
|
if (!n) { note(host, 'Arquivo vazio.'); return; }
|
||||||
|
const b = await fetchRange(url, 0, n - 1);
|
||||||
|
const lines = [];
|
||||||
|
for (let i = 0; i < b.length; i += 16) {
|
||||||
|
const row = [...b.subarray(i, i + 16)];
|
||||||
|
const h = row.map((x) => x.toString(16).padStart(2, '0')).join(' ').padEnd(47);
|
||||||
|
const a = row.map((x) => (x >= 32 && x < 127 ? String.fromCharCode(x) : '·')).join('');
|
||||||
|
lines.push(`<span class="o">${i.toString(16).padStart(6, '0')}</span> ${h} <span class="a">${esc(a)}</span>`);
|
||||||
|
}
|
||||||
|
host.insertAdjacentHTML('beforeend', `<pre class="pv-hex">${lines.join('\n')}</pre>`);
|
||||||
|
if (item.size > n) note(host, `Mostrando os primeiros ${human(n)} de ${human(item.size)}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function text(host, item, url, large) {
|
||||||
|
const cap = large ? 262144 : 24576;
|
||||||
|
const b = await fetchRange(url, 0, Math.min(item.size, cap) - 1);
|
||||||
|
const sample = b.subarray(0, 2048);
|
||||||
|
const bin = sample.filter((x) => x === 0 || (x < 9) ).length;
|
||||||
|
if (bin > sample.length * 0.02) { note(host, 'Este arquivo não parece ser texto.'); return hex(host, item, url, large); }
|
||||||
|
let s = new TextDecoder('utf-8', { fatal: false }).decode(b);
|
||||||
|
if ((s.match(/�/g) || []).length > s.length * 0.02) s = new TextDecoder('windows-1252').decode(b);
|
||||||
|
host.insertAdjacentHTML('beforeend', `<pre class="pv-text">${esc(s)}</pre>`);
|
||||||
|
if (item.size > b.length) note(host, `Mostrando o início (${human(b.length)} de ${human(item.size)}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function image(host, item, url, state, thumb) {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.alt = `Pré-visualização de ${item.name}`;
|
||||||
|
img.className = 'pv-img';
|
||||||
|
img.decoding = 'async';
|
||||||
|
img.onload = () => {
|
||||||
|
if (state.dead) return resolve();
|
||||||
|
host.innerHTML = '';
|
||||||
|
host.appendChild(img);
|
||||||
|
if (!thumb) {
|
||||||
|
host.classList.add('zoomable');
|
||||||
|
img.onclick = () => host.classList.toggle('actual');
|
||||||
|
}
|
||||||
|
if (!thumb) note(host, `${img.naturalWidth} × ${img.naturalHeight} px. Clique para alternar entre ajustar e tamanho real.`);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
img.onerror = () => { if (!state.dead) failure(host, item, 'A imagem não pôde ser decodificada.', true); resolve(); };
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function media(host, item, url, tag, state) {
|
||||||
|
const el = document.createElement(tag);
|
||||||
|
el.controls = true;
|
||||||
|
el.preload = 'metadata';
|
||||||
|
el.className = `pv-${tag}`;
|
||||||
|
if (tag === 'video') el.playsInline = true;
|
||||||
|
el.onerror = () => { if (!state.dead) failure(host, item, `Não foi possível reproduzir este ${tag === 'video' ? 'vídeo' : 'áudio'}.`, true); };
|
||||||
|
el.src = url;
|
||||||
|
host.innerHTML = '';
|
||||||
|
host.appendChild(el);
|
||||||
|
state.cleanup.push(() => { el.pause?.(); el.removeAttribute('src'); el.load?.(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
let pdfjsPromise;
|
||||||
|
function loadPdfjs() {
|
||||||
|
pdfjsPromise ??= import('./vendor/pdf.min.mjs').then((m) => {
|
||||||
|
m.GlobalWorkerOptions.workerSrc = new URL('./vendor/pdf.worker.min.mjs', import.meta.url).href;
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
return pdfjsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdf(host, item, url, state, large) {
|
||||||
|
const pdfjs = await loadPdfjs();
|
||||||
|
const task = pdfjs.getDocument({ url, disableAutoFetch: true, isEvalSupported: false });
|
||||||
|
state.cleanup.push(() => task.destroy());
|
||||||
|
const doc = await task.promise;
|
||||||
|
if (state.dead) return;
|
||||||
|
host.innerHTML = '<div class="pv-pdfbar"><button class="btn sm" data-d="-1" aria-label="Página anterior">‹</button><span class="pg"></span><button class="btn sm" data-d="1" aria-label="Próxima página">›</button></div><div class="pv-pdfwrap"><canvas class="pv-canvas"></canvas></div>';
|
||||||
|
const canvas = host.querySelector('canvas');
|
||||||
|
const label = host.querySelector('.pg');
|
||||||
|
let n = 1, busy = false;
|
||||||
|
async function draw() {
|
||||||
|
if (busy || state.dead) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const page = await doc.getPage(n);
|
||||||
|
const base = page.getViewport({ scale: 1 });
|
||||||
|
const width = Math.max(240, (host.clientWidth || 320) - 16);
|
||||||
|
const scale = (width / base.width) * (large ? 1 : 1);
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const vp = page.getViewport({ scale: scale * dpr });
|
||||||
|
canvas.width = vp.width; canvas.height = vp.height;
|
||||||
|
canvas.style.width = `${vp.width / dpr}px`;
|
||||||
|
await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise;
|
||||||
|
label.textContent = `Página ${n} de ${doc.numPages}`;
|
||||||
|
} catch (e) {
|
||||||
|
if (!state.dead) { host.innerHTML = ''; failure(host, item, e, true); }
|
||||||
|
}
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
host.querySelectorAll('[data-d]').forEach((b) => (b.onclick = () => { n = Math.min(doc.numPages, Math.max(1, n + +b.dataset.d)); draw(); }));
|
||||||
|
await draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
const colIndex = (ref) => [...ref.replace(/\d+/g, '')].reduce((a, c) => a * 26 + c.charCodeAt(0) - 64, 0) - 1;
|
||||||
|
const colName = (i) => { let s = ''; i++; while (i > 0) { s = String.fromCharCode(65 + ((i - 1) % 26)) + s; i = Math.floor((i - 1) / 26); } return s; };
|
||||||
|
|
||||||
|
async function office(host, item, url, state) {
|
||||||
|
const ents = await zipDir(url, item.size);
|
||||||
|
const find = (n) => ents.find((e) => e.name === n);
|
||||||
|
const ext = item.ext.toLowerCase();
|
||||||
|
let html = '';
|
||||||
|
if (ext === 'docx') {
|
||||||
|
const d = xml(await zipRead(url, find('word/document.xml')));
|
||||||
|
const paras = [...d.getElementsByTagName('w:p')].slice(0, 400).map((p) => {
|
||||||
|
let t = '';
|
||||||
|
p.querySelectorAll('t, tab, br').forEach((n) => { t += n.localName === 't' ? n.textContent : n.localName === 'tab' ? '\t' : '\n'; });
|
||||||
|
const h = p.querySelector('pStyle')?.getAttribute('w:val') || '';
|
||||||
|
const tag = /^(Heading|Ttulo|Title)/i.test(h) ? 'h4' : 'p';
|
||||||
|
return t.trim() ? `<${tag}>${esc(t)}</${tag}>` : '';
|
||||||
|
});
|
||||||
|
html = `<div class="pv-page">${paras.join('') || '<p class="sub">Sem texto.</p>'}</div>`;
|
||||||
|
} else if (ext === 'odt') {
|
||||||
|
const d = xml(await zipRead(url, find('content.xml')));
|
||||||
|
const els = [...d.getElementsByTagName('text:p'), ...[]].slice(0, 400);
|
||||||
|
html = `<div class="pv-page">${els.map((p) => (p.textContent.trim() ? `<p>${esc(p.textContent)}</p>` : '')).join('') || '<p class="sub">Sem texto.</p>'}</div>`;
|
||||||
|
} else if (ext === 'xlsx') {
|
||||||
|
const ss = find('xl/sharedStrings.xml');
|
||||||
|
const strings = ss ? [...xml(await zipRead(url, ss)).getElementsByTagName('si')].map((si) => si.textContent) : [];
|
||||||
|
const sheet = ents.filter((e) => /^xl\/worksheets\/sheet\d+\.xml$/.test(e.name)).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))[0];
|
||||||
|
const d = xml(await zipRead(url, sheet));
|
||||||
|
const rows = [...d.getElementsByTagName('row')].slice(0, 80);
|
||||||
|
let maxc = 0;
|
||||||
|
const grid = rows.map((r) => {
|
||||||
|
const cells = {};
|
||||||
|
[...r.getElementsByTagName('c')].forEach((c) => {
|
||||||
|
const i = colIndex(c.getAttribute('r') || 'A1');
|
||||||
|
const v = c.getElementsByTagName('v')[0]?.textContent ?? c.getElementsByTagName('t')[0]?.textContent ?? '';
|
||||||
|
cells[i] = c.getAttribute('t') === 's' ? strings[+v] ?? '' : v;
|
||||||
|
maxc = Math.max(maxc, i + 1);
|
||||||
|
});
|
||||||
|
return [r.getAttribute('r'), cells];
|
||||||
|
});
|
||||||
|
maxc = Math.min(maxc, 16);
|
||||||
|
html = `<div class="pv-sheet"><table><thead><tr><th></th>${Array.from({ length: maxc }, (_, i) => `<th>${colName(i)}</th>`).join('')}</tr></thead><tbody>${grid
|
||||||
|
.map(([n, cells]) => `<tr><th>${esc(n)}</th>${Array.from({ length: maxc }, (_, i) => `<td>${esc(cells[i] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
|
||||||
|
} else if (ext === 'ods') {
|
||||||
|
const d = xml(await zipRead(url, find('content.xml')));
|
||||||
|
const rows = [...d.getElementsByTagName('table:table-row')].slice(0, 80);
|
||||||
|
html = `<div class="pv-sheet"><table><tbody>${rows.map((r) => `<tr>${[...r.getElementsByTagName('table:table-cell')].slice(0, 16).map((c) => `<td>${esc(c.textContent)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
|
||||||
|
} else {
|
||||||
|
// pptx / odp: texto por slide
|
||||||
|
let slides;
|
||||||
|
if (ext === 'pptx') {
|
||||||
|
const files = ents.filter((e) => /^ppt\/slides\/slide\d+\.xml$/.test(e.name)).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })).slice(0, 60);
|
||||||
|
slides = [];
|
||||||
|
for (const f of files) {
|
||||||
|
const d = xml(await zipRead(url, f));
|
||||||
|
slides.push([...d.getElementsByTagName('a:p')].map((p) => p.textContent).filter((t) => t.trim()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const d = xml(await zipRead(url, find('content.xml')));
|
||||||
|
slides = [...d.getElementsByTagName('draw:page')].slice(0, 60).map((pg) => [...pg.getElementsByTagName('text:p')].map((p) => p.textContent).filter((t) => t.trim()));
|
||||||
|
}
|
||||||
|
html = slides.map((s, i) => `<div class="pv-slide"><small>Slide ${i + 1}</small>${s.map((t, j) => (j === 0 ? `<h4>${esc(t)}</h4>` : `<p>${esc(t)}</p>`)).join('') || '<p class="sub">Sem texto.</p>'}</div>`).join('');
|
||||||
|
}
|
||||||
|
if (state.dead) return;
|
||||||
|
host.innerHTML = html;
|
||||||
|
note(host, 'Texto extraído do documento. A formatação, as imagens e os gráficos originais não são exibidos.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function zip(host, item, url, state) {
|
||||||
|
const ents = await zipDir(url, item.size);
|
||||||
|
if (state.dead) return;
|
||||||
|
host.innerHTML = `<ul class="pv-zip">${ents.slice(0, 400).map((e) => `<li><span>${esc(e.name)}</span><small>${e.name.endsWith('/') ? 'pasta' : human(e.usize)}</small></li>`).join('')}</ul>`;
|
||||||
|
note(host, `${ents.length} item(ns) no arquivo compactado.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ API
|
||||||
|
/** Desenha a pré-visualização de `item` dentro de `host`. Devolve uma função de limpeza. */
|
||||||
|
function mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal }) {
|
||||||
|
const ico = kind === 'video' ? '🎞' : '🎵';
|
||||||
|
const canInline = inlineMedia && large;
|
||||||
|
host.innerHTML = `<div class="pv-card"><div class="pv-bigicon" aria-hidden="true">${ico}</div>
|
||||||
|
<b>${esc(item.name)}</b><span class="sub">${esc(KIND_LABEL[kind])} · ${human(item.size)}</span>
|
||||||
|
${inlineMedia
|
||||||
|
? '<button class="btn primary" data-pv="open">Reproduzir em tela grande</button>'
|
||||||
|
: '<button class="btn primary" data-pv="ext">Abrir no player do sistema</button><span class="pv-why">Neste app o ' + (kind === 'video' ? 'vídeo' : 'áudio') + ' abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.</span>'}
|
||||||
|
</div>`;
|
||||||
|
host.onclick = (e) => {
|
||||||
|
const a = e.target.closest('[data-pv]')?.dataset.pv;
|
||||||
|
if (a === 'open') onOpen?.();
|
||||||
|
else if (a === 'ext') onExternal?.();
|
||||||
|
};
|
||||||
|
void canInline;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPreview(host, item, { url, large = false, thumb = false, inlineMedia = true, onOpen = null, onExternal = null }) {
|
||||||
|
const state = { dead: false, cleanup: [] };
|
||||||
|
host.dataset.url = url;
|
||||||
|
host.classList.remove('zoomable', 'actual');
|
||||||
|
host.innerHTML = '<div class="pv-loading"><i></i></div>';
|
||||||
|
const kind = kindOf(item);
|
||||||
|
// Vídeo e áudio: nunca criamos <video>/<audio> no painel pequeno, e no Linux do app desktop
|
||||||
|
// (WebKitGTK sem plugins de mídia) nem na tela grande, pois o processo da página morre.
|
||||||
|
if ((kind === 'video' || kind === 'audio') && (!large || !inlineMedia)) {
|
||||||
|
mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal });
|
||||||
|
return () => { state.dead = true; };
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (!item.size) { host.innerHTML = ''; note(host, 'Arquivo vazio.'); return; }
|
||||||
|
if (kind === 'image') await image(host, item, url, state, thumb);
|
||||||
|
else if (kind === 'video') media(host, item, url, 'video', state);
|
||||||
|
else if (kind === 'audio') media(host, item, url, 'audio', state);
|
||||||
|
else if (kind === 'pdf') await pdf(host, item, url, state, large);
|
||||||
|
else if (kind === 'office') await office(host, item, url, state);
|
||||||
|
else if (kind === 'zip') await zip(host, item, url, state);
|
||||||
|
else if (kind === 'text') { host.innerHTML = ''; await text(host, item, url, large); }
|
||||||
|
else { host.innerHTML = ''; await hex(host, item, url, large); }
|
||||||
|
} catch (e) {
|
||||||
|
if (!state.dead) await failure(host, item, e, kind !== 'hex' && kind !== 'text');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { state.dead = true; state.cleanup.forEach((f) => { try { f(); } catch { /* ignora */ } }); };
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Bibliotecas de terceiros
|
||||||
|
|
||||||
|
- **pdf.js** 4.10.38 (build "legacy"), © Mozilla, licença Apache-2.0 (`PDFJS-LICENSE`). Usada só para pré-visualizar PDFs, localmente e sem internet. Origem: https://github.com/mozilla/pdf.js
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* @licstart The following is the entire license notice for the
|
||||||
|
* JavaScript code in this page
|
||||||
|
*
|
||||||
|
* Copyright 2024 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
* @licend The above is the entire license notice for the
|
||||||
|
* JavaScript code in this page
|
||||||
|
*/var t={9306:(t,e,i)=>{var s=i(4901),n=i(6823),r=TypeError;t.exports=function(t){if(s(t))return t;throw new r(n(t)+" is not a function")}},3506:(t,e,i)=>{var s=i(3925),n=String,r=TypeError;t.exports=function(t){if(s(t))return t;throw new r("Can't set "+n(t)+" as a prototype")}},7080:(t,e,i)=>{var s=i(4402).has;t.exports=function(t){s(t);return t}},679:(t,e,i)=>{var s=i(1625),n=TypeError;t.exports=function(t,e){if(s(e,t))return t;throw new n("Incorrect invocation")}},8551:(t,e,i)=>{var s=i(34),n=String,r=TypeError;t.exports=function(t){if(s(t))return t;throw new r(n(t)+" is not an object")}},7811:t=>{t.exports="undefined"!=typeof ArrayBuffer&&"undefined"!=typeof DataView},7394:(t,e,i)=>{var s=i(4576),n=i(6706),r=i(2195),a=s.ArrayBuffer,o=s.TypeError;t.exports=a&&n(a.prototype,"byteLength","get")||function(t){if("ArrayBuffer"!==r(t))throw new o("ArrayBuffer expected");return t.byteLength}},3238:(t,e,i)=>{var s=i(4576),n=i(7476),r=i(7394),a=s.ArrayBuffer,o=a&&a.prototype,l=o&&n(o.slice);t.exports=function(t){if(0!==r(t))return!1;if(!l)return!1;try{l(t,0,0);return!1}catch(t){return!0}}},5169:(t,e,i)=>{var s=i(3238),n=TypeError;t.exports=function(t){if(s(t))throw new n("ArrayBuffer is detached");return t}},5636:(t,e,i)=>{var s=i(4576),n=i(9504),r=i(6706),a=i(7696),o=i(5169),l=i(7394),h=i(4483),c=i(1548),d=s.structuredClone,u=s.ArrayBuffer,p=s.DataView,g=Math.min,f=u.prototype,m=p.prototype,b=n(f.slice),v=r(f,"resizable","get"),y=r(f,"maxByteLength","get"),w=n(m.getInt8),A=n(m.setInt8);t.exports=(c||h)&&function(t,e,i){var s,n=l(t),r=void 0===e?n:a(e),f=!v||!v(t);o(t);if(c){t=d(t,{transfer:[t]});if(n===r&&(i||f))return t}if(n>=r&&(!i||f))s=b(t,0,r);else{var m=i&&!f&&y?{maxByteLength:y(t)}:void 0;s=new u(r,m);for(var x=new p(t),_=new p(s),E=g(r,n),S=0;S<E;S++)A(_,S,w(x,S))}c||h(t);return s}},4644:(t,e,i)=>{var s,n,r,a=i(7811),o=i(3724),l=i(4576),h=i(4901),c=i(34),d=i(9297),u=i(6955),p=i(6823),g=i(6699),f=i(6840),m=i(2106),b=i(1625),v=i(2787),y=i(2967),w=i(8227),A=i(3392),x=i(1181),_=x.enforce,E=x.get,S=l.Int8Array,C=S&&S.prototype,T=l.Uint8ClampedArray,M=T&&T.prototype,P=S&&v(S),D=C&&v(C),k=Object.prototype,R=l.TypeError,I=w("toStringTag"),L=A("TYPED_ARRAY_TAG"),O="TypedArrayConstructor",N=a&&!!y&&"Opera"!==u(l.opera),B=!1,H={Int8Array:1,Uint8Array:1,Uint8ClampedArray:1,Int16Array:2,Uint16Array:2,Int32Array:4,Uint32Array:4,Float32Array:4,Float64Array:8},U={BigInt64Array:8,BigUint64Array:8},getTypedArrayConstructor=function(t){var e=v(t);if(c(e)){var i=E(e);return i&&d(i,O)?i[O]:getTypedArrayConstructor(e)}},isTypedArray=function(t){if(!c(t))return!1;var e=u(t);return d(H,e)||d(U,e)};for(s in H)(r=(n=l[s])&&n.prototype)?_(r)[O]=n:N=!1;for(s in U)(r=(n=l[s])&&n.prototype)&&(_(r)[O]=n);if(!N||!h(P)||P===Function.prototype){P=function TypedArray(){throw new R("Incorrect invocation")};if(N)for(s in H)l[s]&&y(l[s],P)}if(!N||!D||D===k){D=P.prototype;if(N)for(s in H)l[s]&&y(l[s].prototype,D)}N&&v(M)!==D&&y(M,D);if(o&&!d(D,I)){B=!0;m(D,I,{configurable:!0,get:function(){return c(this)?this[L]:void 0}});for(s in H)l[s]&&g(l[s],L,s)}t.exports={NATIVE_ARRAY_BUFFER_VIEWS:N,TYPED_ARRAY_TAG:B&&L,aTypedArray:function(t){if(isTypedArray(t))return t;throw new R("Target is not a typed array")},aTypedArrayConstructor:function(t){if(h(t)&&(!y||b(P,t)))return t;throw new R(p(t)+" is not a typed array constructor")},exportTypedArrayMethod:function(t,e,i,s){if(o){if(i)for(var n in H){var r=l[n];if(r&&d(r.prototype,t))try{delete r.prototype[t]}catch(i){try{r.prototype[t]=e}catch(t){}}}D[t]&&!i||f(D,t,i?e:N&&C[t]||e,s)}},exportTypedArrayStaticMethod:function(t,e,i){var s,n;if(o){if(y){if(i)for(s in H)if((n=l[s])&&d(n,t))try{delete n[t]}catch(t){}if(P[t]&&!i)return;try{return f(P,t,i?e:N&&P[t]||e)}catch(t){}}for(s in H)!(n=l[s])||n[t]&&!i||f(n,t,e)}},getTypedArrayConstructor,isView:function isView(t){if(!c(t))return!1;var e=u(t);return"DataView"===e||d(H,e)||d(U,e)},isTypedArray,TypedArray:P,TypedArrayPrototype:D}},5370:(t,e,i)=>{var s=i(6198);t.exports=function(t,e,i){for(var n=0,r=arguments.length>2?i:s(e),a=new t(r);r>n;)a[n]=e[n++];return a}},9617:(t,e,i)=>{var s=i(5397),n=i(5610),r=i(6198),createMethod=function(t){return function(e,i,a){var o=s(e),l=r(o);if(0===l)return!t&&-1;var h,c=n(a,l);if(t&&i!=i){for(;l>c;)if((h=o[c++])!=h)return!0}else for(;l>c;c++)if((t||c in o)&&o[c]===i)return t||c||0;return!t&&-1}};t.exports={includes:createMethod(!0),indexOf:createMethod(!1)}},4527:(t,e,i)=>{var s=i(3724),n=i(4376),r=TypeError,a=Object.getOwnPropertyDescriptor,o=s&&!function(){if(void 0!==this)return!0;try{Object.defineProperty([],"length",{writable:!1}).length=1}catch(t){return t instanceof TypeError}}();t.exports=o?function(t,e){if(n(t)&&!a(t,"length").writable)throw new r("Cannot set read only .length");return t.length=e}:function(t,e){return t.length=e}},7680:(t,e,i)=>{var s=i(9504);t.exports=s([].slice)},7628:(t,e,i)=>{var s=i(6198);t.exports=function(t,e){for(var i=s(t),n=new e(i),r=0;r<i;r++)n[r]=t[i-r-1];return n}},9928:(t,e,i)=>{Line truncated
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* @licstart The following is the entire license notice for the
|
||||||
|
* JavaScript code in this page
|
||||||
|
*
|
||||||
|
* Copyright 2024 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
* @licend The above is the entire license notice for the
|
||||||
|
* JavaScript code in this page
|
||||||
|
*/var e={9306:(e,t,i)=>{var a=i(4901),r=i(6823),s=TypeError;e.exports=function(e){if(a(e))return e;throw new s(r(e)+" is not a function")}},3506:(e,t,i)=>{var a=i(3925),r=String,s=TypeError;e.exports=function(e){if(a(e))return e;throw new s("Can't set "+r(e)+" as a prototype")}},7080:(e,t,i)=>{var a=i(4402).has;e.exports=function(e){a(e);return e}},679:(e,t,i)=>{var a=i(1625),r=TypeError;e.exports=function(e,t){if(a(t,e))return e;throw new r("Incorrect invocation")}},8551:(e,t,i)=>{var a=i(34),r=String,s=TypeError;e.exports=function(e){if(a(e))return e;throw new s(r(e)+" is not an object")}},7811:e=>{e.exports="undefined"!=typeof ArrayBuffer&&"undefined"!=typeof DataView},7394:(e,t,i)=>{var a=i(4576),r=i(6706),s=i(2195),n=a.ArrayBuffer,o=a.TypeError;e.exports=n&&r(n.prototype,"byteLength","get")||function(e){if("ArrayBuffer"!==s(e))throw new o("ArrayBuffer expected");return e.byteLength}},3238:(e,t,i)=>{var a=i(4576),r=i(7476),s=i(7394),n=a.ArrayBuffer,o=n&&n.prototype,g=o&&r(o.slice);e.exports=function(e){if(0!==s(e))return!1;if(!g)return!1;try{g(e,0,0);return!1}catch(e){return!0}}},5169:(e,t,i)=>{var a=i(3238),r=TypeError;e.exports=function(e){if(a(e))throw new r("ArrayBuffer is detached");return e}},5636:(e,t,i)=>{var a=i(4576),r=i(9504),s=i(6706),n=i(7696),o=i(5169),g=i(7394),c=i(4483),l=i(1548),h=a.structuredClone,C=a.ArrayBuffer,Q=a.DataView,E=Math.min,u=C.prototype,d=Q.prototype,f=r(u.slice),p=s(u,"resizable","get"),m=s(u,"maxByteLength","get"),y=r(d.getInt8),w=r(d.setInt8);e.exports=(l||c)&&function(e,t,i){var a,r=g(e),s=void 0===t?r:n(t),u=!p||!p(e);o(e);if(l){e=h(e,{transfer:[e]});if(r===s&&(i||u))return e}if(r>=s&&(!i||u))a=f(e,0,s);else{var d=i&&!u&&m?{maxByteLength:m(e)}:void 0;a=new C(s,d);for(var b=new Q(e),D=new Q(a),S=E(s,r),k=0;k<S;k++)w(D,k,y(b,k))}l||c(e);return a}},4644:(e,t,i)=>{var a,r,s,n=i(7811),o=i(3724),g=i(4576),c=i(4901),l=i(34),h=i(9297),C=i(6955),Q=i(6823),E=i(6699),u=i(6840),d=i(2106),f=i(1625),p=i(2787),m=i(2967),y=i(8227),w=i(3392),b=i(1181),D=b.enforce,S=b.get,k=g.Int8Array,R=k&&k.prototype,N=g.Uint8ClampedArray,G=N&&N.prototype,M=k&&p(k),U=R&&p(R),x=Object.prototype,L=g.TypeError,H=y("toStringTag"),J=w("TYPED_ARRAY_TAG"),v="TypedArrayConstructor",Y=n&&!!m&&"Opera"!==C(g.opera),K=!1,T={Int8Array:1,Uint8Array:1,Uint8ClampedArray:1,Int16Array:2,Uint16Array:2,Int32Array:4,Uint32Array:4,Float32Array:4,Float64Array:8},q={BigInt64Array:8,BigUint64Array:8},getTypedArrayConstructor=function(e){var t=p(e);if(l(t)){var i=S(t);return i&&h(i,v)?i[v]:getTypedArrayConstructor(t)}},isTypedArray=function(e){if(!l(e))return!1;var t=C(e);return h(T,t)||h(q,t)};for(a in T)(s=(r=g[a])&&r.prototype)?D(s)[v]=r:Y=!1;for(a in q)(s=(r=g[a])&&r.prototype)&&(D(s)[v]=r);if(!Y||!c(M)||M===Function.prototype){M=function TypedArray(){throw new L("Incorrect invocation")};if(Y)for(a in T)g[a]&&m(g[a],M)}if(!Y||!U||U===x){U=M.prototype;if(Y)for(a in T)g[a]&&m(g[a].prototype,U)}Y&&p(G)!==U&&m(G,U);if(o&&!h(U,H)){K=!0;d(U,H,{configurable:!0,get:function(){return l(this)?this[J]:void 0}});for(a in T)g[a]&&E(g[a],J,a)}e.exports={NATIVE_ARRAY_BUFFER_VIEWS:Y,TYPED_ARRAY_TAG:K&&J,aTypedArray:function(e){if(isTypedArray(e))return e;throw new L("Target is not a typed array")},aTypedArrayConstructor:function(e){if(c(e)&&(!m||f(M,e)))return e;throw new L(Q(e)+" is not a typed array constructor")},exportTypedArrayMethod:function(e,t,i,a){if(o){if(i)for(var r in T){var s=g[r];if(s&&h(s.prototype,e))try{delete s.prototype[e]}catch(i){try{s.prototype[e]=t}catch(e){}}}U[e]&&!i||u(U,e,i?t:Y&&R[e]||t,a)}},exportTypedArrayStaticMethod:function(e,t,i){var a,r;if(o){if(m){if(i)for(a in T)if((r=g[a])&&h(r,e))try{delete r[e]}catch(e){}if(M[e]&&!i)return;try{return u(M,e,i?t:Y&&M[e]||t)}catch(e){}}for(a in T)!(r=g[a])||r[e]&&!i||u(r,e,t)}},getTypedArrayConstructor,isView:function isView(e){if(!l(e))return!1;var t=C(e);return"DataView"===t||h(T,t)||h(q,t)},isTypedArray,TypedArray:M,TypedArrayPrototype:U}},5370:(e,t,i)=>{var a=i(6198);e.exports=function(e,t,i){for(var r=0,s=arguments.length>2?i:a(t),n=new e(s);s>r;)n[r]=t[r++];return n}},9617:(e,t,i)=>{var a=i(5397),r=i(5610),s=i(6198),createMethod=function(e){return function(t,i,n){var o=a(t),g=s(o);if(0===g)return!e&&-1;var c,l=r(n,g);if(e&&i!=i){for(;g>l;)if((c=o[l++])!=c)return!0}else for(;g>l;l++)if((e||l in o)&&o[l]===i)return e||l||0;return!e&&-1}};e.exports={includes:createMethod(!0),indexOf:createMethod(!1)}},4527:(e,t,i)=>{var a=i(3724),r=i(4376),s=TypeError,n=Object.getOwnPropertyDescriptor,o=a&&!function(){if(void 0!==this)return!0;try{Object.defineProperty([],"length",{writable:!1}).length=1}catch(e){return e instanceof TypeError}}();e.exports=o?function(e,t){if(r(e)&&!n(e,"length").writable)throw new s("Cannot set read only .length");return e.length=t}:function(e,t){return e.length=t}},7680:(e,t,i)=>{var a=i(9504);e.exports=a([].slice)},7628:(e,t,i)=>{var a=i(6198);e.exports=function(e,t){for(var i=a(e),r=new t(i),s=0;s<i;s++)r[s]=e[i-s-1];return r}},9928:(e,t,i)=>{Line truncated
|
||||||
@@ -12,7 +12,7 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "guara", version, about = "Guará — recuperação de arquivos (somente leitura na origem)", long_about = None)]
|
#[command(name = "guara", version, about = "Guará: recuperação de arquivos (somente leitura na origem)", long_about = None)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
cmd: Cmd,
|
cmd: Cmd,
|
||||||
@@ -50,6 +50,50 @@ enum Cmd {
|
|||||||
},
|
},
|
||||||
/// Cria uma imagem do disco (tolerante a setores ruins) para trabalhar na cópia
|
/// Cria uma imagem do disco (tolerante a setores ruins) para trabalhar na cópia
|
||||||
Image { path: String, dest: String },
|
Image { path: String, dest: String },
|
||||||
|
/// Exporta os achados de uma sessão para CSV (abre no Excel)
|
||||||
|
Export {
|
||||||
|
session: String,
|
||||||
|
/// Arquivo CSV de saída
|
||||||
|
csv: String,
|
||||||
|
#[command(flatten)]
|
||||||
|
filter: FilterArgs,
|
||||||
|
},
|
||||||
|
/// Gera o relatório HTML de recuperação (identificação do caso, origem, achados, hashes)
|
||||||
|
Report {
|
||||||
|
session: String,
|
||||||
|
/// Arquivo HTML de saída
|
||||||
|
out: String,
|
||||||
|
#[arg(long, default_value = "")]
|
||||||
|
case: String,
|
||||||
|
#[arg(long, default_value = "")]
|
||||||
|
examiner: String,
|
||||||
|
#[arg(long, default_value = "")]
|
||||||
|
org: String,
|
||||||
|
#[arg(long, default_value = "")]
|
||||||
|
notes: String,
|
||||||
|
/// Calcula MD5/SHA-1/SHA-256 da origem e inclui no relatório (pode demorar)
|
||||||
|
#[arg(long)]
|
||||||
|
hash_source: bool,
|
||||||
|
},
|
||||||
|
/// Procura texto em todo o disco, inclusive espaço livre (UTF-8 e UTF-16)
|
||||||
|
Search {
|
||||||
|
path: String,
|
||||||
|
/// Termos a procurar
|
||||||
|
#[arg(required = true)]
|
||||||
|
terms: Vec<String>,
|
||||||
|
/// Diferencia maiúsculas de minúsculas
|
||||||
|
#[arg(long)]
|
||||||
|
case_sensitive: bool,
|
||||||
|
/// Não procura em UTF-16
|
||||||
|
#[arg(long)]
|
||||||
|
no_utf16: bool,
|
||||||
|
#[arg(long, default_value_t = 200)]
|
||||||
|
max: usize,
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// Calcula MD5, SHA-1 e SHA-256 do dispositivo ou imagem (verificação de integridade)
|
||||||
|
Hash { path: String },
|
||||||
/// Abre a interface gráfica no navegador (modo local)
|
/// Abre a interface gráfica no navegador (modo local)
|
||||||
Ui {
|
Ui {
|
||||||
#[arg(long, default_value_t = 0)]
|
#[arg(long, default_value_t = 0)]
|
||||||
@@ -202,7 +246,9 @@ fn run(cli: Cli) -> Result<(), String> {
|
|||||||
"a varredura dessa sessão não terminou; use `guara resume` primeiro".into(),
|
"a varredura dessa sessão não terminou; use `guara resume` primeiro".into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
recover_job(&eng, &job, &dest, &filter)
|
recover_job(&eng, &job, &dest, &filter)?;
|
||||||
|
eng.save_session(&job, &session).map_err(s)?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
Cmd::Image { path, dest } => {
|
Cmd::Image { path, dest } => {
|
||||||
let job = eng.start_image(&path, &dest).map_err(s)?;
|
let job = eng.start_image(&path, &dest).map_err(s)?;
|
||||||
@@ -220,6 +266,107 @@ fn run(cli: Cli) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
Cmd::Export {
|
||||||
|
session,
|
||||||
|
csv,
|
||||||
|
filter,
|
||||||
|
} => {
|
||||||
|
let job = eng.load_session(&session).map_err(s)?;
|
||||||
|
let q = query_from(&filter)?;
|
||||||
|
let r = eng
|
||||||
|
.rpc("export_csv", json!({ "job": job, "path": csv, "query": q }))
|
||||||
|
.map_err(s)?;
|
||||||
|
println!("{} linha(s) gravadas em {csv}", r["rows"]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Cmd::Report {
|
||||||
|
session,
|
||||||
|
out,
|
||||||
|
case,
|
||||||
|
examiner,
|
||||||
|
org,
|
||||||
|
notes,
|
||||||
|
hash_source,
|
||||||
|
} => {
|
||||||
|
let job = eng.load_session(&session).map_err(s)?;
|
||||||
|
let mut hashes = Value::Null;
|
||||||
|
if hash_source {
|
||||||
|
let st = eng.status(&job).map_err(s)?;
|
||||||
|
let target = st["target"].as_str().unwrap_or("").to_string();
|
||||||
|
let hj = eng.start_hash(&target).map_err(s)?;
|
||||||
|
let hst = wait(&eng, &hj, None)?;
|
||||||
|
eprintln!();
|
||||||
|
if hst["status"] == "error" {
|
||||||
|
return Err(hst["error"].as_str().unwrap_or("falha").to_string());
|
||||||
|
}
|
||||||
|
hashes = hst["report"].clone();
|
||||||
|
}
|
||||||
|
let case = json!({ "case_id": case, "examiner": examiner, "organization": org, "notes": notes });
|
||||||
|
eng.rpc(
|
||||||
|
"export_report",
|
||||||
|
json!({ "job": job, "path": out, "case": case, "hashes": hashes }),
|
||||||
|
)
|
||||||
|
.map_err(s)?;
|
||||||
|
println!("Relatório gravado em {out}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Cmd::Search {
|
||||||
|
path,
|
||||||
|
terms,
|
||||||
|
case_sensitive,
|
||||||
|
no_utf16,
|
||||||
|
max,
|
||||||
|
json,
|
||||||
|
} => {
|
||||||
|
let opts = json!({ "path": path, "terms": terms, "ignore_case": !case_sensitive, "utf16": !no_utf16, "max_hits": max });
|
||||||
|
let job = eng.rpc("search_start", opts).map_err(s)?["job"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let st = wait(&eng, &job, None)?;
|
||||||
|
eprintln!();
|
||||||
|
if st["status"] == "error" {
|
||||||
|
return Err(st["error"].as_str().unwrap_or("falha").to_string());
|
||||||
|
}
|
||||||
|
let res = eng
|
||||||
|
.rpc("search_results", json!({ "job": job, "limit": 2000 }))
|
||||||
|
.map_err(s)?;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&res).unwrap());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for h in res["items"].as_array().into_iter().flatten() {
|
||||||
|
println!(
|
||||||
|
"{:>14} {} {:<6} {:<8} {}",
|
||||||
|
h["offset"],
|
||||||
|
h["partition"]
|
||||||
|
.as_u64()
|
||||||
|
.map(|p| format!("part.{p}"))
|
||||||
|
.unwrap_or_else(|| "fora".into()),
|
||||||
|
h["encoding"].as_str().unwrap_or(""),
|
||||||
|
h["term"].as_str().unwrap_or(""),
|
||||||
|
h["context"].as_str().unwrap_or("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!("{} ocorrência(s).", res["total"]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Cmd::Hash { path } => {
|
||||||
|
let job = eng.start_hash(&path).map_err(s)?;
|
||||||
|
let st = wait(&eng, &job, None)?;
|
||||||
|
eprintln!();
|
||||||
|
if st["status"] == "error" {
|
||||||
|
return Err(st["error"].as_str().unwrap_or("falha").to_string());
|
||||||
|
}
|
||||||
|
let r = &st["report"];
|
||||||
|
println!(
|
||||||
|
"MD5 {}\nSHA-1 {}\nSHA-256 {}",
|
||||||
|
r["md5"].as_str().unwrap_or(""),
|
||||||
|
r["sha1"].as_str().unwrap_or(""),
|
||||||
|
r["sha256"].as_str().unwrap_or("")
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Cmd::Ui { port, no_open } => serve::run(port, !no_open),
|
Cmd::Ui { port, no_open } => serve::run(port, !no_open),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,6 +393,7 @@ fn query_from(f: &FilterArgs) -> Result<Query, String> {
|
|||||||
sort: Some("confidence".into()),
|
sort: Some("confidence".into()),
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: 2000,
|
limit: 2000,
|
||||||
|
..Default::default()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +438,9 @@ fn drive_scan(
|
|||||||
}
|
}
|
||||||
if let Some(dest) = recover {
|
if let Some(dest) = recover {
|
||||||
recover_job(eng, job, dest, filter)?;
|
recover_job(eng, job, dest, filter)?;
|
||||||
|
if let Some(p) = &out.session {
|
||||||
|
eng.save_session(job, p).map_err(s)?;
|
||||||
|
}
|
||||||
} else if st["found"].as_u64().unwrap_or(0) > 0 && out.session.is_some() {
|
} else if st["found"].as_u64().unwrap_or(0) > 0 && out.session.is_some() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Para recuperar: guara recover {} <pasta-de-destino>",
|
"Para recuperar: guara recover {} <pasta-de-destino>",
|
||||||
@@ -391,8 +542,13 @@ fn wait(eng: &Engine, job: &str, session: Option<&str>) -> Result<Value, String>
|
|||||||
.and_then(|v| v.checked_div(t))
|
.and_then(|v| v.checked_div(t))
|
||||||
.map_or(" … ".to_string(), |v| format!("{v:>3}%"));
|
.map_or(" … ".to_string(), |v| format!("{v:>3}%"));
|
||||||
let found = st["found"].as_u64().unwrap_or(0);
|
let found = st["found"].as_u64().unwrap_or(0);
|
||||||
|
let found = if st["hits"].as_u64().unwrap_or(0) > 0 {
|
||||||
|
st["hits"].as_u64().unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
found
|
||||||
|
};
|
||||||
let line = format!(
|
let line = format!(
|
||||||
"{pct} {} · {found} arquivo(s) encontrado(s) · {:.0}s",
|
"{pct} {} · {found} resultado(s) · {:.0}s",
|
||||||
p["phase"].as_str().unwrap_or(""),
|
p["phase"].as_str().unwrap_or(""),
|
||||||
st["elapsed"].as_f64().unwrap_or(0.0)
|
st["elapsed"].as_f64().unwrap_or(0.0)
|
||||||
);
|
);
|
||||||
@@ -443,7 +599,7 @@ fn print_layout(v: &Value) {
|
|||||||
|
|
||||||
fn print_summary(st: &Value) {
|
fn print_summary(st: &Value) {
|
||||||
println!(
|
println!(
|
||||||
"Concluído em {:.1}s — {} arquivo(s) encontrado(s).",
|
"Concluído em {:.1}s: {} arquivo(s) encontrado(s).",
|
||||||
st["elapsed"].as_f64().unwrap_or(0.0),
|
st["elapsed"].as_f64().unwrap_or(0.0),
|
||||||
st["found"]
|
st["found"]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,12 +3,18 @@
|
|||||||
use guara_core::service::Engine;
|
use guara_core::service::Engine;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use tiny_http::{Header, Method, Response, Server};
|
use std::sync::Arc;
|
||||||
|
use tiny_http::{Header, Method, Request, Response, Server};
|
||||||
|
|
||||||
const INDEX: &str = include_str!("../../../apps/gui/ui/index.html");
|
const INDEX: &str = include_str!("../../../apps/gui/ui/index.html");
|
||||||
const CSS: &str = include_str!("../../../apps/gui/ui/app.css");
|
const CSS: &str = include_str!("../../../apps/gui/ui/app.css");
|
||||||
const JS: &str = include_str!("../../../apps/gui/ui/app.js");
|
const JS: &str = include_str!("../../../apps/gui/ui/app.js");
|
||||||
|
const PREVIEW_JS: &str = include_str!("../../../apps/gui/ui/preview.js");
|
||||||
|
const LOGO: &str = include_str!("../../../apps/gui/ui/guara-logo.svg");
|
||||||
|
const EXPLORER_JS: &str = include_str!("../../../apps/gui/ui/explorer.js");
|
||||||
const ICON: &str = include_str!("../../../apps/gui/ui/guara.svg");
|
const ICON: &str = include_str!("../../../apps/gui/ui/guara.svg");
|
||||||
|
const PDF_JS: &str = include_str!("../../../apps/gui/ui/vendor/pdf.min.mjs");
|
||||||
|
const PDF_WORKER: &str = include_str!("../../../apps/gui/ui/vendor/pdf.worker.min.mjs");
|
||||||
|
|
||||||
pub fn run(port: u16, open: bool) -> Result<(), String> {
|
pub fn run(port: u16, open: bool) -> Result<(), String> {
|
||||||
let server = Server::http(("127.0.0.1", port)).map_err(|e| e.to_string())?;
|
let server = Server::http(("127.0.0.1", port)).map_err(|e| e.to_string())?;
|
||||||
@@ -19,55 +25,97 @@ pub fn run(port: u16, open: bool) -> Result<(), String> {
|
|||||||
.unwrap_or(port);
|
.unwrap_or(port);
|
||||||
let mut tok = [0u8; 16];
|
let mut tok = [0u8; 16];
|
||||||
getrandom::getrandom(&mut tok).map_err(|e| e.to_string())?;
|
getrandom::getrandom(&mut tok).map_err(|e| e.to_string())?;
|
||||||
let token: String = tok.iter().map(|b| format!("{b:02x}")).collect();
|
let token: Arc<str> = tok
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:02x}"))
|
||||||
|
.collect::<String>()
|
||||||
|
.into();
|
||||||
let url = format!("http://127.0.0.1:{real_port}/?token={token}");
|
let url = format!("http://127.0.0.1:{real_port}/?token={token}");
|
||||||
eprintln!("Guará UI em {url}\n(Ctrl+C para encerrar. Para ler discos brutos, rode como administrador/root.)");
|
eprintln!("Guará UI em {url}\n(Ctrl+C para encerrar. Para ler discos brutos, rode como administrador/root.)");
|
||||||
if open {
|
if open {
|
||||||
open_browser(&url);
|
open_browser(&url);
|
||||||
}
|
}
|
||||||
let eng = Engine::new();
|
let eng = Arc::new(Engine::new());
|
||||||
let hosts = [
|
let hosts: Arc<[String; 2]> = Arc::new([
|
||||||
format!("127.0.0.1:{real_port}"),
|
format!("127.0.0.1:{real_port}"),
|
||||||
format!("localhost:{real_port}"),
|
format!("localhost:{real_port}"),
|
||||||
];
|
]);
|
||||||
for mut req in server.incoming_requests() {
|
for req in server.incoming_requests() {
|
||||||
|
let (eng, token, hosts) = (eng.clone(), token.clone(), hosts.clone());
|
||||||
|
// Uma thread por requisição: vídeo/áudio em streaming não pode travar a interface.
|
||||||
|
std::thread::spawn(move || handle(req, &eng, &token, &hosts[..]));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header(name: &str, value: &str) -> Header {
|
||||||
|
Header::from_bytes(name.as_bytes(), value.as_bytes()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle(req: Request, eng: &Engine, token: &str, hosts: &[String]) {
|
||||||
let host_ok = req
|
let host_ok = req
|
||||||
.headers()
|
.headers()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|h| h.field.equiv("Host") && hosts.iter().any(|x| x == h.value.as_str()));
|
.any(|h| h.field.equiv("Host") && hosts.iter().any(|x| x == h.value.as_str()));
|
||||||
if !host_ok {
|
if !host_ok {
|
||||||
let _ = req.respond(Response::from_string("host inválido").with_status_code(403));
|
let _ = req.respond(Response::from_string("host inválido").with_status_code(403));
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
let path = req.url().split('?').next().unwrap_or("/").to_string();
|
let Some(mut req) = guara_core::http::handle_media(eng, token, req) else {
|
||||||
let ct = |v: &str| Header::from_bytes("Content-Type", v).unwrap();
|
return;
|
||||||
match (req.method().clone(), path.as_str()) {
|
};
|
||||||
|
let full = req.url().to_string();
|
||||||
|
let (path, _query) = full.split_once('?').unwrap_or((full.as_str(), ""));
|
||||||
|
let ct = |v: &str| header("Content-Type", v);
|
||||||
|
match (req.method().clone(), path) {
|
||||||
(Method::Get, "/") | (Method::Get, "/index.html") => {
|
(Method::Get, "/") | (Method::Get, "/index.html") => {
|
||||||
let _ = req.respond(
|
let _ = req
|
||||||
Response::from_string(INDEX).with_header(ct("text/html; charset=utf-8")),
|
.respond(Response::from_string(INDEX).with_header(ct("text/html; charset=utf-8")));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
(Method::Get, "/app.css") => {
|
(Method::Get, "/app.css") => {
|
||||||
let _ = req
|
let _ =
|
||||||
.respond(Response::from_string(CSS).with_header(ct("text/css; charset=utf-8")));
|
req.respond(Response::from_string(CSS).with_header(ct("text/css; charset=utf-8")));
|
||||||
}
|
}
|
||||||
(Method::Get, "/app.js") => {
|
(Method::Get, "/app.js") => {
|
||||||
let _ = req.respond(
|
let _ = req.respond(
|
||||||
Response::from_string(JS).with_header(ct("text/javascript; charset=utf-8")),
|
Response::from_string(JS).with_header(ct("text/javascript; charset=utf-8")),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
(Method::Get, "/preview.js") => {
|
||||||
|
let _ = req.respond(
|
||||||
|
Response::from_string(PREVIEW_JS).with_header(ct("text/javascript; charset=utf-8")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(Method::Get, "/guara-logo.svg") => {
|
||||||
|
let _ = req.respond(Response::from_string(LOGO).with_header(ct("image/svg+xml")));
|
||||||
|
}
|
||||||
|
(Method::Get, "/explorer.js") => {
|
||||||
|
let _ = req.respond(
|
||||||
|
Response::from_string(EXPLORER_JS)
|
||||||
|
.with_header(ct("text/javascript; charset=utf-8")),
|
||||||
|
);
|
||||||
|
}
|
||||||
(Method::Get, "/guara.svg") => {
|
(Method::Get, "/guara.svg") => {
|
||||||
let _ = req.respond(Response::from_string(ICON).with_header(ct("image/svg+xml")));
|
let _ = req.respond(Response::from_string(ICON).with_header(ct("image/svg+xml")));
|
||||||
}
|
}
|
||||||
|
(Method::Get, "/vendor/pdf.min.mjs") => {
|
||||||
|
let _ = req.respond(
|
||||||
|
Response::from_string(PDF_JS).with_header(ct("text/javascript; charset=utf-8")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(Method::Get, "/vendor/pdf.worker.min.mjs") => {
|
||||||
|
let _ = req.respond(
|
||||||
|
Response::from_string(PDF_WORKER).with_header(ct("text/javascript; charset=utf-8")),
|
||||||
|
);
|
||||||
|
}
|
||||||
(Method::Post, p) if p.starts_with("/rpc/") => {
|
(Method::Post, p) if p.starts_with("/rpc/") => {
|
||||||
let ok = req
|
let ok = req
|
||||||
.headers()
|
.headers()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|h| h.field.equiv("X-Guara-Token") && h.value.as_str() == token);
|
.any(|h| h.field.equiv("X-Guara-Token") && h.value.as_str() == token);
|
||||||
if !ok {
|
if !ok {
|
||||||
let _ =
|
let _ = req.respond(Response::from_string("token inválido").with_status_code(401));
|
||||||
req.respond(Response::from_string("token inválido").with_status_code(401));
|
return;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
let method = p.trim_start_matches("/rpc/").to_string();
|
let method = p.trim_start_matches("/rpc/").to_string();
|
||||||
let mut body = String::new();
|
let mut body = String::new();
|
||||||
@@ -88,8 +136,6 @@ pub fn run(port: u16, open: bool) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_browser(url: &str) {
|
fn open_browser(url: &str) {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ rust-version.workspace = true
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
sha1 = "0.10"
|
||||||
|
md-5 = "0.10"
|
||||||
|
jpeg-decoder = { version = "0.3", default-features = false }
|
||||||
|
jpeg-encoder = "0.6"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png", "gif", "bmp", "webp"] }
|
||||||
|
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
tiny_http = "0.12"
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
//! Navegação estilo gerenciador de arquivos: filtros, ordenação, facetas
|
||||||
|
//! (tipos, datas, pastas, câmeras) e cache de miniaturas.
|
||||||
|
|
||||||
|
use crate::types::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::{BTreeMap, HashMap, VecDeque};
|
||||||
|
use std::sync::{Arc, Condvar, Mutex};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Query {
|
||||||
|
#[serde(default)]
|
||||||
|
pub category: Option<Category>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub min_confidence: Option<Confidence>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub query: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sort: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub offset: usize,
|
||||||
|
#[serde(default = "default_limit")]
|
||||||
|
pub limit: usize,
|
||||||
|
/// Pasta (prefixo), "@carved" (achados por assinatura) ou "@raiz".
|
||||||
|
#[serde(default)]
|
||||||
|
pub folder: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub year: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub month: Option<u32>,
|
||||||
|
/// Só arquivos sem data conhecida.
|
||||||
|
#[serde(default)]
|
||||||
|
pub undated: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub camera: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub origin: Option<Origin>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub min_size: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_size: Option<u64>,
|
||||||
|
/// Megapixels mínimos.
|
||||||
|
#[serde(default)]
|
||||||
|
pub min_mp: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub has_gps: Option<bool>,
|
||||||
|
/// Mostra só a primeira ocorrência de cada conteúdo repetido.
|
||||||
|
#[serde(default)]
|
||||||
|
pub dedupe: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_limit() -> usize {
|
||||||
|
200
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Query {
|
||||||
|
fn default() -> Self {
|
||||||
|
serde_json::from_str("{}").unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Query {
|
||||||
|
pub fn needle(&self) -> Option<String> {
|
||||||
|
self.query
|
||||||
|
.as_deref()
|
||||||
|
.map(|x| x.to_lowercase())
|
||||||
|
.filter(|x| !x.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Itens que atendem ao filtro (na ordem de achado), já sem duplicadas se pedido.
|
||||||
|
pub fn select<'a>(&self, found: &'a [Found]) -> Vec<&'a Found> {
|
||||||
|
let needle = self.needle();
|
||||||
|
let mut v: Vec<&Found> = found.iter().filter(|f| self.matches(f, &needle)).collect();
|
||||||
|
if self.dedupe == Some(true) {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
v.retain(|f| f.sig.map(|g| seen.insert((g, f.size))).unwrap_or(true));
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn matches(&self, f: &Found, needle: &Option<String>) -> bool {
|
||||||
|
if self.category.is_some_and(|c| f.category != c) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.min_confidence.is_some_and(|c| f.confidence < c) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.origin.is_some_and(|o| f.origin != o) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.min_size.is_some_and(|m| f.size < m) || self.max_size.is_some_and(|m| f.size > m) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(n) = needle {
|
||||||
|
let hay =
|
||||||
|
format!("{} {}", f.full_path(), f.camera.as_deref().unwrap_or("")).to_lowercase();
|
||||||
|
if !hay.contains(n.as_str()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(folder) = &self.folder {
|
||||||
|
let ok = match folder.as_str() {
|
||||||
|
"@carved" => f.origin == Origin::Carved,
|
||||||
|
"@raiz" => {
|
||||||
|
f.origin != Origin::Carved
|
||||||
|
&& f.dir.as_deref().map(str::is_empty).unwrap_or(true)
|
||||||
|
}
|
||||||
|
p => f
|
||||||
|
.dir
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|d| d == p || d.starts_with(&format!("{p}/"))),
|
||||||
|
};
|
||||||
|
if !ok {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.year.is_some() || self.month.is_some() {
|
||||||
|
let Some(t) = f.when() else { return false };
|
||||||
|
let (y, m, _) = crate::meta::ymd(t);
|
||||||
|
if self.year.is_some_and(|yy| yy != y) || self.month.is_some_and(|mm| mm != m) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.undated == Some(true) && f.when().is_some() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(c) = &self.camera {
|
||||||
|
if f.camera.as_deref() != Some(c.as_str()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(mp) = self.min_mp {
|
||||||
|
match f.px {
|
||||||
|
Some((w, h)) if (w as f64 * h as f64) / 1e6 >= mp => {}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.has_gps == Some(true) && !f.gps {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sort_items(items: &mut [&Found], sort: Option<&str>) {
|
||||||
|
match sort {
|
||||||
|
Some("name") => items.sort_by_key(|f| f.name.to_lowercase()),
|
||||||
|
Some("size") => items.sort_by_key(|f| std::cmp::Reverse(f.size)),
|
||||||
|
Some("date") => items.sort_by(|a, b| b.when().cmp(&a.when()).then(a.id.cmp(&b.id))),
|
||||||
|
Some("date_asc") => items.sort_by(|a, b| match (a.when(), b.when()) {
|
||||||
|
(Some(x), Some(y)) => x.cmp(&y).then(a.id.cmp(&b.id)),
|
||||||
|
(Some(_), None) => std::cmp::Ordering::Less,
|
||||||
|
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||||
|
_ => a.id.cmp(&b.id),
|
||||||
|
}),
|
||||||
|
Some("confidence") => {
|
||||||
|
items.sort_by(|a, b| b.confidence.cmp(&a.confidence).then(b.size.cmp(&a.size)))
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn item_json(f: &Found) -> Value {
|
||||||
|
json!({
|
||||||
|
"id": f.id, "name": f.name, "dir": f.dir, "ext": f.ext, "category": f.category,
|
||||||
|
"size": f.size, "origin": f.origin, "confidence": f.confidence,
|
||||||
|
"modified": f.modified, "created": f.created, "accessed": f.accessed,
|
||||||
|
"taken": f.taken, "when": f.when(), "camera": f.camera, "px": f.px, "gps": f.gps,
|
||||||
|
"note": f.note,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contagens para a navegação lateral.
|
||||||
|
pub fn facets(found: &[Found]) -> Value {
|
||||||
|
let mut cats: HashMap<&'static str, usize> = HashMap::new();
|
||||||
|
let mut years: BTreeMap<i64, (usize, BTreeMap<u32, usize>)> = BTreeMap::new();
|
||||||
|
let mut cams: BTreeMap<String, usize> = BTreeMap::new();
|
||||||
|
let mut folders: BTreeMap<String, usize> = BTreeMap::new();
|
||||||
|
let (mut undated, mut carved, mut root, mut gps) = (0usize, 0usize, 0usize, 0usize);
|
||||||
|
let mut conf = [0usize; 3];
|
||||||
|
let mut seen_sig = std::collections::HashSet::new();
|
||||||
|
let mut dups = 0usize;
|
||||||
|
let (mut small, mut mp1) = (0usize, 0usize);
|
||||||
|
for f in found {
|
||||||
|
if f.size < 30 * 1024 {
|
||||||
|
small += 1;
|
||||||
|
}
|
||||||
|
if f.px.is_some_and(|(w, h)| w as u64 * h as u64 >= 1_000_000) {
|
||||||
|
mp1 += 1;
|
||||||
|
}
|
||||||
|
if let Some(g) = f.sig {
|
||||||
|
if !seen_sig.insert((g, f.size)) {
|
||||||
|
dups += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*cats.entry(cat_key(f.category)).or_default() += 1;
|
||||||
|
conf[f.confidence as usize] += 1;
|
||||||
|
match f.when() {
|
||||||
|
Some(t) => {
|
||||||
|
let (y, m, _) = crate::meta::ymd(t);
|
||||||
|
let e = years.entry(y).or_default();
|
||||||
|
e.0 += 1;
|
||||||
|
*e.1.entry(m).or_default() += 1;
|
||||||
|
}
|
||||||
|
None => undated += 1,
|
||||||
|
}
|
||||||
|
if let Some(c) = &f.camera {
|
||||||
|
*cams.entry(c.clone()).or_default() += 1;
|
||||||
|
}
|
||||||
|
if f.gps {
|
||||||
|
gps += 1;
|
||||||
|
}
|
||||||
|
if f.origin == Origin::Carved {
|
||||||
|
carved += 1;
|
||||||
|
} else if let Some(d) = f.dir.as_deref().filter(|d| !d.is_empty()) {
|
||||||
|
let mut acc = String::new();
|
||||||
|
for part in d.split('/') {
|
||||||
|
if !acc.is_empty() {
|
||||||
|
acc.push('/');
|
||||||
|
}
|
||||||
|
acc.push_str(part);
|
||||||
|
*folders.entry(acc.clone()).or_default() += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
root += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cams_v: Vec<Value> = {
|
||||||
|
let mut v: Vec<_> = cams.into_iter().collect();
|
||||||
|
v.sort_by_key(|x| std::cmp::Reverse(x.1));
|
||||||
|
v.into_iter()
|
||||||
|
.map(|(n, c)| json!({ "name": n, "count": c }))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
json!({
|
||||||
|
"total": found.len(),
|
||||||
|
"categories": cats,
|
||||||
|
"confidence": { "low": conf[0], "medium": conf[1], "high": conf[2] },
|
||||||
|
"years": years.iter().rev().map(|(y, (n, ms))| json!({
|
||||||
|
"year": y, "count": n,
|
||||||
|
"months": ms.iter().rev().map(|(m, c)| json!({"month": m, "count": c})).collect::<Vec<_>>()
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
"undated": undated,
|
||||||
|
"cameras": cams_v,
|
||||||
|
"folders": folders.iter().map(|(p, c)| json!({
|
||||||
|
"path": p, "name": p.rsplit('/').next().unwrap_or(p), "depth": p.matches('/').count(), "count": c
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
"carved": carved,
|
||||||
|
"root": root,
|
||||||
|
"gps": gps,
|
||||||
|
"duplicates": dups,
|
||||||
|
"small": small,
|
||||||
|
"mp1": mp1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cat_key(c: Category) -> &'static str {
|
||||||
|
match c {
|
||||||
|
Category::Image => "image",
|
||||||
|
Category::Document => "document",
|
||||||
|
Category::Video => "video",
|
||||||
|
Category::Audio => "audio",
|
||||||
|
Category::Archive => "archive",
|
||||||
|
Category::Database => "database",
|
||||||
|
Category::Other => "other",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ cache de miniaturas
|
||||||
|
|
||||||
|
type Key = (String, u64, u32);
|
||||||
|
|
||||||
|
pub struct ThumbCache {
|
||||||
|
map: HashMap<Key, Arc<Vec<u8>>>,
|
||||||
|
order: VecDeque<Key>,
|
||||||
|
bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThumbCache {
|
||||||
|
const CAP: usize = 128 * 1024 * 1024;
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
map: HashMap::new(),
|
||||||
|
order: VecDeque::new(),
|
||||||
|
bytes: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn get(&self, k: &Key) -> Option<Arc<Vec<u8>>> {
|
||||||
|
self.map.get(k).cloned()
|
||||||
|
}
|
||||||
|
pub fn put(&mut self, k: Key, v: Arc<Vec<u8>>) {
|
||||||
|
self.bytes += v.len();
|
||||||
|
if self.map.insert(k.clone(), v).is_none() {
|
||||||
|
self.order.push_back(k);
|
||||||
|
}
|
||||||
|
while self.bytes > Self::CAP {
|
||||||
|
let Some(old) = self.order.pop_front() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if let Some(v) = self.map.remove(&old) {
|
||||||
|
self.bytes -= v.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ThumbCache {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Limita quantas miniaturas são decodificadas ao mesmo tempo (memória/CPU).
|
||||||
|
pub struct Gate {
|
||||||
|
n: Mutex<usize>,
|
||||||
|
cv: Condvar,
|
||||||
|
max: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Permit<'a>(&'a Gate);
|
||||||
|
|
||||||
|
impl Gate {
|
||||||
|
pub fn new(max: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
n: Mutex::new(0),
|
||||||
|
cv: Condvar::new(),
|
||||||
|
max: max.max(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn acquire(&self) -> Permit<'_> {
|
||||||
|
let mut n = self.n.lock().unwrap();
|
||||||
|
while *n >= self.max {
|
||||||
|
n = self.cv.wait(n).unwrap();
|
||||||
|
}
|
||||||
|
*n += 1;
|
||||||
|
Permit(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Permit<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
*self.0.n.lock().unwrap() -= 1;
|
||||||
|
self.0.cv.notify_one();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assinatura rápida: tamanho + FNV-1a do início (32 KiB) e do fim (32 KiB).
|
||||||
|
pub fn quick_sig(src: &dyn crate::source::Source, f: &Found) -> Option<u64> {
|
||||||
|
use crate::recover::read_found_at;
|
||||||
|
if f.size == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut h: u64 = 0xcbf29ce484222325 ^ f.size;
|
||||||
|
let mut feed = |off: u64| {
|
||||||
|
let mut buf = vec![0u8; 32 * 1024];
|
||||||
|
let mut got = 0;
|
||||||
|
while got < buf.len() {
|
||||||
|
match read_found_at(src, f, off + got as u64, &mut buf[got..]) {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(n) => got += n,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for &b in &buf[..got] {
|
||||||
|
h ^= b as u64;
|
||||||
|
h = h.wrapping_mul(0x100000001b3);
|
||||||
|
}
|
||||||
|
got
|
||||||
|
};
|
||||||
|
let a = feed(0);
|
||||||
|
if a == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if f.size > 64 * 1024 {
|
||||||
|
feed(f.size - 32 * 1024);
|
||||||
|
}
|
||||||
|
Some(h)
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
//! Abre um arquivo achado no aplicativo padrão do sistema (player de vídeo, leitor
|
||||||
|
//! de PDF, etc.). Usado quando o WebView do app não consegue reproduzir a mídia:
|
||||||
|
//! o WebKitGTK empacotado no AppImage não traz os plugins de mídia do GStreamer e
|
||||||
|
//! o processo da página morre ao criar um `<video>`.
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::fs::sanitize_name;
|
||||||
|
use crate::types::Found;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Pasta temporária própria do usuário (nunca compartilhada).
|
||||||
|
fn temp_dir() -> PathBuf {
|
||||||
|
let uid = crate::owner::invoking_user()
|
||||||
|
.map(|u| u.uid.to_string())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
// SAFETY: getuid não tem pré-condições.
|
||||||
|
unsafe { libc::getuid() }.to_string()
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
"user".to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
std::env::temp_dir().join(format!("guara-abrir-{uid}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove cópias com mais de um dia.
|
||||||
|
fn cleanup(dir: &Path) {
|
||||||
|
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let limit = std::time::Duration::from_secs(24 * 3600);
|
||||||
|
for e in rd.flatten() {
|
||||||
|
if let Ok(age) = e
|
||||||
|
.metadata()
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.and_then(|t| t.elapsed().map_err(std::io::Error::other))
|
||||||
|
{
|
||||||
|
if age > limit {
|
||||||
|
let _ = std::fs::remove_file(e.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copia o arquivo achado para uma pasta temporária do usuário e o abre no aplicativo padrão.
|
||||||
|
pub fn open_found(src: &dyn crate::source::Source, f: &Found) -> Result<PathBuf> {
|
||||||
|
const MAX: u64 = 8 * 1024 * 1024 * 1024;
|
||||||
|
if f.size > MAX {
|
||||||
|
return Err(Error::msg("Arquivo grande demais para abrir direto. Recupere-o e abra pelo seu gerenciador de arquivos."));
|
||||||
|
}
|
||||||
|
let dir = temp_dir();
|
||||||
|
crate::owner::create_dir_all_owned(&dir)?;
|
||||||
|
cleanup(&dir);
|
||||||
|
let path = dir.join(format!("{}-{}", f.id, sanitize_name(&f.name)));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
crate::recover::write_file(src, f, &path)?;
|
||||||
|
open_path(&path)
|
||||||
|
.map_err(|e| Error::msg(format!("Não foi possível abrir {}: {e}", path.display())))?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abre o caminho com o aplicativo padrão, na sessão do usuário real mesmo se formos root.
|
||||||
|
pub fn open_path(path: &Path) -> std::io::Result<()> {
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let mut cmd = if let Some(u) = crate::owner::invoking_user() {
|
||||||
|
let mut c = Command::new("runuser");
|
||||||
|
c.args(["-u", &u.name, "--", "env"]);
|
||||||
|
for k in ["DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY"] {
|
||||||
|
if let Ok(v) = std::env::var(k) {
|
||||||
|
c.arg(format!("{k}={v}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.arg(format!("XDG_RUNTIME_DIR=/run/user/{}", u.uid));
|
||||||
|
c.arg(format!(
|
||||||
|
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{}/bus",
|
||||||
|
u.uid
|
||||||
|
));
|
||||||
|
c.arg("xdg-open").arg(path);
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
let mut c = Command::new("xdg-open");
|
||||||
|
c.arg(path);
|
||||||
|
c
|
||||||
|
};
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let mut cmd = {
|
||||||
|
let mut c = Command::new("open");
|
||||||
|
c.arg(path);
|
||||||
|
c
|
||||||
|
};
|
||||||
|
#[cfg(windows)]
|
||||||
|
let mut cmd = {
|
||||||
|
let mut c = Command::new("cmd");
|
||||||
|
c.args(["/C", "start", ""]).arg(path);
|
||||||
|
c
|
||||||
|
};
|
||||||
|
let mut child = cmd
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()?;
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = child.wait();
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -174,6 +174,14 @@ fn walk(
|
|||||||
let mdate = u16::from_le_bytes([e[14], e[15]]);
|
let mdate = u16::from_le_bytes([e[14], e[15]]);
|
||||||
let mtime = u16::from_le_bytes([e[12], e[13]]);
|
let mtime = u16::from_le_bytes([e[12], e[13]]);
|
||||||
let mod_unix = super::dos_to_unix(mdate, mtime);
|
let mod_unix = super::dos_to_unix(mdate, mtime);
|
||||||
|
let cre_unix = super::dos_to_unix(
|
||||||
|
u16::from_le_bytes([e[10], e[11]]),
|
||||||
|
u16::from_le_bytes([e[8], e[9]]),
|
||||||
|
);
|
||||||
|
let acc_unix = super::dos_to_unix(
|
||||||
|
u16::from_le_bytes([e[18], e[19]]),
|
||||||
|
u16::from_le_bytes([e[16], e[17]]),
|
||||||
|
);
|
||||||
let mut stream: Option<&[u8]> = None;
|
let mut stream: Option<&[u8]> = None;
|
||||||
let mut name_u16: Vec<u8> = vec![];
|
let mut name_u16: Vec<u8> = vec![];
|
||||||
for s in 1..=secondary.min(19) {
|
for s in 1..=secondary.min(19) {
|
||||||
@@ -259,6 +267,8 @@ fn walk(
|
|||||||
origin: Origin::Exfat,
|
origin: Origin::Exfat,
|
||||||
data: DataLoc::Extents { extents: ex },
|
data: DataLoc::Extents { extents: ex },
|
||||||
modified: mod_unix,
|
modified: mod_unix,
|
||||||
|
created: cre_unix,
|
||||||
|
accessed: acc_unix,
|
||||||
base: conf,
|
base: conf,
|
||||||
note,
|
note,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -237,6 +237,11 @@ impl Walk<'_, '_, '_, '_> {
|
|||||||
u16::from_le_bytes([e[24], e[25]]),
|
u16::from_le_bytes([e[24], e[25]]),
|
||||||
u16::from_le_bytes([e[22], e[23]]),
|
u16::from_le_bytes([e[22], e[23]]),
|
||||||
);
|
);
|
||||||
|
let ctime = super::dos_to_unix(
|
||||||
|
u16::from_le_bytes([e[16], e[17]]),
|
||||||
|
u16::from_le_bytes([e[14], e[15]]),
|
||||||
|
);
|
||||||
|
let atime = super::dos_to_unix(u16::from_le_bytes([e[18], e[19]]), 0);
|
||||||
self.count += 1;
|
self.count += 1;
|
||||||
if self.count % 512 == 0 {
|
if self.count % 512 == 0 {
|
||||||
(self.ctx.progress)(self.count, 0);
|
(self.ctx.progress)(self.count, 0);
|
||||||
@@ -277,7 +282,7 @@ impl Walk<'_, '_, '_, '_> {
|
|||||||
if size == 0 || !self.fat.valid(start) {
|
if size == 0 || !self.fat.valid(start) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
self.deleted_file(&name, path, start, size, mtime);
|
self.deleted_file(&name, path, start, size, (ctime, mtime, atime));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +296,14 @@ impl Walk<'_, '_, '_, '_> {
|
|||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
fn deleted_file(&mut self, name: &str, dir: &str, start: u32, size: u64, mtime: Option<i64>) {
|
fn deleted_file(
|
||||||
|
&mut self,
|
||||||
|
name: &str,
|
||||||
|
dir: &str,
|
||||||
|
start: u32,
|
||||||
|
size: u64,
|
||||||
|
times: (Option<i64>, Option<i64>, Option<i64>),
|
||||||
|
) {
|
||||||
let fat = self.fat;
|
let fat = self.fat;
|
||||||
let cb = fat.cluster_bytes();
|
let cb = fat.cluster_bytes();
|
||||||
let need = size.div_ceil(cb);
|
let need = size.div_ceil(cb);
|
||||||
@@ -336,7 +348,9 @@ impl Walk<'_, '_, '_, '_> {
|
|||||||
data: DataLoc::Extents {
|
data: DataLoc::Extents {
|
||||||
extents: trim_extents(extents, size),
|
extents: trim_extents(extents, size),
|
||||||
},
|
},
|
||||||
modified: mtime,
|
modified: times.1,
|
||||||
|
created: times.0,
|
||||||
|
accessed: times.2,
|
||||||
base: conf,
|
base: conf,
|
||||||
note,
|
note,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ pub struct NewFound<'a> {
|
|||||||
pub origin: Origin,
|
pub origin: Origin,
|
||||||
pub data: DataLoc,
|
pub data: DataLoc,
|
||||||
pub modified: Option<i64>,
|
pub modified: Option<i64>,
|
||||||
|
pub created: Option<i64>,
|
||||||
|
pub accessed: Option<i64>,
|
||||||
pub base: Confidence,
|
pub base: Confidence,
|
||||||
pub note: Option<&'a str>,
|
pub note: Option<&'a str>,
|
||||||
}
|
}
|
||||||
@@ -98,6 +100,13 @@ pub fn make_found(ctx: &Ctx, n: NewFound) -> Found {
|
|||||||
confidence: conf,
|
confidence: conf,
|
||||||
data: n.data,
|
data: n.data,
|
||||||
modified: n.modified,
|
modified: n.modified,
|
||||||
|
created: n.created,
|
||||||
|
accessed: n.accessed,
|
||||||
|
taken: None,
|
||||||
|
camera: None,
|
||||||
|
px: None,
|
||||||
|
gps: false,
|
||||||
|
sig: None,
|
||||||
note,
|
note,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ struct Rec {
|
|||||||
parent: u64,
|
parent: u64,
|
||||||
name: Option<(String, u8)>,
|
name: Option<(String, u8)>,
|
||||||
mtime: Option<i64>,
|
mtime: Option<i64>,
|
||||||
|
ctime: Option<i64>,
|
||||||
|
atime: Option<i64>,
|
||||||
data: Option<Data>,
|
data: Option<Data>,
|
||||||
has_attr_list: bool,
|
has_attr_list: bool,
|
||||||
}
|
}
|
||||||
@@ -199,6 +201,8 @@ pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan {
|
|||||||
origin: Origin::Ntfs,
|
origin: Origin::Ntfs,
|
||||||
data,
|
data,
|
||||||
modified: rec.mtime,
|
modified: rec.mtime,
|
||||||
|
created: rec.ctime,
|
||||||
|
accessed: rec.atime,
|
||||||
base: conf,
|
base: conf,
|
||||||
note: if rec.has_attr_list {
|
note: if rec.has_attr_list {
|
||||||
Some("registro com lista de atributos: pode estar incompleto")
|
Some("registro com lista de atributos: pode estar incompleto")
|
||||||
@@ -320,6 +324,8 @@ fn parse_record(r: &mut [u8], bps: u64, _base: u64, _cl: u64) -> Option<Rec> {
|
|||||||
parent: 0,
|
parent: 0,
|
||||||
name: None,
|
name: None,
|
||||||
mtime: None,
|
mtime: None,
|
||||||
|
ctime: None,
|
||||||
|
atime: None,
|
||||||
data: None,
|
data: None,
|
||||||
has_attr_list: false,
|
has_attr_list: false,
|
||||||
};
|
};
|
||||||
@@ -341,7 +347,11 @@ fn parse_record(r: &mut [u8], bps: u64, _base: u64, _cl: u64) -> Option<Rec> {
|
|||||||
0x10 if !nonres => {
|
0x10 if !nonres => {
|
||||||
let vo = le16(r, p + 20) as usize;
|
let vo = le16(r, p + 20) as usize;
|
||||||
if p + vo + 16 <= r.len() {
|
if p + vo + 16 <= r.len() {
|
||||||
|
rec.ctime = filetime_to_unix(le64(r, p + vo));
|
||||||
rec.mtime = filetime_to_unix(le64(r, p + vo + 8));
|
rec.mtime = filetime_to_unix(le64(r, p + vo + 8));
|
||||||
|
if p + vo + 32 <= r.len() {
|
||||||
|
rec.atime = filetime_to_unix(le64(r, p + vo + 24));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
0x20 => rec.has_attr_list = true,
|
0x20 => rec.has_attr_list = true,
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//! Hashes de integridade usados em perícia: MD5, SHA-1 e SHA-256 numa única passada.
|
||||||
|
|
||||||
|
use md5::Md5;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha1::Sha1;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Hashes {
|
||||||
|
pub md5: String,
|
||||||
|
pub sha1: String,
|
||||||
|
pub sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct MultiHasher {
|
||||||
|
md5: Md5,
|
||||||
|
sha1: Sha1,
|
||||||
|
sha256: Sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MultiHasher {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
pub fn update(&mut self, data: &[u8]) {
|
||||||
|
self.md5.update(data);
|
||||||
|
self.sha1.update(data);
|
||||||
|
self.sha256.update(data);
|
||||||
|
}
|
||||||
|
pub fn finalize(self) -> Hashes {
|
||||||
|
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
|
||||||
|
Hashes {
|
||||||
|
md5: hex(&self.md5.finalize()),
|
||||||
|
sha1: hex(&self.sha1.finalize()),
|
||||||
|
sha256: hex(&self.sha256.finalize()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hash_bytes(data: &[u8]) -> Hashes {
|
||||||
|
let mut h = MultiHasher::new();
|
||||||
|
h.update(data);
|
||||||
|
h.finalize()
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
//! Rotas HTTP de mídia (`/file` com Range e `/thumb`) compartilhadas pelo modo `guara ui`.
|
||||||
|
|
||||||
|
use crate::service::Engine;
|
||||||
|
use tiny_http::{Header, Method, Request, Response, StatusCode};
|
||||||
|
|
||||||
|
pub fn header(name: &str, value: &str) -> Header {
|
||||||
|
Header::from_bytes(name.as_bytes(), value.as_bytes()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// O cabeçalho Host bate com um dos endereços esperados (defesa contra DNS rebinding)?
|
||||||
|
pub fn host_ok(req: &Request, hosts: &[String]) -> bool {
|
||||||
|
req.headers()
|
||||||
|
.iter()
|
||||||
|
.any(|h| h.field.equiv("Host") && hosts.iter().any(|x| x == h.value.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Responde `/file/<job>/<id>` (com Range) e `/thumb/<job>/<id>?s=NNN`.
|
||||||
|
/// Devolve a requisição quando ela não é uma rota de mídia.
|
||||||
|
pub fn handle_media(eng: &Engine, token: &str, req: Request) -> Option<Request> {
|
||||||
|
let full = req.url().to_string();
|
||||||
|
let (path, query) = full.split_once('?').unwrap_or((full.as_str(), ""));
|
||||||
|
let is_file = path.starts_with("/file/");
|
||||||
|
let is_thumb = path.starts_with("/thumb/");
|
||||||
|
if !(is_file || is_thumb) {
|
||||||
|
return Some(req);
|
||||||
|
}
|
||||||
|
if req.method() == &Method::Options {
|
||||||
|
let r = Response::empty(204)
|
||||||
|
.with_header(header("Access-Control-Allow-Origin", "*"))
|
||||||
|
.with_header(header("Access-Control-Allow-Headers", "Range"))
|
||||||
|
.with_header(header("Access-Control-Allow-Methods", "GET, OPTIONS"));
|
||||||
|
let _ = req.respond(r);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if req.method() != &Method::Get {
|
||||||
|
let _ = req.respond(Response::from_string("método não permitido").with_status_code(405));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !query
|
||||||
|
.split('&')
|
||||||
|
.any(|kv| kv.strip_prefix("t=") == Some(token))
|
||||||
|
{
|
||||||
|
let _ = req.respond(Response::from_string("token inválido").with_status_code(401));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut it = path
|
||||||
|
.trim_start_matches("/file/")
|
||||||
|
.trim_start_matches("/thumb/")
|
||||||
|
.split('/');
|
||||||
|
let (job, id) = (
|
||||||
|
it.next().unwrap_or(""),
|
||||||
|
it.next().and_then(|s| s.parse::<u64>().ok()),
|
||||||
|
);
|
||||||
|
let Some(id) = id else {
|
||||||
|
let _ = req.respond(Response::from_string("id inválido").with_status_code(400));
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
if is_thumb {
|
||||||
|
let size = query
|
||||||
|
.split('&')
|
||||||
|
.find_map(|kv| kv.strip_prefix("s="))
|
||||||
|
.and_then(|v| v.parse::<u32>().ok())
|
||||||
|
.unwrap_or(256);
|
||||||
|
match eng.thumb(job, id, size) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let r = Response::from_data(bytes.as_ref().clone())
|
||||||
|
.with_header(header("Content-Type", "image/jpeg"))
|
||||||
|
.with_header(header("Cache-Control", "private, max-age=3600"))
|
||||||
|
.with_header(header("Access-Control-Allow-Origin", "*"));
|
||||||
|
let _ = req.respond(r);
|
||||||
|
}
|
||||||
|
Err(code) => {
|
||||||
|
let _ = req.respond(
|
||||||
|
Response::from_string("sem miniatura")
|
||||||
|
.with_status_code(code)
|
||||||
|
.with_header(header("Access-Control-Allow-Origin", "*")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let range = req
|
||||||
|
.headers()
|
||||||
|
.iter()
|
||||||
|
.find(|h| h.field.equiv("Range"))
|
||||||
|
.map(|h| h.value.as_str().to_string());
|
||||||
|
match eng.file_slice(job, id, range.as_deref()) {
|
||||||
|
Ok(slice) => {
|
||||||
|
let (status, len) = (slice.status(), slice.len() as usize);
|
||||||
|
let headers: Vec<Header> = slice.headers().iter().map(|(k, v)| header(k, v)).collect();
|
||||||
|
let resp = Response::new(StatusCode(status), headers, slice.reader(), Some(len), None);
|
||||||
|
let _ = req.respond(resp);
|
||||||
|
}
|
||||||
|
Err(code) => {
|
||||||
|
let _ = req.respond(
|
||||||
|
Response::from_string("indisponível")
|
||||||
|
.with_status_code(code)
|
||||||
|
.with_header(header("Access-Control-Allow-Origin", "*")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
//! erros e hash. Serve para trabalhar sempre sobre a cópia.
|
//! erros e hash. Serve para trabalhar sempre sobre a cópia.
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
use crate::hashing::MultiHasher;
|
||||||
use crate::source::Source;
|
use crate::source::Source;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -12,6 +12,10 @@ use std::path::Path;
|
|||||||
pub struct ImageReport {
|
pub struct ImageReport {
|
||||||
pub bytes: u64,
|
pub bytes: u64,
|
||||||
pub sha256: String,
|
pub sha256: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub md5: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sha1: String,
|
||||||
/// (offset, tamanho) dos trechos ilegíveis, preenchidos com zeros.
|
/// (offset, tamanho) dos trechos ilegíveis, preenchidos com zeros.
|
||||||
pub bad_ranges: Vec<(u64, u64)>,
|
pub bad_ranges: Vec<(u64, u64)>,
|
||||||
pub cancelled: bool,
|
pub cancelled: bool,
|
||||||
@@ -36,9 +40,10 @@ pub fn create_image(
|
|||||||
dest.display()
|
dest.display()
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
crate::owner::give_to_user(dest);
|
||||||
let size = src.size();
|
let size = src.size();
|
||||||
let mut rep = ImageReport::default();
|
let mut rep = ImageReport::default();
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = MultiHasher::new();
|
||||||
let mut buf = vec![0u8; BLOCK];
|
let mut buf = vec![0u8; BLOCK];
|
||||||
let mut pos = 0u64;
|
let mut pos = 0u64;
|
||||||
while pos < size {
|
while pos < size {
|
||||||
@@ -72,13 +77,12 @@ pub fn create_image(
|
|||||||
}
|
}
|
||||||
out.flush()?;
|
out.flush()?;
|
||||||
rep.bytes = pos;
|
rep.bytes = pos;
|
||||||
rep.sha256 = hasher
|
let h = hasher.finalize();
|
||||||
.finalize()
|
rep.sha256 = h.sha256;
|
||||||
.iter()
|
rep.md5 = h.md5;
|
||||||
.map(|b| format!("{b:02x}"))
|
rep.sha1 = h.sha1;
|
||||||
.collect();
|
|
||||||
let map = dest.with_extension("map.json");
|
let map = dest.with_extension("map.json");
|
||||||
std::fs::write(map, serde_json::to_vec_pretty(&rep)?)?;
|
crate::owner::write_owned(&map, serde_json::to_vec_pretty(&rep)?)?;
|
||||||
Ok(rep)
|
Ok(rep)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Guará — núcleo de recuperação de arquivos.
|
//! Guará: núcleo de recuperação de arquivos.
|
||||||
//!
|
//!
|
||||||
//! Princípio central: a origem é aberta **somente para leitura**; nada neste
|
//! Princípio central: a origem é aberta **somente para leitura**; nada neste
|
||||||
//! crate escreve no dispositivo que está sendo recuperado.
|
//! crate escreve no dispositivo que está sendo recuperado.
|
||||||
@@ -6,14 +6,24 @@
|
|||||||
pub mod carve;
|
pub mod carve;
|
||||||
pub mod device;
|
pub mod device;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod explorer;
|
||||||
|
pub mod external;
|
||||||
pub mod fs;
|
pub mod fs;
|
||||||
|
pub mod hashing;
|
||||||
|
pub mod http;
|
||||||
pub mod image;
|
pub mod image;
|
||||||
|
pub mod meta;
|
||||||
|
pub mod owner;
|
||||||
pub mod partition;
|
pub mod partition;
|
||||||
pub mod recover;
|
pub mod recover;
|
||||||
|
pub mod report;
|
||||||
pub mod scan;
|
pub mod scan;
|
||||||
|
pub mod search;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
pub mod settings;
|
||||||
pub mod signatures;
|
pub mod signatures;
|
||||||
pub mod source;
|
pub mod source;
|
||||||
|
pub mod thumb;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result};
|
||||||
|
|||||||
@@ -0,0 +1,952 @@
|
|||||||
|
//! Extração de metadados de arquivos recuperados: EXIF (câmera, datas, GPS),
|
||||||
|
//! informações de PDF, propriedades de documentos Office/OpenDocument e
|
||||||
|
//! dados de vídeo MP4/MOV. Tudo é leitura posicional, sem gravar em disco.
|
||||||
|
|
||||||
|
use crate::recover::read_found_at;
|
||||||
|
use crate::source::Source;
|
||||||
|
use crate::types::Found;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Meta {
|
||||||
|
pub kind: String,
|
||||||
|
pub fields: Vec<(String, String)>,
|
||||||
|
/// (latitude, longitude) em graus decimais.
|
||||||
|
pub gps: Option<(f64, f64)>,
|
||||||
|
/// Data em que a foto foi tirada (EXIF), s desde 1970 (hora da câmera lida como UTC).
|
||||||
|
#[serde(default)]
|
||||||
|
pub taken: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub camera: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub px: Option<(u32, u32)>,
|
||||||
|
/// Orientação EXIF (1..8).
|
||||||
|
#[serde(default)]
|
||||||
|
pub orientation: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Meta {
|
||||||
|
fn add(&mut self, k: &str, v: impl Into<String>) {
|
||||||
|
let v = v.into();
|
||||||
|
let v = v.trim().to_string();
|
||||||
|
if !v.is_empty() {
|
||||||
|
self.fields.push((k.to_string(), v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Fr<'a> {
|
||||||
|
src: &'a dyn Source,
|
||||||
|
f: &'a Found,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fr<'_> {
|
||||||
|
fn read(&self, off: u64, len: usize) -> Vec<u8> {
|
||||||
|
let mut out = vec![0u8; len.min(self.f.size.saturating_sub(off) as usize)];
|
||||||
|
let mut got = 0;
|
||||||
|
while got < out.len() {
|
||||||
|
match read_found_at(self.src, self.f, off + got as u64, &mut out[got..]) {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(n) => got += n,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.truncate(got);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract(src: &dyn Source, f: &Found) -> Meta {
|
||||||
|
let fr = Fr { src, f };
|
||||||
|
let mut m = Meta::default();
|
||||||
|
match f.ext.to_ascii_lowercase().as_str() {
|
||||||
|
"jpg" | "jpeg" => {
|
||||||
|
m.kind = "Imagem JPEG".into();
|
||||||
|
jpeg(&fr, &mut m);
|
||||||
|
}
|
||||||
|
"png" => {
|
||||||
|
m.kind = "Imagem PNG".into();
|
||||||
|
let h = fr.read(0, 32);
|
||||||
|
if h.len() >= 24 && &h[12..16] == b"IHDR" {
|
||||||
|
m.px = Some((be32(&h[16..]), be32(&h[20..])));
|
||||||
|
m.add(
|
||||||
|
"Dimensões",
|
||||||
|
format!("{} × {} px", be32(&h[16..]), be32(&h[20..])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"gif" => {
|
||||||
|
m.kind = "Imagem GIF".into();
|
||||||
|
let h = fr.read(0, 16);
|
||||||
|
if h.len() >= 10 {
|
||||||
|
m.px = Some((le16(&h[6..]) as u32, le16(&h[8..]) as u32));
|
||||||
|
m.add(
|
||||||
|
"Dimensões",
|
||||||
|
format!("{} × {} px", le16(&h[6..]), le16(&h[8..])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"bmp" => {
|
||||||
|
m.kind = "Imagem BMP".into();
|
||||||
|
let h = fr.read(0, 32);
|
||||||
|
if h.len() >= 26 {
|
||||||
|
m.px = Some((le32(&h[18..]), (le32(&h[22..]) as i32).unsigned_abs()));
|
||||||
|
m.add(
|
||||||
|
"Dimensões",
|
||||||
|
format!(
|
||||||
|
"{} × {} px",
|
||||||
|
le32(&h[18..]),
|
||||||
|
(le32(&h[22..]) as i32).unsigned_abs()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"pdf" => {
|
||||||
|
m.kind = "Documento PDF".into();
|
||||||
|
pdf(&fr, &mut m);
|
||||||
|
}
|
||||||
|
"docx" | "xlsx" | "pptx" => {
|
||||||
|
m.kind = "Documento Office".into();
|
||||||
|
ooxml(&fr, &mut m);
|
||||||
|
}
|
||||||
|
"odt" | "ods" | "odp" => {
|
||||||
|
m.kind = "Documento OpenDocument".into();
|
||||||
|
odf(&fr, &mut m);
|
||||||
|
}
|
||||||
|
"mp4" | "mov" | "m4v" | "m4a" | "3gp" => {
|
||||||
|
m.kind = "Mídia MP4/MOV".into();
|
||||||
|
mp4(&fr, &mut m);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
fn be16(b: &[u8]) -> u16 {
|
||||||
|
u16::from_be_bytes([b[0], b[1]])
|
||||||
|
}
|
||||||
|
fn be32(b: &[u8]) -> u32 {
|
||||||
|
u32::from_be_bytes([b[0], b[1], b[2], b[3]])
|
||||||
|
}
|
||||||
|
fn le16(b: &[u8]) -> u16 {
|
||||||
|
u16::from_le_bytes([b[0], b[1]])
|
||||||
|
}
|
||||||
|
fn le32(b: &[u8]) -> u32 {
|
||||||
|
u32::from_le_bytes([b[0], b[1], b[2], b[3]])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ EXIF
|
||||||
|
|
||||||
|
fn jpeg(fr: &Fr, m: &mut Meta) {
|
||||||
|
let d = fr.read(0, 256 * 1024);
|
||||||
|
jpeg_bytes(&d, m);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lê os segmentos de cabeçalho de um JPEG (já em memória): dimensões e EXIF.
|
||||||
|
pub fn jpeg_bytes(d: &[u8], m: &mut Meta) {
|
||||||
|
let mut p = 2usize;
|
||||||
|
while p + 4 <= d.len() && d[p] == 0xFF {
|
||||||
|
let mk = d[p + 1];
|
||||||
|
if mk == 0xDA || mk == 0xD9 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let len = be16(&d[p + 2..]) as usize;
|
||||||
|
if len < 2 || p + 2 + len > d.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if mk == 0xE1 && d[p + 4..].starts_with(b"Exif\0\0") {
|
||||||
|
exif(&d[p + 10..p + 2 + len], m);
|
||||||
|
}
|
||||||
|
if (mk == 0xC0 || mk == 0xC2) && p + 9 <= d.len() {
|
||||||
|
m.px = Some((be16(&d[p + 7..]) as u32, be16(&d[p + 5..]) as u32));
|
||||||
|
m.add(
|
||||||
|
"Dimensões",
|
||||||
|
format!("{} × {} px", be16(&d[p + 7..]), be16(&d[p + 5..])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
p += 2 + len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Miniatura JPEG embutida no EXIF (IFD1), útil quando a imagem principal está danificada.
|
||||||
|
pub fn exif_thumbnail(d: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
let mut p = 2usize;
|
||||||
|
while p + 4 <= d.len() && d[p] == 0xFF {
|
||||||
|
let mk = d[p + 1];
|
||||||
|
if mk == 0xDA || mk == 0xD9 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let len = be16(&d[p + 2..]) as usize;
|
||||||
|
if len < 2 || p + 2 + len > d.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if mk == 0xE1 && d[p + 4..].starts_with(b"Exif\0\0") {
|
||||||
|
let tiff = &d[p + 10..p + 2 + len];
|
||||||
|
if tiff.len() < 8 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let le = match &tiff[0..2] {
|
||||||
|
b"II" => true,
|
||||||
|
b"MM" => false,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let t = Tiff { d: tiff, le };
|
||||||
|
let ifd0 = t.u32(4)? as usize;
|
||||||
|
let n = t.u16(ifd0)? as usize;
|
||||||
|
let ifd1 = t.u32(ifd0 + 2 + n * 12)? as usize;
|
||||||
|
if ifd1 == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (mut off, mut length) = (None, None);
|
||||||
|
for e in t.ifd(ifd1) {
|
||||||
|
match e.tag {
|
||||||
|
0x0201 => off = t.u32(e.pos),
|
||||||
|
0x0202 => length = t.u32(e.pos),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (o, l) = (off? as usize, length? as usize);
|
||||||
|
return tiff.get(o..o + l).map(|b| b.to_vec());
|
||||||
|
}
|
||||||
|
p += 2 + len;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Tiff<'a> {
|
||||||
|
d: &'a [u8],
|
||||||
|
le: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Entry {
|
||||||
|
tag: u16,
|
||||||
|
typ: u16,
|
||||||
|
count: u32,
|
||||||
|
pos: usize, // onde estão os dados do valor dentro do TIFF
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tiff<'_> {
|
||||||
|
fn u16(&self, o: usize) -> Option<u16> {
|
||||||
|
let b = self.d.get(o..o + 2)?;
|
||||||
|
Some(if self.le {
|
||||||
|
u16::from_le_bytes([b[0], b[1]])
|
||||||
|
} else {
|
||||||
|
u16::from_be_bytes([b[0], b[1]])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn u32(&self, o: usize) -> Option<u32> {
|
||||||
|
let b = self.d.get(o..o + 4)?;
|
||||||
|
Some(if self.le {
|
||||||
|
u32::from_le_bytes([b[0], b[1], b[2], b[3]])
|
||||||
|
} else {
|
||||||
|
u32::from_be_bytes([b[0], b[1], b[2], b[3]])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn ifd(&self, off: usize) -> Vec<Entry> {
|
||||||
|
let mut v = vec![];
|
||||||
|
let Some(n) = self.u16(off) else { return v };
|
||||||
|
for i in 0..(n as usize).min(200) {
|
||||||
|
let e = off + 2 + i * 12;
|
||||||
|
let (Some(tag), Some(typ), Some(count)) =
|
||||||
|
(self.u16(e), self.u16(e + 2), self.u32(e + 4))
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let size = match typ {
|
||||||
|
1 | 2 | 6 | 7 => 1,
|
||||||
|
3 | 8 => 2,
|
||||||
|
4 | 9 | 11 => 4,
|
||||||
|
5 | 10 | 12 => 8,
|
||||||
|
_ => 1,
|
||||||
|
} * count as usize;
|
||||||
|
let pos = if size <= 4 {
|
||||||
|
e + 8
|
||||||
|
} else {
|
||||||
|
self.u32(e + 8).unwrap_or(0) as usize
|
||||||
|
};
|
||||||
|
v.push(Entry {
|
||||||
|
tag,
|
||||||
|
typ,
|
||||||
|
count,
|
||||||
|
pos,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
fn ascii(&self, e: &Entry) -> String {
|
||||||
|
let end = (e.pos + e.count as usize).min(self.d.len());
|
||||||
|
let b = self.d.get(e.pos..end).unwrap_or(&[]);
|
||||||
|
String::from_utf8_lossy(b)
|
||||||
|
.trim_end_matches('\0')
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
fn rational(&self, e: &Entry, i: usize) -> Option<f64> {
|
||||||
|
let o = e.pos + i * 8;
|
||||||
|
let (n, d) = (self.u32(o)?, self.u32(o + 4)?);
|
||||||
|
(d != 0).then(|| n as f64 / d as f64)
|
||||||
|
}
|
||||||
|
fn num(&self, e: &Entry) -> Option<u32> {
|
||||||
|
match e.typ {
|
||||||
|
3 => self.u16(e.pos).map(u32::from),
|
||||||
|
4 => self.u32(e.pos),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exif(d: &[u8], m: &mut Meta) {
|
||||||
|
if d.len() < 8 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let le = match &d[0..2] {
|
||||||
|
b"II" => true,
|
||||||
|
b"MM" => false,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let t = Tiff { d, le };
|
||||||
|
let Some(ifd0) = t.u32(4) else { return };
|
||||||
|
let mut exif_ptr = None;
|
||||||
|
let mut gps_ptr = None;
|
||||||
|
let (mut make, mut model) = (String::new(), String::new());
|
||||||
|
for e in t.ifd(ifd0 as usize) {
|
||||||
|
match e.tag {
|
||||||
|
0x010F => {
|
||||||
|
make = t.ascii(&e);
|
||||||
|
m.add("Fabricante da câmera", make.clone());
|
||||||
|
}
|
||||||
|
0x0110 => {
|
||||||
|
model = t.ascii(&e);
|
||||||
|
m.add("Modelo da câmera", model.clone());
|
||||||
|
}
|
||||||
|
0x0112 => m.orientation = t.num(&e).unwrap_or(1) as u16,
|
||||||
|
0x0131 => m.add("Software", t.ascii(&e)),
|
||||||
|
0x0132 => m.add("Data (EXIF)", exif_date(&t.ascii(&e))),
|
||||||
|
0x013B => m.add("Autor", t.ascii(&e)),
|
||||||
|
0x8298 => m.add("Copyright", t.ascii(&e)),
|
||||||
|
0x8769 => exif_ptr = t.u32(e.pos),
|
||||||
|
0x8825 => gps_ptr = t.u32(e.pos),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let first = make.split_whitespace().next().unwrap_or("").to_lowercase();
|
||||||
|
let cam = if model.is_empty() {
|
||||||
|
make.clone()
|
||||||
|
} else if make.is_empty() || (!first.is_empty() && model.to_lowercase().starts_with(&first)) {
|
||||||
|
model.clone()
|
||||||
|
} else {
|
||||||
|
format!("{make} {model}")
|
||||||
|
};
|
||||||
|
if !cam.trim().is_empty() {
|
||||||
|
m.camera = Some(cam.trim().to_string());
|
||||||
|
}
|
||||||
|
if let Some(o) = exif_ptr {
|
||||||
|
let (mut w, mut h) = (None, None);
|
||||||
|
for e in t.ifd(o as usize) {
|
||||||
|
match e.tag {
|
||||||
|
0x9003 => {
|
||||||
|
m.taken = exif_epoch(&t.ascii(&e));
|
||||||
|
m.add("Foto tirada em", exif_date(&t.ascii(&e)));
|
||||||
|
}
|
||||||
|
0x9004 => m.add("Digitalizada em", exif_date(&t.ascii(&e))),
|
||||||
|
0xA434 => m.add("Lente", t.ascii(&e)),
|
||||||
|
0xA431 => m.add("Nº de série do corpo", t.ascii(&e)),
|
||||||
|
0xA430 => m.add("Proprietário da câmera", t.ascii(&e)),
|
||||||
|
0xA002 => w = t.num(&e),
|
||||||
|
0xA003 => h = t.num(&e),
|
||||||
|
0x829A => {
|
||||||
|
if let Some(v) = t.rational(&e, 0) {
|
||||||
|
m.add(
|
||||||
|
"Exposição",
|
||||||
|
if v > 0.0 && v < 1.0 {
|
||||||
|
format!("1/{:.0} s", 1.0 / v)
|
||||||
|
} else {
|
||||||
|
format!("{v:.1} s")
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0x829D => {
|
||||||
|
if let Some(v) = t.rational(&e, 0) {
|
||||||
|
m.add("Abertura", format!("f/{v:.1}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0x8827 => {
|
||||||
|
if let Some(v) = t.num(&e) {
|
||||||
|
m.add("ISO", v.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0x920A => {
|
||||||
|
if let Some(v) = t.rational(&e, 0) {
|
||||||
|
m.add("Distância focal", format!("{v:.0} mm"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let (Some(w), Some(h)) = (w, h) {
|
||||||
|
if m.px.is_none() {
|
||||||
|
m.px = Some((w, h));
|
||||||
|
}
|
||||||
|
if !m.fields.iter().any(|(k, _)| k == "Dimensões") {
|
||||||
|
m.add("Dimensões", format!("{w} × {h} px"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(o) = gps_ptr {
|
||||||
|
let (mut lat, mut lon, mut lat_ref, mut lon_ref, mut alt) = (None, None, 'N', 'E', None);
|
||||||
|
for e in t.ifd(o as usize) {
|
||||||
|
let dms = |e: &Entry| -> Option<f64> {
|
||||||
|
Some(t.rational(e, 0)? + t.rational(e, 1)? / 60.0 + t.rational(e, 2)? / 3600.0)
|
||||||
|
};
|
||||||
|
match e.tag {
|
||||||
|
0x0001 => lat_ref = t.ascii(&e).chars().next().unwrap_or('N'),
|
||||||
|
0x0002 => lat = dms(&e),
|
||||||
|
0x0003 => lon_ref = t.ascii(&e).chars().next().unwrap_or('E'),
|
||||||
|
0x0004 => lon = dms(&e),
|
||||||
|
0x0006 => alt = t.rational(&e, 0),
|
||||||
|
0x001D => m.add("Data do GPS", t.ascii(&e).replace(':', "-")),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let (Some(a), Some(b)) = (lat, lon) {
|
||||||
|
let a = if lat_ref == 'S' { -a } else { a };
|
||||||
|
let b = if lon_ref == 'W' { -b } else { b };
|
||||||
|
if a.abs() <= 90.0 && b.abs() <= 180.0 && !(a == 0.0 && b == 0.0) {
|
||||||
|
m.gps = Some((a, b));
|
||||||
|
m.add("Localização (GPS)", format!("{a:.6}, {b:.6}"));
|
||||||
|
if let Some(h) = alt {
|
||||||
|
m.add("Altitude", format!("{h:.0} m"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "2023:07:14 18:22:05" -> segundos desde 1970 (hora da câmera lida como UTC)
|
||||||
|
fn exif_epoch(s: &str) -> Option<i64> {
|
||||||
|
let b = s.as_bytes();
|
||||||
|
if b.len() < 19 || b[4] != b':' || b[7] != b':' {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let n = |a: usize, c: usize| s.get(a..c)?.parse::<i64>().ok();
|
||||||
|
let (y, mo, d, h, mi, se) = (
|
||||||
|
n(0, 4)?,
|
||||||
|
n(5, 7)?,
|
||||||
|
n(8, 10)?,
|
||||||
|
n(11, 13)?,
|
||||||
|
n(14, 16)?,
|
||||||
|
n(17, 19)?,
|
||||||
|
);
|
||||||
|
if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || y < 1970 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(crate::fs::days_from_civil(y, mo, d) * 86400 + h * 3600 + mi * 60 + se)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "2023:07:14 18:22:05" -> "14/07/2023 18:22:05"
|
||||||
|
fn exif_date(s: &str) -> String {
|
||||||
|
let b = s.as_bytes();
|
||||||
|
if b.len() >= 19 && b[4] == b':' && b[7] == b':' {
|
||||||
|
format!("{}/{}/{} {}", &s[8..10], &s[5..7], &s[0..4], &s[11..19])
|
||||||
|
} else {
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ PDF
|
||||||
|
|
||||||
|
fn pdf(fr: &Fr, m: &mut Meta) {
|
||||||
|
let size = fr.f.size;
|
||||||
|
let head = fr.read(0, 96 * 1024);
|
||||||
|
let tail = if size > 192 * 1024 {
|
||||||
|
fr.read(size - 96 * 1024, 96 * 1024)
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
let mut all = head.clone();
|
||||||
|
all.extend_from_slice(&tail);
|
||||||
|
if head.starts_with(b"%PDF-") {
|
||||||
|
m.add(
|
||||||
|
"Versão do PDF",
|
||||||
|
String::from_utf8_lossy(&head[5..8.min(head.len())]).to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (key, label) in [
|
||||||
|
("/Title", "Título"),
|
||||||
|
("/Author", "Autor"),
|
||||||
|
("/Subject", "Assunto"),
|
||||||
|
("/Creator", "Criado com"),
|
||||||
|
("/Producer", "Gerado por"),
|
||||||
|
] {
|
||||||
|
if let Some(v) = pdf_string(&all, key) {
|
||||||
|
m.add(label, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (key, label) in [
|
||||||
|
("/CreationDate", "Criado em"),
|
||||||
|
("/ModDate", "Modificado em"),
|
||||||
|
] {
|
||||||
|
if let Some(v) = pdf_string(&all, key) {
|
||||||
|
m.add(label, pdf_date(&v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let pages = find_all(&all, b"/Count ").into_iter().filter_map(|i| {
|
||||||
|
let s: String = all[i + 7..]
|
||||||
|
.iter()
|
||||||
|
.take(8)
|
||||||
|
.map(|&c| c as char)
|
||||||
|
.take_while(|c| c.is_ascii_digit())
|
||||||
|
.collect();
|
||||||
|
s.parse::<u32>().ok()
|
||||||
|
});
|
||||||
|
if let Some(n) = pages.max() {
|
||||||
|
m.add("Páginas (aprox.)", n.to_string());
|
||||||
|
}
|
||||||
|
if !find_all(&all, b"/Encrypt").is_empty() {
|
||||||
|
m.add("Criptografado", "sim");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_all(h: &[u8], n: &[u8]) -> Vec<usize> {
|
||||||
|
let mut v = vec![];
|
||||||
|
let mut i = 0;
|
||||||
|
while i + n.len() <= h.len() {
|
||||||
|
if &h[i..i + n.len()] == n {
|
||||||
|
v.push(i);
|
||||||
|
i += n.len();
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pdf_string(d: &[u8], key: &str) -> Option<String> {
|
||||||
|
let k = key.as_bytes();
|
||||||
|
for at in find_all(d, k) {
|
||||||
|
let mut p = at + k.len();
|
||||||
|
// evita /Creator casar com /CreatorXyz
|
||||||
|
if d.get(p).map(|c| c.is_ascii_alphanumeric()).unwrap_or(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
while d.get(p).map(|c| c.is_ascii_whitespace()).unwrap_or(false) {
|
||||||
|
p += 1;
|
||||||
|
}
|
||||||
|
match d.get(p)? {
|
||||||
|
b'(' => {
|
||||||
|
let mut depth = 0;
|
||||||
|
let mut out = vec![];
|
||||||
|
let mut i = p;
|
||||||
|
while i < d.len() && out.len() < 400 {
|
||||||
|
let c = d[i];
|
||||||
|
match c {
|
||||||
|
b'\\' if i + 1 < d.len() => {
|
||||||
|
i += 1;
|
||||||
|
if d[i].is_ascii_digit() && d[i] < b'8' {
|
||||||
|
// escape octal \ddd (1 a 3 dígitos)
|
||||||
|
let mut v = 0u32;
|
||||||
|
let mut n = 0;
|
||||||
|
while n < 3 && i < d.len() && d[i] >= b'0' && d[i] < b'8' {
|
||||||
|
v = v * 8 + (d[i] - b'0') as u32;
|
||||||
|
i += 1;
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
out.push((v & 0xFF) as u8);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(match d[i] {
|
||||||
|
b'n' => b'\n',
|
||||||
|
b'r' => b'\r',
|
||||||
|
b't' => b'\t',
|
||||||
|
o => o,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
b'(' => {
|
||||||
|
depth += 1;
|
||||||
|
if depth > 1 {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b')' => {
|
||||||
|
depth -= 1;
|
||||||
|
if depth == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
_ => out.push(c),
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return Some(pdf_text(&out));
|
||||||
|
}
|
||||||
|
b'<' if d.get(p + 1) != Some(&b'<') => {
|
||||||
|
let hex: Vec<u8> = d[p + 1..]
|
||||||
|
.iter()
|
||||||
|
.take_while(|&&c| c != b'>')
|
||||||
|
.copied()
|
||||||
|
.filter(|c| c.is_ascii_hexdigit())
|
||||||
|
.collect();
|
||||||
|
let bytes: Vec<u8> = hex
|
||||||
|
.chunks(2)
|
||||||
|
.filter(|c| c.len() == 2)
|
||||||
|
.map(|c| u8::from_str_radix(std::str::from_utf8(c).unwrap(), 16).unwrap_or(0))
|
||||||
|
.collect();
|
||||||
|
return Some(pdf_text(&bytes));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pdf_text(b: &[u8]) -> String {
|
||||||
|
if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
|
||||||
|
let u: Vec<u16> = b[2..]
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|c| u16::from_be_bytes([c[0], c[1]]))
|
||||||
|
.collect();
|
||||||
|
return String::from_utf16_lossy(&u);
|
||||||
|
}
|
||||||
|
// PDFDocEncoding ~ Latin-1 na prática
|
||||||
|
b.iter().map(|&c| c as char).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "D:20230714182205-03'00'" -> "14/07/2023 18:22:05"
|
||||||
|
fn pdf_date(s: &str) -> String {
|
||||||
|
let t = s.trim_start_matches("D:");
|
||||||
|
let digits: String = t.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||||
|
if digits.len() >= 8 {
|
||||||
|
let g = |a: usize, b: usize| digits.get(a..b).unwrap_or("00");
|
||||||
|
return format!(
|
||||||
|
"{}/{}/{} {}:{}:{}",
|
||||||
|
g(6, 8),
|
||||||
|
g(4, 6),
|
||||||
|
g(0, 4),
|
||||||
|
g(8, 10),
|
||||||
|
g(10, 12),
|
||||||
|
g(12, 14)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ ZIP (Office/ODF)
|
||||||
|
|
||||||
|
struct ZipEnt {
|
||||||
|
name: String,
|
||||||
|
method: u16,
|
||||||
|
csize: u64,
|
||||||
|
usize: u64,
|
||||||
|
off: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zip_dir(fr: &Fr) -> Vec<ZipEnt> {
|
||||||
|
let size = fr.f.size;
|
||||||
|
let tail_len = size.min(66_000);
|
||||||
|
let tail = fr.read(size - tail_len, tail_len as usize);
|
||||||
|
let Some(e) = (0..tail.len().saturating_sub(21))
|
||||||
|
.rev()
|
||||||
|
.find(|&i| tail[i..].starts_with(b"PK\x05\x06"))
|
||||||
|
else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
let n = le16(&tail[e + 10..]) as usize;
|
||||||
|
let cd_size = le32(&tail[e + 12..]) as usize;
|
||||||
|
let cd_off = le32(&tail[e + 16..]) as u64;
|
||||||
|
if cd_size > 8 << 20 || cd_off + cd_size as u64 > size {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
let cd = fr.read(cd_off, cd_size);
|
||||||
|
let mut out = vec![];
|
||||||
|
let mut p = 0;
|
||||||
|
while out.len() < n.min(5000) && p + 46 <= cd.len() && &cd[p..p + 4] == b"PK\x01\x02" {
|
||||||
|
let nl = le16(&cd[p + 28..]) as usize;
|
||||||
|
let xl = le16(&cd[p + 30..]) as usize;
|
||||||
|
let cl = le16(&cd[p + 32..]) as usize;
|
||||||
|
if p + 46 + nl > cd.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.push(ZipEnt {
|
||||||
|
name: String::from_utf8_lossy(&cd[p + 46..p + 46 + nl]).to_string(),
|
||||||
|
method: le16(&cd[p + 10..]),
|
||||||
|
csize: le32(&cd[p + 20..]) as u64,
|
||||||
|
usize: le32(&cd[p + 24..]) as u64,
|
||||||
|
off: le32(&cd[p + 42..]) as u64,
|
||||||
|
});
|
||||||
|
p += 46 + nl + xl + cl;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zip_read(fr: &Fr, e: &ZipEnt) -> Option<Vec<u8>> {
|
||||||
|
if e.usize > 4 << 20 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let lh = fr.read(e.off, 30);
|
||||||
|
if lh.len() < 30 || &lh[0..4] != b"PK\x03\x04" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let start = e.off + 30 + le16(&lh[26..]) as u64 + le16(&lh[28..]) as u64;
|
||||||
|
let data = fr.read(start, e.csize as usize);
|
||||||
|
match e.method {
|
||||||
|
0 => Some(data),
|
||||||
|
8 => {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
flate2::read::DeflateDecoder::new(&data[..])
|
||||||
|
.take(4 << 20)
|
||||||
|
.read_to_end(&mut out)
|
||||||
|
.ok()?;
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Texto do primeiro elemento `<tag ...>texto</tag>`.
|
||||||
|
fn xml_tag(xml: &str, tag: &str) -> Option<String> {
|
||||||
|
let open = format!("<{tag}");
|
||||||
|
let mut from = 0;
|
||||||
|
while let Some(i) = xml[from..].find(&open) {
|
||||||
|
let s = from + i + open.len();
|
||||||
|
match xml[s..].chars().next() {
|
||||||
|
Some('>') | Some(' ') | Some('/') => {
|
||||||
|
let gt = xml[s..].find('>')? + s;
|
||||||
|
if xml[..gt].ends_with('/') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let end = xml[gt + 1..].find(&format!("</{tag}>"))? + gt + 1;
|
||||||
|
return Some(unescape(&xml[gt + 1..end]));
|
||||||
|
}
|
||||||
|
_ => from = s,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unescape(s: &str) -> String {
|
||||||
|
s.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("&", "&")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "2023-07-14T18:22:05Z" -> "14/07/2023 18:22:05 (UTC)"
|
||||||
|
fn iso_date(s: &str) -> String {
|
||||||
|
let b = s.as_bytes();
|
||||||
|
if b.len() >= 19 && b[4] == b'-' && b[10] == b'T' {
|
||||||
|
let utc = if s.ends_with('Z') { " (UTC)" } else { "" };
|
||||||
|
return format!(
|
||||||
|
"{}/{}/{} {}{}",
|
||||||
|
&s[8..10],
|
||||||
|
&s[5..7],
|
||||||
|
&s[0..4],
|
||||||
|
&s[11..19],
|
||||||
|
utc
|
||||||
|
);
|
||||||
|
}
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ooxml(fr: &Fr, m: &mut Meta) {
|
||||||
|
let ents = zip_dir(fr);
|
||||||
|
let get = |n: &str| {
|
||||||
|
ents.iter()
|
||||||
|
.find(|e| e.name == n)
|
||||||
|
.and_then(|e| zip_read(fr, e))
|
||||||
|
.map(|b| String::from_utf8_lossy(&b).to_string())
|
||||||
|
};
|
||||||
|
if let Some(x) = get("docProps/core.xml") {
|
||||||
|
for (tag, label) in [
|
||||||
|
("dc:title", "Título"),
|
||||||
|
("dc:subject", "Assunto"),
|
||||||
|
("dc:creator", "Autor"),
|
||||||
|
("cp:lastModifiedBy", "Última modificação por"),
|
||||||
|
("cp:keywords", "Palavras-chave"),
|
||||||
|
] {
|
||||||
|
if let Some(v) = xml_tag(&x, tag) {
|
||||||
|
m.add(label, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (tag, label) in [
|
||||||
|
("dcterms:created", "Criado em"),
|
||||||
|
("dcterms:modified", "Modificado em"),
|
||||||
|
] {
|
||||||
|
if let Some(v) = xml_tag(&x, tag) {
|
||||||
|
m.add(label, iso_date(&v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(x) = get("docProps/app.xml") {
|
||||||
|
for (tag, label) in [
|
||||||
|
("Application", "Aplicativo"),
|
||||||
|
("Company", "Empresa"),
|
||||||
|
("Pages", "Páginas"),
|
||||||
|
("Words", "Palavras"),
|
||||||
|
] {
|
||||||
|
if let Some(v) = xml_tag(&x, tag) {
|
||||||
|
m.add(label, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn odf(fr: &Fr, m: &mut Meta) {
|
||||||
|
let ents = zip_dir(fr);
|
||||||
|
let Some(x) = ents
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.name == "meta.xml")
|
||||||
|
.and_then(|e| zip_read(fr, e))
|
||||||
|
.map(|b| String::from_utf8_lossy(&b).to_string())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for (tag, label) in [
|
||||||
|
("dc:title", "Título"),
|
||||||
|
("meta:initial-creator", "Criado por"),
|
||||||
|
("dc:creator", "Última modificação por"),
|
||||||
|
("meta:generator", "Aplicativo"),
|
||||||
|
] {
|
||||||
|
if let Some(v) = xml_tag(&x, tag) {
|
||||||
|
m.add(label, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (tag, label) in [
|
||||||
|
("meta:creation-date", "Criado em"),
|
||||||
|
("dc:date", "Modificado em"),
|
||||||
|
] {
|
||||||
|
if let Some(v) = xml_tag(&x, tag) {
|
||||||
|
m.add(label, iso_date(&v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ MP4 / MOV
|
||||||
|
|
||||||
|
fn mp4(fr: &Fr, m: &mut Meta) {
|
||||||
|
let size = fr.f.size;
|
||||||
|
let mut p = 0u64;
|
||||||
|
for _ in 0..64 {
|
||||||
|
let h = fr.read(p, 16);
|
||||||
|
if h.len() < 8 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut sz = be32(&h) as u64;
|
||||||
|
if sz == 1 && h.len() >= 16 {
|
||||||
|
sz = u64::from_be_bytes(h[8..16].try_into().unwrap());
|
||||||
|
} else if sz == 0 {
|
||||||
|
sz = size - p;
|
||||||
|
}
|
||||||
|
if sz < 8 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if &h[4..8] == b"moov" {
|
||||||
|
let body = fr.read(p + 8, (sz - 8).min(4 << 20) as usize);
|
||||||
|
moov(&body, m);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
p += sz;
|
||||||
|
if p >= size {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn boxes(d: &[u8]) -> Vec<(&[u8], &[u8])> {
|
||||||
|
let mut v = vec![];
|
||||||
|
let mut p = 0;
|
||||||
|
while p + 8 <= d.len() {
|
||||||
|
let sz = be32(&d[p..]) as usize;
|
||||||
|
if sz < 8 || p + sz > d.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
v.push((&d[p + 4..p + 8], &d[p + 8..p + sz]));
|
||||||
|
p += sz;
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
fn moov(d: &[u8], m: &mut Meta) {
|
||||||
|
const EPOCH_1904: u64 = 2_082_844_800;
|
||||||
|
for (ty, body) in boxes(d) {
|
||||||
|
match ty {
|
||||||
|
b"mvhd" if body.len() >= 24 => {
|
||||||
|
let v = body[0];
|
||||||
|
let (created, ts, dur) = if v == 1 && body.len() >= 32 {
|
||||||
|
(
|
||||||
|
u64::from_be_bytes(body[4..12].try_into().unwrap()),
|
||||||
|
be32(&body[20..]) as u64,
|
||||||
|
u64::from_be_bytes(body[24..32].try_into().unwrap()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
be32(&body[4..]) as u64,
|
||||||
|
be32(&body[12..]) as u64,
|
||||||
|
be32(&body[16..]) as u64,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if created > EPOCH_1904 {
|
||||||
|
m.add(
|
||||||
|
"Gravado em (metadado)",
|
||||||
|
fmt_unix((created - EPOCH_1904) as i64),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ts > 0 && dur > 0 {
|
||||||
|
let s = dur / ts;
|
||||||
|
m.add(
|
||||||
|
"Duração",
|
||||||
|
format!("{}:{:02}:{:02}", s / 3600, s % 3600 / 60, s % 60),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b"trak" => {
|
||||||
|
for (t2, b2) in boxes(body) {
|
||||||
|
if t2 == b"tkhd" && b2.len() >= 84 {
|
||||||
|
let off = if b2[0] == 1 { 88 } else { 76 };
|
||||||
|
if b2.len() >= off + 8 {
|
||||||
|
let (w, h) = (be32(&b2[off..]) >> 16, be32(&b2[off + 4..]) >> 16);
|
||||||
|
if w > 0 && h > 0 && !m.fields.iter().any(|(k, _)| k == "Dimensões") {
|
||||||
|
m.add("Dimensões", format!("{w} × {h} px"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fmt_unix(t: i64) -> String {
|
||||||
|
let days = t.div_euclid(86400);
|
||||||
|
let rem = t.rem_euclid(86400);
|
||||||
|
let (y, mo, d) = civil_from_days(days);
|
||||||
|
format!(
|
||||||
|
"{d:02}/{mo:02}/{y} {:02}:{:02}:{:02} (UTC)",
|
||||||
|
rem / 3600,
|
||||||
|
rem % 3600 / 60,
|
||||||
|
rem % 60
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (ano, mês, dia) de um instante em segundos desde 1970.
|
||||||
|
pub fn ymd(t: i64) -> (i64, u32, u32) {
|
||||||
|
let (y, m, d) = civil_from_days(t.div_euclid(86400));
|
||||||
|
(y, m as u32, d as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inverso de `days_from_civil` (Howard Hinnant).
|
||||||
|
fn civil_from_days(z: i64) -> (i64, i64, i64) {
|
||||||
|
let z = z + 719468;
|
||||||
|
let era = if z >= 0 { z } else { z - 146096 } / 146097;
|
||||||
|
let doe = z - era * 146097;
|
||||||
|
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||||
|
let y = yoe + era * 400;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||||
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||||
|
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
//! Quando o Guará roda como administrador (sudo/pkexec), tudo que ele grava para
|
||||||
|
//! o usuário (sessões, relatórios, arquivos recuperados) pertence ao usuário que
|
||||||
|
//! o abriu, e não ao root, e as pastas sugeridas ficam na pasta desse usuário.
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct User {
|
||||||
|
pub name: String,
|
||||||
|
pub uid: u32,
|
||||||
|
pub gid: u32,
|
||||||
|
pub home: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// O usuário real por trás de um processo root iniciado por sudo/pkexec.
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub fn invoking_user() -> Option<User> {
|
||||||
|
// SAFETY: geteuid não tem pré-condições.
|
||||||
|
if unsafe { libc::geteuid() } != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let uid = std::env::var("SUDO_UID")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| std::env::var("PKEXEC_UID").ok())
|
||||||
|
.and_then(|v| v.parse::<u32>().ok())
|
||||||
|
.filter(|&u| u != 0);
|
||||||
|
let name = std::env::var("SUDO_USER").ok().filter(|u| u != "root");
|
||||||
|
let passwd = std::fs::read_to_string("/etc/passwd").ok()?;
|
||||||
|
lookup_user(&passwd, uid, name.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Procura o usuário (por uid ou, na falta dele, por nome) num conteúdo no formato do /etc/passwd.
|
||||||
|
pub fn lookup_user(passwd: &str, uid: Option<u32>, name: Option<&str>) -> Option<User> {
|
||||||
|
for l in passwd.lines() {
|
||||||
|
let f: Vec<&str> = l.split(':').collect();
|
||||||
|
if f.len() < 6 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (Ok(fuid), Ok(fgid)) = (f[2].parse::<u32>(), f[3].parse::<u32>()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if uid == Some(fuid) || (uid.is_none() && name == Some(f[0])) {
|
||||||
|
return Some(User {
|
||||||
|
name: f[0].to_string(),
|
||||||
|
uid: fuid,
|
||||||
|
gid: fgid,
|
||||||
|
home: PathBuf::from(f[5]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
pub fn invoking_user() -> Option<User> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pasta pessoal para sugerir ao usuário (a dele, mesmo se estivermos como root).
|
||||||
|
pub fn user_home() -> PathBuf {
|
||||||
|
if let Some(u) = invoking_user() {
|
||||||
|
return u.home;
|
||||||
|
}
|
||||||
|
PathBuf::from(
|
||||||
|
std::env::var("HOME")
|
||||||
|
.or_else(|_| std::env::var("USERPROFILE"))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entrega o arquivo/pasta ao usuário que abriu o Guará (não faz nada fora do modo root).
|
||||||
|
pub fn give_to_user(path: &Path) {
|
||||||
|
#[cfg(unix)]
|
||||||
|
if let Some(u) = invoking_user() {
|
||||||
|
let _ = std::os::unix::fs::lchown(path, Some(u.uid), Some(u.gid));
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let _ = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Como `create_dir_all`, mas as pastas criadas ficam com o dono certo.
|
||||||
|
pub fn create_dir_all_owned(path: &Path) -> io::Result<()> {
|
||||||
|
let mut missing = vec![];
|
||||||
|
let mut cur = Some(path);
|
||||||
|
while let Some(p) = cur {
|
||||||
|
if p.as_os_str().is_empty() || p.exists() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
missing.push(p.to_path_buf());
|
||||||
|
cur = p.parent();
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(path)?;
|
||||||
|
for p in missing.iter().rev() {
|
||||||
|
give_to_user(p);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Como `fs::write`, mas o arquivo fica com o dono certo.
|
||||||
|
pub fn write_owned(path: &Path, bytes: impl AsRef<[u8]>) -> io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
create_dir_all_owned(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::write(path, bytes)?;
|
||||||
|
give_to_user(path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const PASSWD: &str = "root:x:0:0:root:/root:/bin/bash\nlucas:x:1000:1000:Lucas:/home/lucas:/bin/bash\nmaria:x:1001:1002::/home/maria:/bin/zsh\nlinha-quebrada\n";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acha_o_usuario_por_uid_ou_nome() {
|
||||||
|
let u = lookup_user(PASSWD, Some(1000), None).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(u.uid, u.gid, u.home.to_str().unwrap()),
|
||||||
|
(1000, 1000, "/home/lucas")
|
||||||
|
);
|
||||||
|
let m = lookup_user(PASSWD, None, Some("maria")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(m.uid, m.gid, m.home.to_str().unwrap()),
|
||||||
|
(1001, 1002, "/home/maria")
|
||||||
|
);
|
||||||
|
assert!(lookup_user(PASSWD, Some(4242), None).is_none());
|
||||||
|
// o uid manda sobre o nome
|
||||||
|
assert_eq!(
|
||||||
|
lookup_user(PASSWD, Some(1001), Some("lucas")).unwrap().uid,
|
||||||
|
1001
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fora_do_modo_root_nao_muda_dono_e_grava_normalmente() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let f = d.path().join("a/b/sessao.json");
|
||||||
|
write_owned(&f, b"{}").unwrap();
|
||||||
|
assert_eq!(std::fs::read(&f).unwrap(), b"{}");
|
||||||
|
assert!(create_dir_all_owned(&d.path().join("x/y")).is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,17 +92,16 @@ fn detect_fat(b: &[u8]) -> Option<(FsKind, Option<u64>)> {
|
|||||||
let root_secs = (root_entries * 32).div_ceil(bps);
|
let root_secs = (root_entries * 32).div_ceil(bps);
|
||||||
let data = total.saturating_sub(reserved + nfats * fatsz + root_secs);
|
let data = total.saturating_sub(reserved + nfats * fatsz + root_secs);
|
||||||
let clusters = data / spc;
|
let clusters = data / spc;
|
||||||
let kind = if clusters < 4085 {
|
// O layout do BPB manda: raiz fixa zerada + FAT de 32 bits = FAT32, mesmo com poucos
|
||||||
FsKind::Fat12
|
// clusters (mkfs.vfat -F 32 em mídias pequenas gera isso e o Linux monta).
|
||||||
} else if clusters < 65525 {
|
let is32 = root_entries == 0 && fat16 == 0 && fat32 != 0;
|
||||||
FsKind::Fat16
|
let kind = if is32 {
|
||||||
} else {
|
|
||||||
FsKind::Fat32
|
FsKind::Fat32
|
||||||
|
} else if clusters < 4085 {
|
||||||
|
FsKind::Fat12
|
||||||
|
} else {
|
||||||
|
FsKind::Fat16
|
||||||
};
|
};
|
||||||
// Coerência: FAT32 tem root_entries == 0 e fat16 == 0.
|
|
||||||
if (kind == FsKind::Fat32) != (root_entries == 0 && fat16 == 0) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some((kind, Some(total * bps)))
|
Some((kind, Some(total * bps)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::fs::sanitize_name;
|
use crate::fs::sanitize_name;
|
||||||
|
use crate::hashing::{Hashes, MultiHasher};
|
||||||
use crate::source::{read_vec, Source};
|
use crate::source::{read_vec, Source};
|
||||||
use crate::types::*;
|
use crate::types::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -17,6 +17,10 @@ pub struct RecoveredFile {
|
|||||||
pub path: String,
|
pub path: String,
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
pub sha256: String,
|
pub sha256: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub md5: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sha1: String,
|
||||||
pub confidence: Confidence,
|
pub confidence: Confidence,
|
||||||
pub origin: Origin,
|
pub origin: Origin,
|
||||||
pub partial: bool,
|
pub partial: bool,
|
||||||
@@ -33,7 +37,7 @@ pub struct RecoverReport {
|
|||||||
|
|
||||||
/// Recusa destinos que ficam no mesmo dispositivo da origem.
|
/// Recusa destinos que ficam no mesmo dispositivo da origem.
|
||||||
pub fn check_destination(source_path: Option<&Path>, dest: &Path) -> Result<()> {
|
pub fn check_destination(source_path: Option<&Path>, dest: &Path) -> Result<()> {
|
||||||
std::fs::create_dir_all(dest)?;
|
crate::owner::create_dir_all_owned(dest)?;
|
||||||
let dest = dest.canonicalize()?;
|
let dest = dest.canonicalize()?;
|
||||||
let Some(sp) = source_path else { return Ok(()) };
|
let Some(sp) = source_path else { return Ok(()) };
|
||||||
let sp_str = sp.to_string_lossy().to_string();
|
let sp_str = sp.to_string_lossy().to_string();
|
||||||
@@ -75,7 +79,7 @@ pub fn recover(
|
|||||||
cancelled: &(dyn Fn() -> bool + Sync),
|
cancelled: &(dyn Fn() -> bool + Sync),
|
||||||
progress: &mut dyn FnMut(usize, usize, u64),
|
progress: &mut dyn FnMut(usize, usize, u64),
|
||||||
) -> Result<RecoverReport> {
|
) -> Result<RecoverReport> {
|
||||||
std::fs::create_dir_all(dest)?;
|
crate::owner::create_dir_all_owned(dest)?;
|
||||||
let mut report = RecoverReport {
|
let mut report = RecoverReport {
|
||||||
destination: dest.display().to_string(),
|
destination: dest.display().to_string(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -90,7 +94,7 @@ pub fn recover(
|
|||||||
let rel = target_rel(f);
|
let rel = target_rel(f);
|
||||||
let mut path = dest.join(&rel);
|
let mut path = dest.join(&rel);
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
if let Err(e) = crate::owner::create_dir_all_owned(parent) {
|
||||||
report.failed.push((f.id, e.to_string()));
|
report.failed.push((f.id, e.to_string()));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -111,7 +115,7 @@ pub fn recover(
|
|||||||
}
|
}
|
||||||
used.insert(path.clone());
|
used.insert(path.clone());
|
||||||
match write_file(src, f, &path) {
|
match write_file(src, f, &path) {
|
||||||
Ok(hash) => {
|
Ok(h) => {
|
||||||
report.total_bytes += f.size;
|
report.total_bytes += f.size;
|
||||||
report.recovered.push(RecoveredFile {
|
report.recovered.push(RecoveredFile {
|
||||||
id: f.id,
|
id: f.id,
|
||||||
@@ -121,7 +125,9 @@ pub fn recover(
|
|||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.replace('\\', "/"),
|
.replace('\\', "/"),
|
||||||
size: f.size,
|
size: f.size,
|
||||||
sha256: hash,
|
sha256: h.sha256,
|
||||||
|
md5: h.md5,
|
||||||
|
sha1: h.sha1,
|
||||||
confidence: f.confidence,
|
confidence: f.confidence,
|
||||||
origin: f.origin,
|
origin: f.origin,
|
||||||
partial: f.confidence == Confidence::Low,
|
partial: f.confidence == Confidence::Low,
|
||||||
@@ -135,11 +141,11 @@ pub fn recover(
|
|||||||
"ferramenta": "Guará",
|
"ferramenta": "Guará",
|
||||||
"versao": env!("CARGO_PKG_VERSION"),
|
"versao": env!("CARGO_PKG_VERSION"),
|
||||||
"gerado_em": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0),
|
"gerado_em": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0),
|
||||||
"algoritmo_hash": "SHA-256",
|
"algoritmos_hash": ["MD5", "SHA-1", "SHA-256"],
|
||||||
"relatorio": &report,
|
"relatorio": &report,
|
||||||
});
|
});
|
||||||
std::fs::write(
|
crate::owner::write_owned(
|
||||||
dest.join("guara-manifesto.json"),
|
&dest.join("guara-manifesto.json"),
|
||||||
serde_json::to_vec_pretty(&manifest)?,
|
serde_json::to_vec_pretty(&manifest)?,
|
||||||
)?;
|
)?;
|
||||||
Ok(report)
|
Ok(report)
|
||||||
@@ -165,12 +171,13 @@ fn target_rel(f: &Found) -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Escreve o conteúdo e devolve o SHA-256.
|
/// Escreve o conteúdo e devolve o SHA-256.
|
||||||
pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<String> {
|
pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<Hashes> {
|
||||||
let mut out = std::fs::OpenOptions::new()
|
let mut out = std::fs::OpenOptions::new()
|
||||||
.write(true)
|
.write(true)
|
||||||
.create_new(true)
|
.create_new(true)
|
||||||
.open(path)?;
|
.open(path)?;
|
||||||
let mut hasher = Sha256::new();
|
crate::owner::give_to_user(path);
|
||||||
|
let mut hasher = MultiHasher::new();
|
||||||
match &f.data {
|
match &f.data {
|
||||||
DataLoc::Resident { bytes } => {
|
DataLoc::Resident { bytes } => {
|
||||||
out.write_all(bytes)?;
|
out.write_all(bytes)?;
|
||||||
@@ -204,9 +211,44 @@ pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<String> {
|
|||||||
let _ = out.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(m as u64));
|
let _ = out.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(m as u64));
|
||||||
}
|
}
|
||||||
out.flush()?;
|
out.flush()?;
|
||||||
Ok(hasher
|
Ok(hasher.finalize())
|
||||||
.finalize()
|
}
|
||||||
.iter()
|
|
||||||
.map(|b| format!("{b:02x}"))
|
/// Lê `buf.len()` bytes do conteúdo lógico de um achado a partir de `offset`
|
||||||
.collect())
|
/// (usado para streaming/pré-visualização sem gravar em disco).
|
||||||
|
pub fn read_found_at(
|
||||||
|
src: &dyn Source,
|
||||||
|
f: &Found,
|
||||||
|
offset: u64,
|
||||||
|
buf: &mut [u8],
|
||||||
|
) -> std::io::Result<usize> {
|
||||||
|
if offset >= f.size || buf.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let want = buf.len().min((f.size - offset) as usize);
|
||||||
|
match &f.data {
|
||||||
|
DataLoc::Resident { bytes } => {
|
||||||
|
let o = offset as usize;
|
||||||
|
let n = want.min(bytes.len().saturating_sub(o));
|
||||||
|
buf[..n].copy_from_slice(&bytes[o..o + n]);
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
DataLoc::Extents { extents } => {
|
||||||
|
let mut pos = 0u64;
|
||||||
|
for e in extents {
|
||||||
|
let elen = e.len.min(f.size.saturating_sub(pos));
|
||||||
|
if offset < pos + elen {
|
||||||
|
let within = offset - pos;
|
||||||
|
let n = want.min((elen - within) as usize);
|
||||||
|
if e.sparse {
|
||||||
|
buf[..n].fill(0);
|
||||||
|
return Ok(n);
|
||||||
|
}
|
||||||
|
return src.read_at(e.offset + within, &mut buf[..n]);
|
||||||
|
}
|
||||||
|
pos += elen;
|
||||||
|
}
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
//! Exportação para análise e relatório de recuperação (cadeia de custódia):
|
||||||
|
//! CSV dos achados e um relatório HTML autocontido, pronto para imprimir.
|
||||||
|
|
||||||
|
use crate::hashing::Hashes;
|
||||||
|
use crate::meta::fmt_unix;
|
||||||
|
use crate::recover::RecoverReport;
|
||||||
|
use crate::types::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct CaseInfo {
|
||||||
|
#[serde(default)]
|
||||||
|
pub case_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub examiner: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub organization: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub notes: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ReportInput<'a> {
|
||||||
|
pub case: &'a CaseInfo,
|
||||||
|
pub target: &'a str,
|
||||||
|
pub options: &'a ScanOptions,
|
||||||
|
pub layout: Option<&'a DiskLayout>,
|
||||||
|
pub found: &'a [Found],
|
||||||
|
pub warnings: &'a [String],
|
||||||
|
pub started_unix: Option<i64>,
|
||||||
|
pub elapsed: f64,
|
||||||
|
pub recoveries: &'a [RecoverReport],
|
||||||
|
pub source_hashes: Option<&'a Hashes>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cell(s: &str) -> String {
|
||||||
|
// aspas e escapa; neutraliza "injeção de fórmula" em planilhas
|
||||||
|
let mut t = s.replace('"', "\"\"");
|
||||||
|
if t.starts_with(['=', '+', '-', '@', '\t', '\r']) {
|
||||||
|
t.insert(0, '\'');
|
||||||
|
}
|
||||||
|
format!("\"{t}\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iso(t: Option<i64>) -> String {
|
||||||
|
match t {
|
||||||
|
Some(t) => {
|
||||||
|
let (y, m, d) = crate::meta::ymd(t);
|
||||||
|
let r = t.rem_euclid(86400);
|
||||||
|
format!(
|
||||||
|
"{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}",
|
||||||
|
r / 3600,
|
||||||
|
r % 3600 / 60,
|
||||||
|
r % 60
|
||||||
|
)
|
||||||
|
}
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CSV com `;` (abre direto no Excel em português) e BOM UTF-8.
|
||||||
|
pub fn csv(found: &[&Found]) -> String {
|
||||||
|
let mut out = String::from("\u{feff}id;nome;pasta;extensao;categoria;tamanho_bytes;origem;confianca;modificado_utc;criado_utc;acessado_utc;foto_tirada;camera;largura;altura;gps;deslocamento_no_disco;observacao\r\n");
|
||||||
|
for f in found {
|
||||||
|
let (w, h) =
|
||||||
|
f.px.map(|(w, h)| (w.to_string(), h.to_string()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let off = f.first_offset().map(|o| o.to_string()).unwrap_or_default();
|
||||||
|
let cat = crate::explorer::cat_key(f.category);
|
||||||
|
let row = [
|
||||||
|
f.id.to_string(),
|
||||||
|
cell(&f.name),
|
||||||
|
cell(f.dir.as_deref().unwrap_or("")),
|
||||||
|
cell(&f.ext),
|
||||||
|
cat.to_string(),
|
||||||
|
f.size.to_string(),
|
||||||
|
format!("{:?}", f.origin).to_lowercase(),
|
||||||
|
format!("{:?}", f.confidence).to_lowercase(),
|
||||||
|
iso(f.modified),
|
||||||
|
iso(f.created),
|
||||||
|
iso(f.accessed),
|
||||||
|
iso(f.taken),
|
||||||
|
cell(f.camera.as_deref().unwrap_or("")),
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
if f.gps { "sim".into() } else { String::new() },
|
||||||
|
off,
|
||||||
|
cell(f.note.as_deref().unwrap_or("")),
|
||||||
|
];
|
||||||
|
out.push_str(&row.join(";"));
|
||||||
|
out.push_str("\r\n");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn esc(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn human(n: u64) -> String {
|
||||||
|
const U: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let mut v = n as f64;
|
||||||
|
let mut i = 0;
|
||||||
|
while v >= 1024.0 && i < 4 {
|
||||||
|
v /= 1024.0;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
format!("{n} B")
|
||||||
|
} else {
|
||||||
|
format!("{v:.1} {}", U[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ROWS: usize = 20_000;
|
||||||
|
|
||||||
|
pub fn html(r: &ReportInput) -> String {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let mut o = String::new();
|
||||||
|
o.push_str(
|
||||||
|
r#"<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Guará: relatório de recuperação</title><style>
|
||||||
|
:root{color-scheme:light}*{box-sizing:border-box}body{margin:0;font:14px/1.5 -apple-system,"Segoe UI",Inter,Roboto,sans-serif;color:#10201f;background:#f3f7f7}
|
||||||
|
main{max-width:1100px;margin:0 auto;padding:36px 28px 60px}header{border-bottom:3px solid #0f766e;padding-bottom:14px;margin-bottom:22px}
|
||||||
|
h1{margin:0;font-size:28px;letter-spacing:-.02em}h2{margin:30px 0 10px;font-size:17px;color:#0f766e}small,.mut{color:#5b6f6d}
|
||||||
|
table{width:100%;border-collapse:collapse;background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
||||||
|
th,td{padding:7px 10px;text-align:left;border-bottom:1px solid #e4eded;vertical-align:top;font-size:13px}th{background:#e7f3f2;color:#0b4f4a;font-weight:600}
|
||||||
|
td.n{text-align:right;font-variant-numeric:tabular-nums}.mono{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;word-break:break-all}
|
||||||
|
.kv td:first-child{width:230px;color:#5b6f6d}.pill{display:inline-block;padding:1px 8px;border-radius:99px;font-size:12px;font-weight:600}
|
||||||
|
.alta{background:#d7f5e0;color:#146c2e}.media{background:#fff0cf;color:#8a5a00}.baixa{background:#fde0e0;color:#a32121}
|
||||||
|
.note{background:#fff7e0;border-left:4px solid #e0a100;padding:10px 14px;border-radius:6px;margin:8px 0}
|
||||||
|
footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background:#fff}main{padding:0}table{box-shadow:none}h2{break-after:avoid}tr{break-inside:avoid}}
|
||||||
|
</style></head><body><main>"#,
|
||||||
|
);
|
||||||
|
o.push_str("<header><h1>Relatório de recuperação de arquivos</h1><div class=\"mut\">Gerado pelo Guará ");
|
||||||
|
o.push_str(env!("CARGO_PKG_VERSION"));
|
||||||
|
o.push_str(&format!(" em {}</div></header>", fmt_unix(now)));
|
||||||
|
|
||||||
|
o.push_str("<h2>Identificação do caso</h2><table class=\"kv\">");
|
||||||
|
for (k, v) in [
|
||||||
|
("Número do caso", &r.case.case_id),
|
||||||
|
("Responsável pelo exame", &r.case.examiner),
|
||||||
|
("Organização", &r.case.organization),
|
||||||
|
] {
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<tr><td>{k}</td><td>{}</td></tr>",
|
||||||
|
esc(if v.is_empty() { "não informado" } else { v })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !r.case.notes.is_empty() {
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<tr><td>Observações</td><td>{}</td></tr>",
|
||||||
|
esc(&r.case.notes).replace('\n', "<br>")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
o.push_str("</table>");
|
||||||
|
|
||||||
|
o.push_str("<h2>Origem examinada</h2><table class=\"kv\">");
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<tr><td>Dispositivo / imagem</td><td class=\"mono\">{}</td></tr>",
|
||||||
|
esc(r.target)
|
||||||
|
));
|
||||||
|
if let Some(l) = r.layout {
|
||||||
|
o.push_str(&format!("<tr><td>Tamanho</td><td>{} ({} bytes)</td></tr><tr><td>Tabela de partições</td><td>{}</td></tr>", human(l.size), l.size, esc(&l.table.to_uppercase())));
|
||||||
|
}
|
||||||
|
if let Some(h) = r.source_hashes {
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<tr><td>Hash da origem</td><td class=\"mono\">MD5 {}<br>SHA-1 {}<br>SHA-256 {}</td></tr>",
|
||||||
|
esc(&h.md5),
|
||||||
|
esc(&h.sha1),
|
||||||
|
esc(&h.sha256)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
o.push_str("<tr><td>Hash da origem</td><td class=\"mut\">não calculado nesta sessão (use <span class=\"mono\">guara hash</span> ou <span class=\"mono\">guara image</span>)</td></tr>");
|
||||||
|
}
|
||||||
|
if let Some(t) = r.started_unix {
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<tr><td>Início da varredura</td><td>{}</td></tr>",
|
||||||
|
fmt_unix(t)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<tr><td>Duração da varredura</td><td>{:.1} s</td></tr><tr><td>Modo</td><td>{}{}</td></tr><tr><td>Garantia de leitura</td><td>A origem foi aberta somente para leitura; nenhuma escrita foi feita nela.</td></tr></table>",
|
||||||
|
r.elapsed,
|
||||||
|
if r.options.mode == ScanMode::Deep { "profunda (metadados + assinatura)" } else { "rápida (metadados)" },
|
||||||
|
if r.options.include_allocated { ", incluindo áreas em uso" } else { "" }
|
||||||
|
));
|
||||||
|
|
||||||
|
if let Some(l) = r.layout {
|
||||||
|
o.push_str("<h2>Partições</h2><table><tr><th>#</th><th>Sistema</th><th>Início (bytes)</th><th>Tamanho</th><th>Rótulo</th><th>Observação</th></tr>");
|
||||||
|
for p in &l.partitions {
|
||||||
|
let obs = [
|
||||||
|
p.lost.then_some("encontrada por varredura".to_string()),
|
||||||
|
p.encrypted
|
||||||
|
.as_ref()
|
||||||
|
.map(|e| format!("cifrada ({e}), não examinada")),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ");
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<tr><td>{}</td><td>{}</td><td class=\"n\">{}</td><td class=\"n\">{}</td><td>{}</td><td>{}</td></tr>",
|
||||||
|
p.index + 1,
|
||||||
|
esc(p.fs.name()),
|
||||||
|
p.start,
|
||||||
|
human(p.len),
|
||||||
|
esc(p.label.as_deref().unwrap_or("")),
|
||||||
|
esc(&obs)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for g in &l.gaps {
|
||||||
|
o.push_str(&format!("<tr><td>-</td><td>espaço não particionado</td><td class=\"n\">{}</td><td class=\"n\">{}</td><td></td><td></td></tr>", g.start, human(g.len)));
|
||||||
|
}
|
||||||
|
o.push_str("</table>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resumo
|
||||||
|
let mut by_cat = std::collections::BTreeMap::new();
|
||||||
|
let mut conf = [0usize; 3];
|
||||||
|
let mut bytes = 0u64;
|
||||||
|
for f in r.found {
|
||||||
|
*by_cat.entry(f.category.label_pt()).or_insert(0usize) += 1;
|
||||||
|
conf[f.confidence as usize] += 1;
|
||||||
|
bytes += f.size;
|
||||||
|
}
|
||||||
|
o.push_str(&format!("<h2>Resumo dos achados</h2><table class=\"kv\"><tr><td>Arquivos encontrados</td><td><b>{}</b> ({})</td></tr>", r.found.len(), human(bytes)));
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<tr><td>Confiança</td><td><span class=\"pill alta\">alta {}</span> <span class=\"pill media\">média {}</span> <span class=\"pill baixa\">baixa {}</span></td></tr>",
|
||||||
|
conf[2], conf[1], conf[0]
|
||||||
|
));
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<tr><td>Por tipo</td><td>{}</td></tr></table>",
|
||||||
|
by_cat
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| format!("{k}: {v}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" · ")
|
||||||
|
));
|
||||||
|
for w in r.warnings {
|
||||||
|
o.push_str(&format!("<div class=\"note\">{}</div>", esc(w)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !r.recoveries.is_empty() {
|
||||||
|
o.push_str("<h2>Arquivos recuperados (integridade)</h2>");
|
||||||
|
for rec in r.recoveries {
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<p>Destino: <span class=\"mono\">{}</span>, {} arquivo(s), {}{}</p>",
|
||||||
|
esc(&rec.destination),
|
||||||
|
rec.recovered.len(),
|
||||||
|
human(rec.total_bytes),
|
||||||
|
if rec.failed.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(", {} falha(s)", rec.failed.len())
|
||||||
|
}
|
||||||
|
));
|
||||||
|
o.push_str("<table><tr><th>Arquivo</th><th>Tamanho</th><th>Hashes</th></tr>");
|
||||||
|
for f in rec.recovered.iter().take(MAX_ROWS) {
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<tr><td class=\"mono\">{}</td><td class=\"n\">{}</td><td class=\"mono\">MD5 {}<br>SHA-1 {}<br>SHA-256 {}</td></tr>",
|
||||||
|
esc(&f.path),
|
||||||
|
human(f.size),
|
||||||
|
f.md5,
|
||||||
|
f.sha1,
|
||||||
|
f.sha256
|
||||||
|
));
|
||||||
|
}
|
||||||
|
o.push_str("</table>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
o.push_str("<h2>Relação de arquivos encontrados</h2>");
|
||||||
|
if r.found.len() > MAX_ROWS {
|
||||||
|
o.push_str(&format!("<div class=\"note\">Mostrando os primeiros {MAX_ROWS} de {}. Use a exportação CSV para a lista completa.</div>", r.found.len()));
|
||||||
|
}
|
||||||
|
o.push_str("<table><tr><th>Nome</th><th>Pasta</th><th>Tamanho</th><th>Modificado (UTC)</th><th>Criado (UTC)</th><th>Origem</th><th>Confiança</th><th>Posição no disco</th></tr>");
|
||||||
|
for f in r.found.iter().take(MAX_ROWS) {
|
||||||
|
let (cl, cc) = match f.confidence {
|
||||||
|
Confidence::High => ("alta", "alta"),
|
||||||
|
Confidence::Medium => ("média", "media"),
|
||||||
|
Confidence::Low => ("baixa", "baixa"),
|
||||||
|
};
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<tr><td>{}</td><td class=\"mono\">{}</td><td class=\"n\">{}</td><td>{}</td><td>{}</td><td>{}</td><td><span class=\"pill {cc}\">{cl}</span></td><td class=\"n mono\">{}</td></tr>",
|
||||||
|
esc(&f.name),
|
||||||
|
esc(f.dir.as_deref().unwrap_or("")),
|
||||||
|
human(f.size),
|
||||||
|
iso(f.modified),
|
||||||
|
iso(f.created),
|
||||||
|
match f.origin {
|
||||||
|
Origin::Carved => "assinatura",
|
||||||
|
Origin::Ntfs => "NTFS",
|
||||||
|
Origin::Fat => "FAT",
|
||||||
|
Origin::Exfat => "exFAT",
|
||||||
|
},
|
||||||
|
f.first_offset().map(|o| o.to_string()).unwrap_or_default()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
o.push_str("</table><footer>Este relatório descreve o que o Guará encontrou e recuperou. Arquivos de confiança baixa podem estar incompletos ou sobrescritos. Datas dos metadados do sistema de arquivos estão em UTC. Guará é software livre, sem garantias.</footer></main></body></html>");
|
||||||
|
o
|
||||||
|
}
|
||||||
@@ -245,6 +245,13 @@ impl CarveEvents for Handler<'_> {
|
|||||||
extents: vec![Extent::new(off, hit.len)],
|
extents: vec![Extent::new(off, hit.len)],
|
||||||
},
|
},
|
||||||
modified: None,
|
modified: None,
|
||||||
|
created: None,
|
||||||
|
accessed: None,
|
||||||
|
taken: None,
|
||||||
|
camera: None,
|
||||||
|
px: None,
|
||||||
|
gps: false,
|
||||||
|
sig: None,
|
||||||
note: if hit.confidence == Confidence::Low {
|
note: if hit.confidence == Confidence::Low {
|
||||||
Some("sem rodapé/estrutura completa: pode estar truncado".into())
|
Some("sem rodapé/estrutura completa: pode estar truncado".into())
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
//! Busca de texto bruta em todo o disco (inclusive espaço livre e não particionado),
|
||||||
|
//! em UTF-8 e UTF-16LE, útil quando não há estrutura de arquivo para recuperar.
|
||||||
|
|
||||||
|
use crate::source::Source;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SearchOpts {
|
||||||
|
pub terms: Vec<String>,
|
||||||
|
/// Ignora maiúsculas/minúsculas (letras ASCII).
|
||||||
|
#[serde(default = "yes")]
|
||||||
|
pub ignore_case: bool,
|
||||||
|
/// Procura também a codificação UTF-16 (Windows, Office, bancos de dados).
|
||||||
|
#[serde(default = "yes")]
|
||||||
|
pub utf16: bool,
|
||||||
|
#[serde(default = "default_max")]
|
||||||
|
pub max_hits: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn yes() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn default_max() -> usize {
|
||||||
|
20_000
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Hit {
|
||||||
|
pub offset: u64,
|
||||||
|
pub term: String,
|
||||||
|
pub encoding: String,
|
||||||
|
pub context: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHUNK: usize = 8 * 1024 * 1024;
|
||||||
|
const CTX: usize = 48;
|
||||||
|
|
||||||
|
struct Pat {
|
||||||
|
term: String,
|
||||||
|
enc: &'static str,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
skip: [usize; 256],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_pats(o: &SearchOpts) -> Vec<Pat> {
|
||||||
|
let mut v = vec![];
|
||||||
|
for t in o.terms.iter().filter(|t| !t.is_empty()) {
|
||||||
|
let folded = if o.ignore_case {
|
||||||
|
t.to_lowercase()
|
||||||
|
} else {
|
||||||
|
t.clone()
|
||||||
|
};
|
||||||
|
let mut add = |enc: &'static str, bytes: Vec<u8>| {
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut skip = [bytes.len(); 256];
|
||||||
|
for (i, &b) in bytes[..bytes.len() - 1].iter().enumerate() {
|
||||||
|
skip[b as usize] = bytes.len() - 1 - i;
|
||||||
|
}
|
||||||
|
v.push(Pat {
|
||||||
|
term: t.clone(),
|
||||||
|
enc,
|
||||||
|
bytes,
|
||||||
|
skip,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
add("UTF-8", folded.as_bytes().to_vec());
|
||||||
|
if o.utf16 {
|
||||||
|
add(
|
||||||
|
"UTF-16",
|
||||||
|
folded
|
||||||
|
.encode_utf16()
|
||||||
|
.flat_map(|c| c.to_le_bytes())
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_all(hay: &[u8], p: &Pat, mut f: impl FnMut(usize) -> bool) {
|
||||||
|
let n = p.bytes.len();
|
||||||
|
if hay.len() < n {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut i = 0;
|
||||||
|
while i + n <= hay.len() {
|
||||||
|
let last = hay[i + n - 1];
|
||||||
|
if last == p.bytes[n - 1] && hay[i..i + n] == p.bytes[..] {
|
||||||
|
if !f(i) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
i += p.skip[last as usize];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snippet(buf: &[u8], at: usize, len: usize, utf16: bool) -> String {
|
||||||
|
let a = at.saturating_sub(CTX);
|
||||||
|
let b = (at + len + CTX).min(buf.len());
|
||||||
|
let s: String = if utf16 {
|
||||||
|
let a = a & !1;
|
||||||
|
let u: Vec<u16> = buf[a..b]
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||||
|
.collect();
|
||||||
|
String::from_utf16_lossy(&u)
|
||||||
|
} else {
|
||||||
|
String::from_utf8_lossy(&buf[a..b]).into_owned()
|
||||||
|
};
|
||||||
|
s.chars()
|
||||||
|
.map(|c| if c.is_control() { '·' } else { c })
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Varre `src` inteira. Retorna o total de ocorrências. `on_hit` pode devolver false para parar.
|
||||||
|
pub fn search(
|
||||||
|
src: &dyn Source,
|
||||||
|
o: &SearchOpts,
|
||||||
|
cancelled: &(dyn Fn() -> bool + Sync),
|
||||||
|
progress: &mut dyn FnMut(u64, u64),
|
||||||
|
on_hit: &mut dyn FnMut(Hit) -> bool,
|
||||||
|
) -> usize {
|
||||||
|
let pats = make_pats(o);
|
||||||
|
if pats.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let overlap = pats.iter().map(|p| p.bytes.len()).max().unwrap_or(1) + 2 * CTX;
|
||||||
|
let size = src.size();
|
||||||
|
let mut pos = 0u64;
|
||||||
|
let mut hits = 0usize;
|
||||||
|
let mut buf = vec![0u8; CHUNK + overlap];
|
||||||
|
'outer: while pos < size {
|
||||||
|
if cancelled() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let want = ((size - pos) as usize).min(CHUNK + overlap);
|
||||||
|
let mut got = 0;
|
||||||
|
while got < want {
|
||||||
|
match src.read_at(pos + got as u64, &mut buf[got..want]) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => got += n,
|
||||||
|
Err(_) => {
|
||||||
|
// setor ruim: pula este trecho
|
||||||
|
for b in &mut buf[got..want] {
|
||||||
|
*b = 0;
|
||||||
|
}
|
||||||
|
got = want;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let fresh = got.min(CHUNK); // início de ocorrências aceitas neste bloco
|
||||||
|
let folded: Vec<u8>;
|
||||||
|
let hay: &[u8] = if o.ignore_case {
|
||||||
|
folded = buf[..got].to_ascii_lowercase();
|
||||||
|
&folded
|
||||||
|
} else {
|
||||||
|
&buf[..got]
|
||||||
|
};
|
||||||
|
let mut chunk_hits: Vec<(usize, usize)> = vec![]; // (offset no bloco, índice do padrão)
|
||||||
|
for (pi, p) in pats.iter().enumerate() {
|
||||||
|
find_all(hay, p, |i| {
|
||||||
|
if i < fresh {
|
||||||
|
chunk_hits.push((i, pi));
|
||||||
|
}
|
||||||
|
true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
chunk_hits.sort();
|
||||||
|
for (i, pi) in chunk_hits {
|
||||||
|
let p = &pats[pi];
|
||||||
|
hits += 1;
|
||||||
|
let h = Hit {
|
||||||
|
offset: pos + i as u64,
|
||||||
|
term: p.term.clone(),
|
||||||
|
encoding: p.enc.to_string(),
|
||||||
|
context: snippet(&buf[..got], i, p.bytes.len(), p.enc == "UTF-16"),
|
||||||
|
};
|
||||||
|
if !on_hit(h) || hits >= o.max_hits {
|
||||||
|
break 'outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos += CHUNK as u64;
|
||||||
|
progress(pos.min(size), size);
|
||||||
|
}
|
||||||
|
progress(size, size);
|
||||||
|
hits
|
||||||
|
}
|
||||||
@@ -33,6 +33,8 @@ pub enum JobKind {
|
|||||||
Scan,
|
Scan,
|
||||||
Recover,
|
Recover,
|
||||||
Image,
|
Image,
|
||||||
|
Search,
|
||||||
|
Hash,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct State {
|
struct State {
|
||||||
@@ -47,6 +49,11 @@ struct State {
|
|||||||
report: Option<Value>,
|
report: Option<Value>,
|
||||||
started: Instant,
|
started: Instant,
|
||||||
finished_in: Option<f64>,
|
finished_in: Option<f64>,
|
||||||
|
/// Leitura de datas EXIF/dimensões em andamento: (feitos, total).
|
||||||
|
enrich: Option<(u64, u64)>,
|
||||||
|
recoveries: Vec<recover::RecoverReport>,
|
||||||
|
started_unix: Option<i64>,
|
||||||
|
hits: Vec<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Job {
|
pub struct Job {
|
||||||
@@ -56,7 +63,7 @@ pub struct Job {
|
|||||||
opts: ScanOptions,
|
opts: ScanOptions,
|
||||||
cancel: AtomicBool,
|
cancel: AtomicBool,
|
||||||
stop_requested: AtomicBool,
|
stop_requested: AtomicBool,
|
||||||
src: Option<Arc<dyn Source>>,
|
src: Mutex<Option<Arc<dyn Source>>>,
|
||||||
st: Mutex<State>,
|
st: Mutex<State>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +82,7 @@ impl Job {
|
|||||||
opts,
|
opts,
|
||||||
cancel: AtomicBool::new(false),
|
cancel: AtomicBool::new(false),
|
||||||
stop_requested: AtomicBool::new(false),
|
stop_requested: AtomicBool::new(false),
|
||||||
src,
|
src: Mutex::new(src),
|
||||||
st: Mutex::new(State {
|
st: Mutex::new(State {
|
||||||
status: Status::Running,
|
status: Status::Running,
|
||||||
progress: Progress::default(),
|
progress: Progress::default(),
|
||||||
@@ -88,9 +95,19 @@ impl Job {
|
|||||||
report: None,
|
report: None,
|
||||||
started: Instant::now(),
|
started: Instant::now(),
|
||||||
finished_in: None,
|
finished_in: None,
|
||||||
|
enrich: None,
|
||||||
|
recoveries: vec![],
|
||||||
|
started_unix: std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.ok()
|
||||||
|
.map(|d| d.as_secs() as i64),
|
||||||
|
hits: vec![],
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
fn source(&self) -> Option<Arc<dyn Source>> {
|
||||||
|
self.src.lock().unwrap().clone()
|
||||||
|
}
|
||||||
fn cancelled(&self) -> bool {
|
fn cancelled(&self) -> bool {
|
||||||
self.cancel.load(Ordering::Relaxed)
|
self.cancel.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
@@ -128,6 +145,8 @@ impl Sink for JobSink {
|
|||||||
pub struct Engine {
|
pub struct Engine {
|
||||||
jobs: Mutex<HashMap<String, Arc<Job>>>,
|
jobs: Mutex<HashMap<String, Arc<Job>>>,
|
||||||
counter: AtomicU64,
|
counter: AtomicU64,
|
||||||
|
thumbs: Mutex<crate::explorer::ThumbCache>,
|
||||||
|
gate: crate::explorer::Gate,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Engine {
|
impl Default for Engine {
|
||||||
@@ -141,6 +160,13 @@ impl Engine {
|
|||||||
Self {
|
Self {
|
||||||
jobs: Mutex::new(HashMap::new()),
|
jobs: Mutex::new(HashMap::new()),
|
||||||
counter: AtomicU64::new(1),
|
counter: AtomicU64::new(1),
|
||||||
|
thumbs: Mutex::new(crate::explorer::ThumbCache::new()),
|
||||||
|
gate: crate::explorer::Gate::new(
|
||||||
|
std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(2)
|
||||||
|
.min(6),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +217,9 @@ impl Engine {
|
|||||||
|
|
||||||
pub fn resume(&self, id: &str) -> Result<()> {
|
pub fn resume(&self, id: &str) -> Result<()> {
|
||||||
let j = self.job(id)?;
|
let j = self.job(id)?;
|
||||||
|
if j.source().is_none() {
|
||||||
|
return Err(no_source());
|
||||||
|
}
|
||||||
let cp = {
|
let cp = {
|
||||||
let mut s = j.st.lock().unwrap();
|
let mut s = j.st.lock().unwrap();
|
||||||
if s.status != Status::Paused {
|
if s.status != Status::Paused {
|
||||||
@@ -231,84 +260,73 @@ impl Engine {
|
|||||||
"error": s.error,
|
"error": s.error,
|
||||||
"report": s.report,
|
"report": s.report,
|
||||||
"elapsed": elapsed,
|
"elapsed": elapsed,
|
||||||
|
"source": j.source().is_some(),
|
||||||
|
"hits": s.hits.len(),
|
||||||
|
"enriching": s.enrich.map(|(d, t)| json!({ "done": d, "total": t })),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn results(&self, id: &str, q: &Query) -> Result<Value> {
|
pub fn results(&self, id: &str, q: &Query) -> Result<Value> {
|
||||||
let j = self.job(id)?;
|
let j = self.job(id)?;
|
||||||
let s = j.st.lock().unwrap();
|
let s = j.st.lock().unwrap();
|
||||||
let needle = q
|
let mut items: Vec<&Found> = q.select(&s.found);
|
||||||
.query
|
crate::explorer::sort_items(&mut items, q.sort.as_deref());
|
||||||
.as_deref()
|
|
||||||
.map(|x| x.to_lowercase())
|
|
||||||
.filter(|x| !x.is_empty());
|
|
||||||
let mut items: Vec<&Found> = s
|
|
||||||
.found
|
|
||||||
.iter()
|
|
||||||
.filter(|f| q.category.map(|c| f.category == c).unwrap_or(true))
|
|
||||||
.filter(|f| q.min_confidence.map(|c| f.confidence >= c).unwrap_or(true))
|
|
||||||
.filter(|f| {
|
|
||||||
needle
|
|
||||||
.as_ref()
|
|
||||||
.map(|n| f.full_path().to_lowercase().contains(n.as_str()))
|
|
||||||
.unwrap_or(true)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
match q.sort.as_deref() {
|
|
||||||
Some("name") => items.sort_by_key(|f| f.name.to_lowercase()),
|
|
||||||
Some("size") => items.sort_by_key(|f| std::cmp::Reverse(f.size)),
|
|
||||||
Some("confidence") => {
|
|
||||||
items.sort_by(|a, b| b.confidence.cmp(&a.confidence).then(b.size.cmp(&a.size)))
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
let total = items.len();
|
let total = items.len();
|
||||||
let page: Vec<Value> = items
|
let page: Vec<Value> = items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.skip(q.offset)
|
.skip(q.offset)
|
||||||
.take(q.limit.clamp(1, 2000))
|
.take(q.limit.clamp(1, 5000))
|
||||||
.map(|f| {
|
.map(crate::explorer::item_json)
|
||||||
json!({
|
|
||||||
"id": f.id, "name": f.name, "dir": f.dir, "ext": f.ext, "category": f.category,
|
|
||||||
"size": f.size, "origin": f.origin, "confidence": f.confidence,
|
|
||||||
"modified": f.modified, "note": f.note,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
Ok(json!({ "total": total, "items": page }))
|
Ok(json!({ "total": total, "items": page }))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn preview(&self, id: &str, file: u64) -> Result<Value> {
|
/// Contagens para a navegação lateral (tipos, datas, pastas, câmeras).
|
||||||
|
pub fn facets(&self, id: &str) -> Result<Value> {
|
||||||
|
let j = self.job(id)?;
|
||||||
|
let s = j.st.lock().unwrap();
|
||||||
|
Ok(crate::explorer::facets(&s.found))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadados detalhados (EXIF, PDF, Office, vídeo…) de um arquivo.
|
||||||
|
pub fn metadata(&self, id: &str, file: u64) -> Result<crate::meta::Meta> {
|
||||||
|
let (f, src) = self.open_file(id, file)?;
|
||||||
|
Ok(crate::meta::extract(src.as_ref(), &f))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Miniatura JPEG (cache em memória; decodificações limitadas em paralelo).
|
||||||
|
pub fn thumb(&self, job: &str, id: u64, size: u32) -> std::result::Result<Arc<Vec<u8>>, u16> {
|
||||||
|
let size = size.clamp(48, 640);
|
||||||
|
let key = (job.to_string(), id, size);
|
||||||
|
if let Some(v) = self.thumbs.lock().unwrap().get(&key) {
|
||||||
|
return if v.is_empty() { Err(422) } else { Ok(v) };
|
||||||
|
}
|
||||||
|
let (f, src) = self.open_file(job, id).map_err(status_of)?;
|
||||||
|
if f.category != Category::Image {
|
||||||
|
return Err(415);
|
||||||
|
}
|
||||||
|
let _permit = self.gate.acquire();
|
||||||
|
if let Some(v) = self.thumbs.lock().unwrap().get(&key) {
|
||||||
|
return if v.is_empty() { Err(422) } else { Ok(v) };
|
||||||
|
}
|
||||||
|
let out = Arc::new(crate::thumb::thumbnail(src.as_ref(), &f, size).unwrap_or_default());
|
||||||
|
self.thumbs.lock().unwrap().put(key, out.clone());
|
||||||
|
if out.is_empty() {
|
||||||
|
Err(422)
|
||||||
|
} else {
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Achado + origem para leitura posicional (streaming de pré-visualização).
|
||||||
|
pub fn open_file(&self, id: &str, file: u64) -> Result<(Found, Arc<dyn Source>)> {
|
||||||
let j = self.job(id)?;
|
let j = self.job(id)?;
|
||||||
let (f, src) = {
|
|
||||||
let s = j.st.lock().unwrap();
|
let s = j.st.lock().unwrap();
|
||||||
let i = *s
|
let i = *s
|
||||||
.index
|
.index
|
||||||
.get(&file)
|
.get(&file)
|
||||||
.ok_or_else(|| Error::msg("arquivo não encontrado"))?;
|
.ok_or_else(|| Error::msg("arquivo não encontrado"))?;
|
||||||
(
|
Ok((s.found[i].clone(), j.source().ok_or_else(no_source)?))
|
||||||
s.found[i].clone(),
|
|
||||||
j.src.clone().ok_or_else(|| Error::msg("sem origem"))?,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let mime = match f.ext.as_str() {
|
|
||||||
"jpg" | "jpeg" => "image/jpeg",
|
|
||||||
"png" => "image/png",
|
|
||||||
"gif" => "image/gif",
|
|
||||||
"webp" => "image/webp",
|
|
||||||
"bmp" => "image/bmp",
|
|
||||||
_ => return Ok(json!({ "mime": null })),
|
|
||||||
};
|
|
||||||
if f.size > 8 * 1024 * 1024 {
|
|
||||||
return Ok(json!({ "mime": null, "reason": "grande demais para pré-visualizar" }));
|
|
||||||
}
|
|
||||||
let tmp =
|
|
||||||
std::env::temp_dir().join(format!("guara-preview-{}-{}", std::process::id(), f.id));
|
|
||||||
let r = recover::write_file(src.as_ref(), &f, &tmp);
|
|
||||||
let bytes = std::fs::read(&tmp);
|
|
||||||
let _ = std::fs::remove_file(&tmp);
|
|
||||||
r?;
|
|
||||||
Ok(json!({ "mime": mime, "base64": base64(&bytes?) }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- recuperação
|
// ---------------------------------------------------------------- recuperação
|
||||||
@@ -321,7 +339,7 @@ impl Engine {
|
|||||||
dest: &str,
|
dest: &str,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let sj = self.job(scan_id)?;
|
let sj = self.job(scan_id)?;
|
||||||
let src = sj.src.clone().ok_or_else(|| Error::msg("sem origem"))?;
|
let src = sj.source().ok_or_else(no_source)?;
|
||||||
let files: Vec<Found> = {
|
let files: Vec<Found> = {
|
||||||
let s = sj.st.lock().unwrap();
|
let s = sj.st.lock().unwrap();
|
||||||
match (&ids, &filter) {
|
match (&ids, &filter) {
|
||||||
@@ -329,25 +347,7 @@ impl Engine {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|i| s.index.get(i).map(|&k| s.found[k].clone()))
|
.filter_map(|i| s.index.get(i).map(|&k| s.found[k].clone()))
|
||||||
.collect(),
|
.collect(),
|
||||||
(None, Some(q)) => {
|
(None, Some(q)) => q.select(&s.found).into_iter().cloned().collect(),
|
||||||
let needle = q
|
|
||||||
.query
|
|
||||||
.as_deref()
|
|
||||||
.map(|x| x.to_lowercase())
|
|
||||||
.filter(|x| !x.is_empty());
|
|
||||||
s.found
|
|
||||||
.iter()
|
|
||||||
.filter(|f| q.category.map(|c| f.category == c).unwrap_or(true))
|
|
||||||
.filter(|f| q.min_confidence.map(|c| f.confidence >= c).unwrap_or(true))
|
|
||||||
.filter(|f| {
|
|
||||||
needle
|
|
||||||
.as_ref()
|
|
||||||
.map(|n| f.full_path().to_lowercase().contains(n.as_str()))
|
|
||||||
.unwrap_or(true)
|
|
||||||
})
|
|
||||||
.cloned()
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
(None, None) => s.found.clone(),
|
(None, None) => s.found.clone(),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -363,6 +363,7 @@ impl Engine {
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let j2 = job.clone();
|
let j2 = job.clone();
|
||||||
|
let parent = sj.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let cancelled = || j2.cancelled();
|
let cancelled = || j2.cancelled();
|
||||||
let mut prog = |done: usize, total: usize, bytes: u64| {
|
let mut prog = |done: usize, total: usize, bytes: u64| {
|
||||||
@@ -379,6 +380,7 @@ impl Engine {
|
|||||||
match res {
|
match res {
|
||||||
Ok(rep) => {
|
Ok(rep) => {
|
||||||
s.report = serde_json::to_value(&rep).ok();
|
s.report = serde_json::to_value(&rep).ok();
|
||||||
|
parent.st.lock().unwrap().recoveries.push(rep.clone());
|
||||||
s.status = if rep.cancelled {
|
s.status = if rep.cancelled {
|
||||||
Status::Stopped
|
Status::Stopped
|
||||||
} else {
|
} else {
|
||||||
@@ -441,6 +443,156 @@ impl Engine {
|
|||||||
Ok(job.id.clone())
|
Ok(job.id.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- exportação e relatório
|
||||||
|
|
||||||
|
/// CSV dos achados que atendem ao filtro (ou todos). Devolve quantas linhas gravou.
|
||||||
|
pub fn export_csv(&self, id: &str, path: &str, q: Option<&Query>) -> Result<usize> {
|
||||||
|
let j = self.job(id)?;
|
||||||
|
let s = j.st.lock().unwrap();
|
||||||
|
let items: Vec<&Found> = match q {
|
||||||
|
Some(q) => q.select(&s.found),
|
||||||
|
None => s.found.iter().collect(),
|
||||||
|
};
|
||||||
|
crate::owner::write_owned(Path::new(path), crate::report::csv(&items))?;
|
||||||
|
Ok(items.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relatório HTML de recuperação (identificação do caso, origem, achados, hashes).
|
||||||
|
pub fn export_report(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
path: &str,
|
||||||
|
case: crate::report::CaseInfo,
|
||||||
|
hashes: Option<crate::hashing::Hashes>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let j = self.job(id)?;
|
||||||
|
let s = j.st.lock().unwrap();
|
||||||
|
let elapsed = s
|
||||||
|
.finished_in
|
||||||
|
.unwrap_or_else(|| s.started.elapsed().as_secs_f64());
|
||||||
|
let html = crate::report::html(&crate::report::ReportInput {
|
||||||
|
case: &case,
|
||||||
|
target: &j.target,
|
||||||
|
options: &j.opts,
|
||||||
|
layout: s.layout.as_ref(),
|
||||||
|
found: &s.found,
|
||||||
|
warnings: &s.warnings,
|
||||||
|
started_unix: s.started_unix,
|
||||||
|
elapsed,
|
||||||
|
recoveries: &s.recoveries,
|
||||||
|
source_hashes: hashes.as_ref(),
|
||||||
|
});
|
||||||
|
crate::owner::write_owned(Path::new(path), html)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- busca de texto e hash da origem
|
||||||
|
|
||||||
|
pub fn start_search(&self, path: &str, opts: crate::search::SearchOpts) -> Result<String> {
|
||||||
|
if opts.terms.iter().all(|t| t.trim().is_empty()) {
|
||||||
|
return Err(Error::msg("informe pelo menos um termo"));
|
||||||
|
}
|
||||||
|
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
|
||||||
|
let job = self.register(
|
||||||
|
JobKind::Search,
|
||||||
|
path.to_string(),
|
||||||
|
ScanOptions::default(),
|
||||||
|
Some(src.clone()),
|
||||||
|
);
|
||||||
|
let j2 = job.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let layout = crate::partition::analyze(src.as_ref(), false);
|
||||||
|
let cancelled = || j2.cancelled();
|
||||||
|
let mut prog = |d: u64, t: u64| {
|
||||||
|
j2.st.lock().unwrap().progress = Progress {
|
||||||
|
phase: "Procurando texto no disco".into(),
|
||||||
|
done: d,
|
||||||
|
total: t,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let mut on_hit = |h: crate::search::Hit| {
|
||||||
|
let part = layout
|
||||||
|
.partitions
|
||||||
|
.iter()
|
||||||
|
.find(|p| h.offset >= p.start && h.offset < p.start + p.len)
|
||||||
|
.map(|p| p.index + 1);
|
||||||
|
let mut s = j2.st.lock().unwrap();
|
||||||
|
s.hits.push(json!({
|
||||||
|
"offset": h.offset, "term": h.term, "encoding": h.encoding,
|
||||||
|
"context": h.context, "partition": part,
|
||||||
|
}));
|
||||||
|
true
|
||||||
|
};
|
||||||
|
crate::search::search(src.as_ref(), &opts, &cancelled, &mut prog, &mut on_hit);
|
||||||
|
let mut s = j2.st.lock().unwrap();
|
||||||
|
s.status = if j2.stop_requested.load(Ordering::Relaxed) {
|
||||||
|
Status::Stopped
|
||||||
|
} else {
|
||||||
|
Status::Done
|
||||||
|
};
|
||||||
|
s.finished_in = Some(s.started.elapsed().as_secs_f64());
|
||||||
|
});
|
||||||
|
Ok(job.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn search_results(&self, id: &str, offset: usize, limit: usize) -> Result<Value> {
|
||||||
|
let j = self.job(id)?;
|
||||||
|
let s = j.st.lock().unwrap();
|
||||||
|
Ok(json!({
|
||||||
|
"total": s.hits.len(),
|
||||||
|
"items": s.hits.iter().skip(offset).take(limit.clamp(1, 2000)).cloned().collect::<Vec<_>>(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcula MD5/SHA-1/SHA-256 da origem inteira (verificação de integridade).
|
||||||
|
pub fn start_hash(&self, path: &str) -> Result<String> {
|
||||||
|
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
|
||||||
|
let job = self.register(
|
||||||
|
JobKind::Hash,
|
||||||
|
path.to_string(),
|
||||||
|
ScanOptions::default(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let j2 = job.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let size = src.size();
|
||||||
|
let mut h = crate::hashing::MultiHasher::new();
|
||||||
|
let mut buf = vec![0u8; 4 << 20];
|
||||||
|
let mut pos = 0u64;
|
||||||
|
let mut err = None;
|
||||||
|
while pos < size && !j2.cancelled() {
|
||||||
|
let want = ((size - pos) as usize).min(buf.len());
|
||||||
|
match crate::source::read_exact_at(src.as_ref(), pos, &mut buf[..want]) {
|
||||||
|
Ok(()) => {
|
||||||
|
h.update(&buf[..want]);
|
||||||
|
pos += want as u64;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
err = Some(format!("falha de leitura em {pos}: {e}. Para discos com defeito use a imagem forense."));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j2.st.lock().unwrap().progress = Progress {
|
||||||
|
phase: "Calculando hashes da origem".into(),
|
||||||
|
done: pos,
|
||||||
|
total: size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let mut s = j2.st.lock().unwrap();
|
||||||
|
if let Some(e) = err {
|
||||||
|
s.error = Some(e);
|
||||||
|
s.status = Status::Error;
|
||||||
|
} else if pos < size {
|
||||||
|
s.status = Status::Stopped;
|
||||||
|
} else {
|
||||||
|
s.report = serde_json::to_value(h.finalize()).ok();
|
||||||
|
s.status = Status::Done;
|
||||||
|
}
|
||||||
|
s.finished_in = Some(s.started.elapsed().as_secs_f64());
|
||||||
|
});
|
||||||
|
Ok(job.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- sessões
|
// ---------------------------------------------------------------- sessões
|
||||||
|
|
||||||
pub fn save_session(&self, id: &str, path: &str) -> Result<()> {
|
pub fn save_session(&self, id: &str, path: &str) -> Result<()> {
|
||||||
@@ -455,19 +607,39 @@ impl Engine {
|
|||||||
layout: s.layout.clone(),
|
layout: s.layout.clone(),
|
||||||
warnings: s.warnings.clone(),
|
warnings: s.warnings.clone(),
|
||||||
done: s.status == Status::Done,
|
done: s.status == Status::Done,
|
||||||
|
recoveries: s.recoveries.clone(),
|
||||||
|
started_unix: s.started_unix,
|
||||||
};
|
};
|
||||||
std::fs::write(path, serde_json::to_vec(&data)?)?;
|
crate::owner::write_owned(Path::new(path), serde_json::to_vec(&data)?)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_session(&self, path: &str) -> Result<String> {
|
pub fn load_session(&self, path: &str) -> Result<String> {
|
||||||
let data: SessionFile = serde_json::from_slice(&std::fs::read(path)?)?;
|
let bytes = std::fs::read(path).map_err(|e| {
|
||||||
let src = open_path(Path::new(&data.target)).map_err(|e| open_error(&data.target, e))?;
|
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||||||
|
Error::msg(format!(
|
||||||
|
"Sem permissão para ler a sessão {path}. Se ela foi criada como administrador (por exemplo em /root), abra o Guará como administrador ou copie o arquivo para a sua pasta."
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Error::msg(format!("Não foi possível ler a sessão {path}: {e}"))
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let data: SessionFile = serde_json::from_slice(&bytes).map_err(|e| {
|
||||||
|
Error::msg(format!(
|
||||||
|
"O arquivo {path} não é uma sessão do Guará válida (ou foi criado por uma versão incompatível): {e}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
// A sessão abre mesmo sem a origem: dá para navegar, exportar e gerar relatório.
|
||||||
|
// Pré-visualização e recuperação voltam quando a origem for reconectada.
|
||||||
|
let (src, missing) = match open_path(Path::new(&data.target)) {
|
||||||
|
Ok(s) => (Some(s), None),
|
||||||
|
Err(e) => (None, Some(open_error(&data.target, e).to_string())),
|
||||||
|
};
|
||||||
let job = self.register(
|
let job = self.register(
|
||||||
JobKind::Scan,
|
JobKind::Scan,
|
||||||
data.target.clone(),
|
data.target.clone(),
|
||||||
data.options.clone(),
|
data.options.clone(),
|
||||||
Some(src),
|
src,
|
||||||
);
|
);
|
||||||
{
|
{
|
||||||
let mut s = job.st.lock().unwrap();
|
let mut s = job.st.lock().unwrap();
|
||||||
@@ -477,6 +649,8 @@ impl Engine {
|
|||||||
s.found = data.found;
|
s.found = data.found;
|
||||||
s.layout = data.layout;
|
s.layout = data.layout;
|
||||||
s.warnings = data.warnings;
|
s.warnings = data.warnings;
|
||||||
|
s.recoveries = data.recoveries;
|
||||||
|
s.started_unix = data.started_unix.or(s.started_unix);
|
||||||
s.checkpoint = data.checkpoint;
|
s.checkpoint = data.checkpoint;
|
||||||
s.status = if data.done {
|
s.status = if data.done {
|
||||||
Status::Done
|
Status::Done
|
||||||
@@ -484,9 +658,45 @@ impl Engine {
|
|||||||
Status::Paused
|
Status::Paused
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if let Some(m) = missing {
|
||||||
|
job.st.lock().unwrap().warnings.push(format!(
|
||||||
|
"A origem {} não está disponível agora ({m}). Você pode navegar, exportar e gerar relatório; para pré-visualizar e recuperar, reconecte o disco ou escolha a origem.",
|
||||||
|
data.target
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if data.done {
|
||||||
|
spawn_enrich(job.clone());
|
||||||
|
}
|
||||||
Ok(job.id.clone())
|
Ok(job.id.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Aponta uma sessão carregada para a origem (disco ou imagem) atual.
|
||||||
|
pub fn relink(&self, id: &str, path: &str) -> Result<()> {
|
||||||
|
let j = self.job(id)?;
|
||||||
|
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
|
||||||
|
{
|
||||||
|
let s = j.st.lock().unwrap();
|
||||||
|
if let Some(l) = &s.layout {
|
||||||
|
if l.size != 0 && l.size != src.size() {
|
||||||
|
return Err(Error::msg(format!(
|
||||||
|
"Essa origem tem {} bytes, mas a sessão foi feita sobre um disco de {} bytes. Escolha o mesmo disco ou uma imagem dele.",
|
||||||
|
src.size(),
|
||||||
|
l.size
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*j.src.lock().unwrap() = Some(src);
|
||||||
|
j.st.lock()
|
||||||
|
.unwrap()
|
||||||
|
.warnings
|
||||||
|
.retain(|w| !w.starts_with("A origem "));
|
||||||
|
if j.st.lock().unwrap().status == Status::Done {
|
||||||
|
spawn_enrich(j);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- RPC
|
// ---------------------------------------------------------------- RPC
|
||||||
|
|
||||||
pub fn rpc(&self, method: &str, p: Value) -> Result<Value> {
|
pub fn rpc(&self, method: &str, p: Value) -> Result<Value> {
|
||||||
@@ -525,6 +735,58 @@ impl Engine {
|
|||||||
json!({})
|
json!({})
|
||||||
}
|
}
|
||||||
"status" => self.status(&s("job")?)?,
|
"status" => self.status(&s("job")?)?,
|
||||||
|
"facets" => self.facets(&s("job")?)?,
|
||||||
|
"open_external" => {
|
||||||
|
let id = p
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.ok_or_else(|| Error::msg("id ausente"))?;
|
||||||
|
let (f, src) = self.open_file(&s("job")?, id)?;
|
||||||
|
let path = crate::external::open_found(src.as_ref(), &f)?;
|
||||||
|
json!({ "path": path.to_string_lossy() })
|
||||||
|
}
|
||||||
|
"export_csv" => {
|
||||||
|
let q: Option<Query> = p
|
||||||
|
.get("query")
|
||||||
|
.cloned()
|
||||||
|
.filter(|v| !v.is_null())
|
||||||
|
.map(serde_json::from_value)
|
||||||
|
.transpose()?;
|
||||||
|
json!({ "rows": self.export_csv(&s("job")?, &s("path")?, q.as_ref())? })
|
||||||
|
}
|
||||||
|
"export_report" => {
|
||||||
|
let case: crate::report::CaseInfo = p
|
||||||
|
.get("case")
|
||||||
|
.cloned()
|
||||||
|
.filter(|v| !v.is_null())
|
||||||
|
.map(serde_json::from_value)
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or_default();
|
||||||
|
let hashes: Option<crate::hashing::Hashes> = p
|
||||||
|
.get("hashes")
|
||||||
|
.cloned()
|
||||||
|
.filter(|v| !v.is_null())
|
||||||
|
.map(serde_json::from_value)
|
||||||
|
.transpose()?;
|
||||||
|
self.export_report(&s("job")?, &s("path")?, case, hashes)?;
|
||||||
|
json!({})
|
||||||
|
}
|
||||||
|
"search_start" => {
|
||||||
|
let opts: crate::search::SearchOpts = serde_json::from_value(p.clone())?;
|
||||||
|
json!({ "job": self.start_search(&s("path")?, opts)? })
|
||||||
|
}
|
||||||
|
"search_results" => self.search_results(
|
||||||
|
&s("job")?,
|
||||||
|
p.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
|
||||||
|
p.get("limit").and_then(|v| v.as_u64()).unwrap_or(200) as usize,
|
||||||
|
)?,
|
||||||
|
"hash_start" => json!({ "job": self.start_hash(&s("path")?)? }),
|
||||||
|
"metadata" => json!(self.metadata(
|
||||||
|
&s("job")?,
|
||||||
|
p.get("id")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.ok_or_else(|| Error::msg("id ausente"))?
|
||||||
|
)?),
|
||||||
"results" => {
|
"results" => {
|
||||||
let q: Query = p
|
let q: Query = p
|
||||||
.get("query")
|
.get("query")
|
||||||
@@ -534,12 +796,12 @@ impl Engine {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
self.results(&s("job")?, &q)?
|
self.results(&s("job")?, &q)?
|
||||||
}
|
}
|
||||||
"preview" => self.preview(
|
"settings_get" => json!(crate::settings::load()),
|
||||||
&s("job")?,
|
"settings_set" => {
|
||||||
p.get("id")
|
let st: crate::settings::Settings = serde_json::from_value(p.clone())?;
|
||||||
.and_then(|v| v.as_u64())
|
crate::settings::save(&st)?;
|
||||||
.ok_or_else(|| Error::msg("id ausente"))?,
|
json!({})
|
||||||
)?,
|
}
|
||||||
"recover_start" => {
|
"recover_start" => {
|
||||||
let ids: Option<Vec<u64>> = p
|
let ids: Option<Vec<u64>> = p
|
||||||
.get("ids")
|
.get("ids")
|
||||||
@@ -560,11 +822,13 @@ impl Engine {
|
|||||||
self.save_session(&s("job")?, &s("path")?)?;
|
self.save_session(&s("job")?, &s("path")?)?;
|
||||||
json!({})
|
json!({})
|
||||||
}
|
}
|
||||||
|
"session_relink" => {
|
||||||
|
self.relink(&s("job")?, &s("path")?)?;
|
||||||
|
json!({})
|
||||||
|
}
|
||||||
"session_load" => json!({ "job": self.load_session(&s("path")?)? }),
|
"session_load" => json!({ "job": self.load_session(&s("path")?)? }),
|
||||||
"defaults" => {
|
"defaults" => {
|
||||||
let home = std::env::var("HOME")
|
let home = crate::owner::user_home().to_string_lossy().to_string();
|
||||||
.or_else(|_| std::env::var("USERPROFILE"))
|
|
||||||
.unwrap_or_default();
|
|
||||||
json!({ "home": home, "sep": std::path::MAIN_SEPARATOR.to_string() })
|
json!({ "home": home, "sep": std::path::MAIN_SEPARATOR.to_string() })
|
||||||
}
|
}
|
||||||
"version" => {
|
"version" => {
|
||||||
@@ -575,38 +839,7 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
pub use crate::explorer::Query;
|
||||||
pub struct Query {
|
|
||||||
#[serde(default)]
|
|
||||||
pub category: Option<Category>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub min_confidence: Option<Confidence>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub query: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub sort: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub offset: usize,
|
|
||||||
#[serde(default = "default_limit")]
|
|
||||||
pub limit: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_limit() -> usize {
|
|
||||||
200
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Query {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
category: None,
|
|
||||||
min_confidence: None,
|
|
||||||
query: None,
|
|
||||||
sort: None,
|
|
||||||
offset: 0,
|
|
||||||
limit: 200,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
struct SessionFile {
|
struct SessionFile {
|
||||||
@@ -618,20 +851,27 @@ struct SessionFile {
|
|||||||
layout: Option<DiskLayout>,
|
layout: Option<DiskLayout>,
|
||||||
warnings: Vec<String>,
|
warnings: Vec<String>,
|
||||||
done: bool,
|
done: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
recoveries: Vec<recover::RecoverReport>,
|
||||||
|
#[serde(default)]
|
||||||
|
started_unix: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cat_key(c: Category) -> &'static str {
|
use crate::explorer::cat_key;
|
||||||
match c {
|
|
||||||
Category::Image => "image",
|
/// 503 quando a origem da sessão está desconectada; 404 nos demais casos.
|
||||||
Category::Document => "document",
|
fn status_of(e: Error) -> u16 {
|
||||||
Category::Video => "video",
|
if e.to_string().contains("não está disponível") {
|
||||||
Category::Audio => "audio",
|
503
|
||||||
Category::Archive => "archive",
|
} else {
|
||||||
Category::Database => "database",
|
404
|
||||||
Category::Other => "other",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn no_source() -> Error {
|
||||||
|
Error::msg("A origem desta sessão não está disponível. Reconecte o disco ou escolha a origem para pré-visualizar e recuperar.")
|
||||||
|
}
|
||||||
|
|
||||||
fn open_error(path: &str, e: std::io::Error) -> Error {
|
fn open_error(path: &str, e: std::io::Error) -> Error {
|
||||||
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||||||
Error::msg(format!(
|
Error::msg(format!(
|
||||||
@@ -644,7 +884,12 @@ fn open_error(path: &str, e: std::io::Error) -> Error {
|
|||||||
|
|
||||||
fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
|
fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let src = job.src.clone().expect("origem");
|
let Some(src) = job.source() else {
|
||||||
|
let mut s = job.st.lock().unwrap();
|
||||||
|
s.status = Status::Error;
|
||||||
|
s.error = Some(no_source().to_string());
|
||||||
|
return;
|
||||||
|
};
|
||||||
let existing: Vec<Found> = job.st.lock().unwrap().found.clone();
|
let existing: Vec<Found> = job.st.lock().unwrap().found.clone();
|
||||||
let cancelled = || job.cancelled();
|
let cancelled = || job.cancelled();
|
||||||
let mut sink = JobSink(job.clone());
|
let mut sink = JobSink(job.clone());
|
||||||
@@ -672,6 +917,55 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.finished_in = Some(s.started.elapsed().as_secs_f64());
|
s.finished_in = Some(s.started.elapsed().as_secs_f64());
|
||||||
|
let done = s.status == Status::Done;
|
||||||
|
drop(s);
|
||||||
|
if done {
|
||||||
|
spawn_enrich(job);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lê EXIF/dimensões das imagens achadas (em segundo plano) para permitir
|
||||||
|
/// navegar por data, câmera e resolução.
|
||||||
|
fn spawn_enrich(job: Arc<Job>) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let Some(src) = job.source() else { return };
|
||||||
|
let todo: Vec<(u64, Found)> = {
|
||||||
|
let s = job.st.lock().unwrap();
|
||||||
|
s.found
|
||||||
|
.iter()
|
||||||
|
.filter(|f| {
|
||||||
|
f.sig.is_none()
|
||||||
|
|| (f.category == Category::Image && f.px.is_none() && f.taken.is_none())
|
||||||
|
})
|
||||||
|
.map(|f| (f.id, f.clone()))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
if todo.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let total = todo.len() as u64;
|
||||||
|
for (i, (id, f)) in todo.into_iter().enumerate() {
|
||||||
|
if job.cancelled() && job.stop_requested.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let sig = crate::explorer::quick_sig(src.as_ref(), &f);
|
||||||
|
let m = (f.category == Category::Image && f.px.is_none() && f.taken.is_none())
|
||||||
|
.then(|| crate::meta::extract(src.as_ref(), &f));
|
||||||
|
let mut s = job.st.lock().unwrap();
|
||||||
|
if let Some(&k) = s.index.get(&id) {
|
||||||
|
let t = &mut s.found[k];
|
||||||
|
t.sig = sig;
|
||||||
|
if let Some(m) = m {
|
||||||
|
t.taken = m.taken;
|
||||||
|
t.camera = m.camera.clone();
|
||||||
|
t.px = m.px.or(t.px);
|
||||||
|
t.gps = m.gps.is_some();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.enrich = Some((i as u64 + 1, total));
|
||||||
|
}
|
||||||
|
job.st.lock().unwrap().enrich = None;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,3 +991,179 @@ pub fn base64(data: &[u8]) -> String {
|
|||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tipo MIME para servir a pré-visualização. Formatos que poderiam executar
|
||||||
|
/// script (HTML/SVG/XML) saem como texto puro.
|
||||||
|
pub fn mime_for(ext: &str) -> &'static str {
|
||||||
|
match ext.to_ascii_lowercase().as_str() {
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"png" => "image/png",
|
||||||
|
"gif" => "image/gif",
|
||||||
|
"webp" => "image/webp",
|
||||||
|
"bmp" => "image/bmp",
|
||||||
|
"avif" => "image/avif",
|
||||||
|
"ico" => "image/x-icon",
|
||||||
|
"mp4" | "m4v" => "video/mp4",
|
||||||
|
"mov" => "video/quicktime",
|
||||||
|
"webm" => "video/webm",
|
||||||
|
"mkv" => "video/x-matroska",
|
||||||
|
"mp3" => "audio/mpeg",
|
||||||
|
"wav" => "audio/wav",
|
||||||
|
"ogg" | "opus" => "audio/ogg",
|
||||||
|
"m4a" => "audio/mp4",
|
||||||
|
"aac" => "audio/aac",
|
||||||
|
"flac" => "audio/flac",
|
||||||
|
"pdf" => "application/pdf",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trecho de um arquivo achado, pronto para responder a uma requisição HTTP
|
||||||
|
/// (com suporte a `Range`) tanto no modo `guara ui` quanto no app Tauri.
|
||||||
|
pub struct FileSlice {
|
||||||
|
found: Found,
|
||||||
|
src: Arc<dyn Source>,
|
||||||
|
pub start: u64,
|
||||||
|
/// Inclusivo.
|
||||||
|
pub end: u64,
|
||||||
|
pub total: u64,
|
||||||
|
pub partial: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileSlice {
|
||||||
|
pub fn len(&self) -> u64 {
|
||||||
|
if self.total == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
self.end - self.start + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.len() == 0
|
||||||
|
}
|
||||||
|
pub fn status(&self) -> u16 {
|
||||||
|
if self.partial {
|
||||||
|
206
|
||||||
|
} else {
|
||||||
|
200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn headers(&self) -> Vec<(&'static str, String)> {
|
||||||
|
let mut h = vec![
|
||||||
|
("Content-Type", mime_for(&self.found.ext).to_string()),
|
||||||
|
("Accept-Ranges", "bytes".into()),
|
||||||
|
("Cache-Control", "no-store".into()),
|
||||||
|
("X-Content-Type-Options", "nosniff".into()),
|
||||||
|
// Conteúdo recuperado é não confiável: nunca executa script nem carrega nada.
|
||||||
|
(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"sandbox; default-src 'none'".into(),
|
||||||
|
),
|
||||||
|
("Access-Control-Allow-Origin", "*".into()),
|
||||||
|
(
|
||||||
|
"Access-Control-Expose-Headers",
|
||||||
|
"Content-Range, Content-Length, Accept-Ranges".into(),
|
||||||
|
),
|
||||||
|
("Content-Disposition", "inline".into()),
|
||||||
|
];
|
||||||
|
if self.partial {
|
||||||
|
h.push((
|
||||||
|
"Content-Range",
|
||||||
|
format!("bytes {}-{}/{}", self.start, self.end, self.total),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
h
|
||||||
|
}
|
||||||
|
/// Lê o trecho inteiro em memória (limitado a `cap` bytes).
|
||||||
|
pub fn read_all(&self, cap: u64) -> std::io::Result<Vec<u8>> {
|
||||||
|
let n = self.len().min(cap) as usize;
|
||||||
|
let mut out = vec![0u8; n];
|
||||||
|
let mut got = 0;
|
||||||
|
while got < n {
|
||||||
|
let r = crate::recover::read_found_at(
|
||||||
|
self.src.as_ref(),
|
||||||
|
&self.found,
|
||||||
|
self.start + got as u64,
|
||||||
|
&mut out[got..],
|
||||||
|
)?;
|
||||||
|
if r == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
got += r;
|
||||||
|
}
|
||||||
|
out.truncate(got);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
pub fn reader(self) -> FileSliceReader {
|
||||||
|
FileSliceReader {
|
||||||
|
pos: self.start,
|
||||||
|
slice: self,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FileSliceReader {
|
||||||
|
slice: FileSlice,
|
||||||
|
pos: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::io::Read for FileSliceReader {
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||||
|
if self.pos > self.slice.end || self.slice.total == 0 {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let left = (self.slice.end - self.pos + 1) as usize;
|
||||||
|
let n = buf.len().min(left).min(1 << 20);
|
||||||
|
let r = crate::recover::read_found_at(
|
||||||
|
self.slice.src.as_ref(),
|
||||||
|
&self.slice.found,
|
||||||
|
self.pos,
|
||||||
|
&mut buf[..n],
|
||||||
|
)?;
|
||||||
|
self.pos += r as u64;
|
||||||
|
Ok(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Engine {
|
||||||
|
/// Resolve `/file/<job>/<id>` + cabeçalho Range. `Err(status)` em caso de falha.
|
||||||
|
pub fn file_slice(
|
||||||
|
&self,
|
||||||
|
job: &str,
|
||||||
|
id: u64,
|
||||||
|
range: Option<&str>,
|
||||||
|
) -> std::result::Result<FileSlice, u16> {
|
||||||
|
let (found, src) = self.open_file(job, id).map_err(status_of)?;
|
||||||
|
let total = found.size;
|
||||||
|
let (mut start, mut end, mut partial) = (0u64, total.saturating_sub(1), false);
|
||||||
|
if let Some(spec) = range.and_then(|r| r.strip_prefix("bytes=")) {
|
||||||
|
let spec = spec.split(',').next().unwrap_or("");
|
||||||
|
if let Some((a, b)) = spec.split_once('-') {
|
||||||
|
let parsed = match (a.trim().parse::<u64>(), b.trim().parse::<u64>()) {
|
||||||
|
(Ok(a), Ok(b)) => Some((a, b.min(total.saturating_sub(1)))),
|
||||||
|
(Ok(a), Err(_)) if b.trim().is_empty() => Some((a, total.saturating_sub(1))),
|
||||||
|
(Err(_), Ok(n)) if a.trim().is_empty() && n > 0 => {
|
||||||
|
Some((total.saturating_sub(n), total.saturating_sub(1)))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some((a, b)) = parsed {
|
||||||
|
if a >= total || a > b {
|
||||||
|
return Err(416);
|
||||||
|
}
|
||||||
|
start = a;
|
||||||
|
end = b;
|
||||||
|
partial = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(FileSlice {
|
||||||
|
found,
|
||||||
|
src,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
total,
|
||||||
|
partial,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
//! Preferências do usuário (tema etc.), compartilhadas entre o app normal e o
|
||||||
|
//! reiniciado como administrador.
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Settings {
|
||||||
|
/// "auto", "light" ou "dark".
|
||||||
|
pub theme: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Settings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
theme: "auto".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pasta de configuração do usuário *real*, mesmo quando rodando como root
|
||||||
|
/// via sudo/pkexec (para o tema não "mudar" ao elevar privilégios).
|
||||||
|
pub fn config_dir() -> PathBuf {
|
||||||
|
if let Ok(d) = std::env::var("GUARA_CONFIG_DIR") {
|
||||||
|
if !d.is_empty() {
|
||||||
|
return PathBuf::from(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
if let Some(u) = crate::owner::invoking_user() {
|
||||||
|
return default_dir(u.home);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_dir(PathBuf::from(
|
||||||
|
std::env::var("HOME")
|
||||||
|
.or_else(|_| std::env::var("USERPROFILE"))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_dir(home: PathBuf) -> PathBuf {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
if let Ok(a) = std::env::var("APPDATA") {
|
||||||
|
return PathBuf::from(a).join("Guara");
|
||||||
|
}
|
||||||
|
home.join("AppData").join("Roaming").join("Guara")
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
home.join("Library")
|
||||||
|
.join("Application Support")
|
||||||
|
.join("Guara")
|
||||||
|
}
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
{
|
||||||
|
match std::env::var("XDG_CONFIG_HOME") {
|
||||||
|
Ok(x)
|
||||||
|
if !x.is_empty()
|
||||||
|
&& std::env::var("SUDO_USER").is_err()
|
||||||
|
&& std::env::var("PKEXEC_UID").is_err() =>
|
||||||
|
{
|
||||||
|
PathBuf::from(x).join("guara")
|
||||||
|
}
|
||||||
|
_ => home.join(".config").join("guara"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file() -> PathBuf {
|
||||||
|
config_dir().join("settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Settings {
|
||||||
|
std::fs::read(file())
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(s: &Settings) -> Result<()> {
|
||||||
|
crate::owner::write_owned(&file(), serde_json::to_vec_pretty(s)?)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -219,8 +219,10 @@ fn jpeg(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
|||||||
// ---------------------------------------------------------------- PNG
|
// ---------------------------------------------------------------- PNG
|
||||||
|
|
||||||
fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||||
|
let total = src.size().saturating_sub(off);
|
||||||
let mut p = 8u64;
|
let mut p = 8u64;
|
||||||
let mut first = true;
|
let mut first = true;
|
||||||
|
let mut saw_idat = false;
|
||||||
for _ in 0..200_000 {
|
for _ in 0..200_000 {
|
||||||
let c = read_vec(src, off + p, 8).ok()?;
|
let c = read_vec(src, off + p, 8).ok()?;
|
||||||
if c.len() < 8 {
|
if c.len() < 8 {
|
||||||
@@ -228,13 +230,20 @@ fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
|||||||
}
|
}
|
||||||
let len = u32::from_be_bytes([c[0], c[1], c[2], c[3]]) as u64;
|
let len = u32::from_be_bytes([c[0], c[1], c[2], c[3]]) as u64;
|
||||||
let ty = &c[4..8];
|
let ty = &c[4..8];
|
||||||
if !ty.iter().all(|b| b.is_ascii_alphabetic()) || len > 0x7FFF_FFFF {
|
// Bloco válido: 4 letras, 3ª maiúscula (bit reservado), tamanho plausível e dentro do
|
||||||
|
// disco. Texto ASCII solto parece "tamanho" de ~1,9 GB e não pode virar um PNG gigante.
|
||||||
|
let valid = ty.iter().all(|b| b.is_ascii_alphabetic())
|
||||||
|
&& ty[2].is_ascii_uppercase()
|
||||||
|
&& len <= 128 * 1024 * 1024
|
||||||
|
&& p + 12 + len <= total;
|
||||||
|
if !valid {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if first && ty != b"IHDR" {
|
if first && ty != b"IHDR" {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
first = false;
|
first = false;
|
||||||
|
saw_idat |= ty == b"IDAT";
|
||||||
p += 12 + len;
|
p += 12 + len;
|
||||||
if p > max {
|
if p > max {
|
||||||
break;
|
break;
|
||||||
@@ -247,11 +256,12 @@ fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if first {
|
// Sem IEND: só vale como imagem truncada se já tem dados e é de tamanho razoável.
|
||||||
|
if first || !saw_idat || p > 64 * 1024 * 1024 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(Hit {
|
Some(Hit {
|
||||||
len: p.min(max).min(src.size().saturating_sub(off)),
|
len: p,
|
||||||
ext: "png",
|
ext: "png",
|
||||||
confidence: Confidence::Low,
|
confidence: Confidence::Low,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
//! Miniaturas para a grade de arquivos. Decodifica com redução na própria
|
||||||
|
//! decodificação (JPEG) para ser rápido mesmo com milhares de fotos e, se a
|
||||||
|
//! imagem estiver danificada, tenta a miniatura embutida no EXIF.
|
||||||
|
|
||||||
|
use crate::recover::read_found_at;
|
||||||
|
use crate::source::Source;
|
||||||
|
use crate::types::Found;
|
||||||
|
use image::{imageops, ImageBuffer, Rgb, RgbImage};
|
||||||
|
|
||||||
|
const MAX_READ: u64 = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
fn read_all(src: &dyn Source, f: &Found) -> Option<Vec<u8>> {
|
||||||
|
if f.size == 0 || f.size > MAX_READ {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut v = vec![0u8; f.size as usize];
|
||||||
|
let mut got = 0;
|
||||||
|
while got < v.len() {
|
||||||
|
match read_found_at(src, f, got as u64, &mut v[got..]) {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(n) => got += n,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.truncate(got);
|
||||||
|
(!v.is_empty()).then_some(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Miniatura JPEG com o maior lado = `max` (None se não for imagem ou não decodificar).
|
||||||
|
pub fn thumbnail(src: &dyn Source, f: &Found, max: u32) -> Option<Vec<u8>> {
|
||||||
|
let data = read_all(src, f)?;
|
||||||
|
let img = match f.ext.to_ascii_lowercase().as_str() {
|
||||||
|
"jpg" | "jpeg" => decode_jpeg(&data, max).or_else(|| {
|
||||||
|
let t = crate::meta::exif_thumbnail(&data)?;
|
||||||
|
decode_jpeg(&t, max)
|
||||||
|
})?,
|
||||||
|
"png" | "gif" | "bmp" | "webp" => decode_other(&data, max)?,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
encode(&img)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_jpeg(data: &[u8], max: u32) -> Option<RgbImage> {
|
||||||
|
let mut m = crate::meta::Meta::default();
|
||||||
|
crate::meta::jpeg_bytes(data, &mut m);
|
||||||
|
let orientation = m.orientation;
|
||||||
|
let mut dec = jpeg_decoder::Decoder::new(std::io::Cursor::new(data));
|
||||||
|
dec.read_info().ok()?;
|
||||||
|
let _ = dec.scale(max.min(65535) as u16, max.min(65535) as u16);
|
||||||
|
let px = dec.decode().ok()?;
|
||||||
|
let info = dec.info()?;
|
||||||
|
let (w, h) = (info.width as u32, info.height as u32);
|
||||||
|
let rgb: Vec<u8> = match info.pixel_format {
|
||||||
|
jpeg_decoder::PixelFormat::RGB24 => px,
|
||||||
|
jpeg_decoder::PixelFormat::L8 => px.iter().flat_map(|&g| [g, g, g]).collect(),
|
||||||
|
jpeg_decoder::PixelFormat::L16 => px
|
||||||
|
.chunks_exact(2)
|
||||||
|
.flat_map(|c| [c[0], c[0], c[0]])
|
||||||
|
.collect(),
|
||||||
|
jpeg_decoder::PixelFormat::CMYK32 => px
|
||||||
|
.chunks_exact(4)
|
||||||
|
.flat_map(|c| {
|
||||||
|
// Adobe CMYK vem invertido
|
||||||
|
let k = c[3] as u32;
|
||||||
|
[
|
||||||
|
(c[0] as u32 * k / 255) as u8,
|
||||||
|
(c[1] as u32 * k / 255) as u8,
|
||||||
|
(c[2] as u32 * k / 255) as u8,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
let img: RgbImage = ImageBuffer::from_raw(w, h, rgb)?;
|
||||||
|
Some(orient(fit(img, max), orientation))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_other(data: &[u8], max: u32) -> Option<RgbImage> {
|
||||||
|
let dynimg = image::load_from_memory(data).ok()?;
|
||||||
|
let rgba = dynimg.to_rgba8();
|
||||||
|
// Achata a transparência sobre cinza claro.
|
||||||
|
let mut rgb = RgbImage::new(rgba.width(), rgba.height());
|
||||||
|
for (x, y, p) in rgba.enumerate_pixels() {
|
||||||
|
let a = p[3] as u32;
|
||||||
|
let mix = |c: u8| ((c as u32 * a + 240 * (255 - a)) / 255) as u8;
|
||||||
|
rgb.put_pixel(x, y, Rgb([mix(p[0]), mix(p[1]), mix(p[2])]));
|
||||||
|
}
|
||||||
|
Some(fit(rgb, max))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fit(img: RgbImage, max: u32) -> RgbImage {
|
||||||
|
let (w, h) = (img.width(), img.height());
|
||||||
|
if w.max(h) <= max {
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
let (nw, nh) = if w >= h {
|
||||||
|
(max, (h as u64 * max as u64 / w as u64).max(1) as u32)
|
||||||
|
} else {
|
||||||
|
((w as u64 * max as u64 / h as u64).max(1) as u32, max)
|
||||||
|
};
|
||||||
|
imageops::resize(&img, nw, nh, imageops::FilterType::Triangle)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn orient(img: RgbImage, o: u16) -> RgbImage {
|
||||||
|
match o {
|
||||||
|
2 => imageops::flip_horizontal(&img),
|
||||||
|
3 => imageops::rotate180(&img),
|
||||||
|
4 => imageops::flip_vertical(&img),
|
||||||
|
5 => imageops::flip_horizontal(&imageops::rotate90(&img)),
|
||||||
|
6 => imageops::rotate90(&img),
|
||||||
|
7 => imageops::flip_horizontal(&imageops::rotate270(&img)),
|
||||||
|
8 => imageops::rotate270(&img),
|
||||||
|
_ => img,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode(img: &RgbImage) -> Option<Vec<u8>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let enc = jpeg_encoder::Encoder::new(&mut out, 80);
|
||||||
|
enc.encode(
|
||||||
|
img.as_raw(),
|
||||||
|
img.width() as u16,
|
||||||
|
img.height() as u16,
|
||||||
|
jpeg_encoder::ColorType::Rgb,
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
@@ -132,9 +132,34 @@ pub struct Found {
|
|||||||
pub data: DataLoc,
|
pub data: DataLoc,
|
||||||
/// Segundos desde 1970-01-01 UTC.
|
/// Segundos desde 1970-01-01 UTC.
|
||||||
pub modified: Option<i64>,
|
pub modified: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub accessed: Option<i64>,
|
||||||
|
/// Data em que a foto foi tirada (EXIF; hora da câmera lida como UTC), s desde 1970.
|
||||||
|
#[serde(default)]
|
||||||
|
pub taken: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub camera: Option<String>,
|
||||||
|
/// Largura × altura em pixels, quando conhecidas.
|
||||||
|
#[serde(default)]
|
||||||
|
pub px: Option<(u32, u32)>,
|
||||||
|
/// Há coordenadas GPS nos metadados.
|
||||||
|
#[serde(default)]
|
||||||
|
pub gps: bool,
|
||||||
|
/// Assinatura rápida do conteúdo (tamanho + início + fim) para achar duplicadas.
|
||||||
|
#[serde(default)]
|
||||||
|
pub sig: Option<u64>,
|
||||||
pub note: Option<String>,
|
pub note: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Found {
|
||||||
|
/// Data mais representativa: quando foi tirada > modificada > criada.
|
||||||
|
pub fn when(&self) -> Option<i64> {
|
||||||
|
self.taken.or(self.modified).or(self.created)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Found {
|
impl Found {
|
||||||
pub fn first_offset(&self) -> Option<u64> {
|
pub fn first_offset(&self) -> Option<u64> {
|
||||||
match &self.data {
|
match &self.data {
|
||||||
|
|||||||
@@ -231,3 +231,200 @@ fn pausa_e_retoma_encontra_o_mesmo() {
|
|||||||
b.sort();
|
b.sort();
|
||||||
assert_eq!(a, b);
|
assert_eq!(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Streaming de pré-visualização: Range devolve exatamente os bytes do arquivo recuperado.
|
||||||
|
#[test]
|
||||||
|
fn file_slice_com_range_bate_com_o_arquivo_recuperado() {
|
||||||
|
use std::io::Read;
|
||||||
|
let Some(dir) = fixtures() else { return };
|
||||||
|
let path = dir.join("ntfs.img");
|
||||||
|
let eng = service::Engine::new();
|
||||||
|
let job = eng
|
||||||
|
.start_scan(
|
||||||
|
path.to_str().unwrap(),
|
||||||
|
ScanOptions {
|
||||||
|
mode: ScanMode::Quick,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
loop {
|
||||||
|
let st = eng.status(&job).unwrap();
|
||||||
|
if st["status"] != "running" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
let res = eng
|
||||||
|
.results(
|
||||||
|
&job,
|
||||||
|
&service::Query {
|
||||||
|
query: Some("relatorio.pdf".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let id = res["items"][0]["id"].as_u64().unwrap();
|
||||||
|
let size = res["items"][0]["size"].as_u64().unwrap();
|
||||||
|
let want = expected(&dir, "ntfs")
|
||||||
|
.into_iter()
|
||||||
|
.find(|(_, p)| p.ends_with("relatorio.pdf"))
|
||||||
|
.unwrap()
|
||||||
|
.0;
|
||||||
|
|
||||||
|
let full = eng.file_slice(&job, id, None).unwrap();
|
||||||
|
assert_eq!((full.status(), full.len()), (200, size));
|
||||||
|
let mut all = vec![];
|
||||||
|
full.reader().read_to_end(&mut all).unwrap();
|
||||||
|
assert_eq!(sha256(&all), want);
|
||||||
|
|
||||||
|
let part = eng.file_slice(&job, id, Some("bytes=100-199")).unwrap();
|
||||||
|
assert_eq!((part.status(), part.len()), (206, 100));
|
||||||
|
assert_eq!(part.read_all(1 << 20).unwrap(), all[100..200]);
|
||||||
|
let tail = eng.file_slice(&job, id, Some("bytes=-50")).unwrap();
|
||||||
|
assert_eq!(tail.read_all(1 << 20).unwrap(), all[all.len() - 50..]);
|
||||||
|
assert_eq!(
|
||||||
|
eng.file_slice(&job, id, Some(&format!("bytes={size}-")))
|
||||||
|
.err(),
|
||||||
|
Some(416)
|
||||||
|
);
|
||||||
|
assert_eq!(eng.file_slice(&job, 999_999, None).err(), Some(404));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configuracoes_de_tema_persistem() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::env::set_var("GUARA_CONFIG_DIR", dir.path());
|
||||||
|
assert_eq!(settings::load().theme, "auto");
|
||||||
|
settings::save(&settings::Settings {
|
||||||
|
theme: "dark".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(settings::load().theme, "dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_done(eng: &service::Engine, job: &str) {
|
||||||
|
for _ in 0..500 {
|
||||||
|
if eng.status(job).unwrap()["status"] != "running" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
panic!("job não terminou");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gera uma sessão no formato da v0.1.0 (sem os campos que vieram depois) a partir de uma
|
||||||
|
/// varredura real da imagem, com o alvo informado.
|
||||||
|
fn sessao_formato_v0_1_0(img: &std::path::Path, alvo: &str) -> tempfile::NamedTempFile {
|
||||||
|
let eng = service::Engine::new();
|
||||||
|
let job = eng
|
||||||
|
.start_scan(img.to_str().unwrap(), ScanOptions::default())
|
||||||
|
.unwrap();
|
||||||
|
wait_done(&eng, &job);
|
||||||
|
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
eng.save_session(&job, tmp.path().to_str().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let mut v: serde_json::Value =
|
||||||
|
serde_json::from_slice(&std::fs::read(tmp.path()).unwrap()).unwrap();
|
||||||
|
let o = v.as_object_mut().unwrap();
|
||||||
|
o.remove("recoveries");
|
||||||
|
o.remove("started_unix");
|
||||||
|
o.insert("target".into(), alvo.into());
|
||||||
|
for f in o["found"].as_array_mut().unwrap() {
|
||||||
|
let f = f.as_object_mut().unwrap();
|
||||||
|
for k in ["created", "accessed", "taken", "camera", "px", "gps", "sig"] {
|
||||||
|
f.remove(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::fs::write(tmp.path(), serde_json::to_vec(&v).unwrap()).unwrap();
|
||||||
|
tmp
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uma sessão gravada pela v0.1.0 continua abrindo nas versões novas.
|
||||||
|
#[test]
|
||||||
|
fn sessao_da_v0_1_0_abre_e_funciona() {
|
||||||
|
let Some(dir) = fixtures() else { return };
|
||||||
|
let img = dir.join("ntfs.img");
|
||||||
|
let tmp = sessao_formato_v0_1_0(&img, img.to_str().unwrap());
|
||||||
|
let eng = service::Engine::new();
|
||||||
|
let job = eng.load_session(tmp.path().to_str().unwrap()).unwrap();
|
||||||
|
wait_done(&eng, &job);
|
||||||
|
let st = eng.status(&job).unwrap();
|
||||||
|
assert_eq!(st["status"], "done");
|
||||||
|
assert_eq!(st["found"], 7);
|
||||||
|
assert_eq!(st["source"], true);
|
||||||
|
let res = eng.results(&job, &service::Query::default()).unwrap();
|
||||||
|
assert_eq!(res["total"], 7);
|
||||||
|
// recuperar a partir da sessão antiga continua dando o mesmo conteúdo
|
||||||
|
let out = tempfile::tempdir().unwrap();
|
||||||
|
let rj = eng
|
||||||
|
.start_recover(
|
||||||
|
&job,
|
||||||
|
None,
|
||||||
|
Some(service::Query::default()),
|
||||||
|
out.path().to_str().unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
wait_done(&eng, &rj);
|
||||||
|
let got = hashes_in(out.path());
|
||||||
|
for (h, p) in expected(&dir, "ntfs") {
|
||||||
|
assert!(got.contains_key(&h), "faltou {p}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Origem indisponível não impede abrir a sessão; reapontar a origem religa tudo.
|
||||||
|
#[test]
|
||||||
|
fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
|
||||||
|
let Some(dir) = fixtures() else { return };
|
||||||
|
let img = dir.join("ntfs.img");
|
||||||
|
let tmp = sessao_formato_v0_1_0(&img, "/dev/disco-que-nao-existe");
|
||||||
|
let eng = service::Engine::new();
|
||||||
|
let job = eng.load_session(tmp.path().to_str().unwrap()).unwrap();
|
||||||
|
let st = eng.status(&job).unwrap();
|
||||||
|
assert_eq!(st["source"], false);
|
||||||
|
assert_eq!(st["found"], 7);
|
||||||
|
assert!(st["warnings"][0]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("não está disponível"));
|
||||||
|
// navegar e exportar funcionam
|
||||||
|
assert_eq!(
|
||||||
|
eng.results(&job, &service::Query::default()).unwrap()["total"],
|
||||||
|
7
|
||||||
|
);
|
||||||
|
let csv = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
eng.export_csv(&job, csv.path().to_str().unwrap(), None)
|
||||||
|
.unwrap(),
|
||||||
|
7
|
||||||
|
);
|
||||||
|
// pré-visualização, retomada e recuperação avisam com clareza
|
||||||
|
assert_eq!(eng.thumb(&job, 1, 128).err(), Some(503));
|
||||||
|
assert!(eng
|
||||||
|
.resume(&job)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("não está disponível"));
|
||||||
|
let out = tempfile::tempdir().unwrap();
|
||||||
|
let e = eng
|
||||||
|
.start_recover(&job, None, None, out.path().to_str().unwrap())
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(e.to_string().contains("não está disponível"), "{e}");
|
||||||
|
// reconecta
|
||||||
|
eng.relink(&job, img.to_str().unwrap()).unwrap();
|
||||||
|
let st = eng.status(&job).unwrap();
|
||||||
|
assert_eq!(st["source"], true);
|
||||||
|
assert!(st["warnings"].as_array().unwrap().is_empty());
|
||||||
|
let rj = eng
|
||||||
|
.start_recover(&job, None, None, out.path().to_str().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
wait_done(&eng, &rj);
|
||||||
|
assert_eq!(hashes_in(out.path()).len(), 7);
|
||||||
|
// arquivo que não é sessão
|
||||||
|
std::fs::write(tmp.path(), b"nao e json").unwrap();
|
||||||
|
let e = eng.load_session(tmp.path().to_str().unwrap()).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
e.to_string().contains("não é uma sessão do Guará válida"),
|
||||||
|
"{e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,601 @@
|
|||||||
|
//! Recursos periciais: hashes, metadados (EXIF/PDF/Office/MP4), miniaturas,
|
||||||
|
//! busca de texto, exportações e navegação (facetas/duplicadas).
|
||||||
|
|
||||||
|
use guara_core::explorer::{self, Query};
|
||||||
|
use guara_core::hashing::hash_bytes;
|
||||||
|
use guara_core::source::MemSource;
|
||||||
|
use guara_core::*;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
fn resident(name: &str, bytes: Vec<u8>) -> Found {
|
||||||
|
let ext = name.rsplit('.').next().unwrap_or("").to_string();
|
||||||
|
Found {
|
||||||
|
id: 1,
|
||||||
|
name: name.into(),
|
||||||
|
dir: None,
|
||||||
|
category: signatures::category_for_ext(&ext),
|
||||||
|
ext,
|
||||||
|
size: bytes.len() as u64,
|
||||||
|
origin: Origin::Carved,
|
||||||
|
confidence: Confidence::High,
|
||||||
|
data: DataLoc::Resident { bytes },
|
||||||
|
modified: None,
|
||||||
|
created: None,
|
||||||
|
accessed: None,
|
||||||
|
taken: None,
|
||||||
|
camera: None,
|
||||||
|
px: None,
|
||||||
|
gps: false,
|
||||||
|
sig: None,
|
||||||
|
note: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ hashes
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashes_conferem_com_vetores_conhecidos() {
|
||||||
|
let h = hash_bytes(b"abc");
|
||||||
|
assert_eq!(h.md5, "900150983cd24fb0d6963f7d28e17f72");
|
||||||
|
assert_eq!(h.sha1, "a9993e364706816aba3e25717850c26c9cd0d89d");
|
||||||
|
assert_eq!(
|
||||||
|
h.sha256,
|
||||||
|
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ EXIF
|
||||||
|
|
||||||
|
fn ifd(entries: &[(u16, u16, u32, Vec<u8>)], start: usize) -> Vec<u8> {
|
||||||
|
let n = entries.len();
|
||||||
|
let mut table = (n as u16).to_le_bytes().to_vec();
|
||||||
|
let mut data: Vec<u8> = vec![];
|
||||||
|
let data_off = start + 2 + 12 * n + 4;
|
||||||
|
for (tag, typ, cnt, val) in entries {
|
||||||
|
table.extend(tag.to_le_bytes());
|
||||||
|
table.extend(typ.to_le_bytes());
|
||||||
|
table.extend(cnt.to_le_bytes());
|
||||||
|
if val.len() <= 4 {
|
||||||
|
let mut v = val.clone();
|
||||||
|
v.resize(4, 0);
|
||||||
|
table.extend(v);
|
||||||
|
} else {
|
||||||
|
if data.len() % 2 == 1 {
|
||||||
|
data.push(0);
|
||||||
|
}
|
||||||
|
table.extend(((data_off + data.len()) as u32).to_le_bytes());
|
||||||
|
data.extend(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
table.extend([0, 0, 0, 0]);
|
||||||
|
table.extend(data);
|
||||||
|
table
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rat(p: &[(u32, u32)]) -> Vec<u8> {
|
||||||
|
p.iter()
|
||||||
|
.flat_map(|(a, b)| a.to_le_bytes().into_iter().chain(b.to_le_bytes()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jpeg_with_exif(orientation: u16, w: usize, h: usize) -> Vec<u8> {
|
||||||
|
// pixels: gradiente
|
||||||
|
let mut px = Vec::with_capacity(w * h * 3);
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
px.extend([(x * 255 / w) as u8, (y * 255 / h) as u8, 128]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut jpg = vec![];
|
||||||
|
jpeg_encoder::Encoder::new(&mut jpg, 85)
|
||||||
|
.encode(&px, w as u16, h as u16, jpeg_encoder::ColorType::Rgb)
|
||||||
|
.unwrap();
|
||||||
|
let asc = |s: &str| {
|
||||||
|
let mut v = s.as_bytes().to_vec();
|
||||||
|
v.push(0);
|
||||||
|
v
|
||||||
|
};
|
||||||
|
let make = asc("Canon");
|
||||||
|
let model = asc("Canon EOS 80D");
|
||||||
|
let dt = asc("2023:07:14 18:22:05");
|
||||||
|
let e0 = |exif: u32, gps: u32| {
|
||||||
|
ifd(
|
||||||
|
&[
|
||||||
|
(0x010F, 2, make.len() as u32, make.clone()),
|
||||||
|
(0x0110, 2, model.len() as u32, model.clone()),
|
||||||
|
(0x0112, 3, 1, orientation.to_le_bytes().to_vec()),
|
||||||
|
(0x8769, 4, 1, exif.to_le_bytes().to_vec()),
|
||||||
|
(0x8825, 4, 1, gps.to_le_bytes().to_vec()),
|
||||||
|
],
|
||||||
|
8,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let first = e0(0, 0);
|
||||||
|
let exif_off = 8 + first.len();
|
||||||
|
let exif_ifd = ifd(&[(0x9003, 2, dt.len() as u32, dt.clone())], exif_off);
|
||||||
|
let gps_off = exif_off + exif_ifd.len();
|
||||||
|
let gps_ifd = ifd(
|
||||||
|
&[
|
||||||
|
(1, 2, 2, b"S\0".to_vec()),
|
||||||
|
(2, 5, 3, rat(&[(23, 1), (33, 1), (1800, 100)])),
|
||||||
|
(3, 2, 2, b"W\0".to_vec()),
|
||||||
|
(4, 5, 3, rat(&[(46, 1), (38, 1), (0, 1)])),
|
||||||
|
],
|
||||||
|
gps_off,
|
||||||
|
);
|
||||||
|
let mut tiff = b"II*\0".to_vec();
|
||||||
|
tiff.extend(8u32.to_le_bytes());
|
||||||
|
tiff.extend(e0(exif_off as u32, gps_off as u32));
|
||||||
|
tiff.extend(exif_ifd);
|
||||||
|
tiff.extend(gps_ifd);
|
||||||
|
let mut app1 = b"Exif\0\0".to_vec();
|
||||||
|
app1.extend(tiff);
|
||||||
|
let mut out = vec![0xFF, 0xD8, 0xFF, 0xE1];
|
||||||
|
out.extend(((app1.len() + 2) as u16).to_be_bytes());
|
||||||
|
out.extend(app1);
|
||||||
|
out.extend(&jpg[2..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exif_camera_data_e_gps() {
|
||||||
|
let f = resident("foto.jpg", jpeg_with_exif(1, 64, 48));
|
||||||
|
let m = meta::extract(&MemSource(vec![]), &f);
|
||||||
|
assert_eq!(m.camera.as_deref(), Some("Canon EOS 80D"));
|
||||||
|
assert_eq!(m.px, Some((64, 48)));
|
||||||
|
// 2023-07-14 18:22:05 lido como UTC
|
||||||
|
assert_eq!(m.taken, Some(1_689_358_925));
|
||||||
|
let (lat, lon) = m.gps.unwrap();
|
||||||
|
assert!((lat + 23.5550).abs() < 0.001, "{lat}");
|
||||||
|
assert!((lon + 46.6333).abs() < 0.001, "{lon}");
|
||||||
|
assert!(m
|
||||||
|
.fields
|
||||||
|
.iter()
|
||||||
|
.any(|(k, v)| k == "Foto tirada em" && v == "14/07/2023 18:22:05"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn miniatura_respeita_orientacao_e_tamanho() {
|
||||||
|
let src = MemSource(vec![]);
|
||||||
|
// 64x48 com orientação 6 (girar 90 graus) => retrato 48x64, reduzido para caber em 32
|
||||||
|
let f = resident("foto.jpg", jpeg_with_exif(6, 64, 48));
|
||||||
|
let t = thumb::thumbnail(&src, &f, 32).expect("miniatura");
|
||||||
|
let mut dec = jpeg_decoder::Decoder::new(&t[..]);
|
||||||
|
dec.decode().unwrap();
|
||||||
|
let i = dec.info().unwrap();
|
||||||
|
assert_eq!((i.width, i.height), (24, 32));
|
||||||
|
// PNG também
|
||||||
|
let mut img = ::image::RgbImage::new(100, 50);
|
||||||
|
for p in img.pixels_mut() {
|
||||||
|
*p = ::image::Rgb([200, 30, 30]);
|
||||||
|
}
|
||||||
|
let mut png = std::io::Cursor::new(vec![]);
|
||||||
|
img.write_to(&mut png, ::image::ImageFormat::Png).unwrap();
|
||||||
|
let f = resident("x.png", png.into_inner());
|
||||||
|
let t = thumb::thumbnail(&src, &f, 40).expect("miniatura png");
|
||||||
|
let mut dec = jpeg_decoder::Decoder::new(&t[..]);
|
||||||
|
dec.decode().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(dec.info().unwrap().width, dec.info().unwrap().height),
|
||||||
|
(40, 20)
|
||||||
|
);
|
||||||
|
// lixo não vira miniatura
|
||||||
|
assert!(thumb::thumbnail(&src, &resident("x.jpg", vec![1, 2, 3, 4, 5]), 32).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ PDF, Office, MP4
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadados_de_pdf() {
|
||||||
|
let pdf = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Count 12 /Kids [] >>\nendobj\n3 0 obj\n<< /Title (Relat\\363rio Final) /Author (Maria \\(Perita\\) Silva) /Creator (LibreOffice Writer) /Producer <FEFF00500044004600310032> /CreationDate (D:20230714182205-03'00') >>\nendobj\ntrailer\n%%EOF\n".to_vec();
|
||||||
|
let m = meta::extract(&MemSource(vec![]), &resident("a.pdf", pdf));
|
||||||
|
let get = |k: &str| {
|
||||||
|
m.fields
|
||||||
|
.iter()
|
||||||
|
.find(|(a, _)| a == k)
|
||||||
|
.map(|(_, v)| v.clone())
|
||||||
|
};
|
||||||
|
assert_eq!(get("Título").as_deref(), Some("Relatório Final"));
|
||||||
|
assert_eq!(get("Autor").as_deref(), Some("Maria (Perita) Silva"));
|
||||||
|
assert_eq!(get("Criado com").as_deref(), Some("LibreOffice Writer"));
|
||||||
|
assert_eq!(get("Gerado por").as_deref(), Some("PDF12"));
|
||||||
|
assert_eq!(get("Criado em").as_deref(), Some("14/07/2023 18:22:05"));
|
||||||
|
assert_eq!(get("Páginas (aprox.)").as_deref(), Some("12"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zip_store(entries: &[(&str, &str)], deflate: bool) -> Vec<u8> {
|
||||||
|
let mut out = vec![];
|
||||||
|
let mut cd = vec![];
|
||||||
|
for (name, body) in entries {
|
||||||
|
let (method, data): (u16, Vec<u8>) = if deflate {
|
||||||
|
let mut e = flate2::write::DeflateEncoder::new(vec![], flate2::Compression::default());
|
||||||
|
e.write_all(body.as_bytes()).unwrap();
|
||||||
|
(8, e.finish().unwrap())
|
||||||
|
} else {
|
||||||
|
(0, body.as_bytes().to_vec())
|
||||||
|
};
|
||||||
|
let off = out.len() as u32;
|
||||||
|
let crc = signatures::crc32(body.as_bytes());
|
||||||
|
let mut lh = b"PK\x03\x04".to_vec();
|
||||||
|
lh.extend([20, 0, 0, 0]);
|
||||||
|
lh.extend(method.to_le_bytes());
|
||||||
|
lh.extend([0, 0, 0, 0]);
|
||||||
|
lh.extend(crc.to_le_bytes());
|
||||||
|
lh.extend((data.len() as u32).to_le_bytes());
|
||||||
|
lh.extend((body.len() as u32).to_le_bytes());
|
||||||
|
lh.extend((name.len() as u16).to_le_bytes());
|
||||||
|
lh.extend([0, 0]);
|
||||||
|
lh.extend(name.as_bytes());
|
||||||
|
out.extend(&lh);
|
||||||
|
out.extend(&data);
|
||||||
|
let mut c = b"PK\x01\x02".to_vec();
|
||||||
|
c.extend([20, 0, 20, 0, 0, 0]);
|
||||||
|
c.extend(method.to_le_bytes());
|
||||||
|
c.extend([0, 0, 0, 0]);
|
||||||
|
c.extend(crc.to_le_bytes());
|
||||||
|
c.extend((data.len() as u32).to_le_bytes());
|
||||||
|
c.extend((body.len() as u32).to_le_bytes());
|
||||||
|
c.extend((name.len() as u16).to_le_bytes());
|
||||||
|
c.extend([0; 12]);
|
||||||
|
c.extend(off.to_le_bytes());
|
||||||
|
c.extend(name.as_bytes());
|
||||||
|
cd.extend(c);
|
||||||
|
}
|
||||||
|
let cd_off = out.len() as u32;
|
||||||
|
out.extend(&cd);
|
||||||
|
out.extend(b"PK\x05\x06\0\0\0\0");
|
||||||
|
out.extend((entries.len() as u16).to_le_bytes());
|
||||||
|
out.extend((entries.len() as u16).to_le_bytes());
|
||||||
|
out.extend((cd.len() as u32).to_le_bytes());
|
||||||
|
out.extend(cd_off.to_le_bytes());
|
||||||
|
out.extend([0, 0]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadados_de_documentos_office() {
|
||||||
|
let core = r#"<?xml version="1.0"?><cp:coreProperties xmlns:cp="x" xmlns:dc="y" xmlns:dcterms="z"><dc:title>Tese & Defesa</dc:title><dc:creator>Ana Souza</dc:creator><cp:lastModifiedBy>Bruno Lima</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">2023-07-14T18:22:05Z</dcterms:created></cp:coreProperties>"#;
|
||||||
|
let app = "<Properties><Application>Microsoft Office Word</Application><Company>ACME</Company></Properties>";
|
||||||
|
for deflate in [false, true] {
|
||||||
|
let z = zip_store(
|
||||||
|
&[
|
||||||
|
("[Content_Types].xml", "<Types/>"),
|
||||||
|
("docProps/core.xml", core),
|
||||||
|
("docProps/app.xml", app),
|
||||||
|
],
|
||||||
|
deflate,
|
||||||
|
);
|
||||||
|
let m = meta::extract(&MemSource(vec![]), &resident("t.docx", z));
|
||||||
|
let get = |k: &str| {
|
||||||
|
m.fields
|
||||||
|
.iter()
|
||||||
|
.find(|(a, _)| a == k)
|
||||||
|
.map(|(_, v)| v.clone())
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
get("Título").as_deref(),
|
||||||
|
Some("Tese & Defesa"),
|
||||||
|
"deflate={deflate}"
|
||||||
|
);
|
||||||
|
assert_eq!(get("Autor").as_deref(), Some("Ana Souza"));
|
||||||
|
assert_eq!(get("Última modificação por").as_deref(), Some("Bruno Lima"));
|
||||||
|
assert_eq!(
|
||||||
|
get("Criado em").as_deref(),
|
||||||
|
Some("14/07/2023 18:22:05 (UTC)")
|
||||||
|
);
|
||||||
|
assert_eq!(get("Aplicativo").as_deref(), Some("Microsoft Office Word"));
|
||||||
|
assert_eq!(get("Empresa").as_deref(), Some("ACME"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadados_de_mp4() {
|
||||||
|
fn bx(ty: &[u8; 4], body: &[u8]) -> Vec<u8> {
|
||||||
|
let mut v = ((body.len() + 8) as u32).to_be_bytes().to_vec();
|
||||||
|
v.extend(ty);
|
||||||
|
v.extend(body);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
let created_1904: u32 = 1_689_358_925 + 2_082_844_800;
|
||||||
|
let mut mvhd = vec![0, 0, 0, 0];
|
||||||
|
mvhd.extend(created_1904.to_be_bytes());
|
||||||
|
mvhd.extend(created_1904.to_be_bytes());
|
||||||
|
mvhd.extend(1000u32.to_be_bytes()); // timescale
|
||||||
|
mvhd.extend(125_000u32.to_be_bytes()); // 125 s
|
||||||
|
mvhd.extend(vec![0u8; 80]);
|
||||||
|
let mut tkhd = vec![0u8; 84];
|
||||||
|
tkhd[76..80].copy_from_slice(&(1920u32 << 16).to_be_bytes());
|
||||||
|
tkhd[80..84].copy_from_slice(&(1080u32 << 16).to_be_bytes());
|
||||||
|
let moov = bx(
|
||||||
|
b"moov",
|
||||||
|
&[bx(b"mvhd", &mvhd), bx(b"trak", &bx(b"tkhd", &tkhd))].concat(),
|
||||||
|
);
|
||||||
|
let mut file = bx(b"ftyp", b"isom\0\0\0\0isom");
|
||||||
|
file.extend(moov);
|
||||||
|
file.extend(bx(b"mdat", &[0u8; 32]));
|
||||||
|
let m = meta::extract(&MemSource(vec![]), &resident("v.mp4", file));
|
||||||
|
let get = |k: &str| {
|
||||||
|
m.fields
|
||||||
|
.iter()
|
||||||
|
.find(|(a, _)| a == k)
|
||||||
|
.map(|(_, v)| v.clone())
|
||||||
|
};
|
||||||
|
assert_eq!(get("Duração").as_deref(), Some("0:02:05"));
|
||||||
|
assert_eq!(
|
||||||
|
get("Gravado em (metadado)").as_deref(),
|
||||||
|
Some("14/07/2023 18:22:05 (UTC)")
|
||||||
|
);
|
||||||
|
assert_eq!(get("Dimensões").as_deref(), Some("1920 × 1080 px"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ busca de texto
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn busca_acha_utf8_utf16_e_na_fronteira_dos_blocos() {
|
||||||
|
let mb = 1024 * 1024;
|
||||||
|
let mut disk = vec![0u8; 20 * mb];
|
||||||
|
let put = |d: &mut Vec<u8>, at: usize, b: &[u8]| d[at..at + b.len()].copy_from_slice(b);
|
||||||
|
put(&mut disk, 1000, b"... Segredo-123 ...");
|
||||||
|
let u16s: Vec<u8> = "segredo-123"
|
||||||
|
.encode_utf16()
|
||||||
|
.flat_map(|c| c.to_le_bytes())
|
||||||
|
.collect();
|
||||||
|
put(&mut disk, 3 * mb + 6, &u16s);
|
||||||
|
// exatamente cruzando o limite do primeiro bloco de 8 MiB
|
||||||
|
put(&mut disk, 8 * mb - 4, b"SEGREDO-123");
|
||||||
|
let opts = search::SearchOpts {
|
||||||
|
terms: vec!["segredo-123".into()],
|
||||||
|
ignore_case: true,
|
||||||
|
utf16: true,
|
||||||
|
max_hits: 100,
|
||||||
|
};
|
||||||
|
let mut hits = vec![];
|
||||||
|
let n = search::search(
|
||||||
|
&MemSource(disk.clone()),
|
||||||
|
&opts,
|
||||||
|
&|| false,
|
||||||
|
&mut |_, _| {},
|
||||||
|
&mut |h| {
|
||||||
|
hits.push(h);
|
||||||
|
true
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(n, 3, "{hits:?}");
|
||||||
|
assert_eq!(hits[0].offset, 1004);
|
||||||
|
assert_eq!(hits[0].encoding, "UTF-8");
|
||||||
|
assert!(hits[0].context.contains("Segredo-123"));
|
||||||
|
assert_eq!(hits[1].offset, 3 * mb as u64 + 6);
|
||||||
|
assert_eq!(hits[1].encoding, "UTF-16");
|
||||||
|
assert_eq!(hits[2].offset, 8 * mb as u64 - 4);
|
||||||
|
// sem ignorar maiúsculas só acha a grafia exata
|
||||||
|
let opts = search::SearchOpts {
|
||||||
|
terms: vec!["Segredo-123".into()],
|
||||||
|
ignore_case: false,
|
||||||
|
utf16: false,
|
||||||
|
max_hits: 100,
|
||||||
|
};
|
||||||
|
let n = search::search(
|
||||||
|
&MemSource(disk),
|
||||||
|
&opts,
|
||||||
|
&|| false,
|
||||||
|
&mut |_, _| {},
|
||||||
|
&mut |_| true,
|
||||||
|
);
|
||||||
|
assert_eq!(n, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ exportações
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn csv_escapa_e_neutraliza_formulas() {
|
||||||
|
let mut f = resident("=SOMA(A1).xlsx", vec![1]);
|
||||||
|
f.note = Some("diz \"oi\"; ok".into());
|
||||||
|
f.camera = Some("Cam;X".into());
|
||||||
|
let csv = report::csv(&[&f]);
|
||||||
|
assert!(csv.starts_with('\u{feff}'));
|
||||||
|
assert!(csv.contains("\"'=SOMA(A1).xlsx\""), "{csv}");
|
||||||
|
assert!(csv.contains("\"diz \"\"oi\"\"; ok\""));
|
||||||
|
assert_eq!(csv.lines().count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relatorio_html_escapa_conteudo_e_traz_o_caso() {
|
||||||
|
let f = resident("<script>alert(1)</script>.txt", vec![1, 2, 3]);
|
||||||
|
let case = report::CaseInfo {
|
||||||
|
case_id: "2026-001".into(),
|
||||||
|
examiner: "Perita <Ana>".into(),
|
||||||
|
organization: "Lab".into(),
|
||||||
|
notes: "obs".into(),
|
||||||
|
};
|
||||||
|
let opts = ScanOptions::default();
|
||||||
|
let hashes = hash_bytes(b"disco");
|
||||||
|
let html = report::html(&report::ReportInput {
|
||||||
|
case: &case,
|
||||||
|
target: "/dev/sdb",
|
||||||
|
options: &opts,
|
||||||
|
layout: None,
|
||||||
|
found: &[f],
|
||||||
|
warnings: &["aviso <b>".to_string()],
|
||||||
|
started_unix: Some(1_689_358_925),
|
||||||
|
elapsed: 1.5,
|
||||||
|
recoveries: &[],
|
||||||
|
source_hashes: Some(&hashes),
|
||||||
|
});
|
||||||
|
assert!(!html.contains("<script>alert"), "HTML não escapado");
|
||||||
|
assert!(html.contains("<script>alert(1)</script>.txt"));
|
||||||
|
assert!(html.contains("2026-001") && html.contains("Perita <Ana>"));
|
||||||
|
assert!(html.contains(&hashes.sha256));
|
||||||
|
assert!(html.contains("somente para leitura"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ navegação
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn facetas_filtros_e_duplicadas() {
|
||||||
|
let mk = |id: u64,
|
||||||
|
name: &str,
|
||||||
|
dir: Option<&str>,
|
||||||
|
taken: Option<i64>,
|
||||||
|
cam: Option<&str>,
|
||||||
|
sig: Option<u64>| {
|
||||||
|
let mut f = resident(name, vec![0; 10]);
|
||||||
|
f.id = id;
|
||||||
|
f.dir = dir.map(String::from);
|
||||||
|
f.origin = if dir.is_some() {
|
||||||
|
Origin::Fat
|
||||||
|
} else {
|
||||||
|
Origin::Carved
|
||||||
|
};
|
||||||
|
f.taken = taken;
|
||||||
|
f.camera = cam.map(String::from);
|
||||||
|
f.sig = sig;
|
||||||
|
f
|
||||||
|
};
|
||||||
|
let jul23 = 1_689_358_925; // 2023-07-14
|
||||||
|
let jan24 = 1_705_000_000; // 2024-01-11
|
||||||
|
let found = vec![
|
||||||
|
mk(
|
||||||
|
1,
|
||||||
|
"a.jpg",
|
||||||
|
Some("DCIM/100"),
|
||||||
|
Some(jul23),
|
||||||
|
Some("Canon"),
|
||||||
|
Some(7),
|
||||||
|
),
|
||||||
|
mk(
|
||||||
|
2,
|
||||||
|
"b.jpg",
|
||||||
|
Some("DCIM/100"),
|
||||||
|
Some(jan24),
|
||||||
|
Some("Canon"),
|
||||||
|
Some(8),
|
||||||
|
),
|
||||||
|
mk(3, "c.jpg", Some("Viagens"), Some(jul23), None, Some(7)), // duplicada de a.jpg
|
||||||
|
mk(4, "d.jpg", None, None, None, None),
|
||||||
|
];
|
||||||
|
let q = |json: &str| -> Query { serde_json::from_str(json).unwrap() };
|
||||||
|
let f = explorer::facets(&found);
|
||||||
|
assert_eq!(f["total"], 4);
|
||||||
|
assert_eq!(f["years"][0]["year"], 2024);
|
||||||
|
assert_eq!(f["years"][1]["year"], 2023);
|
||||||
|
assert_eq!(f["years"][1]["count"], 2);
|
||||||
|
assert_eq!(f["undated"], 1);
|
||||||
|
assert_eq!(f["carved"], 1);
|
||||||
|
assert_eq!(f["duplicates"], 1);
|
||||||
|
assert_eq!(f["small"], 4); // todos têm 10 bytes
|
||||||
|
assert_eq!(q(r#"{"min_size":30720}"#).select(&found).len(), 0);
|
||||||
|
assert_eq!(f["cameras"][0]["name"], "Canon");
|
||||||
|
let dcim = f["folders"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|d| d["path"] == "DCIM")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(dcim["count"], 2);
|
||||||
|
|
||||||
|
assert_eq!(q(r#"{"year":2023}"#).select(&found).len(), 2);
|
||||||
|
assert_eq!(q(r#"{"year":2023,"month":7}"#).select(&found).len(), 2);
|
||||||
|
assert_eq!(q(r#"{"year":2023,"month":8}"#).select(&found).len(), 0);
|
||||||
|
assert_eq!(q(r#"{"folder":"DCIM"}"#).select(&found).len(), 2);
|
||||||
|
assert_eq!(q(r#"{"folder":"@carved"}"#).select(&found).len(), 1);
|
||||||
|
assert_eq!(q(r#"{"camera":"Canon"}"#).select(&found).len(), 2);
|
||||||
|
assert_eq!(q(r#"{"undated":true}"#).select(&found).len(), 1);
|
||||||
|
assert_eq!(q(r#"{"dedupe":true}"#).select(&found).len(), 3);
|
||||||
|
assert_eq!(q(r#"{"query":"viagens"}"#).select(&found).len(), 1);
|
||||||
|
let mut items = q("{}").select(&found);
|
||||||
|
explorer::sort_items(&mut items, Some("date"));
|
||||||
|
assert_eq!(items[0].id, 2); // mais recente primeiro
|
||||||
|
assert_eq!(items.last().unwrap().id, 4); // sem data por último
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ carving
|
||||||
|
|
||||||
|
fn png_chunk(ty: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut c = (data.len() as u32).to_be_bytes().to_vec();
|
||||||
|
c.extend(ty);
|
||||||
|
c.extend(data);
|
||||||
|
c.extend([0, 0, 0, 0]);
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regressão: texto ASCII depois de um PNG truncado vira "tamanho" de ~1,9 GB;
|
||||||
|
/// o carving não pode inventar um PNG gigante.
|
||||||
|
#[test]
|
||||||
|
fn png_truncado_seguido_de_texto_nao_vira_arquivo_gigante() {
|
||||||
|
let mut d = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||||
|
d.extend(png_chunk(b"IHDR", &[0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]));
|
||||||
|
d.extend(png_chunk(b"IDAT", &vec![7u8; 5000]));
|
||||||
|
let real_end = d.len();
|
||||||
|
d.extend(b"ualiBase64 texto qualquer que segue no disco, como parte de outro arquivo html ");
|
||||||
|
d.resize(4 * 1024 * 1024, 0x41);
|
||||||
|
let src = MemSource(d.clone());
|
||||||
|
let hit =
|
||||||
|
signatures::detect(&src, 0, &d[..64], 8 << 30).expect("PNG truncado ainda é reconhecido");
|
||||||
|
assert_eq!(
|
||||||
|
hit.len as usize, real_end,
|
||||||
|
"deve parar no fim do último bloco válido"
|
||||||
|
);
|
||||||
|
assert_eq!(hit.confidence, Confidence::Low);
|
||||||
|
|
||||||
|
// sem nenhum dado de imagem (só cabeçalho) não é arquivo
|
||||||
|
let mut d2 = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||||
|
d2.extend(png_chunk(b"IHDR", &[0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]));
|
||||||
|
d2.resize(1 << 20, b'e');
|
||||||
|
assert!(signatures::detect(&MemSource(d2.clone()), 0, &d2[..64], 8 << 30).is_none());
|
||||||
|
|
||||||
|
// PNG completo continua com confiança alta e tamanho exato
|
||||||
|
let mut ok = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||||
|
ok.extend(png_chunk(b"IHDR", &[0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]));
|
||||||
|
ok.extend(png_chunk(b"IDAT", &[1; 100]));
|
||||||
|
ok.extend(png_chunk(b"IEND", &[]));
|
||||||
|
let n = ok.len();
|
||||||
|
ok.resize(1 << 20, 0);
|
||||||
|
let h = signatures::detect(&MemSource(ok.clone()), 0, &ok[..64], 8 << 30).unwrap();
|
||||||
|
assert_eq!((h.len as usize, h.confidence), (n, Confidence::High));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ abrir no player do sistema
|
||||||
|
|
||||||
|
/// O vídeo é copiado para uma pasta temporária e entregue ao aplicativo padrão do sistema
|
||||||
|
/// (aqui um `xdg-open`/`open` falso grava o que recebeu).
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
#[test]
|
||||||
|
fn abre_no_player_do_sistema() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let bin = tempfile::tempdir().unwrap();
|
||||||
|
let marker = bin.path().join("recebido");
|
||||||
|
let name = if cfg!(target_os = "linux") {
|
||||||
|
"xdg-open"
|
||||||
|
} else {
|
||||||
|
"open"
|
||||||
|
};
|
||||||
|
let script = bin.path().join(name);
|
||||||
|
std::fs::write(
|
||||||
|
&script,
|
||||||
|
format!("#!/bin/sh\ncp \"$1\" '{}'\n", marker.display()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||||
|
let old = std::env::var("PATH").unwrap_or_default();
|
||||||
|
std::env::set_var("PATH", format!("{}:{old}", bin.path().display()));
|
||||||
|
let content: Vec<u8> = (0..5000u32).map(|i| (i * 7) as u8).collect();
|
||||||
|
let mut f = resident("meu video.mp4", content.clone());
|
||||||
|
f.id = 42;
|
||||||
|
let path = external::open_found(&MemSource(vec![]), &f).unwrap();
|
||||||
|
std::env::set_var("PATH", old);
|
||||||
|
assert!(path
|
||||||
|
.file_name()
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("42-"));
|
||||||
|
for _ in 0..100 {
|
||||||
|
if marker.exists() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||||
|
}
|
||||||
|
assert_eq!(std::fs::read(&marker).unwrap(), content);
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), content);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
@@ -32,6 +32,15 @@ fn check(fs: &str, only_carvable: bool) {
|
|||||||
assert!(seen.insert(o), "[{fs}] achado duplicado no offset {o}");
|
assert!(seen.insert(o), "[{fs}] achado duplicado no offset {o}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if fs != "ext4" {
|
||||||
|
for f in res.found.iter().filter(|f| f.origin != Origin::Carved) {
|
||||||
|
assert!(
|
||||||
|
f.modified.is_some() && f.created.is_some(),
|
||||||
|
"[{fs}] {} sem datas de modificação/criação",
|
||||||
|
f.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
assert_eq!(before, sha256_file(&img), "a origem foi modificada!");
|
assert_eq!(before, sha256_file(&img), "a origem foi modificada!");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,3 +95,58 @@ fn varredura_rapida_nao_faz_carving() {
|
|||||||
7
|
7
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regressão: mkfs.vfat -F 32 em mídia pequena (< 65525 clusters) é FAT32 pelo BPB.
|
||||||
|
#[test]
|
||||||
|
fn fat32_pequeno_com_poucos_clusters_e_reconhecido() {
|
||||||
|
use std::process::Command;
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let img = dir.path().join("p.img");
|
||||||
|
let ok = Command::new("truncate")
|
||||||
|
.args(["-s", "32M"])
|
||||||
|
.arg(&img)
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
&& Command::new("mkfs.vfat")
|
||||||
|
.args(["-F", "32"])
|
||||||
|
.arg(&img)
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let file = dir.path().join("arquivo.txt");
|
||||||
|
std::fs::write(&file, b"conteudo de teste\n".repeat(500)).unwrap();
|
||||||
|
let ok = ok
|
||||||
|
&& Command::new("mcopy")
|
||||||
|
.arg("-i")
|
||||||
|
.arg(&img)
|
||||||
|
.arg(&file)
|
||||||
|
.arg("::/")
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
&& Command::new("mdel")
|
||||||
|
.arg("-i")
|
||||||
|
.arg(&img)
|
||||||
|
.arg("::/arquivo.txt")
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !ok {
|
||||||
|
eprintln!("SKIP: mtools/dosfstools ausentes");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let res = scan_path(
|
||||||
|
&img,
|
||||||
|
&ScanOptions {
|
||||||
|
mode: ScanMode::Quick,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let layout = res.layout.unwrap();
|
||||||
|
assert_eq!(layout.partitions[0].fs, FsKind::Fat32);
|
||||||
|
assert_eq!(res.found.len(), 1);
|
||||||
|
let out = tempfile::tempdir().unwrap();
|
||||||
|
recover_all(&img, &res.found, out.path());
|
||||||
|
assert!(hashes_in(out.path()).contains_key(&sha256(&b"conteudo de teste\n".repeat(500))));
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Gera a identidade visual do Guará.
|
||||||
|
|
||||||
|
Conceito: pixels de tamanhos distintos, soltos à esquerda, que se juntam numa
|
||||||
|
grade cada vez mais densa até formar a cabeça de um lobo-guará à direita.
|
||||||
|
|
||||||
|
- O lobo é uma silhueta VETORIAL suave (curvas), sutil, sem serrilhado.
|
||||||
|
- À esquerda a silhueta é recortada por uma máscara de quadrados de tamanhos
|
||||||
|
distintos que ficam cada vez mais densos até se fundirem no lobo inteiro.
|
||||||
|
- Partículas soltas (quadrados) completam a dissolução fora da silhueta.
|
||||||
|
|
||||||
|
Uso: python3 scripts/make-logo.py -> apps/gui/ui/guara-logo.svg e guara.svg
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
OUT = Path(__file__).resolve().parent.parent / "apps/gui/ui"
|
||||||
|
|
||||||
|
W, H = 160.0, 100.0
|
||||||
|
CELL = 2.5 # módulo da grade de pixels
|
||||||
|
COLS, ROWS = int(W / CELL), int(H / CELL)
|
||||||
|
|
||||||
|
# --- silhueta -------------------------------------------------------------
|
||||||
|
# Cabeça de lobo-guará (3/4, olhando para a direita): orelhas enormes, focinho
|
||||||
|
# longo e fino, e a juba escorrendo para a esquerda (é ela que se desmancha em
|
||||||
|
# pixels). Coordenadas 0..100 na altura; x pode ser negativo (juba).
|
||||||
|
WOLF = [
|
||||||
|
(100, 57), (97.5, 54), (91, 51), (83, 48), (75, 45), (69, 41), (65, 36), (62, 29), (59, 20), (56, 11),
|
||||||
|
(52.5, 4), (49, 0.5), (46, 4), (43, 13), (41, 23), (40, 32), (38, 31), (35, 22), (31, 11), (27.5, 3),
|
||||||
|
(24.5, 0.5), (22, 5), (20, 15), (19, 27), (9, 35), (-6, 45), (-20, 58), (-30, 76), (-34, 100), (-10, 100), (20, 100), (44, 100),
|
||||||
|
(60, 100), (59.2, 95), (59.6, 89), (62, 82), (65, 78), (72, 74), (82, 71.5), (92, 69), (98.5, 66.5), (100, 63),
|
||||||
|
]
|
||||||
|
# Interior das orelhas (escuro, como no animal real).
|
||||||
|
EAR_IN = [
|
||||||
|
[(53, 9), (50, 3), (47, 10), (45, 20), (47, 27), (51, 24), (54, 15)],
|
||||||
|
[(27.5, 8), (25, 4), (23, 14), (22, 24), (25, 27), (28, 20)],
|
||||||
|
]
|
||||||
|
WX0, WSCALE = 64.0, 88.0 # nariz em x=152 (margem de 8 à direita)
|
||||||
|
Y0, YS = 6.0, 88.0 # topo das orelhas em y=6, base em y=94 # x do desenho onde wx=0 e largura de 100 unidades do lobo
|
||||||
|
|
||||||
|
|
||||||
|
def to_wolf(x, y):
|
||||||
|
return (x - WX0) * 100.0 / WSCALE, y * 100.0 / H
|
||||||
|
|
||||||
|
|
||||||
|
def wolf_at(x, y):
|
||||||
|
wx, wy = to_wolf(x, y)
|
||||||
|
if not _inside(WOLF, wx, wy):
|
||||||
|
return False
|
||||||
|
return math.hypot(wx - 71.0, wy - 49.0) >= 1.7 # olho
|
||||||
|
|
||||||
|
|
||||||
|
def ear_in(x, y):
|
||||||
|
wx, wy = to_wolf(x, y)
|
||||||
|
return any(_inside(p, wx, wy) for p in EAR_IN)
|
||||||
|
|
||||||
|
|
||||||
|
def _inside(poly, x, y):
|
||||||
|
c = False
|
||||||
|
j = len(poly) - 1
|
||||||
|
for i in range(len(poly)):
|
||||||
|
xi, yi = poly[i]
|
||||||
|
xj, yj = poly[j]
|
||||||
|
if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (yj - yi) + xi:
|
||||||
|
c = not c
|
||||||
|
j = i
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def smooth(t):
|
||||||
|
t = max(0.0, min(1.0, t))
|
||||||
|
return t * t * (3 - 2 * t)
|
||||||
|
|
||||||
|
|
||||||
|
def mix(c1, c2, t):
|
||||||
|
t = max(0.0, min(1.0, t))
|
||||||
|
a = [int(c1[i:i + 2], 16) for i in (1, 3, 5)]
|
||||||
|
b = [int(c2[i:i + 2], 16) for i in (1, 3, 5)]
|
||||||
|
return "#" + "".join(f"{round(p + (q - p) * t):02x}" for p, q in zip(a, b))
|
||||||
|
|
||||||
|
|
||||||
|
def smooth_path(pts, dup=()):
|
||||||
|
"""Curva fechada (Catmull-Rom -> Bézier); `dup` = índices de quinas vivas."""
|
||||||
|
P = []
|
||||||
|
for i, p in enumerate(pts):
|
||||||
|
P.append(p)
|
||||||
|
if i in dup:
|
||||||
|
P.append(p)
|
||||||
|
n = len(P)
|
||||||
|
d = f"M{P[0][0]:.2f},{P[0][1]:.2f}"
|
||||||
|
for i in range(n):
|
||||||
|
p0, p1, p2, p3 = P[(i - 1) % n], P[i], P[(i + 1) % n], P[(i + 2) % n]
|
||||||
|
c1 = (p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6)
|
||||||
|
c2 = (p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6)
|
||||||
|
d += f"C{c1[0]:.2f},{c1[1]:.2f} {c2[0]:.2f},{c2[1]:.2f} {p2[0]:.2f},{p2[1]:.2f}"
|
||||||
|
return d + "Z"
|
||||||
|
|
||||||
|
|
||||||
|
def tx(pts):
|
||||||
|
return [(WX0 + x * WSCALE / 100.0, Y0 + y * YS / 100.0) for x, y in pts]
|
||||||
|
|
||||||
|
|
||||||
|
def lerp(a, b, t):
|
||||||
|
return a + (b - a) * t
|
||||||
|
|
||||||
|
|
||||||
|
def build(pal, scatter=1.0, seed=5, uid="a", x_min=8.0):
|
||||||
|
rnd = random.Random(seed)
|
||||||
|
wolf = tx(WOLF)
|
||||||
|
corners = {i for i, (_, y) in enumerate(WOLF) if y >= 99.5 or y <= 0.6} # pontas e cantos vivos
|
||||||
|
body = smooth_path(wolf, dup=corners)
|
||||||
|
ears = [smooth_path(tx(e)) for e in EAR_IN]
|
||||||
|
cols, rows = int(W / CELL), int(H / CELL)
|
||||||
|
|
||||||
|
# gradiente da figura (o mesmo para a silhueta e para cada pixel => fusão sem emenda)
|
||||||
|
gx0, gy0, gx1, gy1 = 58.0, 0.0, 160.0, 100.0
|
||||||
|
stops = pal["stops"]
|
||||||
|
|
||||||
|
def grad(x, y):
|
||||||
|
dx, dy = gx1 - gx0, gy1 - gy0
|
||||||
|
t = max(0.0, min(1.0, ((x - gx0) * dx + (y - gy0) * dy) / (dx * dx + dy * dy)))
|
||||||
|
for (t0, c0), (t1, c1) in zip(stops, stops[1:]):
|
||||||
|
if t <= t1:
|
||||||
|
return mix(c0, c1, (t - t0) / (t1 - t0))
|
||||||
|
return stops[-1][1]
|
||||||
|
|
||||||
|
X_SOLID = 80.0 # múltiplo do módulo: a partir daqui a figura é inteira e lisa
|
||||||
|
|
||||||
|
def ramp(x):
|
||||||
|
return min(1.0, max(0.0, x / X_SOLID)) ** 2.0
|
||||||
|
|
||||||
|
# envelope da "cauda": nasce na largura do pescoço e se afunila para a esquerda
|
||||||
|
def envelope(x, y):
|
||||||
|
k = smooth((x + 6) / 80.0)
|
||||||
|
yc = lerp(70, 62, k)
|
||||||
|
hh = lerp(5, 31, k ** 1.15)
|
||||||
|
return math.exp(-(abs(y - yc) / hh) ** 3.0)
|
||||||
|
|
||||||
|
occ = set()
|
||||||
|
|
||||||
|
def free(cx, cy, n, m=0):
|
||||||
|
return all((cx + i, cy + j) not in occ for i in range(-m, n + m) for j in range(-m, n + m))
|
||||||
|
|
||||||
|
def take(cx, cy, n):
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(n):
|
||||||
|
occ.add((cx + i, cy + j))
|
||||||
|
|
||||||
|
mask_sq, free_sq = [], []
|
||||||
|
|
||||||
|
def emit(cx, cy, n, sz_scale, op, gap):
|
||||||
|
x0, y0 = cx * CELL, cy * CELL
|
||||||
|
size = n * CELL
|
||||||
|
sz = size * sz_scale
|
||||||
|
off = (size - sz) / 2
|
||||||
|
cxm, cym = x0 + size / 2, y0 + size / 2
|
||||||
|
inside = _inside(wolf, cxm, cym)
|
||||||
|
if inside and x0 >= 56:
|
||||||
|
# faz parte da figura lisa: vira janela da máscara
|
||||||
|
e = 0.12
|
||||||
|
mask_sq.append(f'<rect x="{x0 - e:.2f}" y="{y0 - e:.2f}" width="{size + 2 * e:.2f}" height="{size + 2 * e:.2f}" fill="#fff"/>')
|
||||||
|
else:
|
||||||
|
free_sq.append(f'<rect x="{x0 + off:.2f}" y="{y0 + off:.2f}" width="{sz:.2f}" height="{sz:.2f}" fill="{grad(cxm, cym)}" fill-opacity="{op:.2f}"/>')
|
||||||
|
|
||||||
|
# 1) campo de pixels da esquerda até a figura: tamanhos em múltiplos do módulo
|
||||||
|
order = [(cx, cy) for cy in range(rows) for cx in range(int(x_min / CELL), int(X_SOLID / CELL))]
|
||||||
|
rnd.shuffle(order)
|
||||||
|
for cx, cy in order:
|
||||||
|
x, y = (cx + 0.5) * CELL, (cy + 0.5) * CELL
|
||||||
|
r, env = ramp(x), envelope(x, y)
|
||||||
|
p = r * env
|
||||||
|
if p < 0.01 or y > 94:
|
||||||
|
continue
|
||||||
|
# blocos grandes na faixa média; sempre 1×1 colados perto da figura
|
||||||
|
n = 1
|
||||||
|
if r < 0.7:
|
||||||
|
big = rnd.random() < 0.9 * (1 - r) * env ** 0.5 * min(1.0, r * 3.2)
|
||||||
|
if big:
|
||||||
|
n = rnd.choice([2, 2, 3]) if r < 0.45 else 2
|
||||||
|
if cx + n > cols or cy + n > rows or not free(cx, cy, n, 0):
|
||||||
|
n = 1
|
||||||
|
if not free(cx, cy, 1, 0):
|
||||||
|
continue
|
||||||
|
acc = p if n == 1 else min(1.0, p * 1.6)
|
||||||
|
if r > 0.88:
|
||||||
|
acc = 1.0 if env > 0.45 else acc
|
||||||
|
if rnd.random() > acc:
|
||||||
|
continue
|
||||||
|
take(cx, cy, n)
|
||||||
|
gap_scale = 1.0 if r > 0.86 else (0.9 - 0.45 * (1 - r)) if n > 1 else rnd.choice([0.5, 0.65, 0.8, 0.9]) * (0.7 + 0.3 * r)
|
||||||
|
op = 0.38 + 0.62 * smooth(r * 1.25)
|
||||||
|
emit(cx, cy, n, gap_scale if r <= 0.86 else 1.02, op, 0)
|
||||||
|
|
||||||
|
# 2) partículas esparsas bem à esquerda e acima/abaixo da cauda (profundidade)
|
||||||
|
placed, tries, target = 0, 0, int(34 * scatter)
|
||||||
|
while placed < target and tries < 6000:
|
||||||
|
tries += 1
|
||||||
|
x = x_min + rnd.random() ** 1.3 * (70 - x_min)
|
||||||
|
cy = rnd.randrange(3, rows - 4)
|
||||||
|
cx = int(x / CELL)
|
||||||
|
y = (cy + 0.5) * CELL
|
||||||
|
env = envelope(x, y)
|
||||||
|
if env > 0.5 or not free(cx, cy, 1, 1):
|
||||||
|
continue
|
||||||
|
r = ramp(x)
|
||||||
|
# sempre perto da cauda, nunca longe demais
|
||||||
|
near = math.exp(-((abs(y - 62) - 26) / 22.0) ** 2)
|
||||||
|
if rnd.random() > 0.55 * near * (0.35 + 0.65 * r):
|
||||||
|
continue
|
||||||
|
take(cx, cy, 1)
|
||||||
|
emit(cx, cy, 1, rnd.choice([0.45, 0.6, 0.75]), 0.22 + 0.38 * r, 0)
|
||||||
|
placed += 1
|
||||||
|
|
||||||
|
ex, ey = tx([(71.0, 49.0)])[0]
|
||||||
|
hx = " ".join(f'<stop offset="{t:g}" stop-color="{c}"/>' for t, c in stops)
|
||||||
|
svg = (
|
||||||
|
"<defs>"
|
||||||
|
f'<linearGradient id="{uid}h" gradientUnits="userSpaceOnUse" x1="{gx0:g}" y1="{gy0:g}" x2="{gx1:g}" y2="{gy1:g}">{hx}</linearGradient>'
|
||||||
|
f'<mask id="{uid}m" maskUnits="userSpaceOnUse" x="0" y="0" width="{W:g}" height="{H:g}"><g shape-rendering="crispEdges">'
|
||||||
|
f'<rect x="{X_SOLID:g}" y="0" width="{W - X_SOLID + 1:g}" height="{H:g}" fill="#fff"/>'
|
||||||
|
+ "".join(mask_sq)
|
||||||
|
+ "</g></mask></defs>"
|
||||||
|
f'<g mask="url(#{uid}m)"><path d="{body}" fill="url(#{uid}h)"/>'
|
||||||
|
+ "".join(f'<path d="{e}" fill="{pal["ear"]}" fill-opacity="{pal["ear_op"]}"/>' for e in ears)
|
||||||
|
+ "</g>"
|
||||||
|
+ '<g shape-rendering="crispEdges">' + "".join(free_sq) + "</g>"
|
||||||
|
+ f'<circle cx="{ex:.2f}" cy="{ey:.2f}" r="1.35" fill="{pal["eye"]}"/>'
|
||||||
|
)
|
||||||
|
return svg
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Marca larga: funciona em fundo claro e escuro. Verde-azulado -> azul petróleo.
|
||||||
|
pal = dict(stops=[(0.0, "#3BE3CC"), (0.45, "#12B5A8"), (1.0, "#0B6A86")], ear="#05323a", ear_op=0.5, eye="#ecfeff")
|
||||||
|
(OUT / "guara-logo.svg").write_text(
|
||||||
|
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W:g} {H:g}" role="img" aria-label="Guará">' + build(pal, uid="w") + "</svg>\n"
|
||||||
|
)
|
||||||
|
# Ícone: fundo petróleo profundo, figura clara.
|
||||||
|
ipal = dict(stops=[(0.0, "#d9fff7"), (0.5, "#8df1e0"), (1.0, "#3fc8cf")], ear="#0b4a55", ear_op=0.45, eye="#083344")
|
||||||
|
(OUT / "guara.svg").write_text(
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Guará">'
|
||||||
|
'<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0f766e"/><stop offset="1" stop-color="#083344"/></linearGradient>'
|
||||||
|
'<clipPath id="ic"><rect width="128" height="128" rx="29"/></clipPath></defs>'
|
||||||
|
'<rect width="128" height="128" rx="29" fill="url(#bg)"/>'
|
||||||
|
'<g clip-path="url(#ic)"><g transform="translate(-13 18) scale(0.86)">' + build(ipal, scatter=0.5, seed=9, uid="i", x_min=30.0) + "</g></g></svg>\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Gera uma imagem FAT32 com muitas fotos JPEG (EXIF de câmeras/datas/GPS) já apagadas.
|
||||||
|
|
||||||
|
Uso: make-photo-fixture.py <saida.img> [quantidade=300] [copias=1]
|
||||||
|
(`copias` repete cada foto N vezes com outro nome: é um jeito rápido de gerar milhares de arquivos.)
|
||||||
|
Serve para testar a navegação por data/câmera/pasta e as miniaturas.
|
||||||
|
"""
|
||||||
|
import os, random, shutil, struct, subprocess, sys, tempfile
|
||||||
|
|
||||||
|
out = sys.argv[1]
|
||||||
|
count = int(sys.argv[2]) if len(sys.argv) > 2 else 300
|
||||||
|
copies = int(sys.argv[3]) if len(sys.argv) > 3 else 1
|
||||||
|
rnd = random.Random(42)
|
||||||
|
|
||||||
|
def ifd_bytes(entries, start):
|
||||||
|
n = len(entries)
|
||||||
|
table = struct.pack('<H', n)
|
||||||
|
data = b''
|
||||||
|
data_off = start + 2 + 12 * n + 4
|
||||||
|
for tag, typ, cnt, val in entries:
|
||||||
|
if isinstance(val, int):
|
||||||
|
vb = struct.pack('<I', val) if typ == 4 else struct.pack('<H', val) + b'\0\0'
|
||||||
|
else:
|
||||||
|
vb = val
|
||||||
|
if len(vb) <= 4:
|
||||||
|
table += struct.pack('<HHI', tag, typ, cnt) + vb.ljust(4, b'\0')
|
||||||
|
else:
|
||||||
|
if len(data) % 2:
|
||||||
|
data += b'\0'
|
||||||
|
table += struct.pack('<HHII', tag, typ, cnt, data_off + len(data))
|
||||||
|
data += vb
|
||||||
|
return table + struct.pack('<I', 0) + data
|
||||||
|
|
||||||
|
def rat(*pairs):
|
||||||
|
return b''.join(struct.pack('<II', a, b) for a, b in pairs)
|
||||||
|
|
||||||
|
def make_exif(make, model, dt, gps, orient):
|
||||||
|
def build(exif_off, gps_off):
|
||||||
|
e0 = [(0x010F, 2, len(make) + 1, make.encode() + b'\0'), (0x0110, 2, len(model) + 1, model.encode() + b'\0'),
|
||||||
|
(0x0112, 3, 1, orient), (0x8769, 4, 1, exif_off)]
|
||||||
|
if gps:
|
||||||
|
e0.append((0x8825, 4, 1, gps_off))
|
||||||
|
return ifd_bytes(e0, 8)
|
||||||
|
ex = [(0x9003, 2, 20, dt.encode() + b'\0')]
|
||||||
|
first = build(0, 0)
|
||||||
|
exif_off = 8 + len(first)
|
||||||
|
exif_ifd = ifd_bytes(ex, exif_off)
|
||||||
|
gps_off = exif_off + len(exif_ifd)
|
||||||
|
gps_ifd = b''
|
||||||
|
if gps:
|
||||||
|
lat, lon = gps
|
||||||
|
def dms(v):
|
||||||
|
v = abs(v); d = int(v); m = int((v - d) * 60); s = round(((v - d) * 60 - m) * 60 * 100)
|
||||||
|
return rat((d, 1), (m, 1), (s, 100))
|
||||||
|
g = [(1, 2, 2, b'S\0' if lat < 0 else b'N\0'), (2, 5, 3, dms(lat)), (3, 2, 2, b'W\0' if lon < 0 else b'E\0'), (4, 5, 3, dms(lon))]
|
||||||
|
gps_ifd = ifd_bytes(g, gps_off)
|
||||||
|
body = build(exif_off, gps_off) + exif_ifd + gps_ifd
|
||||||
|
tiff = b'II*\0' + struct.pack('<I', 8) + body
|
||||||
|
return b'Exif\0\0' + tiff
|
||||||
|
|
||||||
|
def add_exif(jpg_bytes, exif):
|
||||||
|
assert jpg_bytes[:2] == b'\xff\xd8'
|
||||||
|
seg = b'\xff\xe1' + struct.pack('>H', len(exif) + 2) + exif
|
||||||
|
return jpg_bytes[:2] + seg + jpg_bytes[2:]
|
||||||
|
|
||||||
|
cams = [('Canon', 'Canon EOS 80D'), ('Apple', 'iPhone 13'), ('samsung', 'SM-G991B'), ('NIKON CORPORATION', 'NIKON D3500')]
|
||||||
|
places = [(-23.5505, -46.6333), (-8.0476, -34.8770), (-22.9068, -43.1729), (38.7223, -9.1393)]
|
||||||
|
folders = ['DCIM/100CANON', 'DCIM/Camera', 'WhatsApp/Images', 'Viagens/Recife', 'Viagens/Lisboa', 'Familia']
|
||||||
|
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
files = []
|
||||||
|
for i in range(count):
|
||||||
|
cam = cams[i % 4]
|
||||||
|
y = rnd.choice([2021, 2022, 2023, 2024]); mo = rnd.randint(1, 12); d = rnd.randint(1, 28)
|
||||||
|
dt = f'{y}:{mo:02d}:{d:02d} {rnd.randint(7, 22):02d}:{rnd.randint(0,59):02d}:{rnd.randint(0,59):02d}'
|
||||||
|
folder = folders[i % len(folders)]
|
||||||
|
name = f'IMG_{i:04d}.jpg'
|
||||||
|
p = os.path.join(tmp, name)
|
||||||
|
w, h = rnd.choice([(480, 320), (320, 480), (640, 360), (400, 400)])
|
||||||
|
subprocess.run(['convert', '-seed', str(i), '-size', f'{w}x{h}', 'plasma:fractal', '-gravity', 'center', '-pointsize', '34', '-fill', 'white',
|
||||||
|
'-stroke', 'black', '-strokewidth', '1', '-annotate', '0', f'{y}-{mo:02d}-{d:02d}\n#{i}', '-quality', '82', p], check=True, stderr=subprocess.DEVNULL)
|
||||||
|
raw = open(p, 'rb').read()
|
||||||
|
if i % 9 != 0: # alguns sem EXIF
|
||||||
|
gps = places[i % 4] if i % 3 == 0 else None
|
||||||
|
raw = add_exif(raw, make_exif(cam[0], cam[1], dt, gps, rnd.choice([1, 1, 1, 6, 8])))
|
||||||
|
open(p, 'wb').write(raw)
|
||||||
|
files.append((folder, name, p))
|
||||||
|
|
||||||
|
if copies > 1:
|
||||||
|
base = list(files)
|
||||||
|
for c in range(1, copies):
|
||||||
|
for folder, name, p in base:
|
||||||
|
files.append((folder, name.replace('IMG_', f'IMG{c:03d}_'), p))
|
||||||
|
mb = max(64, len(files) * 40 // 1024 + 48)
|
||||||
|
if os.path.exists(out):
|
||||||
|
os.remove(out)
|
||||||
|
subprocess.run(['truncate', '-s', f'{mb}M', out], check=True)
|
||||||
|
subprocess.run(['mkfs.vfat', '-F', '32', out], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
made = set()
|
||||||
|
for folder, name, p in files:
|
||||||
|
cur = ''
|
||||||
|
for part in folder.split('/'):
|
||||||
|
cur += '/' + part
|
||||||
|
if cur not in made:
|
||||||
|
subprocess.run(['mmd', '-i', out, '::' + cur], check=True, stderr=subprocess.DEVNULL)
|
||||||
|
made.add(cur)
|
||||||
|
for folder, name, p in files:
|
||||||
|
subprocess.run(['mcopy', '-i', out, p, f'::/{folder}/{name}'], check=True)
|
||||||
|
for folder, name, p in files:
|
||||||
|
subprocess.run(['mdel', '-i', out, f'::/{folder}/{name}'], check=True)
|
||||||
|
shutil.rmtree(tmp)
|
||||||
|
print(f'ok: {out} ({len(files)} fotos apagadas)')
|
||||||