Can embed any file in a PNG/WebM/GIF/JPEG and upload it to a third-party host through 4chan
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

438 lines
16 KiB

import { Buffer } from "buffer";
import { fileTypeFromBuffer } from 'file-type';
import App from "./App.svelte";
import { settings } from "./stores";
import * as png from "./png";
import * as webm from "./webm";
import * as gif from "./gif";
let csettings: any;
settings.subscribe(b => csettings = b);
type Awaited<T> = T extends PromiseLike<infer U> ? U : T
const xmlhttprequest = typeof GM_xmlhttpRequest != 'undefined' ? GM_xmlhttpRequest : (typeof GM != "undefined" ? GM.xmlHttpRequest : GM_xmlhttpRequest);
const headerStringToObject = (s: string) =>
Object.fromEntries(s.split('\n').map(e => {
const [name, ...rest] = e.split(':');
return [name.toLowerCase(), rest.join(':').trim()];
}));
function GM_head(...[url, opt]: Parameters<typeof fetch>) {
return new Promise<string>((resolve, reject) => {
// https://www.tampermonkey.net/documentation.php?ext=dhdg#GM_xmlhttpRequest
const gmopt: Tampermonkey.Request<any> = {
url: url.toString(),
data: opt?.body?.toString(),
method: "HEAD",
onload: (resp) => {
resolve(resp.responseHeaders);
},
ontimeout: () => reject("fetch timeout"),
onerror: () => reject("fetch error"),
onabort: () => reject("fetch abort")
};
xmlhttprequest(gmopt);
});
}
function GM_fetch(...[url, opt]: Parameters<typeof fetch>) {
function blobTo(to: string, blob: Blob) {
if (to == "arrayBuffer" && blob.arrayBuffer)
return blob.arrayBuffer();
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = function (event) {
if (!event) return;
if (to == "base64")
resolve(event.target!.result);
else
resolve(event.target!.result);
};
if (to == "arrayBuffer") fileReader.readAsArrayBuffer(blob);
else if (to == "base64") fileReader.readAsDataURL(blob); // "data:*/*;base64,......"
else if (to == "text") fileReader.readAsText(blob, "utf-8");
else reject("unknown to");
});
}
return new Promise<ReturnType<typeof fetch>>((resolve, reject) => {
// https://www.tampermonkey.net/documentation.php?ext=dhdg#GM_xmlhttpRequest
const gmopt: Tampermonkey.Request<any> = {
url: url.toString(),
data: opt?.body?.toString(),
responseType: "blob",
headers: opt?.headers as any,
method: "GET",
onload: (resp) => {
const blob = resp.response as Blob;
const ref = resp as any as Awaited<ReturnType<typeof fetch>>;
ref.blob = () => Promise.resolve(blob);
ref.arrayBuffer = () => blobTo("arrayBuffer", blob) as Promise<ArrayBuffer>;
ref.text = () => blobTo("text", blob) as Promise<string>;
ref.json = async () => JSON.parse(await (blobTo("text", blob) as Promise<any>));
resolve(resp as any);
},
ontimeout: () => reject("fetch timeout"),
onerror: () => reject("fetch error"),
onabort: () => reject("fetch abort")
};
xmlhttprequest(gmopt);
});
}
async function* streamRemote(url: string, chunkSize = 128 * 1024, fetchRestOnNonCanceled = true) {
const headers = await GM_head(url);
const h = headerStringToObject(headers);
const size = +h['content-length'];
let ptr = 0;
let fetchSize = chunkSize;
while (ptr != size) {
const res = await GM_fetch(url, { headers: { range: `bytes=${ptr}-${ptr + fetchSize - 1}` } }) as any as Tampermonkey.Response<any>;
const obj = headerStringToObject(res.responseHeaders);
if (!('content-length' in obj))
return;
const len = +obj['content-length'];
ptr += len;
if (fetchRestOnNonCanceled)
fetchSize = size;
yield Buffer.from(await (res as any).arrayBuffer());
}
}
function iteratorToStream<T>(iterator: AsyncGenerator<T>) {
return new ReadableStream<T>({
async pull(controller) {
const { value, done } = await iterator.next();
if (done) {
controller.close();
} else {
controller.enqueue(value);
}
},
});
}
const processors: [RegExp,
(reader: ReadableStreamDefaultReader<Uint8Array>) => Promise<{ filename: string; data: Buffer } | undefined>,
(container: File, inj: File) => Promise<Buffer>][] = [
[/\.png$/, png.extract, png.inject],
[/\.webm$/, webm.extract, webm.inject],
[/\.gif$/, gif.extract, gif.inject],
];
const processImage = async (src: string) => {
const proc = processors.find(e => src.match(e[0]));
if (!proc)
return;
// const resp = await GM_fetch(src);
// const reader = resp.body;
const iter = streamRemote(src);
const reader = iteratorToStream(iter);
if (!reader)
return;
return await proc[1](reader.getReader());
};
const textToElement = <T = HTMLElement>(s: string) =>
document.createRange().createContextualFragment(s).children[0] as any as T;
const processPost = async (post: HTMLDivElement) => {
const thumb = post.querySelector(".fileThumb") as HTMLAnchorElement;
const origlink = post.querySelector('.file-info > a') as HTMLAnchorElement;
if (!thumb || !origlink)
return;
const res = await processImage(origlink.href);
if (!res)
return;
const replyBox = post.querySelector('.post');
replyBox?.classList.toggle('hasembed');
// add buttons
const fi = post.querySelector(".file-info")!;
let a: HTMLAnchorElement | null;
a = fi.querySelector('.fa.fa-eye');
let inlining = true;
if (!a) {
inlining = false;
a = textToElement<HTMLAnchorElement>(`
<a class="fa fa-eye">
</a>`);
}
let type = await fileTypeFromBuffer(res.data);
let cont: HTMLImageElement | HTMLVideoElement | HTMLAudioElement | HTMLAnchorElement;
let w: number, h: number;
if (type?.mime.startsWith("image")) {
cont = document.createElement("img");
} else if (type?.mime.startsWith("video")) {
cont = document.createElement("video");
//cont.autoplay = true;
cont.loop = true;
cont.controls = true;
cont.pause();
} else if (type?.mime.startsWith("audio")) {
cont = document.createElement("audio");
cont.autoplay = false;
cont.controls = true;
cont.pause();
} else {
// If type detection fails, you'd better have an extension
if (!type)
type = { mime: "application/unknown" as any, 'ext': "data" as any };
cont = document.createElement('a');
let fn = res.filename;
if (!fn.includes('.'))
fn += '.' + type.ext;
cont.download = fn;
cont.textContent = "Download " + cont.download;
}
let src: string | null;
src = post.getAttribute('data-processed');
if (!src)
src = URL.createObjectURL(new Blob([res.data], { type: type.mime }));
if (!(cont instanceof HTMLAnchorElement))
cont.src = src;
else
cont.href = src;
await new Promise(res => {
if (cont instanceof HTMLImageElement)
cont.onload = res;
else if (cont instanceof HTMLVideoElement)
cont.onloadedmetadata = res;
else if (cont instanceof HTMLAudioElement)
cont.onloadedmetadata = res;
else
res(void 0); // Don't know what this is: don't wait
});
if (cont instanceof HTMLImageElement) {
w = cont.naturalWidth;
h = cont.naturalHeight;
}
if (cont instanceof HTMLVideoElement) {
w = cont.videoWidth;
h = cont.videoHeight;
}
const playable = cont instanceof HTMLAudioElement || cont instanceof HTMLVideoElement;
const contract = () => {
if (cont instanceof HTMLAudioElement)
return;
cont.style.width = `unset`;
cont.style.height = `unset`;
cont.style.maxWidth = "125px";
cont.style.maxHeight = "125px";
};
const expand = () => {
cont.style.width = `${w}px`;
cont.style.height = `${h}px`;
cont.style.maxWidth = "unset";
cont.style.maxHeight = "unset";
};
const imgcont = document.createElement('div');
const p = thumb.parentElement!;
p.removeChild(thumb);
imgcont.appendChild(thumb);
p.appendChild(imgcont);
thumb.style.display = "flex";
thumb.style.gap = "5px";
thumb.style.flexDirection = "column";
a.classList.toggle("disabled");
a.classList.toggle("pee-button");
let contracted = true;
contract();
contract();
cont.onclick = (e) => {
contracted = !contracted;
(contracted) ? contract() : expand();
e.stopPropagation();
};
let visible = false;
a.onclick = () => {
visible = !visible;
if (visible) {
console.log(csettings);
if ((cont instanceof HTMLVideoElement && csettings.apv) ||
(cont instanceof HTMLAudioElement && csettings.apa))
cont.play();
if ((cont instanceof HTMLImageElement && csettings.xpi) ||
(cont instanceof HTMLVideoElement && csettings.xpv))
expand();
imgcont.appendChild(cont);
} else {
if (playable) {
(cont as any).pause();
}
contract();
imgcont.removeChild(cont);
}
a!.classList.toggle("disabled");
};
if (!inlining)
fi.children[1].insertAdjacentElement('afterend', a);
post.setAttribute('data-processed', src);
};
const startup = async () => {
await Promise.all([...document.querySelectorAll('.postContainer')].filter(e => e.textContent?.includes("191 KB")).map(e => processPost(e as any)));
// Basically this is a misnommer: fires even when inlining existings posts, also posts are inlined through some kind of dom projection
document.addEventListener('ThreadUpdate', <any>(async (e: CustomEvent<any>) => {
let newPosts = e.detail.newPosts;
for (const post of newPosts) {
let postContainer = document.getElementById("pc" + post.substring(post.indexOf(".") + 1)) as HTMLDivElement;
processPost(postContainer);
}
}));
const mo = new MutationObserver(reco => {
for (const rec of reco)
if (rec.type == "childList")
rec.addedNodes.forEach(e => {
if (!(e instanceof HTMLElement))
return;
// apparently querySelector cannot select the root element if it matches
let el = (e as any).querySelectorAll(".postContainer");
if (!el && e.classList.contains('postContainer'))
el = e;
if (el)
[...el].map(el => processPost(el as any));
});
});
document.querySelectorAll('.board').forEach(e => {
mo.observe(e!, { childList: true, subtree: true });
});
const posts = [...document.querySelectorAll('.postContainer')];
const scts = document.getElementById('shortcuts');
const button = textToElement(`<span></span>`);
const app = new App({
target: button
});
scts?.appendChild(button);
await Promise.all(posts.map(e => processPost(e as any)));
};
const getSelectedFile = () => {
return new Promise<File>(res => {
document.addEventListener('QRFile', e => res((e as any).detail), { once: true });
document.dispatchEvent(new CustomEvent('QRGetFile'));
});
};
document.addEventListener('4chanXInitFinished', startup);
document.addEventListener('QRDialogCreation', <any>((e: CustomEvent<string>) => {
const target = e.target as HTMLDivElement;
const bts = target.querySelector('#qr-filename-container');
const i = document.createElement('i');
i.className = "fa fa-magnet";
const a = document.createElement('a');
a.appendChild(i);
a.title = "Embed File (Select a file before...)";
bts?.appendChild(a);
a.onclick = async (e) => {
const file = await getSelectedFile();
if (!file)
return;
const input = document.createElement('input') as HTMLInputElement;
input.setAttribute("type", "file");
const type = file.type;
input.onchange = (async ev => {
if (input.files) {
try {
const proc = processors.find(e => file.name.match(e[0]));
if (!proc)
throw new Error("Container filetype not supported");
const buff = await proc[2](file, input.files[0]);
document.dispatchEvent(new CustomEvent('QRSetFile', {
//detail: { file: new Blob([buff]), name: file.name, type: file.type }
detail: { file: new Blob([buff], { type }), name: file.name }
}));
document.dispatchEvent(new CustomEvent("CreateNotification", {
detail: {
type: 'success',
content: 'File successfully embedded!',
lifetime: 3
}
}));
} catch (err) {
const e = err as Error;
document.dispatchEvent(new CustomEvent("CreateNotification", {
detail: {
type: 'error',
content: "Couldn't embed file: " + e.message,
lifetime: 3
}
}));
}
}
});
input.click();
};
}), { once: true });
const customStyles = document.createElement('style');
customStyles.appendChild(document.createTextNode(
`
.pee-hidden {
display: none;
}
.extractedImg {
width:auto;
height:auto;
max-width:125px;
max-height:125px;
cursor: pointer;
}
.postContainer > div.hasembed {
border-right: 3px dashed deeppink !important;
}
.expanded-image > .post > .file .fileThumb > img[data-md5] {
display: none;
}
.expanded-image > .post > .file .fileThumb .full-image {
display: inline;
}
`
));
document.documentElement.insertBefore(customStyles, null);
// import * as gif from './gif';
// onload = () => {
// let container = document.getElementById("container") as HTMLInputElement;
// let injection = document.getElementById("injection") as HTMLInputElement;
// container.onchange = injection.onchange = async () => {
// if (container.files?.length && injection.files?.length) {
// let res = await gif.inject(container.files[0], injection.files[0]);
// let result = document.getElementById("result") as HTMLImageElement;
// let extracted = document.getElementById("extracted") as HTMLImageElement;
// let res2 = new Blob([res], { type: 'image/gif' });
// result.src = URL.createObjectURL(res2);
// let embedded = await gif.extract(res2.stream().getReader());
// extracted.src = URL.createObjectURL(new Blob([embedded?.data!]));
// let dlr = document.getElementById("dlr") as HTMLAnchorElement;
// let dle = document.getElementById("dle") as HTMLAnchorElement;
// dlr.href = result.src;
// dle.href = extracted.src;
// }
// }
// }