mirror of
				https://github.com/TeamPiped/Piped.git
				synced 2024-08-14 23:57:27 +00:00 
			
		
		
		
	Merge a5c0d8007c into a055ae6686
				
					
				
			This commit is contained in:
		
						commit
						d26ce27691
					
				
					 9 changed files with 220 additions and 19 deletions
				
			
		| 
						 | 
				
			
			@ -21,6 +21,7 @@
 | 
			
		|||
        "linkify-html": "4.1.1",
 | 
			
		||||
        "linkifyjs": "4.1.1",
 | 
			
		||||
        "mux.js": "6.3.0",
 | 
			
		||||
        "pako": "2.1.0",
 | 
			
		||||
        "qrcode": "^1.5.3",
 | 
			
		||||
        "shaka-player": "4.5.0",
 | 
			
		||||
        "stream-browserify": "3.0.0",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										7
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										7
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -38,6 +38,9 @@ dependencies:
 | 
			
		|||
  mux.js:
 | 
			
		||||
    specifier: 6.3.0
 | 
			
		||||
    version: 6.3.0
 | 
			
		||||
  pako:
 | 
			
		||||
    specifier: 2.1.0
 | 
			
		||||
    version: 2.1.0
 | 
			
		||||
  qrcode:
 | 
			
		||||
    specifier: ^1.5.3
 | 
			
		||||
    version: 1.5.3
 | 
			
		||||
| 
						 | 
				
			
			@ -4194,6 +4197,10 @@ packages:
 | 
			
		|||
    engines: {node: '>=6'}
 | 
			
		||||
    dev: false
 | 
			
		||||
 | 
			
		||||
  /pako@2.1.0:
 | 
			
		||||
    resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
 | 
			
		||||
    dev: false
 | 
			
		||||
 | 
			
		||||
  /parent-module@1.0.1:
 | 
			
		||||
    resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
 | 
			
		||||
    engines: {node: '>=6'}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										65
									
								
								src/App.vue
									
										
									
									
									
								
							
							
						
						
									
										65
									
								
								src/App.vue
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -9,7 +9,7 @@
 | 
			
		|||
            </router-view>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <FooterComponent />
 | 
			
		||||
        <FooterComponent :config="config" />
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -19,6 +19,10 @@ import FooterComponent from "./components/FooterComponent.vue";
 | 
			
		|||
 | 
			
		||||
const darkModePreference = window.matchMedia("(prefers-color-scheme: dark)");
 | 
			
		||||
 | 
			
		||||
import { generateKey, encodeArrayToBase64, decodeBase64ToArray, decryptAESGCM } from "./utils/encryptionUtils";
 | 
			
		||||
import { decompressGzip } from "./utils/compressionUtils";
 | 
			
		||||
