PixHost Enhanced

Drag-and-drop, Ctrl+V, re-host remote URLs to PixHost (auto-converts WebP/AVIF to PNG)

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name         PixHost Enhanced
// @namespace    https://pixhost.to/
// @version      0.3
// @description  Drag-and-drop, Ctrl+V, re-host remote URLs to PixHost (auto-converts WebP/AVIF to PNG)
// @author       Colder (URL re-host added locally)
// @match        https://pixhost.to/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      api.pixhost.to
// @connect      pixhost.to
// @connect      *
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const TAG = '[PixHost+]';
    const log  = (...a) => console.log(TAG, ...a);
    const warn = (...a) => console.warn(TAG, ...a);
    const err  = (...a) => console.error(TAG, ...a);

    log('script loaded, version 0.5');

    GM_addStyle(`
        #custom-upload-zone {
            position: fixed;
            top: 20px;
            right: 20px;
            width: 300px;
            background: white;
            border: 2px solid #ccc;
            border-radius: 8px;
            padding: 15px;
            z-index: 9999;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }

        #url-input {
            width: 100%;
            min-height: 60px;
            padding: 6px;
            border: 1px solid #ccc;
            border-radius: 4px;
            font-family: monospace;
            font-size: 11px;
            resize: vertical;
            box-sizing: border-box;
        }

        #convert-urls-btn {
            width: 100%;
            margin-top: 6px;
            padding: 8px;
            background: #4CAF50;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-weight: bold;
        }

        #convert-urls-btn:hover { background: #43a047; }
        #convert-urls-btn:disabled { background: #aaa; cursor: not-allowed; }

        .divider {
            text-align: center;
            color: #999;
            font-size: 11px;
            margin: 10px 0;
        }

        #drop-zone {
            border: 2px dashed #ccc;
            border-radius: 4px;
            padding: 20px;
            text-align: center;
            margin-bottom: 10px;
            background: #f9f9f9;
            transition: all 0.3s ease;
            cursor: pointer;
        }

        #drop-zone.drag-over {
            background: #e1f5fe;
            border-color: #2196F3;
        }

        .url-output { margin-top: 10px; }

        .url-textarea {
            width: 100%;
            min-height: 120px;
            margin: 5px 0;
            font-family: monospace;
            font-size: 12px;
            resize: vertical;
            white-space: pre;
            box-sizing: border-box;
        }

        .action-buttons {
            display: flex;
            gap: 5px;
            margin-top: 5px;
        }

        .copy-btn {
            background: #2196F3;
            color: white;
            border: none;
            padding: 8px 5px;
            border-radius: 4px;
            cursor: pointer;
            flex: 1;
            font-size: 11px;
            font-weight: bold;
            text-align: center;
            transition: background 0.2s;
        }

        .copy-btn:hover { background: #1976D2; }

        #status-container {
            margin-top: 10px;
            padding: 10px;
            border-radius: 4px;
            display: none;
            font-size: 13px;
            text-align: center;
        }

        .success { background: #E8F5E9; color: #2E7D32; }
        .error   { background: #FFEBEE; color: #C62828; }

        .progress {
            margin-top: 10px;
            font-size: 0.9em;
            color: #666;
            text-align: center;
        }
    `);

    const uploadInterface = document.createElement('div');
    uploadInterface.id = 'custom-upload-zone';
    uploadInterface.innerHTML = `
        <textarea id="url-input" placeholder="Paste image URLs (one per line, [img]...[/img] OK)"></textarea>
        <button id="convert-urls-btn">Re-host URLs to PixHost</button>
        <div class="divider">— or —</div>
        <div id="drop-zone" title="Click to browse files">
            Drag & Drop Images Here<br>
            <small>or click / Ctrl+V to paste</small>
            <input type="file" id="file-input" multiple style="display: none" accept="image/*">
        </div>
        <div class="progress"></div>
        <div id="status-container"></div>
        <div class="url-output"></div>
    `;

    document.body.appendChild(uploadInterface);

    const dropZone        = document.getElementById('drop-zone');
    const fileInput       = document.getElementById('file-input');
    const urlInput        = document.getElementById('url-input');
    const convertBtn      = document.getElementById('convert-urls-btn');
    const urlOutput       = document.querySelector('.url-output');
    const progressDiv     = document.querySelector('.progress');
    const statusContainer = document.getElementById('status-container');

    let uploadQueue   = [];
    let uploadResults = [];
    let isUploading   = false;
    let statusTimeout;

    dropZone.addEventListener('click', () => fileInput.click());

    ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(evt => {
        dropZone.addEventListener(evt, preventDefaults, false);
        document.body.addEventListener(evt, preventDefaults, false);
    });
    ['dragenter', 'dragover'].forEach(evt => dropZone.addEventListener(evt, highlight, false));
    ['dragleave', 'drop'].forEach(evt => dropZone.addEventListener(evt, unhighlight, false));

    dropZone.addEventListener('drop', handleDrop, false);
    fileInput.addEventListener('change', (e) => handleFilesArray([...e.target.files]), false);
    convertBtn.addEventListener('click', handleUrlConvert);

    document.addEventListener('paste', (e) => {
        // Don't hijack paste into the URL textarea or other inputs
        if (e.target === urlInput) return;
        if (!e.clipboardData) return;
        const items = e.clipboardData.items;
        const files = [];
        for (let i = 0; i < items.length; i++) {
            if (items[i].type.indexOf('image') !== -1) {
                const file = items[i].getAsFile();
                if (file) files.push(file);
            }
        }
        if (files.length > 0) {
            e.preventDefault();
            handleFilesArray(files);
        }
    });

    function preventDefaults(e) { e.preventDefault(); e.stopPropagation(); }
    function highlight()         { dropZone.classList.add('drag-over'); }
    function unhighlight()       { dropZone.classList.remove('drag-over'); }
    function handleDrop(e)       { handleFilesArray([...e.dataTransfer.files]); }

    // --- URL re-host pipeline -------------------------------------------------

    function handleUrlConvert() {
        const urls = urlInput.value.split('\n')
            .map(s => s.trim())
            .filter(Boolean)
            .map(s => {
                const m = s.match(/\[img\](.*?)\[\/img\]/i);
                return m ? m[1].trim() : s;
            });

        log('handleUrlConvert parsed URLs:', urls);

        if (urls.length === 0) {
            showStatus('No URLs entered.', 'error');
            return;
        }
        urlInput.value = '';
        handleUrlsArray(urls);
    }

    async function handleUrlsArray(urls) {
        convertBtn.disabled = true;
        showStatus(`Fetching ${urls.length} URL(s)...`, 'success');

        const fetched = [];
        for (const url of urls) {
            try {
                const blob = await fetchAsBlob(url);

                let detected = await detectImageType(blob);
                let finalBlob = blob;

                if (!detected) {
                    // Source served something that isn't PNG/JPEG/GIF natively.
                    // Try to decode it via the browser (handles WebP, AVIF, BMP, etc.)
                    // and re-encode as PNG so pixhost will accept it.
                    log('handleUrlsArray: not PNG/JPEG/GIF, trying canvas re-encode', { url, claimedType: blob.type, size: blob.size });
                    try {
                        finalBlob = await reencodeAsPng(blob);
                        detected = 'image/png';
                        log('handleUrlsArray re-encoded to PNG', { url, originalSize: blob.size, newSize: finalBlob.size });
                    } catch (decodeErr) {
                        const head = await blob.slice(0, 200).text().catch(() => '<binary>');
                        err('Source not decodable as image', {
                            url,
                            claimedType: blob.type || '(none)',
                            size: blob.size,
                            first200chars: head,
                            decodeError: decodeErr.message || decodeErr,
                        });
                        throw new Error(`Cannot decode ${blob.type || 'unknown'} (${decodeErr.message || decodeErr})`);
                    }
                }

                let name = (url.split('/').pop() || 'image').split('?')[0] || 'image';
                // If we re-encoded to PNG, force a .png extension so pixhost names it sensibly
                if (detected === 'image/png' && !/\.png$/i.test(name)) {
                    name = name.replace(/\.[^.]+$/, '') + '.png';
                }
                log('handleUrlsArray wrapping as File', { url, name, type: detected, size: finalBlob.size });
                fetched.push(new File([finalBlob], name, { type: detected }));
            } catch (e) {
                err('handleUrlsArray fetch failed', { url, error: e });
                showStatus(`Failed: ${url} (${e.message || e})`, 'error');
            }
        }

        convertBtn.disabled = false;
        if (fetched.length > 0) {
            handleFilesArray(fetched);
        } else {
            warn('handleUrlsArray: nothing succeeded');
        }
    }

    // Decode any browser-supported image format and re-encode as PNG via <canvas>.
    // Works for WebP / AVIF / BMP / SVG. Will reject if the bytes aren't a decodable image.
    function reencodeAsPng(blob) {
        return new Promise((resolve, reject) => {
            const objUrl = URL.createObjectURL(blob);
            const img = new Image();
            img.onload = () => {
                try {
                    const canvas = document.createElement('canvas');
                    canvas.width  = img.naturalWidth;
                    canvas.height = img.naturalHeight;
                    if (!canvas.width || !canvas.height) {
                        URL.revokeObjectURL(objUrl);
                        reject(new Error('decoded image has zero dimension'));
                        return;
                    }
                    const ctx = canvas.getContext('2d');
                    ctx.drawImage(img, 0, 0);
                    canvas.toBlob((out) => {
                        URL.revokeObjectURL(objUrl);
                        if (out) resolve(out);
                        else reject(new Error('canvas.toBlob returned null'));
                    }, 'image/png');
                } catch (e) {
                    URL.revokeObjectURL(objUrl);
                    reject(e);
                }
            };
            img.onerror = () => {
                URL.revokeObjectURL(objUrl);
                reject(new Error('browser decoder rejected the bytes (not a real image, or unsupported codec)'));
            };
            img.src = objUrl;
        });
    }

    // Magic-byte sniff: returns the real image MIME type or null.
    // Pixhost only accepts PNG / JPEG / GIF (per docs), so we test exactly those.
    async function detectImageType(blob) {
        try {
            const buf = new Uint8Array(await blob.slice(0, 12).arrayBuffer());
            if (buf.length >= 8 &&
                buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47 &&
                buf[4] === 0x0D && buf[5] === 0x0A && buf[6] === 0x1A && buf[7] === 0x0A) return 'image/png';
            if (buf.length >= 3 &&
                buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return 'image/jpeg';
            if (buf.length >= 6 &&
                buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 &&
                buf[3] === 0x38 && (buf[4] === 0x37 || buf[4] === 0x39) && buf[5] === 0x61) return 'image/gif';
            return null;
        } catch (e) {
            err('detectImageType failed', e);
            return null;
        }
    }

    function fetchAsBlob(url) {
        log('fetchAsBlob ->', url);
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: url,
                responseType: 'blob',
                // Ask for the formats pixhost accepts. Many CDNs (Fandom, Imgur,
                // Cloudflare Images) do Accept-based negotiation and would otherwise
                // hand us WebP/AVIF, which pixhost rejects with HTTP 414.
                headers: {
                    'Accept': 'image/png,image/jpeg,image/gif,image/*;q=0.8,*/*;q=0.5',
                },
                onload: (r) => {
                    log('fetchAsBlob onload', { url, status: r.status, finalUrl: r.finalUrl, type: r.response && r.response.type, size: r.response && r.response.size });
                    if (r.status >= 200 && r.status < 300) resolve(r.response);
                    else {
                        err('fetchAsBlob non-2xx', { url, status: r.status, statusText: r.statusText, headers: r.responseHeaders });
                        reject(new Error(`HTTP ${r.status}`));
                    }
                },
                onerror: (e) => {
                    err('fetchAsBlob onerror', { url, error: e });
                    reject(new Error(e.statusText || 'Network error'));
                },
                ontimeout: () => {
                    err('fetchAsBlob timeout', url);
                    reject(new Error('Timeout'));
                },
            });
        });
    }

    function guessTypeFromUrl(url) {
        const ext = (url.split('.').pop() || '').split('?')[0].toLowerCase();
        const map = {
            jpg: 'image/jpeg', jpeg: 'image/jpeg',
            png: 'image/png', gif: 'image/gif',
            webp: 'image/webp', bmp: 'image/bmp',
        };
        return map[ext] || 'image/jpeg';
    }

    // --- File upload pipeline (unchanged from upstream) -----------------------

    function handleFilesArray(filesArray) {
        log('handleFilesArray received', filesArray.map(f => ({ name: f.name, size: f.size, type: f.type })));
        const validFiles = filesArray
            .filter(f => f.type.startsWith('image/'))
            .sort((a, b) => a.name.localeCompare(b.name));

        if (validFiles.length === 0) {
            warn('handleFilesArray: no valid images after filter');
            showStatus('No valid images found.', 'error');
            return;
        }

        uploadQueue = uploadQueue.concat(validFiles);
        uploadQueue.sort((a, b) => a.name.localeCompare(b.name));

        updateProgress();
        if (!isUploading) processQueue();
    }

    function updateProgress() {
        const total     = uploadQueue.length + uploadResults.length;
        const completed = uploadResults.length;
        progressDiv.textContent = total > 0 ? `Progress: ${completed}/${total} files` : '';
    }

    async function processQueue() {
        if (uploadQueue.length === 0) {
            if (uploadResults.length > 0) {
                log('processQueue done, displaying results', uploadResults);
                displayUrls(uploadResults);
                uploadResults = [];
            } else {
                warn('processQueue: queue empty but no results');
            }
            isUploading = false;
            updateProgress();
            return;
        }

        isUploading = true;
        const file = uploadQueue.shift();

        const formData = new FormData();
        formData.append('img', file);
        formData.append('content_type', '0');
        formData.append('max_th_size', '420');

        try {
            await uploadFile(file, formData);
        } catch (error) {
            err('processQueue caught', { name: file.name, error });
            showStatus(`Error uploading ${file.name}: ${error}`, 'error');
        }

        processQueue();
    }

    function uploadFile(file, formData) {
        log('uploadFile ->', { name: file.name, size: file.size, type: file.type });
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: 'https://api.pixhost.to/images',
                data: formData,
                headers: { 'Accept': 'application/json' },
                onload: async function(response) {
                    log('uploadFile onload', {
                        name: file.name,
                        status: response.status,
                        statusText: response.statusText,
                        finalUrl: response.finalUrl,
                        responseHeaders: response.responseHeaders,
                        bodySnippet: (response.responseText || '').slice(0, 500),
                    });
                    try {
                        if (response.status < 200 || response.status >= 300) {
                            throw new Error(`API HTTP ${response.status}: ${response.responseText && response.responseText.slice(0, 200)}`);
                        }
                        let data;
                        try {
                            data = JSON.parse(response.responseText);
                        } catch (e) {
                            err('uploadFile JSON parse failed', { body: response.responseText });
                            throw new Error('API did not return JSON (got HTML/text — see console)');
                        }
                        log('uploadFile parsed', data);
                        if (!data.show_url) {
                            throw new Error(data.error || 'API response missing show_url');
                        }
                        const directUrl = await extractDirectUrl(data.show_url);
                        uploadResults.push({
                            name: data.name || file.name,
                            directUrl: directUrl
                        });
                        updateProgress();
                        showStatus(`Uploaded: ${file.name}`, 'success');
                        resolve();
                    } catch (e) {
                        err('uploadFile rejection', e);
                        reject(e.message || e);
                    }
                },
                onerror: function(error) {
                    err('uploadFile onerror', error);
                    reject(error.statusText || 'Network error');
                },
                ontimeout: () => {
                    err('uploadFile timeout', file.name);
                    reject('Timeout');
                },
            });
        });
    }

    function extractDirectUrl(showUrl) {
        log('extractDirectUrl ->', showUrl);
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: showUrl,
                onload: function(response) {
                    log('extractDirectUrl onload', {
                        showUrl,
                        status: response.status,
                        finalUrl: response.finalUrl,
                        bodyLength: (response.responseText || '').length,
                    });
                    if (response.status < 200 || response.status >= 300) {
                        err('extractDirectUrl non-2xx', { showUrl, status: response.status, body: (response.responseText || '').slice(0, 500) });
                        reject(`HTTP ${response.status} on show page`);
                        return;
                    }
                    const parser = new DOMParser();
                    const doc = parser.parseFromString(response.responseText, 'text/html');

                    // Try several selectors in order — pixhost may have changed the DOM
                    const candidates = [
                        '#image',
                        'img#image',
                        'img.image',
                        'img[src*="img"][src*="pixhost"]',
                        'img[src*="//img"]',
                        '.image-show img',
                        '#show_image img',
                        'img',
                    ];
                    for (const sel of candidates) {
                        const el = doc.querySelector(sel);
                        if (el && el.src && /^https?:/.test(el.src)) {
                            log('extractDirectUrl matched selector', { selector: sel, src: el.src });
                            resolve(el.src);
                            return;
                        }
                    }

                    err('extractDirectUrl: no selector matched. Dumping all <img> on show page:');
                    doc.querySelectorAll('img').forEach((img, i) => {
                        err(`  img[${i}]`, { id: img.id, class: img.className, src: img.src, alt: img.alt });
                    });
                    err('First 1000 chars of show page HTML:', (response.responseText || '').slice(0, 1000));
                    reject('Could not scrape direct image URL — see console for HTML dump');
                },
                onerror: function(error) {
                    err('extractDirectUrl onerror', error);
                    reject(error.statusText || 'Network error');
                },
                ontimeout: () => {
                    err('extractDirectUrl timeout', showUrl);
                    reject('Timeout');
                },
            });
        });
    }

    function displayUrls(results) {
        const rawUrls  = results.map(r => r.directUrl).join('\n');
        const bbcode   = results.map(r => `[img]${r.directUrl}[/img]`).join('\n');
        const markdown = results.map(r => `![${r.name}](${r.directUrl})`).join('\n');

        urlOutput.innerHTML = `
            <textarea class="url-textarea" readonly spellcheck="false">${rawUrls}</textarea>
            <div class="action-buttons">
                <button class="copy-btn" data-clipboard-text="${encodeURIComponent(rawUrls)}">Copy URLs</button>
                <button class="copy-btn" data-clipboard-text="${encodeURIComponent(bbcode)}">Copy BBCode</button>
                <button class="copy-btn" data-clipboard-text="${encodeURIComponent(markdown)}">Copy MD</button>
            </div>
        `;

        urlOutput.querySelectorAll('.copy-btn').forEach(btn => {
            btn.addEventListener('click', function() {
                const text = decodeURIComponent(this.getAttribute('data-clipboard-text'));
                navigator.clipboard.writeText(text).then(() => {
                    const originalText = this.textContent;
                    this.textContent = 'Copied!';
                    setTimeout(() => { this.textContent = originalText; }, 1200);
                });
            });
        });
    }

    function showStatus(message, type) {
        statusContainer.className = `status ${type}`;
        statusContainer.textContent = message;
        statusContainer.style.display = 'block';

        clearTimeout(statusTimeout);
        statusTimeout = setTimeout(() => {
            statusContainer.style.display = 'none';
        }, 3000);
    }
})();