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.

348 lines
11 KiB

2 years ago
/* eslint-disable */
import { Buffer } from "buffer";
import { buf } from "crc-32";
import { fileTypeFromBuffer } from 'file-type';
const IDAT = Buffer.from("IDAT");
const IEND = Buffer.from("IEND");
const tEXt = Buffer.from("tEXt");
const CUM0 = Buffer.from("CUM0");
let concatAB = (...bufs: Buffer[]) => {
let sz = bufs.map(e => e.byteLength).reduce((a, b) => a + b);
const ret = Buffer.alloc(sz);
let ptr = 0;
for (const b of bufs) {
b.copy(ret, ptr);
ptr += b.byteLength;
}
return ret;
}
let extractTextData = async (reader: ReadableStreamDefaultReader<Uint8Array>) => {
let total = Buffer.from('');
let ptr = 8;
let req = 8; // required bytes: require the png signature
try {
let chunk: ReadableStreamDefaultReadResult<Uint8Array>;
const catchup = async () => {
while (total.byteLength < req) {
chunk = await reader.read();
if (chunk.done)
throw new Error("Unexpected EOF");
total = concatAB(total, Buffer.from(chunk.value));
}
}
do {
req += 8; // require the bytes that store the length of the next chunk and its name
await catchup();
// at this point, ptr pointing to length of current chunk
let length = total.readInt32BE(ptr);
// ptr pointing to type of current chunk
ptr += 4;
const name = total.slice(ptr, ptr + 4);
if (Buffer.compare(IDAT, name) == 0 ||
Buffer.compare(IEND, name) == 0) {
// reached idat or iend before finding a tEXt, bail out
throw new Error("Couldn't find tEXt chunk");
}
req += length + 4; // require the rest of the chunk + CRC
//let crc = total.readInt32BE(ptr + 4 + length); // dont really care
ptr += 4; // ptr now points to the chunk data
if (Buffer.compare(tEXt, name) == 0) {
// our specific format stores a single file, CUM0 stores it as Base64. Could be enhanced to use more characters (the whole printable ascii characters, ie base85, but we lack good encoders...)
// catchup because we need to know the type;
await catchup();
if (Buffer.compare(total.slice(ptr, ptr + 4), CUM0) == 0) {
let data = Buffer.from(total.slice(ptr + 4, ptr + length - 4).toString(), 'base64');
let fns = data.readUInt32LE(0);
let filename = data.slice(4, 4 + fns).toString();
return { data: data.slice(4 + fns), filename };
}
// Unknown tEXt format
}
ptr += length + 4; // skips over data section and crc
} while (!chunk!.done);
} catch (e) {
console.error(e);
await reader.cancel();
reader.releaseLock();
}
}
let TMFetch = GM_xmlhttpRequest || GM.xmlHttpRequest;
let getUrl = (u: string) => {
let xml: Tampermonkey.AbortHandle<void>;
let pos = 0;
let total = 0;
return new ReadableStream<Uint8Array>({
cancel() {
xml?.abort();
},
start(cont) {
/*
get a small 128k chunk first then get the rest if required
*/
return new Promise<void>(reso => {
const size = (cont.desiredSize! > 2 ** 17 ? cont.desiredSize! : 2 ** 17);
const range = !total ? `bytes=${pos}-${pos + size - 1}` : `bytes=${pos}-${total - 1}`;
console.log(range, cont.desiredSize);
xml = TMFetch({
url: u,
fetch: true,
method: "GET",
binary: true,
responseType: "arraybuffer",
headers: {
range
},
// a bit spagetthi
onload(res) {
let hr = res.responseHeaders.split('\n').map(e => e.split(':'));
let r = hr.find(e => e[0] == "content-range")!;
let er = hr.find(e => e[0] == "content-length")!;
cont.enqueue(Buffer.from(res.response))
pos += +er[1];
if (r) {
let m = r[1].match(/bytes 0-\d+\/(\d+)/)
if (m)
total = +m[1];
}
if (pos >= total) {
cont.close()
reso();
return;
}
reso();
}
});
})
},
pull(cont) {
console.log("pulling") // shouldn't ever happen
return this.start!(cont);
}
}, {
highWaterMark: 40,
size(c) {
return 1;
}
})
}
let processImage = async (src: string) => {
if (!src.match(/\.png$/))
return;
let resp = getUrl(src);
let reader = resp?.getReader();
if (!reader)
return;
return await extractTextData(reader);
};
/* Used for debugging */
let processImage2 = async (src: string) => {
if (!src.match(/\.png$/))
return;
let resp = getUrl(src);
let reader = resp.getReader();
let data = Buffer.alloc(0);
let chunk;
while ((chunk = await reader.read()) && !chunk.done) {
data = concatAB(data, Buffer.from(chunk.value));
}
return {
filename: 'aaaa',
data
};
};
let processPost = async (post: HTMLDivElement) => {
let thumb = post.querySelector(".fileThumb") as HTMLAnchorElement;
if (!thumb)
return;
console.log("Processing post", post)
let res = await processImage(thumb.href);
if (!res)
return;
// add buttons
let fi = post.querySelector(".file-info")!;
let a = document.createElement('a');
a.className = "fa fa-eye";
let type = await fileTypeFromBuffer(res.data);
let cont: HTMLImageElement | HTMLVideoElement;
let w: number, h: number;
if (type?.mime.startsWith("image")) {
cont = document.createElement("img");
} else if (type?.mime.startsWith("video")) {
cont = document.createElement("video");
} else
return; // TODO: handle new file types??? Or direct "download"?
cont.src = URL.createObjectURL(new Blob([res.data]));
await new Promise(res => {
cont.onload = res;
});
if (cont instanceof HTMLImageElement) {
w = cont.naturalWidth;
h = cont.naturalHeight;
}
if (cont instanceof HTMLVideoElement) {
w = cont.width;
h = cont.height;
}
let contract = () => {
cont.style.width = "auto";
cont.style.height = "auto";
cont.style.maxWidth = "125px";
cont.style.maxHeight = "125px";
}
let expand = () => {
cont.style.width = `${w}px`;
cont.style.height = `${h}px`;
cont.style.maxWidth = "unset";
cont.style.maxHeight = "unset";
}
let imgcont = document.createElement('div');
let 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");
let contracted = true;
contract();
cont.onclick = (e) => {
contracted = !contracted;
(contracted) ? contract() : expand();
e.stopPropagation();
}
let visible = false;
a.onclick = () => {
visible = !visible;
if (visible) {
imgcont.appendChild(cont)
} else {
imgcont.removeChild(cont);
}
a.classList.toggle("disabled");
}
fi.children[1].insertAdjacentElement('afterend', a);
}
let buildTextChunk = async (f: File) => {
let ab = await f.arrayBuffer();
let fns = Buffer.alloc(4);
fns.writeInt32LE(f.name.length, 0)
let fb = Buffer.from(await new Blob([fns, f.name, ab]).arrayBuffer()).toString('base64');
let buff = Buffer.alloc(4 /*Length storage*/ + 4 /*Chunk Type*/ + 4 /*Magic*/ + 1 /*Null separator*/ + fb.length + 4 /* CRC */);
let ptr = 0;
buff.writeInt32BE(buff.byteLength - 12, ptr); // doesn't count chunk type, lenght storage and crc
ptr += 4;
buff.write("tEXtCUM0\0", ptr); // Writes Chunktype+ Magic+null byte
ptr += 9;
buff.write(fb, ptr);
ptr += fb.length;
// CRC over the chunk name to the last piece of data
let checksum = buf(buff.slice(4, -4))
buff.writeInt32BE(checksum, ptr);
return buff;
}
let buildInjection = async (container: File, inj: File) => {
let tEXtChunk = await buildTextChunk(inj);
let ogFile = Buffer.from(await container.arrayBuffer());
let ret = Buffer.alloc(tEXtChunk.byteLength + ogFile.byteLength);
let ptr = 8;
let wptr = 8;
let wrote = false;
ogFile.copy(ret, 0, 0, ptr);// copy PNG signature
// copy every chunk as is except inject the text chunk before the first IDAT or END
while (ptr < ogFile.byteLength) {
let len = ogFile.readInt32BE(ptr);
let name = ogFile.slice(ptr + 4, ptr + 8);
if (name.equals(IDAT) || name.equals(IEND)) {
if (!wrote) {
wrote = true;
tEXtChunk.copy(ret, wptr);
wptr += tEXtChunk.byteLength;
}
}
ret.writeInt32BE(len, wptr);
wptr += 4;
name.copy(ret, wptr);
wptr += 4;
ogFile.slice(ptr + 8, ptr + 8 + len + 4).copy(ret, wptr);
ptr += len + 8 + 4;
wptr += len + 4;
}
return { file: new Blob([ret]), name: container.name };
}
const startup = async () => {
await Promise.all([...document.querySelectorAll('.postContainer')].map(e => processPost(e as any)));
2 years ago
document.addEventListener('PostsInserted', <any>(async (e: CustomEvent<string>) => {
processPost(e.target as any);
}));
let getSelectedFile = () => {
return new Promise<File>(res => {
document.addEventListener('QRFile', e => res((e as any).detail), { once: true });
document.dispatchEvent(new CustomEvent('QRGetFile'));
})
}
let injected = false;
document.addEventListener('QRDialogCreation', <any>((e: CustomEvent<string>) => {
if (injected)
return;
injected = true;
let target = e.target as HTMLDivElement;
let bts = target.querySelector('#qr-filename-container')
let i = document.createElement('i');
i.className = "fa fa-magnet";
let a = document.createElement('a')
a.appendChild(i);
a.title = "Embed File (Select a file before...)";
bts?.appendChild(a);
a.onclick = async (e) => {
let file = await getSelectedFile();
if (!file)
return;
let input = document.createElement('input') as HTMLInputElement;
input.setAttribute("type", "file");
input.onchange = (async ev => {
if (input.files)
document.dispatchEvent(new CustomEvent('QRSetFile', { detail: await buildInjection(file, input.files[0]) }))
})
input.click();
}
}));
};
document.addEventListener('4chanXInitFinished', startup);