import { state } from "./utils/store";
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    components: {
 | 
			
		||||
        NavBar,
 | 
			
		||||
| 
						 | 
				
			
			@ -27,6 +31,7 @@ export default {
 | 
			
		|||
    data() {
 | 
			
		||||
        return {
 | 
			
		||||
            theme: "dark",
 | 
			
		||||
            config: null,
 | 
			
		||||
        };
 | 
			
		||||
    },
 | 
			
		||||
    mounted() {
 | 
			
		||||
| 
						 | 
				
			
			@ -35,6 +40,15 @@ export default {
 | 
			
		|||
            this.setTheme();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.fetchJson(this.authApiUrl() + "/config")
 | 
			
		||||
            .then(config => {
 | 
			
		||||
                this.config = config;
 | 
			
		||||
                state.config = config;
 | 
			
		||||
            })
 | 
			
		||||
            .then(() => {
 | 
			
		||||
                this.onConfigLoaded();
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
        if ("indexedDB" in window) {
 | 
			
		||||
            const request = indexedDB.open("piped-db", 5);
 | 
			
		||||
            request.onupgradeneeded = ev => {
 | 
			
		||||
| 
						 | 
				
			
			@ -115,6 +129,55 @@ export default {
 | 
			
		|||
            const themeColor = document.querySelector("meta[name='theme-color']");
 | 
			
		||||
            themeColor.setAttribute("content", currentColor[this.theme]);
 | 
			
		||||
        },
 | 
			
		||||
        async onConfigLoaded() {
 | 
			
		||||
            if (this.config.s3Enabled && this.authenticated) {
 | 
			
		||||
                if (this.getPreferenceBoolean("syncPreferences", false, false)) {
 | 
			
		||||
                    var e2e2_b64_key = this.getPreferenceString("e2ee_key", null, false);
 | 
			
		||||
                    if (!e2e2_b64_key) {
 | 
			
		||||
                        const key = new Uint8Array(await generateKey());
 | 
			
		||||
                        const encoded = encodeArrayToBase64(key);
 | 
			
		||||
                        this.setPreference("e2ee_key", encoded);
 | 
			
		||||
                        e2e2_b64_key = encoded;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    const statResult = await this.fetchJson(
 | 
			
		||||
                        this.authApiUrl() + "/storage/stat",
 | 
			
		||||
                        {
 | 
			
		||||
                            file: "pipedpref",
 | 
			
		||||
                        },
 | 
			
		||||
                        {
 | 
			
		||||
                            headers: {
 | 
			
		||||
                                Authorization: this.getAuthToken(),
 | 
			
		||||
                            },
 | 
			
		||||
                        },
 | 
			
		||||
                    );
 | 
			
		||||
 | 
			
		||||
                    if (statResult.status === "exists") {
 | 
			
		||||
                        const data = await fetch(this.authApiUrl() + "/storage/get?file=pipedpref", {
 | 
			
		||||
                            method: "GET",
 | 
			
		||||
                            headers: {
 | 
			
		||||
                                Authorization: this.getAuthToken(),
 | 
			
		||||
                            },
 | 
			
		||||
                        }).then(resp => resp.arrayBuffer());
 | 
			
		||||
 | 
			
		||||
                        const cryptoKey = decodeBase64ToArray(e2e2_b64_key).buffer;
 | 
			
		||||
 | 
			
		||||
                        const decrypted = await decryptAESGCM(data, cryptoKey);
 | 
			
		||||
 | 
			
		||||
                        const decompressed = await decompressGzip(new Uint8Array(decrypted));
 | 
			
		||||
 | 
			
		||||
                        const localStorageJson = JSON.parse(decompressed);
 | 
			
		||||
 | 
			
		||||
                        // import into localStorage
 | 
			
		||||
                        for (var key in localStorageJson) {
 | 
			
		||||
                            if (Object.prototype.hasOwnProperty.call(localStorageJson, key)) {
 | 
			
		||||
                                localStorage[key] = localStorageJson[key];
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -29,6 +29,12 @@
 | 
			
		|||
 | 
			
		||||
<script>
 | 
			
		||||
export default {
 | 
			
		||||
    props: {
 | 
			
		||||
        config: {
 | 
			
		||||
            type: Object,
 | 
			
		||||
            required: true,
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
    data() {
 | 
			
		||||
        return {
 | 
			
		||||
            donationHref: null,
 | 
			
		||||
| 
						 | 
				
			
			@ -36,16 +42,13 @@ export default {
 | 
			
		|||
            privacyPolicyHref: null,
 | 
			
		||||
        };
 | 
			
		||||
    },
 | 
			
		||||
    mounted() {
 | 
			
		||||
        this.fetchConfig();
 | 
			
		||||
    },
 | 
			
		||||
    methods: {
 | 
			
		||||
        async fetchConfig() {
 | 
			
		||||
            this.fetchJson(this.apiUrl() + "/config").then(config => {
 | 
			
		||||
                this.donationHref = config?.donationUrl;
 | 
			
		||||
                this.statusPageHref = config?.statusPageUrl;
 | 
			
		||||
                this.privacyPolicyHref = config?.privacyPolicyUrl;
 | 
			
		||||
            });
 | 
			
		||||
    watch: {
 | 
			
		||||
        config: {
 | 
			
		||||
            handler() {
 | 
			
		||||
                this.donationHref = this.config?.donationUrl;
 | 
			
		||||
                this.statusPageHref = this.config?.statusPageUrl;
 | 
			
		||||
                this.privacyPolicyHref = this.config?.privacyPolicyUrl;
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -380,6 +380,9 @@
 | 
			
		|||
<script>
 | 
			
		||||
import CountryMap from "@/utils/CountryMaps/en.json";
 | 
			
		||||
import ConfirmModal from "./ConfirmModal.vue";
 | 
			
		||||
import { state } from "../utils/store";
 | 
			
		||||
import { encryptAESGCM, decodeBase64ToArray } from "../utils/encryptionUtils";
 | 
			
		||||
import { compressGzip } from "../utils/compressionUtils";
 | 
			
		||||
export default {
 | 
			
		||||
    components: {
 | 
			
		||||
        ConfirmModal,
 | 
			
		||||
| 
						 | 
				
			
			@ -614,6 +617,53 @@ export default {
 | 
			
		|||
                localStorage.setItem("hideWatched", this.hideWatched);
 | 
			
		||||
                localStorage.setItem("mobileChapterLayout", this.mobileChapterLayout);
 | 
			
		||||
 | 
			
		||||
                const config = state.config;
 | 
			
		||||
 | 
			
		||||
                const key = this.getPreferenceString("e2ee_key", null, false);
 | 
			
		||||
 | 
			
		||||
                if (config.s3Enabled && this.authenticated && key) {
 | 
			
		||||
                    const statResult = await this.fetchJson(
 | 
			
		||||
                        this.authApiUrl() + "/storage/stat",
 | 
			
		||||
                        {
 | 
			
		||||
                            file: "pipedpref",
 | 
			
		||||
                        },
 | 
			
		||||
                        {
 | 
			
		||||
                            headers: {
 | 
			
		||||
                                Authorization: this.getAuthToken(),
 | 
			
		||||
                            },
 | 
			
		||||
                        },
 | 
			
		||||
                    );
 | 
			
		||||
 | 
			
		||||
                    const etag = statResult.etag;
 | 
			
		||||
 | 
			
		||||
                    // export localStorage to JSON
 | 
			
		||||
                    const localStorageJson = {};
 | 
			
		||||
                    for (let i = 0; i < localStorage.length; i++) {
 | 
			
		||||
                        const key = localStorage.key(i);
 | 
			
		||||
                        localStorageJson[key] = localStorage.getItem(key);
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    const importedKey = decodeBase64ToArray(key).buffer;
 | 
			
		||||
 | 
			
		||||
                    const data = await compressGzip(JSON.stringify(localStorageJson));
 | 
			
		||||
 | 
			
		||||
                    const encrypted = await encryptAESGCM(data, importedKey);
 | 
			
		||||
 | 
			
		||||
                    await this.fetchJson(
 | 
			
		||||
                        this.authApiUrl() + "/storage/put",
 | 
			
		||||
                        {},
 | 
			
		||||
                        {
 | 
			
		||||
                            method: "POST",
 | 
			
		||||
                            headers: {
 | 
			
		||||
                                Authorization: this.getAuthToken(),
 | 
			
		||||
                                "x-file-name": "pipedpref",
 | 
			
		||||
                                "x-last-etag": etag,
 | 
			
		||||
                            },
 | 
			
		||||
                            body: encrypted,
 | 
			
		||||
                        },
 | 
			
		||||
                    );
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (shouldReload) window.location.reload();
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										16
									
								
								src/main.js
									
										
									
									
									
								
							
							
						
						
									
										16
									
								
								src/main.js
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -125,10 +125,10 @@ const mixin = {
 | 
			
		|||
                if (!disableAlert) alert(this.$t("info.local_storage"));
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        getPreferenceBoolean(key, defaultVal) {
 | 
			
		||||
        getPreferenceBoolean(key, defaultVal, allowQuery = true) {
 | 
			
		||||
            var value;
 | 
			
		||||
            if (
 | 
			
		||||
                (value = new URLSearchParams(window.location.search).get(key)) !== null ||
 | 
			
		||||
                (allowQuery && (value = new URLSearchParams(window.location.search).get(key)) !== null) ||
 | 
			
		||||
                (this.testLocalStorage && (value = localStorage.getItem(key)) !== null)
 | 
			
		||||
            ) {
 | 
			
		||||
                switch (String(value).toLowerCase()) {
 | 
			
		||||
| 
						 | 
				
			
			@ -142,29 +142,29 @@ const mixin = {
 | 
			
		|||
                }
 | 
			
		||||
            } else return defaultVal;
 | 
			
		||||
        },
 | 
			
		||||
        getPreferenceString(key, defaultVal) {
 | 
			
		||||
        getPreferenceString(key, defaultVal, allowQuery = true) {
 | 
			
		||||
            var value;
 | 
			
		||||
            if (
 | 
			
		||||
                (value = new URLSearchParams(window.location.search).get(key)) !== null ||
 | 
			
		||||
                (allowQuery && (value = new URLSearchParams(window.location.search).get(key)) !== null) ||
 | 
			
		||||
                (this.testLocalStorage && (value = localStorage.getItem(key)) !== null)
 | 
			
		||||
            ) {
 | 
			
		||||
                return value;
 | 
			
		||||
            } else return defaultVal;
 | 
			
		||||
        },
 | 
			
		||||
        getPreferenceNumber(key, defaultVal) {
 | 
			
		||||
        getPreferenceNumber(key, defaultVal, allowQuery = true) {
 | 
			
		||||
            var value;
 | 
			
		||||
            if (
 | 
			
		||||
                (value = new URLSearchParams(window.location.search).get(key)) !== null ||
 | 
			
		||||
                (allowQuery && (value = new URLSearchParams(window.location.search).get(key)) !== null) ||
 | 
			
		||||
                (this.testLocalStorage && (value = localStorage.getItem(key)) !== null)
 | 
			
		||||
            ) {
 | 
			
		||||
                const num = Number(value);
 | 
			
		||||
                return isNaN(num) ? defaultVal : num;
 | 
			
		||||
            } else return defaultVal;
 | 
			
		||||
        },
 | 
			
		||||
        getPreferenceJSON(key, defaultVal) {
 | 
			
		||||
        getPreferenceJSON(key, defaultVal, allowQuery = true) {
 | 
			
		||||
            var value;
 | 
			
		||||
            if (
 | 
			
		||||
                (value = new URLSearchParams(window.location.search).get(key)) !== null ||
 | 
			
		||||
                (allowQuery && (value = new URLSearchParams(window.location.search).get(key)) !== null) ||
 | 
			
		||||
                (this.testLocalStorage && (value = localStorage.getItem(key)) !== null)
 | 
			
		||||
            ) {
 | 
			
		||||
                return JSON.parse(value);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										38
									
								
								src/utils/compressionUtils.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/utils/compressionUtils.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,38 @@
 | 
			
		|||
export const compressGzip = async data => {
 | 
			
		||||
    // Firefox does not support CompressionStream yet
 | 
			
		||||
    if (typeof CompressionStream !== "undefined") {
 | 
			
		||||
        let bytes = new TextEncoder().encode(data);
 | 
			
		||||
        // eslint-disable-next-line no-undef
 | 
			
		||||
        const cs = new CompressionStream("gzip");
 | 
			
		||||
        const writer = cs.writable.getWriter();
 | 
			
		||||
        writer.write(bytes);
 | 
			
		||||
        writer.close();
 | 
			
		||||
        const compAb = await new Response(cs.readable).arrayBuffer();
 | 
			
		||||
        bytes = new Uint8Array(compAb);
 | 
			
		||||
 | 
			
		||||
        return bytes;
 | 
			
		||||
    } else {
 | 
			
		||||
        const pako = await import("pako");
 | 
			
		||||
        return pako.gzip(data);
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const decompressGzip = async compressedData => {
 | 
			
		||||
    // Firefox does not support DecompressionStream yet
 | 
			
		||||
    if (typeof DecompressionStream !== "undefined") {
 | 
			
		||||
        // eslint-disable-next-line no-undef
 | 
			
		||||
        const ds = new DecompressionStream("gzip");
 | 
			
		||||
        const writer = ds.writable.getWriter();
 | 
			
		||||
        writer.write(compressedData);
 | 
			
		||||
        writer.close();
 | 
			
		||||
        const decompAb = await new Response(ds.readable).arrayBuffer();
 | 
			
		||||
        const bytes = new Uint8Array(decompAb);
 | 
			
		||||
 | 
			
		||||
        return new TextDecoder().decode(bytes);
 | 
			
		||||
    } else {
 | 
			
		||||
        const pako = await import("pako");
 | 
			
		||||
        const inflated = pako.inflate(compressedData, { to: "string" });
 | 
			
		||||
 | 
			
		||||
        return inflated;
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										36
									
								
								src/utils/encryptionUtils.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								src/utils/encryptionUtils.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,36 @@
 | 
			
		|||
// These functions accept and return Uint8Arrays
 | 
			
		||||
 | 
			
		||||
export async function encryptAESGCM(plaintext, key) {
 | 
			
		||||
    const iv = crypto.getRandomValues(new Uint8Array(12));
 | 
			
		||||
    const algorithm = { name: "AES-GCM", iv: iv };
 | 
			
		||||
    const keyMaterial = await crypto.subtle.importKey("raw", key, algorithm, false, ["encrypt"]);
 | 
			
		||||
    const ciphertext = await crypto.subtle.encrypt(algorithm, keyMaterial, plaintext);
 | 
			
		||||
 | 
			
		||||
    return new Uint8Array([...iv, ...new Uint8Array(ciphertext)]);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function decryptAESGCM(ciphertextArray, key) {
 | 
			
		||||
    const iv = new Uint8Array(ciphertextArray.slice(0, 12));
 | 
			
		||||
    const algorithm = { name: "AES-GCM", iv: iv };
 | 
			
		||||
    const keyMaterial = await crypto.subtle.importKey("raw", key, algorithm, false, ["decrypt"]);
 | 
			
		||||
    const decrypted = await crypto.subtle.decrypt(algorithm, keyMaterial, new Uint8Array(ciphertextArray.slice(12)));
 | 
			
		||||
 | 
			
		||||
    return decrypted;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function generateKey() {
 | 
			
		||||
    const algorithm = { name: "AES-GCM", length: 256 };
 | 
			
		||||
    const key = await crypto.subtle.generateKey(algorithm, true, ["encrypt", "decrypt"]);
 | 
			
		||||
 | 
			
		||||
    return await crypto.subtle.exportKey("raw", key);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function encodeArrayToBase64(array) {
 | 
			
		||||
    const chars = String.fromCharCode.apply(null, array);
 | 
			
		||||
    return btoa(chars);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function decodeBase64ToArray(base64) {
 | 
			
		||||
    const chars = atob(base64);
 | 
			
		||||
    return new Uint8Array(chars.split("").map(c => c.charCodeAt(0)));
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										3
									
								
								src/utils/store.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/utils/store.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
import { reactive } from "vue";
 | 
			
		||||
 | 
			
		||||
export const state = reactive({});
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue