WebdevComponent

WebDAV 云端备份共享组件库(createWebdev 工厂;Enhanced_Media_Helper / jav-code-scanner 等脚本可 @require 使用)

2026/08/30のページです。最新版はこちら

このスクリプトは単体で利用できません。右のようなメタデータを含むスクリプトから、ライブラリとして読み込まれます: // @require https://update.greasyfork.org/scripts/593538/1916640/WebdevComponent.js

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         WebdevComponent
// @namespace    cdn.bootcss.com
// @version      1.0.0
// @description  WebDAV 云端备份共享组件库(createWebdev 工厂;Enhanced_Media_Helper / jav-code-scanner 等脚本可 @require 使用)
// @author       cores
// @license      MPL
// @grant        none
// ==/UserScript==
/**
 * webdev-component.js — 共享 WebDAV 客户端组件 v1.0.0(先供 Enhanced_Media_Helper 使用,后续可给 jav-code-scanner)
 *
 * 设计原则:
 * - 纯逻辑组件:不引用 GM_*、不引用任何脚本全局对象;浏览器 UMD + CommonJS(Node 测试)双出口。
 * - 实例化 `createWebdev(env)`,依赖全部由消费方注入:
 *     storage        { get(key, fallback), set(key, value) }  设置持久化(每个脚本各自的 GM 存储)
 *     request        (opts) => Promise<res>                   网络适配器(GM_xmlhttpRequest 等)
 *     exportPayload  () => string                             备份载荷生成器(如番号库全量 JSON)
 *     以及 key / defaultFile / encMark 三套命名空间常量
 * - 凭据安全:账号/密码只经注入的 storage 按脚本隔离保存;加密备份采用 AES-256-GCM + PBKDF2-SHA256(120k 迭代)。
 * - 备份协议与 jav-code-scanner 同构(PUT/GET JSON 文件;加密包为信封格式),ENC_MARK 按消费方独立。
 *
 * 同步说明:
 * - Enhanced_Media_Helper.js 内嵌本文件(修改后运行 `node tools/sync-webdev.js` 同步嵌入,勿手改嵌入副本)
 * - `node tools/sync-webdev.js` 还会生成 Greasy Fork 库文件 `webdev-library.user.js`(上传即用)
 * - UMD 幂等:同名全局已存在时不再覆盖,保证「@require 库 + 内嵌副本」同时存在时无冲突
 */
