936 lines
16 KiB
JavaScript
936 lines
16 KiB
JavaScript
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"
|
||
);
|
||
}
|
||
}
|
||
); |