first commit
This commit is contained in:
+936
@@ -0,0 +1,936 @@
|
||||
const encodeForm =
|
||||
document.getElementById("encode-form");
|
||||
|
||||
const decodeForm =
|
||||
document.getElementById("decode-form");
|
||||
|
||||
|
||||
const encodeImage =
|
||||
document.getElementById("encode-image");
|
||||
|
||||
const decodeImage =
|
||||
document.getElementById("decode-image");
|
||||
|
||||
|
||||
const encodeDropzone =
|
||||
document.getElementById("encode-dropzone");
|
||||
|
||||
const decodeDropzone =
|
||||
document.getElementById("decode-dropzone");
|
||||
|
||||
|
||||
const encodeImageInfo =
|
||||
document.getElementById("encode-image-info");
|
||||
|
||||
const decodeImageInfo =
|
||||
document.getElementById("decode-image-info");
|
||||
|
||||
|
||||
const messageSection =
|
||||
document.getElementById("message-section");
|
||||
|
||||
const fileSection =
|
||||
document.getElementById("file-section");
|
||||
|
||||
|
||||
const messageInput =
|
||||
document.getElementById("message");
|
||||
|
||||
const messageSize =
|
||||
document.getElementById("message-size");
|
||||
|
||||
const secretFile =
|
||||
document.getElementById("secret-file");
|
||||
|
||||
|
||||
const encodePassword =
|
||||
document.getElementById("encode-password");
|
||||
|
||||
const decodePassword =
|
||||
document.getElementById("decode-password");
|
||||
|
||||
|
||||
const encodeButton =
|
||||
document.getElementById("encode-button");
|
||||
|
||||
const decodeButton =
|
||||
document.getElementById("decode-button");
|
||||
|
||||
|
||||
const encodeStatus =
|
||||
document.getElementById("encode-status");
|
||||
|
||||
const decodeStatus =
|
||||
document.getElementById("decode-status");
|
||||
|
||||
|
||||
const decodedMessage =
|
||||
document.getElementById("decoded-message");
|
||||
|
||||
const messageOutput =
|
||||
document.getElementById("message-output");
|
||||
|
||||
const copyMessage =
|
||||
document.getElementById("copy-message");
|
||||
|
||||
|
||||
function getEncodeMode() {
|
||||
const selected =
|
||||
document.querySelector(
|
||||
'input[name="encode-mode"]:checked'
|
||||
);
|
||||
|
||||
return selected
|
||||
? selected.value
|
||||
: "message";
|
||||
}
|
||||
|
||||
|
||||
function setStatus(
|
||||
element,
|
||||
message,
|
||||
type = ""
|
||||
) {
|
||||
element.textContent =
|
||||
message;
|
||||
|
||||
element.className =
|
||||
`status ${type}`;
|
||||
}
|
||||
|
||||
|
||||
function setLoading(
|
||||
button,
|
||||
loading
|
||||
) {
|
||||
if (loading) {
|
||||
|
||||
button.disabled =
|
||||
true;
|
||||
|
||||
button.dataset.originalText =
|
||||
button.textContent;
|
||||
|
||||
button.textContent =
|
||||
"Working...";
|
||||
|
||||
} else {
|
||||
|
||||
button.disabled =
|
||||
false;
|
||||
|
||||
button.textContent =
|
||||
button.dataset.originalText ||
|
||||
button.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function formatBytes(bytes) {
|
||||
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
|
||||
function setSelectedFile(
|
||||
input,
|
||||
file
|
||||
) {
|
||||
/*
|
||||
* DataTransfer lets us put a file selected
|
||||
* through drag-and-drop into the file input.
|
||||
*/
|
||||
const dataTransfer =
|
||||
new DataTransfer();
|
||||
|
||||
dataTransfer.items.add(file);
|
||||
|
||||
input.files =
|
||||
dataTransfer.files;
|
||||
|
||||
input.dispatchEvent(
|
||||
new Event(
|
||||
"change",
|
||||
{
|
||||
bubbles: true
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function setupDropzone(
|
||||
dropzone,
|
||||
input
|
||||
) {
|
||||
|
||||
dropzone.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
input.click();
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
dropzone.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
|
||||
if (
|
||||
event.key === "Enter" ||
|
||||
event.key === " "
|
||||
) {
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
input.click();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
[
|
||||
"dragenter",
|
||||
"dragover"
|
||||
].forEach((eventName) => {
|
||||
|
||||
dropzone.addEventListener(
|
||||
eventName,
|
||||
(event) => {
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
dropzone.classList.add(
|
||||
"dragging"
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
[
|
||||
"dragleave",
|
||||
"drop"
|
||||
].forEach((eventName) => {
|
||||
|
||||
dropzone.addEventListener(
|
||||
eventName,
|
||||
(event) => {
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
dropzone.classList.remove(
|
||||
"dragging"
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
dropzone.addEventListener(
|
||||
"drop",
|
||||
(event) => {
|
||||
|
||||
const file =
|
||||
event.dataTransfer.files[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!file.type.includes("png") &&
|
||||
!file.name
|
||||
.toLowerCase()
|
||||
.endsWith(".png")
|
||||
) {
|
||||
|
||||
alert(
|
||||
"Please select a PNG image."
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(
|
||||
input,
|
||||
file
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
input.addEventListener(
|
||||
"change",
|
||||
() => {
|
||||
|
||||
const file =
|
||||
input.files[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
dropzone.classList.add(
|
||||
"has-file"
|
||||
);
|
||||
|
||||
const strong =
|
||||
dropzone.querySelector(
|
||||
"strong"
|
||||
);
|
||||
|
||||
const span =
|
||||
dropzone.querySelector(
|
||||
"span"
|
||||
);
|
||||
|
||||
strong.textContent =
|
||||
file.name;
|
||||
|
||||
span.textContent =
|
||||
formatBytes(file.size);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
setupDropzone(
|
||||
encodeDropzone,
|
||||
encodeImage
|
||||
);
|
||||
|
||||
setupDropzone(
|
||||
decodeDropzone,
|
||||
decodeImage
|
||||
);
|
||||
|
||||
|
||||
async function getImageInfo(file) {
|
||||
|
||||
const form =
|
||||
new FormData();
|
||||
|
||||
form.append(
|
||||
"image",
|
||||
file
|
||||
);
|
||||
|
||||
|
||||
const response =
|
||||
await fetch(
|
||||
"/api/info",
|
||||
{
|
||||
method: "POST",
|
||||
body: form
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
const data =
|
||||
await response.json();
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
throw new Error(
|
||||
data.error ||
|
||||
"Unable to inspect image"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
async function updateImageInfo(
|
||||
file,
|
||||
element,
|
||||
statusElement
|
||||
) {
|
||||
|
||||
element.classList.add(
|
||||
"hidden"
|
||||
);
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
|
||||
const info =
|
||||
await getImageInfo(file);
|
||||
|
||||
|
||||
element.innerHTML =
|
||||
`
|
||||
<strong>
|
||||
${info.width} × ${info.height}
|
||||
</strong>
|
||||
|
||||
<span>
|
||||
${info.format.toUpperCase()}
|
||||
· ${info.capacityKB} KB capacity
|
||||
</span>
|
||||
`;
|
||||
|
||||
|
||||
element.classList.remove(
|
||||
"hidden"
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
|
||||
setStatus(
|
||||
statusElement,
|
||||
error.message,
|
||||
"error"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
encodeImage.addEventListener(
|
||||
"change",
|
||||
async () => {
|
||||
|
||||
await updateImageInfo(
|
||||
encodeImage.files[0],
|
||||
encodeImageInfo,
|
||||
encodeStatus
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
messageInput.addEventListener(
|
||||
"input",
|
||||
() => {
|
||||
|
||||
const count =
|
||||
messageInput.value.length;
|
||||
|
||||
messageSize.textContent =
|
||||
`${count.toLocaleString()} character${
|
||||
count === 1
|
||||
? ""
|
||||
: "s"
|
||||
}`;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
document
|
||||
.querySelectorAll(
|
||||
'input[name="encode-mode"]'
|
||||
)
|
||||
.forEach((radio) => {
|
||||
|
||||
radio.addEventListener(
|
||||
"change",
|
||||
() => {
|
||||
|
||||
const mode =
|
||||
getEncodeMode();
|
||||
|
||||
|
||||
if (
|
||||
mode === "message"
|
||||
) {
|
||||
|
||||
messageSection
|
||||
.classList
|
||||
.remove("hidden");
|
||||
|
||||
fileSection
|
||||
.classList
|
||||
.add("hidden");
|
||||
|
||||
} else {
|
||||
|
||||
messageSection
|
||||
.classList
|
||||
.add("hidden");
|
||||
|
||||
fileSection
|
||||
.classList
|
||||
.remove("hidden");
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
encodeForm.addEventListener(
|
||||
"submit",
|
||||
async (event) => {
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
|
||||
const image =
|
||||
encodeImage.files[0];
|
||||
|
||||
const password =
|
||||
encodePassword.value;
|
||||
|
||||
const mode =
|
||||
getEncodeMode();
|
||||
|
||||
|
||||
if (!image) {
|
||||
|
||||
setStatus(
|
||||
encodeStatus,
|
||||
"Please select a PNG image.",
|
||||
"error"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const form =
|
||||
new FormData();
|
||||
|
||||
form.append(
|
||||
"image",
|
||||
image
|
||||
);
|
||||
|
||||
/*
|
||||
* Password is optional.
|
||||
*/
|
||||
if (password) {
|
||||
|
||||
form.append(
|
||||
"password",
|
||||
password
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (
|
||||
mode === "message"
|
||||
) {
|
||||
|
||||
if (
|
||||
!messageInput.value
|
||||
) {
|
||||
|
||||
setStatus(
|
||||
encodeStatus,
|
||||
"Please enter a message.",
|
||||
"error"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
form.append(
|
||||
"message",
|
||||
messageInput.value
|
||||
);
|
||||
|
||||
} else {
|
||||
|
||||
const file =
|
||||
secretFile.files[0];
|
||||
|
||||
|
||||
if (!file) {
|
||||
|
||||
setStatus(
|
||||
encodeStatus,
|
||||
"Please select a file.",
|
||||
"error"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
form.append(
|
||||
"file",
|
||||
file
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
setLoading(
|
||||
encodeButton,
|
||||
true
|
||||
);
|
||||
|
||||
|
||||
setStatus(
|
||||
encodeStatus,
|
||||
"Encoding..."
|
||||
);
|
||||
|
||||
|
||||
try {
|
||||
|
||||
const response =
|
||||
await fetch(
|
||||
"/api/encode",
|
||||
{
|
||||
method: "POST",
|
||||
body: form
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
const data =
|
||||
await response.json();
|
||||
|
||||
throw new Error(
|
||||
data.error ||
|
||||
"Encoding failed"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const blob =
|
||||
await response.blob();
|
||||
|
||||
|
||||
const url =
|
||||
URL.createObjectURL(
|
||||
blob
|
||||
);
|
||||
|
||||
|
||||
const link =
|
||||
document.createElement(
|
||||
"a"
|
||||
);
|
||||
|
||||
|
||||
link.href =
|
||||
url;
|
||||
|
||||
link.download =
|
||||
"encoded.png";
|
||||
|
||||
|
||||
document.body.appendChild(
|
||||
link
|
||||
);
|
||||
|
||||
link.click();
|
||||
|
||||
link.remove();
|
||||
|
||||
|
||||
URL.revokeObjectURL(
|
||||
url
|
||||
);
|
||||
|
||||
|
||||
setStatus(
|
||||
encodeStatus,
|
||||
"Encoded successfully. Download started.",
|
||||
"success"
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
|
||||
setStatus(
|
||||
encodeStatus,
|
||||
error.message,
|
||||
"error"
|
||||
);
|
||||
|
||||
} finally {
|
||||
|
||||
setLoading(
|
||||
encodeButton,
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
decodeForm.addEventListener(
|
||||
"submit",
|
||||
async (event) => {
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
|
||||
const image =
|
||||
decodeImage.files[0];
|
||||
|
||||
const password =
|
||||
decodePassword.value;
|
||||
|
||||
|
||||
decodedMessage
|
||||
.classList
|
||||
.add("hidden");
|
||||
|
||||
|
||||
if (!image) {
|
||||
|
||||
setStatus(
|
||||
decodeStatus,
|
||||
"Please select an encoded PNG.",
|
||||
"error"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const form =
|
||||
new FormData();
|
||||
|
||||
|
||||
form.append(
|
||||
"image",
|
||||
image
|
||||
);
|
||||
|
||||
|
||||
/*
|
||||
* Password is optional.
|
||||
*/
|
||||
if (password) {
|
||||
|
||||
form.append(
|
||||
"password",
|
||||
password
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
setLoading(
|
||||
decodeButton,
|
||||
true
|
||||
);
|
||||
|
||||
|
||||
setStatus(
|
||||
decodeStatus,
|
||||
"Decoding..."
|
||||
);
|
||||
|
||||
|
||||
try {
|
||||
|
||||
const response =
|
||||
await fetch(
|
||||
"/api/decode",
|
||||
{
|
||||
method: "POST",
|
||||
body: form
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
const data =
|
||||
await response.json();
|
||||
|
||||
throw new Error(
|
||||
data.error ||
|
||||
"Decoding failed"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const contentType =
|
||||
response.headers.get(
|
||||
"content-type"
|
||||
);
|
||||
|
||||
|
||||
/*
|
||||
* Message payload.
|
||||
*/
|
||||
if (
|
||||
contentType &&
|
||||
contentType.startsWith(
|
||||
"application/json"
|
||||
)
|
||||
) {
|
||||
|
||||
const result =
|
||||
await response.json();
|
||||
|
||||
|
||||
if (
|
||||
result.type !==
|
||||
"message"
|
||||
) {
|
||||
|
||||
throw new Error(
|
||||
"Unknown decoded payload"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
messageOutput.textContent =
|
||||
result.message;
|
||||
|
||||
|
||||
decodedMessage
|
||||
.classList
|
||||
.remove("hidden");
|
||||
|
||||
|
||||
setStatus(
|
||||
decodeStatus,
|
||||
"Message decoded successfully.",
|
||||
"success"
|
||||
);
|
||||
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* File payload.
|
||||
*/
|
||||
|
||||
const blob =
|
||||
await response.blob();
|
||||
|
||||
|
||||
const url =
|
||||
URL.createObjectURL(
|
||||
blob
|
||||
);
|
||||
|
||||
|
||||
let filename =
|
||||
"extracted-file";
|
||||
|
||||
|
||||
const disposition =
|
||||
response.headers.get(
|
||||
"content-disposition"
|
||||
);
|
||||
|
||||
|
||||
if (disposition) {
|
||||
|
||||
const match =
|
||||
disposition.match(
|
||||
/filename="([^"]+)"/
|
||||
);
|
||||
|
||||
|
||||
if (match) {
|
||||
filename =
|
||||
match[1];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const link =
|
||||
document.createElement(
|
||||
"a"
|
||||
);
|
||||
|
||||
|
||||
link.href =
|
||||
url;
|
||||
|
||||
link.download =
|
||||
filename;
|
||||
|
||||
|
||||
document.body.appendChild(
|
||||
link
|
||||
);
|
||||
|
||||
link.click();
|
||||
|
||||
link.remove();
|
||||
|
||||
|
||||
URL.revokeObjectURL(
|
||||
url
|
||||
);
|
||||
|
||||
|
||||
setStatus(
|
||||
decodeStatus,
|
||||
`File extracted: ${filename}`,
|
||||
"success"
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
|
||||
setStatus(
|
||||
decodeStatus,
|
||||
error.message,
|
||||
"error"
|
||||
);
|
||||
|
||||
} finally {
|
||||
|
||||
setLoading(
|
||||
decodeButton,
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
copyMessage.addEventListener(
|
||||
"click",
|
||||
async () => {
|
||||
|
||||
try {
|
||||
|
||||
await navigator.clipboard.writeText(
|
||||
messageOutput.textContent
|
||||
);
|
||||
|
||||
|
||||
const original =
|
||||
copyMessage.textContent;
|
||||
|
||||
copyMessage.textContent =
|
||||
"Copied!";
|
||||
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
copyMessage.textContent =
|
||||
original;
|
||||
},
|
||||
1500
|
||||
);
|
||||
|
||||
} catch {
|
||||
|
||||
setStatus(
|
||||
decodeStatus,
|
||||
"Unable to copy message.",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,335 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
>
|
||||
|
||||
<title>Steg Tool</title>
|
||||
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/style.css"
|
||||
>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<main class="container">
|
||||
|
||||
<header class="header">
|
||||
<h1>Steg Tool</h1>
|
||||
|
||||
<p>
|
||||
Hide messages or files inside PNG images.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
|
||||
<!-- ENCODE -->
|
||||
|
||||
<section class="card">
|
||||
|
||||
<div class="card-header">
|
||||
<h2>Encode</h2>
|
||||
|
||||
<p>
|
||||
Hide a message or file inside a PNG.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<form id="encode-form">
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
<label for="encode-image">
|
||||
Cover image
|
||||
</label>
|
||||
|
||||
<div
|
||||
id="encode-dropzone"
|
||||
class="dropzone"
|
||||
tabindex="0"
|
||||
>
|
||||
|
||||
<input
|
||||
id="encode-image"
|
||||
type="file"
|
||||
accept="image/png"
|
||||
hidden
|
||||
>
|
||||
|
||||
<div class="dropzone-icon">
|
||||
↑
|
||||
</div>
|
||||
|
||||
<strong>
|
||||
Choose a PNG
|
||||
</strong>
|
||||
|
||||
<span>
|
||||
or drag and drop one here
|
||||
</span>
|
||||
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="encode-image-info"
|
||||
class="image-info hidden"
|
||||
></div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
<span class="label">
|
||||
Payload
|
||||
</span>
|
||||
|
||||
<div class="mode-selector">
|
||||
|
||||
<label class="mode-option">
|
||||
|
||||
<input
|
||||
type="radio"
|
||||
name="encode-mode"
|
||||
value="message"
|
||||
checked
|
||||
>
|
||||
|
||||
<span>Message</span>
|
||||
|
||||
</label>
|
||||
|
||||
|
||||
<label class="mode-option">
|
||||
|
||||
<input
|
||||
type="radio"
|
||||
name="encode-mode"
|
||||
value="file"
|
||||
>
|
||||
|
||||
<span>File</span>
|
||||
|
||||
</label>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
id="message-section"
|
||||
class="form-group"
|
||||
>
|
||||
|
||||
<label for="message">
|
||||
Message
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
id="message"
|
||||
rows="6"
|
||||
placeholder="Enter your secret message..."
|
||||
></textarea>
|
||||
|
||||
<div
|
||||
id="message-size"
|
||||
class="field-hint"
|
||||
>
|
||||
0 characters
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
id="file-section"
|
||||
class="form-group hidden"
|
||||
>
|
||||
|
||||
<label for="secret-file">
|
||||
Secret file
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="secret-file"
|
||||
type="file"
|
||||
>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
<label for="encode-password">
|
||||
Password
|
||||
<span class="optional">
|
||||
optional
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="encode-password"
|
||||
type="password"
|
||||
placeholder="Leave blank for no encryption"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
|
||||
<div class="field-hint">
|
||||
Without a password, anyone with the PNG
|
||||
can extract the payload.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
id="encode-button"
|
||||
class="button"
|
||||
>
|
||||
Encode PNG
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
|
||||
<div
|
||||
id="encode-status"
|
||||
class="status"
|
||||
></div>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
<!-- DECODE -->
|
||||
|
||||
<section class="card">
|
||||
|
||||
<div class="card-header">
|
||||
<h2>Decode</h2>
|
||||
|
||||
<p>
|
||||
Extract a hidden message or file from a PNG.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<form id="decode-form">
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
<label for="decode-image">
|
||||
Encoded PNG
|
||||
</label>
|
||||
|
||||
<div
|
||||
id="decode-dropzone"
|
||||
class="dropzone"
|
||||
tabindex="0"
|
||||
>
|
||||
|
||||
<input
|
||||
id="decode-image"
|
||||
type="file"
|
||||
accept="image/png"
|
||||
hidden
|
||||
>
|
||||
|
||||
<div class="dropzone-icon">
|
||||
↑
|
||||
</div>
|
||||
|
||||
<strong>
|
||||
Choose an encoded PNG
|
||||
</strong>
|
||||
|
||||
<span>
|
||||
or drag and drop one here
|
||||
</span>
|
||||
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="decode-image-info"
|
||||
class="image-info hidden"
|
||||
></div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
<label for="decode-password">
|
||||
Password
|
||||
<span class="optional">
|
||||
optional
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="decode-password"
|
||||
type="password"
|
||||
placeholder="Leave blank if not encrypted"
|
||||
autocomplete="current-password"
|
||||
>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
id="decode-button"
|
||||
class="button"
|
||||
>
|
||||
Decode PNG
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
|
||||
<div
|
||||
id="decode-status"
|
||||
class="status"
|
||||
></div>
|
||||
|
||||
|
||||
<div
|
||||
id="decoded-message"
|
||||
class="result hidden"
|
||||
>
|
||||
|
||||
<div class="result-header">
|
||||
|
||||
<h3>Decoded message</h3>
|
||||
|
||||
<button
|
||||
id="copy-message"
|
||||
type="button"
|
||||
class="secondary-button"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<pre id="message-output"></pre>
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
|
||||
<script src="/app.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,685 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
|
||||
color: #18181b;
|
||||
|
||||
background: #f4f4f5;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
|
||||
min-height: 100vh;
|
||||
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top,
|
||||
#ffffff 0,
|
||||
#f4f4f5 45%,
|
||||
#e4e4e7 100%
|
||||
);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.container {
|
||||
width:
|
||||
min(
|
||||
900px,
|
||||
calc(100% - 32px)
|
||||
);
|
||||
|
||||
margin:
|
||||
0 auto;
|
||||
|
||||
padding:
|
||||
56px 0 80px;
|
||||
}
|
||||
|
||||
|
||||
/* Header */
|
||||
|
||||
.header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin:
|
||||
0 0 8px;
|
||||
|
||||
font-size:
|
||||
clamp(
|
||||
32px,
|
||||
6vw,
|
||||
46px
|
||||
);
|
||||
|
||||
letter-spacing:
|
||||
-0.04em;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin: 0;
|
||||
|
||||
color: #71717a;
|
||||
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
/* Cards */
|
||||
|
||||
.card {
|
||||
margin-bottom: 24px;
|
||||
|
||||
padding: 28px;
|
||||
|
||||
background:
|
||||
rgba(
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
0.92
|
||||
);
|
||||
|
||||
border:
|
||||
1px solid #e4e4e7;
|
||||
|
||||
border-radius: 16px;
|
||||
|
||||
box-shadow:
|
||||
0 8px 30px
|
||||
rgba(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0.05
|
||||
);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
margin:
|
||||
0 0 6px;
|
||||
|
||||
font-size: 22px;
|
||||
|
||||
letter-spacing:
|
||||
-0.02em;
|
||||
}
|
||||
|
||||
.card-header p {
|
||||
margin: 0;
|
||||
|
||||
color: #71717a;
|
||||
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
/* Forms */
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
label,
|
||||
.label {
|
||||
display: block;
|
||||
|
||||
margin-bottom: 9px;
|
||||
|
||||
font-size: 14px;
|
||||
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.optional {
|
||||
margin-left: 4px;
|
||||
|
||||
color: #a1a1aa;
|
||||
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin-top: 7px;
|
||||
|
||||
color: #71717a;
|
||||
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
input[type="password"],
|
||||
input[type="file"],
|
||||
textarea {
|
||||
width: 100%;
|
||||
|
||||
border:
|
||||
1px solid #d4d4d8;
|
||||
|
||||
border-radius: 8px;
|
||||
|
||||
background: #ffffff;
|
||||
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
input[type="password"] {
|
||||
padding:
|
||||
11px 12px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
display: block;
|
||||
|
||||
min-height: 130px;
|
||||
|
||||
padding: 12px;
|
||||
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
|
||||
border-color: #71717a;
|
||||
|
||||
box-shadow:
|
||||
0 0 0 3px
|
||||
rgba(
|
||||
113,
|
||||
113,
|
||||
122,
|
||||
0.12
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* Dropzone */
|
||||
|
||||
.dropzone {
|
||||
display:
|
||||
flex;
|
||||
|
||||
flex-direction:
|
||||
column;
|
||||
|
||||
align-items:
|
||||
center;
|
||||
|
||||
justify-content:
|
||||
center;
|
||||
|
||||
min-height:
|
||||
170px;
|
||||
|
||||
padding:
|
||||
28px;
|
||||
|
||||
border:
|
||||
1.5px dashed #a1a1aa;
|
||||
|
||||
border-radius:
|
||||
12px;
|
||||
|
||||
background:
|
||||
#fafafa;
|
||||
|
||||
text-align:
|
||||
center;
|
||||
|
||||
cursor:
|
||||
pointer;
|
||||
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
background 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.dropzone:hover,
|
||||
.dropzone:focus {
|
||||
outline: none;
|
||||
|
||||
border-color:
|
||||
#52525b;
|
||||
|
||||
background:
|
||||
#f4f4f5;
|
||||
}
|
||||
|
||||
.dropzone.dragging {
|
||||
border-color:
|
||||
#18181b;
|
||||
|
||||
background:
|
||||
#e4e4e7;
|
||||
|
||||
transform:
|
||||
scale(1.01);
|
||||
}
|
||||
|
||||
.dropzone.has-file {
|
||||
border-style:
|
||||
solid;
|
||||
|
||||
border-color:
|
||||
#a1a1aa;
|
||||
|
||||
background:
|
||||
#fafafa;
|
||||
}
|
||||
|
||||
.dropzone-icon {
|
||||
display:
|
||||
flex;
|
||||
|
||||
align-items:
|
||||
center;
|
||||
|
||||
justify-content:
|
||||
center;
|
||||
|
||||
width:
|
||||
42px;
|
||||
|
||||
height:
|
||||
42px;
|
||||
|
||||
margin-bottom:
|
||||
12px;
|
||||
|
||||
border-radius:
|
||||
50%;
|
||||
|
||||
background:
|
||||
#e4e4e7;
|
||||
|
||||
font-size:
|
||||
22px;
|
||||
|
||||
font-weight:
|
||||
700;
|
||||
}
|
||||
|
||||
.dropzone strong {
|
||||
max-width:
|
||||
100%;
|
||||
|
||||
overflow:
|
||||
hidden;
|
||||
|
||||
text-overflow:
|
||||
ellipsis;
|
||||
|
||||
white-space:
|
||||
nowrap;
|
||||
}
|
||||
|
||||
.dropzone span {
|
||||
margin-top:
|
||||
5px;
|
||||
|
||||
color:
|
||||
#71717a;
|
||||
|
||||
font-size:
|
||||
13px;
|
||||
}
|
||||
|
||||
|
||||
/* Mode */
|
||||
|
||||
.mode-selector {
|
||||
display:
|
||||
grid;
|
||||
|
||||
grid-template-columns:
|
||||
1fr 1fr;
|
||||
|
||||
gap:
|
||||
10px;
|
||||
}
|
||||
|
||||
.mode-option {
|
||||
display:
|
||||
flex;
|
||||
|
||||
align-items:
|
||||
center;
|
||||
|
||||
justify-content:
|
||||
center;
|
||||
|
||||
gap:
|
||||
8px;
|
||||
|
||||
padding:
|
||||
11px;
|
||||
|
||||
margin: 0;
|
||||
|
||||
border:
|
||||
1px solid #d4d4d8;
|
||||
|
||||
border-radius:
|
||||
8px;
|
||||
|
||||
background:
|
||||
#ffffff;
|
||||
|
||||
cursor:
|
||||
pointer;
|
||||
|
||||
font-weight:
|
||||
550;
|
||||
|
||||
transition:
|
||||
background 120ms ease,
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
.mode-option:hover {
|
||||
background:
|
||||
#f4f4f5;
|
||||
}
|
||||
|
||||
.mode-option:has(
|
||||
input:checked
|
||||
) {
|
||||
border-color:
|
||||
#18181b;
|
||||
|
||||
background:
|
||||
#f4f4f5;
|
||||
}
|
||||
|
||||
.mode-option input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
/* Image info */
|
||||
|
||||
.image-info {
|
||||
display:
|
||||
flex;
|
||||
|
||||
justify-content:
|
||||
space-between;
|
||||
|
||||
gap:
|
||||
12px;
|
||||
|
||||
margin-top:
|
||||
10px;
|
||||
|
||||
padding:
|
||||
10px 12px;
|
||||
|
||||
border:
|
||||
1px solid #e4e4e7;
|
||||
|
||||
border-radius:
|
||||
8px;
|
||||
|
||||
background:
|
||||
#fafafa;
|
||||
|
||||
color:
|
||||
#52525b;
|
||||
|
||||
font-size:
|
||||
13px;
|
||||
}
|
||||
|
||||
.image-info strong {
|
||||
color:
|
||||
#18181b;
|
||||
}
|
||||
|
||||
|
||||
/* Buttons */
|
||||
|
||||
.button {
|
||||
width:
|
||||
100%;
|
||||
|
||||
padding:
|
||||
12px 16px;
|
||||
|
||||
border:
|
||||
0;
|
||||
|
||||
border-radius:
|
||||
8px;
|
||||
|
||||
background:
|
||||
#18181b;
|
||||
|
||||
color:
|
||||
#ffffff;
|
||||
|
||||
font-weight:
|
||||
650;
|
||||
|
||||
cursor:
|
||||
pointer;
|
||||
|
||||
transition:
|
||||
background 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background:
|
||||
#27272a;
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform:
|
||||
translateY(1px);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity:
|
||||
0.5;
|
||||
|
||||
cursor:
|
||||
not-allowed;
|
||||
|
||||
transform:
|
||||
none;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
padding:
|
||||
7px 11px;
|
||||
|
||||
border:
|
||||
1px solid #d4d4d8;
|
||||
|
||||
border-radius:
|
||||
7px;
|
||||
|
||||
background:
|
||||
#ffffff;
|
||||
|
||||
color:
|
||||
#18181b;
|
||||
|
||||
font-size:
|
||||
13px;
|
||||
|
||||
cursor:
|
||||
pointer;
|
||||
}
|
||||
|
||||
.secondary-button:hover {
|
||||
background:
|
||||
#f4f4f5;
|
||||
}
|
||||
|
||||
|
||||
/* Status */
|
||||
|
||||
.status {
|
||||
min-height:
|
||||
20px;
|
||||
|
||||
margin-top:
|
||||
14px;
|
||||
|
||||
font-size:
|
||||
14px;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
color:
|
||||
#166534;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
color:
|
||||
#b91c1c;
|
||||
}
|
||||
|
||||
|
||||
/* Results */
|
||||
|
||||
.result {
|
||||
margin-top:
|
||||
24px;
|
||||
|
||||
padding-top:
|
||||
22px;
|
||||
|
||||
border-top:
|
||||
1px solid #e4e4e7;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display:
|
||||
flex;
|
||||
|
||||
align-items:
|
||||
center;
|
||||
|
||||
justify-content:
|
||||
space-between;
|
||||
|
||||
gap:
|
||||
12px;
|
||||
|
||||
margin-bottom:
|
||||
10px;
|
||||
}
|
||||
|
||||
.result-header h3 {
|
||||
margin:
|
||||
0;
|
||||
}
|
||||
|
||||
.result pre {
|
||||
margin:
|
||||
0;
|
||||
|
||||
padding:
|
||||
16px;
|
||||
|
||||
max-height:
|
||||
400px;
|
||||
|
||||
overflow:
|
||||
auto;
|
||||
|
||||
border-radius:
|
||||
8px;
|
||||
|
||||
background:
|
||||
#f4f4f5;
|
||||
|
||||
white-space:
|
||||
pre-wrap;
|
||||
|
||||
overflow-wrap:
|
||||
anywhere;
|
||||
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
Monaco,
|
||||
Consolas,
|
||||
monospace;
|
||||
|
||||
font-size:
|
||||
13px;
|
||||
|
||||
line-height:
|
||||
1.6;
|
||||
}
|
||||
|
||||
|
||||
/* Utility */
|
||||
|
||||
.hidden {
|
||||
display:
|
||||
none !important;
|
||||
}
|
||||
|
||||
|
||||
/* Mobile */
|
||||
|
||||
@media (
|
||||
max-width: 600px
|
||||
) {
|
||||
|
||||
.container {
|
||||
width:
|
||||
calc(100% - 20px);
|
||||
|
||||
padding:
|
||||
28px 0 50px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding:
|
||||
20px;
|
||||
}
|
||||
|
||||
.mode-selector {
|
||||
grid-template-columns:
|
||||
1fr;
|
||||
}
|
||||
|
||||
.image-info {
|
||||
flex-direction:
|
||||
column;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
min-height:
|
||||
150px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user