feat: Cloud settings sync (#505)

Co-authored-by: Ven <vendicated@riseup.net>
This commit is contained in:
Lewis Crichton 2023-04-07 01:27:18 +01:00 committed by GitHub
parent 2672dea8e3
commit 97f8d4d515
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 564 additions and 12 deletions

124
src/utils/cloud.tsx Normal file
View file

@ -0,0 +1,124 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import * as DataStore from "@api/DataStore";
import { showNotification } from "@api/Notifications";
import { Settings } from "@api/settings";
import { findByProps } from "@webpack";
import { UserStore } from "@webpack/common";
import Logger from "./Logger";
import { openModal } from "./modal";
export const cloudLogger = new Logger("Cloud", "#39b7e0");
export const getCloudUrl = () => new URL(Settings.cloud.url);
export async function getAuthorization() {
const secrets = await DataStore.get<Record<string, string>>("Vencord_cloudSecret") ?? {};
return secrets[getCloudUrl().origin];
}
async function setAuthorization(secret: string) {
await DataStore.update<Record<string, string>>("Vencord_cloudSecret", secrets => {
secrets ??= {};
secrets[getCloudUrl().origin] = secret;
return secrets;
});
}
export async function deauthorizeCloud() {
await DataStore.update<Record<string, string>>("Vencord_cloudSecret", secrets => {
secrets ??= {};
delete secrets[getCloudUrl().origin];
return secrets;
});
}
export async function authorizeCloud() {
if (await getAuthorization() !== undefined) {
Settings.cloud.authenticated = true;
return;
}
try {
const oauthConfiguration = await fetch(new URL("/v1/oauth/settings", getCloudUrl()));
var { clientId, redirectUri } = await oauthConfiguration.json();
} catch {
showNotification({
title: "Cloud Integration",
body: "Setup failed (couldn't retrieve OAuth configuration)."
});
Settings.cloud.authenticated = false;
return;
}
const { OAuth2AuthorizeModal } = findByProps("OAuth2AuthorizeModal");
openModal((props: any) => <OAuth2AuthorizeModal
{...props}
scopes={["identify"]}
responseType="code"
redirectUri={redirectUri}
permissions={0n}
clientId={clientId}
cancelCompletesFlow={false}
callback={async (callbackUrl: string) => {
if (!callbackUrl) {
Settings.cloud.authenticated = false;
return;
}
try {
const res = await fetch(callbackUrl, {
headers: new Headers({ Accept: "application/json" })
});
const { secret } = await res.json();
if (secret) {
cloudLogger.info("Authorized with secret");
await setAuthorization(secret);
showNotification({
title: "Cloud Integration",
body: "Cloud integrations enabled!"
});
Settings.cloud.authenticated = true;
} else {
showNotification({
title: "Cloud Integration",
body: "Setup failed (no secret returned?)."
});
Settings.cloud.authenticated = false;
}
} catch (e: any) {
cloudLogger.error("Failed to authorize", e);
showNotification({
title: "Cloud Integration",
body: `Setup failed (${e.toString()}).`
});
Settings.cloud.authenticated = false;
}
}
}
/>);
}
export async function getCloudAuth() {
const userId = UserStore.getCurrentUser().id;
const secret = await getAuthorization();
return window.btoa(`${secret}:${userId}`);
}

19
src/utils/localStorage.ts Normal file
View file

@ -0,0 +1,19 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export const { localStorage } = window;

View file

@ -16,8 +16,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { showNotification } from "@api/Notifications";
import { PlainSettings, Settings } from "@api/settings";
import { Toasts } from "@webpack/common";
import { deflateSync, inflateSync } from "fflate";
import { getCloudAuth, getCloudUrl } from "./cloud";
import IpcEvents from "./IpcEvents";
import Logger from "./Logger";
@ -64,17 +68,18 @@ export async function downloadSettingsBackup() {
}
}
const toastSuccess = () => Toasts.show({
type: Toasts.Type.SUCCESS,
message: "Settings successfully imported. Restart to apply changes!",
id: Toasts.genId()
});
const toast = (type: number, message: string) =>
Toasts.show({
type,
message,
id: Toasts.genId()
});
const toastFailure = (err: any) => Toasts.show({
type: Toasts.Type.FAILURE,
message: `Failed to import settings: ${String(err)}`,
id: Toasts.genId()
});
const toastSuccess = () =>
toast(Toasts.Type.SUCCESS, "Settings successfully imported. Restart to apply changes!");
const toastFailure = (err: any) =>
toast(Toasts.Type.FAILURE, `Failed to import settings: ${String(err)}`);
export async function uploadSettingsBackup(showToast = true): Promise<void> {
if (IS_DISCORD_DESKTOP) {
@ -121,3 +126,169 @@ export async function uploadSettingsBackup(showToast = true): Promise<void> {
setImmediate(() => document.body.removeChild(input));
}
}
// Cloud settings
const cloudSettingsLogger = new Logger("Cloud:Settings", "#39b7e0");
export async function putCloudSettings() {
const settings = await exportSettings();
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "PUT",
headers: new Headers({
Authorization: await getCloudAuth(),
"Content-Type": "application/octet-stream"
}),
body: deflateSync(new TextEncoder().encode(settings))
});
if (!res.ok) {
cloudSettingsLogger.error(`Failed to sync up, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings to cloud (API returned ${res.status}).`,
color: "var(--red-360)"
});
return;
}
const { written } = await res.json();
PlainSettings.cloud.settingsSyncVersion = written;
VencordNative.ipc.invoke(IpcEvents.SET_SETTINGS, JSON.stringify(PlainSettings, null, 4));
cloudSettingsLogger.info("Settings uploaded to cloud successfully");
showNotification({
title: "Cloud Settings",
body: "Synchronized your settings to the cloud!",
color: "var(--green-360)"
});
} catch (e: any) {
cloudSettingsLogger.error("Failed to sync up", e);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings to the cloud (${e.toString()}).`,
color: "var(--red-360)"
});
}
}
export async function getCloudSettings(shouldNotify = true, force = false) {
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "GET",
headers: new Headers({
Authorization: await getCloudAuth(),
Accept: "application/octet-stream",
"If-None-Match": Settings.cloud.settingsSyncVersion.toString()
}),
});
if (res.status === 404) {
cloudSettingsLogger.info("No settings on the cloud");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "There are no settings in the cloud."
});
return false;
}
if (res.status === 304) {
cloudSettingsLogger.info("Settings up to date");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your settings are up to date."
});
return false;
}
if (!res.ok) {
cloudSettingsLogger.error(`Failed to sync down, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings from the cloud (API returned ${res.status}).`,
color: "var(--red-360)"
});
return false;
}
const written = Number(res.headers.get("etag")!);
const localWritten = Settings.cloud.settingsSyncVersion;
// don't need to check for written > localWritten because the server will return 304 due to if-none-match
if (!force && written < localWritten) {
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your local settings are newer than the cloud ones."
});
return;
}
const data = await res.arrayBuffer();
const settings = new TextDecoder().decode(inflateSync(new Uint8Array(data)));
await importSettings(settings);
// sync with server timestamp instead of local one
PlainSettings.cloud.settingsSyncVersion = written;
VencordNative.ipc.invoke(IpcEvents.SET_SETTINGS, JSON.stringify(PlainSettings, null, 4));
cloudSettingsLogger.info("Settings loaded from cloud successfully");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your settings have been updated! Click here to restart to fully apply changes!",
color: "var(--green-360)",
onClick: () => window.DiscordNative.app.relaunch()
});
return true;
} catch (e: any) {
cloudSettingsLogger.error("Failed to sync down", e);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings from the cloud (${e.toString()}).`,
color: "var(--red-360)"
});
return false;
}
}
export async function deleteCloudSettings() {
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "DELETE",
headers: new Headers({
Authorization: await getCloudAuth()
}),
});
if (!res.ok) {
cloudSettingsLogger.error(`Failed to delete, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not delete settings (API returned ${res.status}).`,
color: "var(--red-360)"
});
return;
}
cloudSettingsLogger.info("Settings deleted from cloud successfully");
showNotification({
title: "Cloud Settings",
body: "Settings deleted from cloud!",
color: "var(--green-360)"
});
} catch (e: any) {
cloudSettingsLogger.error("Failed to delete", e);
showNotification({
title: "Cloud Settings",
body: `Could not delete settings (${e.toString()}).`,
color: "var(--red-360)"
});
}
}