feat: Add image uploader and metadata viewer components

- Implemented ImageUploader.svelte for drag-and-drop image uploads with metadata extraction.
- Created MetadataViewer.svelte to display extracted metadata in a user-friendly format.
- Added PrivacyInfo.svelte to inform users about client-side processing and privacy risks.
- Introduced ThemeToggle.svelte for dark mode functionality.
- Set up main.js for application entry point and configured Svelte with vite.
- Added Tailwind CSS for styling and configured Vite for asset management.
- Established svelte.config.js and tailwind.config.js for project settings.
This commit is contained in:
sharif committed 2025-06-23 22:23:22 +06:00
1 parent cf0d872dba
commit cbce104341
18 files changed
+5263

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
name: Deploy to GitHub Pages
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Deploy to GitHub Pages
if: github.ref == 'refs/heads/main'
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./dist
cname: # Add your custom domain here if you have one
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/untrace/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Clean your images. Keep your identity. Remove EXIF metadata from images in your browser.">
<meta name="keywords" content="EXIF, metadata, privacy, image, cleaner, security">
<title>Untrace - Clean Your Images</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+2800
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}
+6
View File
@@ -0,0 +1,6 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="8" fill="#2563eb"/>
<path d="M8 12h16v8H8v-8z" stroke="white" stroke-width="1.5" fill="none"/>
<path d="M11 15l3 3 6-6" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 9l3 3-3 3" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 454 B

+333
View File
@@ -0,0 +1,333 @@
<script>
import { onMount } from "svelte";
import ImageUploader from "./lib/ImageUploader.svelte";
import MetadataViewer from "./lib/MetadataViewer.svelte";
import ImageProcessor from "./lib/ImageProcessor.svelte";
import ThemeToggle from "./lib/ThemeToggle.svelte";
import Header from "./lib/Header.svelte";
import Footer from "./lib/Footer.svelte";
import PrivacyInfo from "./lib/PrivacyInfo.svelte";
let selectedFile = null;
let metadata = null;
let processedImage = null;
let isProcessing = false;
let showMetadata = false;
let showFileInfo = false;
let showPrivacyInfo = false;
let darkMode = false;
onMount(() => {
const savedTheme = localStorage.getItem("theme");
if (savedTheme) {
darkMode = savedTheme === "dark";
} else {
darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
}
updateTheme();
});
function updateTheme() {
if (darkMode) {
document.documentElement.setAttribute("data-theme", "dark");
} else {
document.documentElement.removeAttribute("data-theme");
}
localStorage.setItem("theme", darkMode ? "dark" : "light");
}
function handleThemeToggle() {
darkMode = !darkMode;
updateTheme();
}
function handleFileSelected(event) {
selectedFile = event.detail.file;
metadata = event.detail.metadata;
processedImage = null;
showMetadata = false;
}
function handleProcessingStart() {
isProcessing = true;
}
function handleProcessingComplete(event) {
isProcessing = false;
processedImage = event.detail.processedImage;
}
function handleReset() {
selectedFile = null;
metadata = null;
processedImage = null;
isProcessing = false;
showMetadata = false;
}
function toggleMetadata() {
showMetadata = !showMetadata;
}
function toggleFileInfo() {
showFileInfo = !showFileInfo;
}
function togglePrivacyInfo() {
showPrivacyInfo = !showPrivacyInfo;
}
</script>
<ThemeToggle {darkMode} on:toggle={handleThemeToggle} />
<main class="container py-8">
<Header />
<div class="grid gap-6">
<section class="card">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Upload Your Image</h2>
</div>
<ImageUploader
on:fileSelected={handleFileSelected}
on:reset={handleReset}
disabled={isProcessing}
/>
</section>
<!-- Processing Section -->
{#if selectedFile}
<section class="grid grid-2 gap-6">
<div class="card">
<div class="flex justify-between items-center mb-3">
<h3 class="text-lg font-semibold">Original</h3>
{#if metadata}
<button class="btn btn-ghost text-sm" on:click={toggleMetadata}>
{showMetadata ? "Hide" : "Show"} Metadata
</button>
{/if}
</div>
<div class="text-center mb-3">
<img
src={URL.createObjectURL(selectedFile)}
alt="Original"
class="image-preview"
/>
</div>
<div class="text-xs text-secondary">
<p>
{selectedFile.name} • {(selectedFile.size / 1024).toFixed(1)} KB
</p>
{#if metadata && metadata.message && !metadata.hasMetadata}
<p class="text-success">✓ No metadata detected</p>
{:else if metadata}
<p class="text-warning">
⚠️ {Object.keys(metadata).filter(
(key) => !["fileType", "hasMetadata", "message"].includes(key)
).length} metadata entries found
</p>
{/if}
</div>
{#if showMetadata && metadata}
<div class="mt-4">
<MetadataViewer {metadata} />
</div>
{/if}
</div>
<div class="card">
<h3 class="text-lg font-semibold mb-3">Clean Metadata</h3>
<ImageProcessor
file={selectedFile}
on:processingStart={handleProcessingStart}
on:processingComplete={handleProcessingComplete}
disabled={isProcessing}
/>
{#if isProcessing}
<div class="mt-3">
<div class="progress-bar">
<div class="progress-fill" style="width: 100%;"></div>
</div>
<p class="text-xs text-secondary mt-1">Processing...</p>
</div>
{/if}
{#if processedImage}
<div class="mt-4 slide-up">
<h4 class="font-medium mb-2">✅ Clean Image</h4>
<div class="text-center mb-3">
<img
src={processedImage.url}
alt="Cleaned"
class="image-preview"
/>
</div>
<div class="text-xs text-secondary mb-3">
<p>
{(processedImage.size / 1024).toFixed(1)} KB •
{processedImage.compressionRatio
? `${processedImage.compressionRatio > 0 ? "-" : "+"}${Math.abs(processedImage.compressionRatio)}% size`
: "Processed"}
</p>
{#if processedImage.wasSecondPass}
<p class="status-warning">
⚠️ Used enhanced cleaning (second pass)
</p>
{/if}
<p class="status-success">✓ All EXIF metadata removed</p>
<p class="status-success">✓ AI/C2PA provenance data stripped</p>
<p class="status-success">✓ Location data cleared</p>
</div>
<a
href={processedImage.url}
download={processedImage.filename}
class="btn btn-success btn-lg w-full"
>
<svg
width="20"
height="20"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
Download Cleaned Image
</a>
</div>
{/if}
</div>
</section>
{/if}
<section class="card">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">🔐 Privacy & Security</h2>
<button
class="btn btn-ghost text-sm"
on:click={togglePrivacyInfo}
type="button"
>
{showPrivacyInfo ? "Hide" : "Show"} Details
</button>
</div>
<div class="mb-3">
<p class="text-sm text-secondary">
<span class="text-success font-semibold">✓</span> 100% client-side
processing
<span class="mx-2">•</span>
<span class="text-success font-semibold">✓</span> No data collection
<span class="mx-2">•</span>
<span class="text-success font-semibold">✓</span> Open source
</p>
</div>
{#if showPrivacyInfo}
<PrivacyInfo />
{/if}
</section>
</div>
</main>
<Footer />
<style>
.w-full {
width: 100%;
}
.status-warning {
color: var(--warning-color, #f59e0b);
}
.image-preview {
max-width: 100%;
max-height: 200px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.progress-bar {
width: 100%;
height: 4px;
background-color: var(--border-color);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: var(--primary-color);
animation: pulse 1s ease-in-out infinite;
}
.slide-up {
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.btn-ghost {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.btn-ghost:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.grid {
display: grid;
gap: 1.5rem;
}
.grid-2 {
grid-template-columns: 1fr 1fr;
}
@media (max-width: 768px) {
.grid-2 {
grid-template-columns: 1fr;
}
.image-preview {
max-height: 150px;
}
}
.text-secondary {
color: var(--text-secondary);
}
</style>
+470
View File
@@ -0,0 +1,470 @@
@import "tailwindcss";
:root {
--primary-color: #2563eb;
--primary-hover: #1d4ed8;
--secondary-color: #64748b;
--success-color: #10b981;
--danger-color: #ef4444;
--warning-color: #f59e0b;
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9;
--text-primary: #0f172a;
--text-secondary: #475569;
--text-muted: #94a3b8;
--border-color: #e2e8f0;
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1),
0 4px 6px -4px rgb(0 0 0 / 0.1);
/* Dark mode colors */
--dark-bg-primary: #0f172a;
--dark-bg-secondary: #1e293b;
--dark-bg-tertiary: #334155;
--dark-text-primary: #f8fafc;
--dark-text-secondary: #cbd5e1;
--dark-text-muted: #64748b;
--dark-border-color: #334155;
}
[data-theme="dark"] {
--bg-primary: var(--dark-bg-primary);
--bg-secondary: var(--dark-bg-secondary);
--bg-tertiary: var(--dark-bg-tertiary);
--text-primary: var(--dark-text-primary);
--text-secondary: var(--dark-text-secondary);
--text-muted: var(--dark-text-muted);
--border-color: var(--dark-border-color);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
line-height: 1.6;
color: var(--text-primary);
background-color: var(--bg-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
outline: none;
position: relative;
overflow: hidden;
}
.btn:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
}
.btn-secondary {
background-color: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover:not(:disabled) {
background-color: var(--bg-secondary);
transform: translateY(-1px);
}
.btn-success {
background-color: var(--success-color);
color: white;
}
.btn-success:hover:not(:disabled) {
background-color: #059669;
transform: translateY(-1px);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-lg {
padding: 1rem 2rem;
font-size: 1rem;
}
.card {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
box-shadow: var(--shadow);
padding: 1.5rem;
transition: all 0.2s ease;
}
.card:hover {
box-shadow: var(--shadow-lg);
}
.upload-area {
border: 2px dashed var(--border-color);
border-radius: 0.75rem;
padding: 3rem 2rem;
text-align: center;
transition: all 0.3s ease;
cursor: pointer;
background-color: var(--bg-secondary);
}
.upload-area:hover {
border-color: var(--primary-color);
background-color: var(--bg-tertiary);
}
.upload-area.dragover {
border-color: var(--primary-color);
background-color: rgba(37, 99, 235, 0.05);
transform: scale(1.02);
}
.upload-icon {
width: 3rem;
height: 3rem;
margin: 0 auto 1rem;
color: var(--text-muted);
}
.metadata-viewer {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 1rem;
font-family: "Monaco", "Menlo", monospace;
font-size: 0.875rem;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
}
.progress-bar {
width: 100%;
height: 0.5rem;
background-color: var(--bg-tertiary);
border-radius: 0.25rem;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: var(--primary-color);
transition: width 0.3s ease;
}
.image-preview {
max-width: 100%;
max-height: 300px;
border-radius: 0.5rem;
box-shadow: var(--shadow);
}
.status-message {
padding: 0.75rem 1rem;
border-radius: 0.5rem;
font-weight: 500;
margin: 1rem 0;
}
.status-success {
background-color: rgba(16, 185, 129, 0.1);
color: var(--success-color);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.status-error {
background-color: rgba(239, 68, 68, 0.1);
color: var(--danger-color);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.status-warning {
background-color: rgba(245, 158, 11, 0.1);
color: var(--warning-color);
border: 1px solid rgba(245, 158, 11, 0.2);
}
.grid {
display: grid;
gap: 1.5rem;
}
.grid-2 {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.gap-4 {
gap: 1rem;
}
.gap-6 {
gap: 1.5rem;
}
.text-center {
text-align: center;
}
.text-sm {
font-size: 0.875rem;
}
.text-lg {
font-size: 1.125rem;
}
.text-xl {
font-size: 1.25rem;
}
.text-2xl {
font-size: 1.5rem;
}
.text-3xl {
font-size: 1.875rem;
}
.font-bold {
font-weight: 700;
}
.font-semibold {
font-weight: 600;
}
.text-muted {
color: var(--text-muted);
}
.text-secondary {
color: var(--text-secondary);
}
.mb-2 {
margin-bottom: 0.5rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.mb-6 {
margin-bottom: 1.5rem;
}
.mt-4 {
margin-top: 1rem;
}
.mt-6 {
margin-top: 1.5rem;
}
.py-2 {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.py-4 {
padding-top: 1rem;
padding-bottom: 1rem;
}
.py-8 {
padding-top: 2rem;
padding-bottom: 2rem;
}
.px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.hidden {
display: none;
}
.fade-in {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.slide-up {
animation: slideUp 0.4s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Responsive Design */
@media (max-width: 768px) {
.container {
padding: 0 0.5rem;
}
.card {
padding: 1rem;
}
.upload-area {
padding: 2rem 1rem;
}
.btn {
padding: 0.625rem 1.25rem;
font-size: 0.8rem;
}
.btn-lg {
padding: 0.875rem 1.75rem;
font-size: 0.9rem;
}
.text-3xl {
font-size: 1.5rem;
}
.text-2xl {
font-size: 1.25rem;
}
}
/* Theme Toggle */
.theme-toggle {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1000;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 50%;
width: 3rem;
height: 3rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: var(--shadow);
transition: all 0.2s ease;
}
.theme-toggle:hover {
transform: scale(1.1);
box-shadow: var(--shadow-lg);
}
/* Accessibility */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Focus styles for better accessibility */
input[type="file"]:focus-visible + label {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-tertiary);
}
::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
+47
View File
@@ -0,0 +1,47 @@
<footer class="mt-12 py-8 border-t border-color text-center">
<div class="container">
<div class="grid grid-2 gap-8 mb-6">
<div>
<h3 class="font-semibold mb-3">About Untrace</h3>
<p class="text-sm text-secondary">
A privacy-first tool that removes EXIF metadata from images completely
in your browser. No servers, no tracking, no data collection.
</p>
</div>
<div>
<h3 class="font-semibold mb-3">Open Source</h3>
<p class="text-sm text-secondary mb-2">
Built with Svelte and deployed on GitHub Pages.
</p>
<a
href="https://github.com/yourusername/untrace"
class="btn btn-secondary text-sm"
target="_blank"
rel="noopener noreferrer"
>
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
/>
</svg>
View Source
</a>
</div>
</div>
<div class="text-sm text-muted">
<p>
© 2025 Untrace. Built for privacy advocates, journalists, and digital
rights.
</p>
<p class="mt-1">No analytics • No tracking • No data collection</p>
</div>
</div>
</footer>
<style>
.border-t {
border-top: 1px solid var(--border-color);
}
</style>
+54
View File
@@ -0,0 +1,54 @@
<header class="text-center mb-6">
<div class="flex items-center justify-center gap-3 mb-3">
<div class="logo">
<svg width="40" height="40" viewBox="0 0 48 48" fill="none">
<rect width="48" height="48" rx="12" fill="var(--primary-color)" />
<path
d="M12 18h24v12H12V18z"
stroke="white"
stroke-width="2"
fill="none"
/>
<path
d="M16 22l4 4 8-8"
stroke="white"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M32 14l4 4-4 4"
stroke="white"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<div>
<h1 class="text-2xl font-bold">Untrace</h1>
<p class="text-base text-secondary">Remove metadata. Protect privacy.</p>
</div>
</div>
<p class="text-sm text-secondary max-w-xl mx-auto">
<strong>100% browser-based</strong> metadata removal tool. No uploads, no tracking.
</p>
</header>
<style>
.logo {
display: flex;
align-items: center;
justify-content: center;
}
.max-w-xl {
max-width: 36rem;
}
.mx-auto {
margin-left: auto;
margin-right: auto;
}
</style>
+287
View File
@@ -0,0 +1,287 @@
<script>
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
export let file;
export let disabled = false;
async function processImage() {
if (!file || disabled) return;
dispatch("processingStart");
try {
const img = new Image();
img.onload = function () {
try {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = img.width;
canvas.height = img.height;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
let outputType = "image/jpeg";
let quality = 0.92;
if (file.type === "image/png" && img.width * img.height < 1000000) {
outputType = "image/png";
quality = undefined;
}
canvas.toBlob(
(blob) => {
if (blob) {
verifyCleanImage(blob, file);
} else {
throw new Error("Failed to create blob from canvas");
}
},
outputType,
quality
);
} catch (error) {
console.error("Error processing with canvas:", error);
handleFallback();
}
};
img.onerror = function () {
console.error("Error loading image");
handleFallback();
};
img.src = URL.createObjectURL(file);
} catch (error) {
console.error("Error in processImage:", error);
handleFallback();
}
}
async function verifyCleanImage(cleanBlob, originalFile) {
try {
const { parse } = await import("exifr");
const cleanMetadata = await parse(cleanBlob, {
tiff: true,
exif: true,
gps: true,
xmp: true,
iptc: true,
icc: true,
silentErrors: true,
});
const hasRemainingMetadata =
cleanMetadata && Object.keys(cleanMetadata).length > 0;
if (hasRemainingMetadata) {
console.warn("Some metadata may still be present:", cleanMetadata);
// For extra security, try a second pass with different format
await performSecondPass(cleanBlob, originalFile);
} else {
console.log("✅ Image successfully cleaned - no metadata detected");
finalizeCleanImage(cleanBlob, originalFile);
}
} catch (error) {
console.log(
"Could not verify metadata removal (this is usually okay):",
error
);
// Proceed anyway - the canvas method should have worked
finalizeCleanImage(cleanBlob, originalFile);
}
}
// Second pass cleaning for stubborn metadata
async function performSecondPass(blob, originalFile) {
try {
console.log("Performing second pass metadata removal...");
const img = new Image();
img.onload = function () {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = img.width;
canvas.height = img.height;
// Use a different approach - explicitly clear and redraw
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#FFFFFF";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
// Force JPEG output for maximum metadata removal
canvas.toBlob(
(finalBlob) => {
if (finalBlob) {
console.log("✅ Second pass completed");
finalizeCleanImage(finalBlob, originalFile, true);
} else {
finalizeCleanImage(blob, originalFile);
}
},
"image/jpeg",
0.95
);
};
img.onerror = () => finalizeCleanImage(blob, originalFile);
img.src = URL.createObjectURL(blob);
} catch (error) {
console.error("Second pass failed:", error);
finalizeCleanImage(blob, originalFile);
}
}
// Finalize the cleaned image
function finalizeCleanImage(blob, originalFile, wasSecondPass = false) {
const url = URL.createObjectURL(blob);
const originalExtension = originalFile.name.split(".").pop();
const nameWithoutExt = originalFile.name.replace(
new RegExp(`\\.${originalExtension}$`, "i"),
""
);
// Determine final extension based on blob type
let finalExtension = originalExtension;
if (blob.type === "image/jpeg") {
finalExtension = "jpg";
} else if (blob.type === "image/png") {
finalExtension = "png";
}
const filename = `${nameWithoutExt}-cleaned.${finalExtension}`;
dispatch("processingComplete", {
processedImage: {
url,
filename,
size: blob.size,
wasSecondPass,
originalSize: originalFile.size,
compressionRatio: (
((originalFile.size - blob.size) / originalFile.size) *
100
).toFixed(1),
},
});
}
function handleFallback() {
// Fallback: return original file
const url = URL.createObjectURL(file);
const fileExtension = file.name.split(".").pop();
const nameWithoutExt = file.name.replace(
new RegExp(`\\.${fileExtension}$`, "i"),
""
);
const filename = `${nameWithoutExt}-cleaned.${fileExtension}`;
dispatch("processingComplete", {
processedImage: {
url,
filename,
size: file.size,
},
});
}
</script>
<div class="processor-section">
<div class="text-center mb-6">
<p class="text-secondary mb-4">
Remove all EXIF metadata from your image to protect your privacy. This
process happens entirely in your browser - no data is sent to any server.
</p>
<button class="btn btn-primary btn-lg" on:click={processImage} {disabled}>
{#if disabled}
<svg
class="animate-spin"
width="20"
height="20"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M4 2a2 2 0 00-2 2v11a2 2 0 002 2h12a2 2 0 002-2V4a2 2 0 00-2-2H4zm0 2h12v11H4V4z"
clip-rule="evenodd"
/>
</svg>
Processing...
{:else}
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
clip-rule="evenodd"
/>
</svg>
Remove Metadata
{/if}
</button>
</div>
<div class="privacy-features">
<h4 class="font-semibold mb-3">What gets removed:</h4>
<div class="grid grid-2 gap-3 text-sm">
<div class="flex items-center gap-2">
<span class="text-success">✓</span>
GPS location data
</div>
<div class="flex items-center gap-2">
<span class="text-success">✓</span>
Camera make & model
</div>
<div class="flex items-center gap-2">
<span class="text-success">✓</span>
Date & time taken
</div>
<div class="flex items-center gap-2">
<span class="text-success">✓</span>
Camera settings
</div>
<div class="flex items-center gap-2">
<span class="text-success">✓</span>
Software information
</div>
<div class="flex items-center gap-2">
<span class="text-success">✓</span>
AI generation tags
</div>
</div>
</div>
</div>
<style>
.text-success {
color: var(--success-color);
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.privacy-features {
background-color: var(--bg-secondary);
padding: 1.5rem;
border-radius: 0.5rem;
border: 1px solid var(--border-color);
}
</style>
+731
View File
@@ -0,0 +1,731 @@
<script>
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
export let disabled = false;
let dragOver = false;
let fileInput;
const acceptedTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/tiff",
"image/heic",
"image/avif",
];
function handleDragOver(event) {
event.preventDefault();
if (!disabled) {
dragOver = true;
}
}
function handleDragLeave(event) {
event.preventDefault();
dragOver = false;
}
function handleDrop(event) {
event.preventDefault();
dragOver = false;
if (disabled) return;
const files = event.dataTransfer.files;
if (files.length > 0) {
processFile(files[0]);
}
}
function handleFileInput(event) {
if (disabled) return;
const files = event.target.files;
if (files.length > 0) {
processFile(files[0]);
}
}
async function processFile(file) {
if (!acceptedTypes.includes(file.type)) {
alert(
"Please select a supported image file (JPEG, PNG, TIFF, HEIC, or AVIF)."
);
return;
}
try {
const metadata = await extractMetadata(file);
dispatch("fileSelected", { file, metadata });
} catch (error) {
console.error("Error processing file:", error);
alert("Error processing the file. Please try again.");
}
}
async function extractMetadata(file) {
try {
const { parse } = await import("exifr");
if (
!file.type.includes("jpeg") &&
!file.type.includes("jpg") &&
!file.type.includes("tiff") &&
!file.type.includes("png") &&
!file.type.includes("heic") &&
!file.type.includes("avif")
) {
console.log("File type doesn't typically contain EXIF data");
return {
fileType: "other",
hasMetadata: false,
message: "This file type typically doesn't contain EXIF metadata",
};
}
const exifData = await parse(file, {
tiff: true,
exif: true,
gps: true,
interop: true,
iop: true,
makerNote: true,
userComment: true,
xmp: true,
iptc: true,
icc: true,
ihdr: true,
jfif: true,
chunked: true,
firstChunkSize: 131072,
chunkSize: 65536,
multiSegment: true,
silentErrors: false,
segments: [
"APP0",
"APP1",
"APP2",
"APP3",
"APP4",
"APP5",
"APP6",
"APP7",
"APP8",
"APP9",
"APP10",
"APP11",
"APP12",
"APP13",
"APP14",
"APP15",
],
mergeOutput: true,
translateKeys: true,
translateValues: true,
reviveValues: true,
sanitize: false,
pick: [],
skip: [],
});
let allMetadata = exifData || {};
let rawMetadata = {};
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
rawMetadata = await extractRawMetadata(uint8Array, file.type);
allMetadata = { ...allMetadata, ...rawMetadata };
if (
file.type.includes("png") &&
(!allMetadata || Object.keys(allMetadata).length === 0)
) {
try {
const pngMetadata = await parse(file, {
ihdr: true,
itxt: true,
text: true,
ztxt: true,
time: true,
phys: true,
splt: true,
hist: true,
chrm: true,
gama: true,
iccp: true,
sbit: true,
srgb: true,
bkgd: true,
trns: true,
mergeOutput: false,
translateKeys: false,
sanitize: false,
});
if (pngMetadata && Object.keys(pngMetadata).length > 0) {
allMetadata = { ...allMetadata, ...pngMetadata };
}
} catch (pngError) {
console.log("PNG-specific parsing failed:", pngError);
}
}
if (!allMetadata || Object.keys(allMetadata).length === 0) {
return {
fileType: file.type.includes("png") ? "png" : "jpeg",
hasMetadata: false,
message: file.type.includes("png")
? "PNG files typically don't contain EXIF metadata"
: "No EXIF data found in this image - image appears to be clean",
};
}
// Organize the metadata into categories
const metadata = {};
// C2PA/AI Provenance data (priority category)
const c2paFields = Object.keys(allMetadata).filter(
(key) =>
key.toLowerCase().includes("c2pa") ||
key.toLowerCase().includes("cai") ||
key.toLowerCase().includes("provenance") ||
key.toLowerCase().includes("manifest") ||
key.toLowerCase().includes("signature") ||
key.toLowerCase().includes("claim") ||
key.toLowerCase().includes("assertion") ||
key.toLowerCase().includes("chatgpt") ||
key.toLowerCase().includes("openai") ||
key.toLowerCase().includes("ai_generated") ||
key.toLowerCase().includes("dall") ||
key.toLowerCase().includes("midjourney") ||
key.toLowerCase().includes("stable") ||
key.toLowerCase().includes("generated")
);
if (c2paFields.length > 0) {
const c2paData = {};
for (const field of c2paFields) {
c2paData[field] = allMetadata[field];
}
metadata["C2PA/AI Provenance"] = c2paData;
}
if (
rawMetadata.C2PA_FOUND ||
rawMetadata.AI_SIGNATURES ||
rawMetadata.JUMBF_BOXES
) {
if (!metadata["C2PA/AI Provenance"]) {
metadata["C2PA/AI Provenance"] = {};
}
// Only add if not already present to avoid duplicates
if (
rawMetadata.TOTAL_SIGNATURES &&
!metadata["C2PA/AI Provenance"]["TOTAL_SIGNATURES"]
) {
metadata["C2PA/AI Provenance"]["TOTAL_SIGNATURES"] =
rawMetadata.TOTAL_SIGNATURES;
}
if (
rawMetadata.C2PA_FOUND &&
!metadata["C2PA/AI Provenance"]["C2PA_FOUND"]
) {
metadata["C2PA/AI Provenance"]["C2PA_FOUND"] = rawMetadata.C2PA_FOUND;
}
if (
rawMetadata.AI_SIGNATURES &&
!metadata["C2PA/AI Provenance"]["AI_SIGNATURES"]
) {
metadata["C2PA/AI Provenance"]["AI_SIGNATURES"] =
rawMetadata.AI_SIGNATURES;
}
if (
rawMetadata.JUMBF_BOXES &&
!metadata["C2PA/AI Provenance"]["JUMBF_BOXES"]
) {
metadata["C2PA/AI Provenance"]["JUMBF_BOXES"] =
rawMetadata.JUMBF_BOXES;
}
}
const gpsFields = [
"latitude",
"longitude",
"GPSLatitude",
"GPSLongitude",
"GPSAltitude",
"GPSSpeed",
"GPSImgDirection",
"GPSDestBearing",
"GPSDateTime",
"GPSLatitudeRef",
"GPSLongitudeRef",
];
const gpsData = {};
for (const field of gpsFields) {
if (allMetadata[field] !== undefined) {
gpsData[field] = allMetadata[field];
}
}
if (Object.keys(gpsData).length > 0) {
metadata.GPS = gpsData;
}
// Camera/EXIF data
const cameraFields = [
"Make",
"Model",
"Software",
"DateTime",
"DateTimeOriginal",
"DateTimeDigitized",
"ExposureTime",
"FNumber",
"ISO",
"Flash",
"FocalLength",
"WhiteBalance",
"LensModel",
"LensMake",
"Artist",
"Copyright",
];
const cameraData = {};
for (const field of cameraFields) {
if (allMetadata[field] !== undefined) {
cameraData[field] = allMetadata[field];
}
}
if (Object.keys(cameraData).length > 0) {
metadata.Camera = cameraData;
}
const technicalFields = [
"ColorSpace",
"Orientation",
"ResolutionUnit",
"XResolution",
"YResolution",
"ExifVersion",
"ComponentsConfiguration",
"ImageWidth",
"ImageHeight",
];
const technicalData = {};
for (const field of technicalFields) {
if (allMetadata[field] !== undefined) {
technicalData[field] = allMetadata[field];
}
}
if (Object.keys(technicalData).length > 0) {
metadata.Technical = technicalData;
}
// XMP data (often contains AI/software info)
const xmpFields = Object.keys(allMetadata).filter(
(key) =>
key.toLowerCase().includes("xmp") ||
key.includes("Creator") ||
key.includes("Subject")
);
if (xmpFields.length > 0) {
const xmpData = {};
for (const field of xmpFields) {
xmpData[field] = allMetadata[field];
}
metadata.XMP = xmpData;
}
// IPTC data
const iptcFields = Object.keys(allMetadata).filter(
(key) =>
key.toLowerCase().includes("iptc") ||
key.includes("Keywords") ||
key.includes("Caption")
);
if (iptcFields.length > 0) {
const iptcData = {};
for (const field of iptcFields) {
iptcData[field] = allMetadata[field];
}
metadata.IPTC = iptcData;
}
// PNG-specific chunks
const pngFields = Object.keys(allMetadata).filter(
(key) =>
key.toLowerCase().includes("text") ||
key.toLowerCase().includes("itxt") ||
key.toLowerCase().includes("ztxt") ||
key.toLowerCase().includes("time") ||
key.toLowerCase().includes("phys") ||
key.toLowerCase().includes("hist") ||
key.toLowerCase().includes("chrm") ||
key.toLowerCase().includes("gama") ||
key.toLowerCase().includes("srgb") ||
key.toLowerCase().includes("iccp")
);
if (pngFields.length > 0) {
const pngData = {};
for (const field of pngFields) {
pngData[field] = allMetadata[field];
}
metadata["PNG Chunks"] = pngData;
}
// Color Profile data (ICC profiles - not privacy concerning)
const colorProfileFields = Object.keys(allMetadata).filter(
(key) =>
key.includes("Profile") ||
key.includes("ColorSpace") ||
key.includes("WhitePoint") ||
key.includes("PrimaryChromaticities") ||
key.includes("ICC")
);
if (colorProfileFields.length > 0) {
const profileData = {};
for (const field of colorProfileFields) {
profileData[field] = allMetadata[field];
}
metadata["Color Profile"] = profileData;
}
// Other interesting fields that might contain privacy-sensitive data
const otherPrivacyFields = Object.keys(allMetadata).filter(
(key) =>
!gpsFields.includes(key) &&
!cameraFields.includes(key) &&
!technicalFields.includes(key) &&
!xmpFields.includes(key) &&
!iptcFields.includes(key) &&
!pngFields.includes(key) &&
!c2paFields.includes(key) &&
!colorProfileFields.includes(key) &&
(key.includes("User") ||
key.includes("Comment") ||
key.includes("Description") ||
key.includes("AI") ||
key.includes("Generated") ||
key.includes("Thumbnail") ||
key.includes("Preview"))
);
if (otherPrivacyFields.length > 0) {
const otherData = {};
for (const field of otherPrivacyFields) {
otherData[field] = allMetadata[field];
}
metadata.Other = otherData;
}
// Add raw metadata summary
if (
rawMetadata.TOTAL_SIGNATURES > 0 ||
rawMetadata.METADATA_MARKERS > 0
) {
metadata["Raw Analysis"] = {
"Total Signatures Found": rawMetadata.TOTAL_SIGNATURES || 0,
"Metadata Markers": rawMetadata.METADATA_MARKERS || 0,
"File Contains Binary Metadata": true,
};
}
console.log("Extracted metadata with exifr:", metadata);
console.log("Raw exifr data:", allMetadata);
console.log("Raw binary analysis:", rawMetadata);
return metadata;
} catch (error) {
console.log("Error extracting metadata:", error.message);
return {
fileType:
file.type.includes("jpeg") || file.type.includes("jpg")
? "jpeg"
: file.type.includes("png")
? "png"
: "other",
hasMetadata: false,
message: file.type.includes("png")
? "PNG files typically don't contain EXIF metadata"
: error.message.includes("not supported") ||
error.message.includes("invalid")
? "No EXIF data found in this image"
: "Image appears to be clean (no metadata found)",
};
}
}
// Function to extract raw metadata by scanning binary data for known signatures
async function extractRawMetadata(uint8Array, fileType) {
const rawMetadata = {
TOTAL_SIGNATURES: 0,
METADATA_MARKERS: 0,
};
// Convert to string for text-based searching
const textDecoder = new TextDecoder("utf-8", {
ignoreBOM: true,
fatal: false,
});
let textContent = "";
// Sample the file in chunks to avoid memory issues with large files
const chunkSize = 1024;
for (
let i = 0;
i < Math.min(uint8Array.length, 1024 * 1024);
i += chunkSize
) {
const chunk = uint8Array.slice(
i,
Math.min(i + chunkSize, uint8Array.length)
);
try {
textContent += textDecoder.decode(chunk, { stream: true });
} catch (e) {
// Skip chunks that can't be decoded as text
}
}
// C2PA/CAI signatures - more specific patterns
const c2paSignatures = [
"c2pa.manifest",
"c2pa.signature",
"c2pa.assertions",
"contentauthenticity.org",
"adobe.com/cai",
"CASS",
"c2pa.created",
"c2pa.converted",
];
// AI generation signatures - specific and definitive patterns only
const aiSignatures = [
"ChatGPT",
"OpenAI API",
"DALL-E",
"dall-e-",
"Midjourney",
"Stable Diffusion",
"AI-generated",
"trainedAlgorithmicMedia",
"digitalSourceType",
"GPT-4",
"generated",
"synthesized",
];
// JUMBF (JPEG Universal Metadata Box Format) signatures
const jumbfSignatures = ["jumb", "JUMB", "jumd", "JUMD"];
// Search for C2PA signatures with more precise matching
let c2paFound = [];
for (const sig of c2paSignatures) {
// Use case-sensitive matching for specific patterns
if (
textContent.includes(sig) ||
textContent.toLowerCase().includes(sig.toLowerCase())
) {
c2paFound.push(sig);
rawMetadata.TOTAL_SIGNATURES++;
}
}
if (c2paFound.length > 0) {
rawMetadata.C2PA_FOUND = c2paFound;
}
// Search for AI signatures with exact matching
let aiFound = [];
for (const sig of aiSignatures) {
// Use more precise matching to avoid false positives
if (
textContent.includes(sig) ||
textContent.toLowerCase().includes(sig.toLowerCase())
) {
// Additional validation: ensure it's not just a fragment
const context = textContent.toLowerCase();
const sigLower = sig.toLowerCase();
// Only count if we find the signature in a meaningful context
if (
context.includes(sigLower) &&
(sig.length > 4 || // Longer signatures are more reliable
context.includes(sigLower + " ") || // Word boundary
context.includes(" " + sigLower) || // Word boundary
context.includes(sigLower + ":") || // Metadata format
context.includes(sigLower + "=") || // Assignment format
context.includes('"' + sigLower + '"')) // Quoted value
) {
aiFound.push(sig);
rawMetadata.TOTAL_SIGNATURES++;
}
}
}
if (aiFound.length > 0) {
rawMetadata.AI_SIGNATURES = aiFound;
}
// Search for JUMBF boxes
let jumbfFound = [];
for (const sig of jumbfSignatures) {
if (textContent.toLowerCase().includes(sig.toLowerCase())) {
jumbfFound.push(sig);
rawMetadata.TOTAL_SIGNATURES++;
}
}
if (jumbfFound.length > 0) {
rawMetadata.JUMBF_BOXES = jumbfFound;
}
// Look for common metadata markers - be more specific
const metadataMarkers = [
"EXIF\x00", // EXIF with null terminator
"IPTC",
"XMP\x00", // XMP with null terminator
"ICC_PROFILE",
"Photoshop 3.0",
"Adobe_CM",
"GPS\x00", // GPS with null terminator
];
for (const marker of metadataMarkers) {
if (textContent.includes(marker)) {
rawMetadata.METADATA_MARKERS++;
}
}
// For JPEG files, look for specific APP segments that might contain C2PA
if (fileType.includes("jpeg") || fileType.includes("jpg")) {
// Look for APP11 segment marker (often used for C2PA)
const app11Marker = new Uint8Array([0xff, 0xeb]);
// Look for APP2 segment marker (sometimes used for C2PA/CAI, but also ICC profiles)
const app2Marker = new Uint8Array([0xff, 0xe2]);
let foundApp11 = false,
foundApp2WithC2PA = false;
for (let i = 0; i < uint8Array.length - 10; i++) {
if (
uint8Array[i] === app11Marker[0] &&
uint8Array[i + 1] === app11Marker[1]
) {
foundApp11 = true;
break;
}
if (
uint8Array[i] === app2Marker[0] &&
uint8Array[i + 1] === app2Marker[1]
) {
// Check if this APP2 segment contains C2PA data (not just ICC profile)
const segmentData = textContent.slice(i, i + 100).toLowerCase();
if (
segmentData.includes("c2pa") ||
segmentData.includes("cai") ||
segmentData.includes("jumb")
) {
foundApp2WithC2PA = true;
}
}
}
if (foundApp11 || foundApp2WithC2PA) {
if (!rawMetadata.JUMBF_BOXES) rawMetadata.JUMBF_BOXES = [];
if (foundApp11) rawMetadata.JUMBF_BOXES.push("APP11_segment");
if (foundApp2WithC2PA) rawMetadata.JUMBF_BOXES.push("APP2_segment");
rawMetadata.TOTAL_SIGNATURES++;
}
}
return rawMetadata;
}
function reset() {
if (fileInput) {
fileInput.value = "";
}
dispatch("reset");
}
</script>
<div class="upload-section">
<div
class="upload-area"
class:dragover={dragOver}
class:disabled
on:dragover={handleDragOver}
on:dragleave={handleDragLeave}
on:drop={handleDrop}
role="button"
tabindex="0"
on:click={() => !disabled && fileInput.click()}
on:keydown={(e) =>
(e.key === "Enter" || e.key === " ") && !disabled && fileInput.click()}
>
<div class="upload-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 48 48">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
/>
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">
{dragOver ? "Drop your image here" : "Upload Image"}
</h3>
<p class="text-secondary mb-4">
Drag and drop your image here, or click to browse
</p>
<p class="text-sm text-muted">
Supports JPEG, PNG, TIFF, HEIC, and AVIF files
</p>
<input
type="file"
accept=".jpg,.jpeg,.png,.tiff,.tif,.heic,.avif"
bind:this={fileInput}
on:change={handleFileInput}
class="hidden"
{disabled}
/>
</div>
</div>
<style>
.upload-section {
width: 100%;
}
.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.disabled:hover {
transform: none !important;
border-color: var(--border-color) !important;
background-color: var(--bg-secondary) !important;
}
</style>
+299
View File
@@ -0,0 +1,299 @@
<script>
export let metadata = {};
function formatMetadata(data) {
if (!data || Object.keys(data).length === 0) {
return "No metadata found";
}
if (data.message && !data.hasMetadata) {
return data.message;
}
let formatted = "";
Object.entries(data).forEach(([section, values]) => {
if (
section === "fileType" ||
section === "hasMetadata" ||
section === "message"
) {
return;
}
formatted += `[${section}]\n`;
if (typeof values === "object" && values !== null) {
Object.entries(values).forEach(([key, value]) => {
let displayValue;
if (Array.isArray(value)) {
displayValue = `[${value.join(", ")}]`;
} else if (typeof value === "string" && value.length > 100) {
displayValue = value.substring(0, 100) + "...";
} else {
displayValue = value;
}
formatted += ` ${key}: ${displayValue}\n`;
});
}
formatted += "\n";
});
return formatted.trim();
}
function getMetadataStats(metadata) {
let totalEntries = 0;
let sections = [];
if (metadata.message && !metadata.hasMetadata) {
return { totalEntries: 0, sections: [] };
}
Object.entries(metadata).forEach(([section, values]) => {
if (
section === "fileType" ||
section === "hasMetadata" ||
section === "message"
) {
return;
}
if (typeof values === "object" && values !== null) {
const count = Object.keys(values).length;
totalEntries += count;
sections.push(`${section}: ${count} entries`);
}
});
return { totalEntries, sections };
}
function hasAIMetadata(metadata) {
if (metadata["C2PA/AI Provenance"]) {
const c2paData = metadata["C2PA/AI Provenance"];
if (
c2paData.AI_SIGNATURES &&
Array.isArray(c2paData.AI_SIGNATURES) &&
c2paData.AI_SIGNATURES.length > 0
) {
return true;
}
if (
c2paData.C2PA_FOUND &&
Array.isArray(c2paData.C2PA_FOUND) &&
c2paData.C2PA_FOUND.length > 0
) {
return true;
}
if (c2paData.JUMBF_BOXES && Array.isArray(c2paData.JUMBF_BOXES)) {
const realJumbf = c2paData.JUMBF_BOXES.filter(
(box) =>
!box.includes("APP2_segment") ||
box.includes("jumb") ||
box.includes("JUMB") ||
box.includes("jumd") ||
box.includes("JUMD")
);
if (realJumbf.length > 0) {
return true;
}
}
if (c2paData.TOTAL_SIGNATURES && c2paData.TOTAL_SIGNATURES > 1) {
return true;
}
}
const aiKeywords = [
"chatgpt",
"openai",
"dall-e",
"dalle",
"midjourney",
"stable diffusion",
"artificial intelligence",
"ai-generated",
"machine learning",
"neural network",
"synthesized",
"gpt-4",
"trainedAlgorithmicMedia",
];
let hasAI = false;
Object.entries(metadata).forEach(([section, values]) => {
if (
section === "fileType" ||
section === "hasMetadata" ||
section === "message" ||
section === "Raw Analysis" ||
section === "C2PA/AI Provenance"
) {
return;
}
if (typeof values === "object" && values !== null) {
Object.entries(values).forEach(([key, value]) => {
const valueString = String(value).toLowerCase();
if (
aiKeywords.some((keyword) =>
valueString.includes(keyword.toLowerCase())
)
) {
hasAI = true;
}
});
}
});
return hasAI;
}
$: formattedMetadata = formatMetadata(metadata);
$: containsAITags = hasAIMetadata(metadata);
$: metadataStats = getMetadataStats(metadata);
</script>
<div class="metadata-section">
<div class="flex justify-between items-center mb-4">
<h4 class="font-semibold">EXIF Metadata</h4>
{#if containsAITags}
<span class="status-warning py-1 px-2 text-xs">
⚠️ Contains AI-related tags
</span>
{/if}
</div>
{#if Object.keys(metadata).length === 0 || (metadata.message && !metadata.hasMetadata)}
<div class="status-success">
{#if metadata.message}
✅ {metadata.message}
{:else}
✅ No metadata found - image is already clean!
{/if}
</div>
{#if metadata.fileType === "png"}
<div class="mt-3 p-3 bg-info rounded text-sm">
<p><strong>ℹ️ About PNG files:</strong></p>
<p>
PNG files typically don't contain EXIF metadata. However, you can
still process this image to ensure it's completely clean and remove
any potential hidden data.
</p>
</div>
{/if}
{:else}
<div class="mb-4">
<div class="metadata-summary">
<p class="text-sm text-secondary">
<strong>Total entries:</strong>
{metadataStats.totalEntries}
</p>
<div class="text-xs text-muted mt-1">
{#each metadataStats.sections as section}
<span class="section-tag">{section}</span>
{/each}
</div>
</div>
</div>
<div class="metadata-viewer">
{formattedMetadata}
</div>
<div class="mt-4">
<h5 class="font-medium mb-2">Privacy Concerns Found:</h5>
<ul class="text-sm text-secondary space-y-1">
{#if metadata.GPS}
<li class="status-warning">📍 GPS location data</li>
{/if}
{#if metadata.Camera}
<li class="status-warning">📷 Camera information</li>
{/if}
{#if metadata.Technical}
<li class="status-warning">🖼️ Image creation details</li>
{/if}
{#if containsAITags}
<li class="status-warning">🤖 AI generation indicators</li>
{#if metadata["C2PA/AI Provenance"]}
<ul class="ml-4 mt-1 text-xs">
{#if metadata["C2PA/AI Provenance"].AI_SIGNATURES && metadata["C2PA/AI Provenance"].AI_SIGNATURES.length > 0}
<li>
• AI Tools: {metadata[
"C2PA/AI Provenance"
].AI_SIGNATURES.join(", ")}
</li>
{/if}
{#if metadata["C2PA/AI Provenance"].C2PA_FOUND && metadata["C2PA/AI Provenance"].C2PA_FOUND.length > 0}
<li>
• C2PA Provenance: {metadata["C2PA/AI Provenance"].C2PA_FOUND
.length} signature(s)
</li>
{/if}
{#if metadata["C2PA/AI Provenance"].TOTAL_SIGNATURES && metadata["C2PA/AI Provenance"].TOTAL_SIGNATURES > 0}
<li>
• Total AI/C2PA markers: {metadata["C2PA/AI Provenance"]
.TOTAL_SIGNATURES}
</li>
{/if}
{#if metadata["C2PA/AI Provenance"].JUMBF_BOXES && metadata["C2PA/AI Provenance"].JUMBF_BOXES.length > 0}
<li>
• JUMBF metadata containers: {metadata["C2PA/AI Provenance"]
.JUMBF_BOXES.length}
</li>
{/if}
</ul>
{/if}
{/if}
{#if metadata.XMP}
<li class="status-warning">📝 XMP metadata</li>
{/if}
{#if metadata.IPTC}
<li class="status-warning">📋 IPTC metadata</li>
{/if}
{#if metadata.Other && Object.keys(metadata.Other).length > 0}
<li class="status-warning">📄 Other metadata</li>
{/if}
</ul>
{#if metadata["Color Profile"]}
<div class="mt-3 p-2 bg-info rounded text-xs">
<p>
<strong>ℹ️ Color Profile:</strong> Contains ICC color management data
(not privacy-concerning)
</p>
</div>
{/if}
</div>
{/if}
</div>
<style>
.space-y-1 > * + * {
margin-top: 0.25rem;
}
.metadata-summary {
background-color: var(--bg-tertiary);
padding: 0.75rem;
border-radius: 0.375rem;
border: 1px solid var(--border-color);
}
.section-tag {
display: inline-block;
background-color: var(--primary-color);
color: white;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
margin-right: 0.5rem;
margin-bottom: 0.25rem;
font-size: 0.75rem;
}
</style>
+108
View File
@@ -0,0 +1,108 @@
<div class="grid grid-2 gap-6">
<div>
<h3 class="font-semibold mb-3 flex items-center gap-2">
<span class="text-success">✓</span>
Client-Side Processing
</h3>
<p class="text-sm text-secondary mb-4">
Images never leave your browser. Processing happens locally.
</p>
<h3 class="font-semibold mb-3 flex items-center gap-2">
<span class="text-success">✓</span>
No Data Collection
</h3>
<p class="text-sm text-secondary mb-4">
No analytics, tracking, or data collection.
</p>
<h3 class="font-semibold mb-3 flex items-center gap-2">
<span class="text-success">✓</span>
Open Source
</h3>
<p class="text-sm text-secondary">
Fully auditable codebase. Verify what happens to your data.
</p>
</div>
<div>
<h3 class="font-semibold mb-3">Why Remove Metadata?</h3>
<div class="space-y-3 text-sm text-secondary">
<div class="privacy-risk">
<strong>📍 Location:</strong> GPS coordinates reveal photo locations
</div>
<div class="privacy-risk">
<strong>📷 Device Info:</strong> Camera models can link photos to devices
</div>
<div class="privacy-risk">
<strong>🕒 Timestamps:</strong> Enable detailed activity timelines
</div>
<div class="privacy-risk">
<strong>🤖 AI Detection:</strong> Software tags reveal AI generation
</div>
</div>
<div class="mt-4 p-3 bg-warning rounded">
<p class="text-sm">
<strong>⚠️</strong> Social media may strip some metadata, but it's safer
to clean images yourself.
</p>
</div>
</div>
</div>
<div class="mt-6 p-4 bg-info rounded">
<h4 class="font-semibold mb-2">Perfect for:</h4>
<div class="grid grid-2 gap-2 text-sm">
<div>• Journalists protecting sources</div>
<div>• Activists maintaining anonymity</div>
<div>• Content creators removing AI tags</div>
<div>• Anyone valuing digital privacy</div>
</div>
</div>
<style>
.text-success {
color: var(--success-color);
}
.space-y-3 > * + * {
margin-top: 0.75rem;
}
.privacy-risk {
padding: 0.75rem;
background-color: var(--bg-tertiary);
border-radius: 0.375rem;
border-left: 3px solid var(--warning-color);
}
.bg-warning {
background-color: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.2);
}
.bg-info {
background-color: rgba(37, 99, 235, 0.05);
border: 1px solid rgba(37, 99, 235, 0.1);
}
.rounded {
border-radius: 0.5rem;
}
.grid {
display: grid;
gap: 1.5rem;
}
.grid-2 {
grid-template-columns: 1fr 1fr;
}
@media (max-width: 768px) {
.grid-2 {
grid-template-columns: 1fr;
}
}
</style>
+34
View File
@@ -0,0 +1,34 @@
<script>
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
export let darkMode = false;
function toggle() {
dispatch("toggle");
}
</script>
<button
class="theme-toggle"
on:click={toggle}
aria-label="Toggle dark mode"
title="Toggle dark mode"
>
{#if darkMode}
<svg width="24" height="24" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
clip-rule="evenodd"
/>
</svg>
{:else}
<svg width="24" height="24" fill="currentColor" viewBox="0 0 20 20">
<path
d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"
/>
</svg>
{/if}
</button>
+8
View File
@@ -0,0 +1,8 @@
import './app.css'
import App from './App.svelte'
const app = new App({
target: document.getElementById('app'),
})
export default app
+7
View File
@@ -0,0 +1,7 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {},
},
plugins: [],
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [tailwindcss(),svelte()],
base: '/untrace/',
build: {
outDir: 'dist',
assetsDir: 'assets'
}
})