This commit is contained in:
Kavin 2023-11-10 02:56:14 +01:00 committed by GitHub
commit d26ce27691
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 220 additions and 19 deletions

View file

@ -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
View file

@ -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'}

View file

@ -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>

View file

@ -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();
watch: {
config: {
handler() {
this.donationHref = this.config?.donationUrl;
this.statusPageHref = this.config?.statusPageUrl;
this.privacyPolicyHref = this.config?.privacyPolicyUrl;
},
methods: {
async fetchConfig() {
this.fetchJson(this.apiUrl() + "/config").then(config => {
this.donationHref = config?.donationUrl;
this.statusPageHref = config?.statusPageUrl;
this.privacyPolicyHref = config?.privacyPolicyUrl;
});
},
},
};

View file

@ -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();
}
},

View file

@ -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);

View 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;
}
};

View 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
View file

@ -0,0 +1,3 @@
import { reactive } from "vue";
export const state = reactive({});