(function (root, factory) {
    if (typeof module === 'object' && module.exports) {
        module.exports = factory();
    } else {
        if (!root.WebdevComponent) root.WebdevComponent = factory();
    }
}(typeof self !== 'undefined' ? self : this, function () {
    'use strict';

    const VERSION = '1.0.0';

    /**
     * 创建 WebDAV 客户端实例。
     * @param {object} env
     * @param {string} [env.key='webdev_opts_v1']        设置存储键名
     * @param {string} [env.defaultFile='webdev-backup.json'] 默认备份文件名
     * @param {string} [env.encMark='webdev-aes-gcm-v1'] 加密备份信封标记
     * @param {{get:Function, set:Function}} [env.storage]     设置持久化适配器
     * @param {Function} [env.request]     网络适配器;opts={method,url,headers,data,timeout,acceptStatuses},resolve 原始响应 / reject Error
     * @param {Function} [env.exportPayload] 备份载荷生成器,返回明文 JSON 字符串
     */
    function createWebdev(env) {
        const o = env || {};
        const cfg = {
            key: String(o.key || 'webdev_opts_v1'),
            defaultFile: String(o.defaultFile || 'webdev-backup.json'),
            encMark: String(o.encMark || 'webdev-aes-gcm-v1')
        };
        const storage = o.storage || { get: () => null, set: () => {} };
        const request = typeof o.request === 'function' ? o.request : null;
        const exportPayload = typeof o.exportPayload === 'function' ? o.exportPayload : null;

        // ===== 设置 =====

        // 归一化设置对象:URL/用户名/密码/文件名/加密开关/加密密码
        function normalize(raw) {
            const s = raw && typeof raw === 'object' ? raw : {};
            return {
                url: String(s.url || '').trim(),
                user: String(s.user || '').trim(),
                pass: String(s.pass || ''),
                file: String(s.file || cfg.defaultFile).trim() || cfg.defaultFile,
                encrypt: s.encrypt === true || s.encrypt === '1' || s.encrypt === 1,
                secret: String(s.secret || '')
            };
        }

        function load() {
            let raw = null;
            try { raw = storage.get(cfg.key, null); } catch (e) { raw = null; }
            return normalize(raw);
        }

        function save(partial) {
            const next = normalize(Object.assign({}, load(), partial || {}));
            try { storage.set(cfg.key, next); } catch (e) { /* ignore */ }
            return next;
        }

        // ===== base64 工具(UTF-8 安全) =====

        function b64EncodeUtf8(str) {
            try {
                return btoa(unescape(encodeURIComponent(String(str || ''))));
            } catch (e) {
                try { return btoa(String(str || '')); } catch (e2) { return ''; }
            }
        }

        function bytesToB64(bytes) {
            const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
            let s = '';
            const chunk = 0x8000;
            for (let i = 0; i < u8.length; i += chunk) {
                s += String.fromCharCode.apply(null, u8.subarray(i, i + chunk));
            }
            return btoa(s);
        }

        function b64ToBytes(b64) {
            const bin = atob(String(b64 || ''));
            const out = new Uint8Array(bin.length);
            for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
            return out;
        }

        // ===== AES-256-GCM(PBKDF2-SHA256,120k 迭代,与 jav-code-scanner 同参数) =====

        function hasSubtleCrypto() {
            try {
                const cryptoObj = (typeof window !== 'undefined' && window.crypto)
                    || (typeof globalThis !== 'undefined' && globalThis.crypto)
                    || null;
                return !!(cryptoObj && cryptoObj.subtle && typeof cryptoObj.getRandomValues === 'function');
            } catch (e) {
                return false;
            }
        }

        function deriveAesKey(password, saltBytes) {
            const enc = new TextEncoder();
            return crypto.subtle.importKey('raw', enc.encode(String(password || '')), 'PBKDF2', false, ['deriveKey'])
                .then((base) => crypto.subtle.deriveKey(
                    {
                        name: 'PBKDF2',
                        salt: saltBytes,
                        iterations: 120000,
                        hash: 'SHA-256'
                    },
                    base,
                    { name: 'AES-GCM', length: 256 },
                    false,
                    ['encrypt', 'decrypt']
                ));
        }

        // 明文 → 加密 JSON 包(AES-256-GCM + PBKDF2)
        function encryptPayload(plainText, password) {
            if (!hasSubtleCrypto()) return Promise.reject(new Error('当前浏览器不支持 WebCrypto 加密'));
            const pwd = String(password || '');
            if (pwd.length < 4) return Promise.reject(new Error('加密密码至少 4 位'));
            const salt = crypto.getRandomValues(new Uint8Array(16));
            const iv = crypto.getRandomValues(new Uint8Array(12));
            const data = new TextEncoder().encode(String(plainText || ''));
            return deriveAesKey(pwd, salt).then((key) => crypto.subtle.encrypt(
                { name: 'AES-GCM', iv: iv },
                key,
                data
            )).then((cipherBuf) => JSON.stringify({
                app: 'webdev-component',
                enc: cfg.encMark,
                v: 1,
                kdf: 'PBKDF2-SHA256',
                iter: 120000,
                salt: bytesToB64(salt),
                iv: bytesToB64(iv),
                ct: bytesToB64(new Uint8Array(cipherBuf)),
                createdAt: new Date().toISOString()
            }, null, 2));
        }

        function isEncryptedPayload(text) {
            try {
                const p = typeof text === 'string' ? JSON.parse(text) : text;
                return !!(p && typeof p === 'object' && p.enc === cfg.encMark && p.ct && p.salt && p.iv);
            } catch (e) {
                return false;
            }
        }

        // 加密包 → 明文 JSON 字符串
        function decryptPayload(encText, password) {
            if (!hasSubtleCrypto()) return Promise.reject(new Error('当前浏览器不支持 WebCrypto 解密'));
            let p;
            try {
                p = typeof encText === 'string' ? JSON.parse(encText) : encText;
            } catch (e) {
                return Promise.reject(new Error('加密包无法解析'));
            }
            if (!p || p.enc !== cfg.encMark || !p.ct || !p.salt || !p.iv) {
                return Promise.reject(new Error('不是本脚本的加密备份'));
            }
            const pwd = String(password || '');
            if (!pwd) return Promise.reject(new Error('请填写加密密码'));
            let salt; let iv; let ct;
            try {
                salt = b64ToBytes(p.salt);
                iv = b64ToBytes(p.iv);
                ct = b64ToBytes(p.ct);
            } catch (e) {
                return Promise.reject(new Error('加密包数据损坏'));
            }
            return deriveAesKey(pwd, salt).then((key) => crypto.subtle.decrypt(
                { name: 'AES-GCM', iv: iv },
                key,
                ct
            )).then((buf) => new TextDecoder().decode(buf)).catch(() => {
                throw new Error('解密失败,请检查加密密码');
            });
        }

        // 若是加密包则解密,否则原样返回
        function maybeDecryptText(text, password) {
            if (!isEncryptedPayload(text)) return Promise.resolve(String(text || ''));
            return decryptPayload(text, password);
        }

        // ===== URL / 认证 =====

        // base 允许为目录或完整文件 URL;以 .json 结尾且未指定 file 时视为完整文件 URL
        function joinUrl(base, file) {
            let b = String(base || '').trim();
            if (!b) return '';
            if (/\.json(\?|#|$)/i.test(b) && !file) return b;
            b = b.replace(/\/+$/, '');
            const f = String(file || cfg.defaultFile).trim().replace(/^\/+/, '') || cfg.defaultFile;
            // base 已以文件名结尾时不再追加
            if (b.toLowerCase().endsWith('/' + f.toLowerCase()) || b.toLowerCase().endsWith(f.toLowerCase())) {
                return b;
            }
            return b + '/' + f;
        }

        function authHeader(opts) {
            const s = normalize(opts);
            if (!s.user && !s.pass) return {};
            return { Authorization: 'Basic ' + b64EncodeUtf8(s.user + ':' + s.pass) };
        }

        function targetUrl(opts) {
            const s = normalize(opts || load());
            return joinUrl(s.url, s.file);
        }

        // ===== 网络 =====

        function _request(opts) {
            if (!request) return Promise.reject(new Error('未注入 request 适配器'));
            return request(opts);
        }

        // ===== 业务动作 =====

        // 测试连通性:先 PROPFIND,失败再 GET(部分盘只开了文件读写)
        function testConnection(opts) {
            const s = normalize(opts || load());
            const url = joinUrl(s.url, s.file);
            if (!url) return Promise.reject(new Error('请填写 WebDAV 地址'));
            if (!/^https?:\/\//i.test(url)) return Promise.reject(new Error('WebDAV 地址需以 http(s):// 开头'));
            const headers = Object.assign({ 'Accept': '*/*', 'Depth': '0' }, authHeader(s));
            return _request({
                method: 'PROPFIND',
                url: url,
                headers: headers,
                timeout: 15000,
                acceptStatuses: [200, 207, 404]
            }).then((res) => {
                return { ok: true, status: res && res.status, url: url, mode: 'PROPFIND' };
            }).catch(() => _request({
                method: 'GET',
                url: url,
                headers: Object.assign({ 'Accept': '*/*' }, authHeader(s)),
                timeout: 15000,
                acceptStatuses: [200, 404]
            }).then((res) => ({
                ok: true,
                status: res && res.status,
                url: url,
                mode: 'GET',
                exists: !!(res && res.status === 200)
            })));
        }

        // 上传备份(exportPayload() 生成载荷;加密时为加密 JSON 包)
        function uploadLibrary(opts) {
            const s = normalize(opts || load());
            const url = joinUrl(s.url, s.file);
            if (!url) return Promise.reject(new Error('请填写 WebDAV 地址'));
            if (!/^https?:\/\//i.test(url)) return Promise.reject(new Error('WebDAV 地址需以 http(s):// 开头'));
            if (!exportPayload) return Promise.reject(new Error('未注入 exportPayload 适配器'));
            const plain = String(exportPayload());
            const bodyP = s.encrypt
                ? encryptPayload(plain, s.secret)
                : Promise.resolve(plain);
            return bodyP.then((body) => {
                const headers = Object.assign({
                    'Content-Type': 'application/json; charset=utf-8',
                    'Accept': '*/*'
                }, authHeader(s));
                return _request({
                    method: 'PUT',
                    url: url,
                    headers: headers,
                    data: body,
                    timeout: 30000,
                    acceptStatuses: [200, 201, 204, 207]
                }).then(() => ({
                    url: url,
                    bytes: body.length,
                    encrypted: !!s.encrypt
                }));
            });
        }

        // 下载并解析远端备份(加密包按设置的加密密码解密;返回 data 供消费方 importData 合并/覆盖)
        function downloadLibrary(opts) {
            const s = normalize(opts || load());
            const url = joinUrl(s.url, s.file);
            if (!url) return Promise.reject(new Error('请填写 WebDAV 地址'));
            if (!/^https?:\/\//i.test(url)) return Promise.reject(new Error('WebDAV 地址需以 http(s):// 开头'));
            const headers = Object.assign({
                'Accept': 'application/json, text/plain, */*'
            }, authHeader(s));
            return _request({
                method: 'GET',
                url: url,
                headers: headers,
                timeout: 30000,
                acceptStatuses: [200]
            }).then((res) => {
                const text = res && (res.responseText != null ? res.responseText : res.response);
                if (text == null || String(text).trim() === '') throw new Error('远端文件为空');
                const raw = String(text);
                // 加密包必须解密;明文直接解析
                return maybeDecryptText(raw, s.secret).then((plain) => {
                    let data;
                    try {
                        data = JSON.parse(plain);
                    } catch (e) {
                        throw new Error('远端文件不是有效的 JSON 备份');
                    }
                    if (!data || !Array.isArray(data.items)) {
                        throw new Error('远端备份格式不正确(缺少 items 数组)');
                    }
                    return {
                        text: plain,
                        encrypted: isEncryptedPayload(raw),
                        url: url,
                        data: data,
                        items: data.items.length
                    };
                });
            });
        }

        return {
            VERSION: VERSION,
            KEY: cfg.key,
            DEFAULT_FILE: cfg.defaultFile,
            ENC_MARK: cfg.encMark,
            normalize: normalize,
            load: load,
            save: save,
            b64EncodeUtf8: b64EncodeUtf8,
            bytesToB64: bytesToB64,
            b64ToBytes: b64ToBytes,
            hasSubtleCrypto: hasSubtleCrypto,
            deriveAesKey: deriveAesKey,
            encryptPayload: encryptPayload,
            isEncryptedPayload: isEncryptedPayload,
            decryptPayload: decryptPayload,
            maybeDecryptText: maybeDecryptText,
            joinUrl: joinUrl,
            authHeader: authHeader,
            targetUrl: targetUrl,
            testConnection: testConnection,
            uploadLibrary: uploadLibrary,
            downloadLibrary: downloadLibrary
        };
    }

    return { VERSION: VERSION, createWebdev: createWebdev };
}));