diff --git a/api/formapi.js b/api/formapi.js index 8115135..f48cd77 100644 --- a/api/formapi.js +++ b/api/formapi.js @@ -1,7 +1,7 @@ const {redirect} = require("pinski/plugins") const db = require("../utils/db") const constants = require("../utils/constants") -const {getUser} = require("../utils/getuser") +const {getUser, setToken} = require("../utils/getuser") const validate = require("../utils/validate") const V = validate.V const {fetchChannel} = require("../utils/youtube") @@ -54,5 +54,45 @@ module.exports = [ }) .go() } + }, + { + route: `/formapi/erase`, methods: ["POST"], upload: true, code: async ({req, fill, body}) => { + return new V() + .with(validate.presetLoad({body})) + .with(validate.presetURLParamsBody()) + .with(validate.presetEnsureParams(["token"])) + .last(async state => { + const {params} = state + const token = params.get("token") + ;["Subscriptions", "Settings", "SeenTokens", "WatchedVideos"].forEach(table => { + db.prepare(`DELETE FROM ${table} WHERE token = ?`).run(token) + }) + return { + statusCode: 303, + headers: { + location: "/", + "set-cookie": `token=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax` + }, + content: { + status: "ok" + } + } + }) + .go() + } + }, + { + route: "/formapi/importsession/(\\w+)", methods: ["GET"], code: async ({req, fill}) => { + return { + statusCode: 303, + headers: setToken({ + location: "/subscriptions" + }, fill[0]), + contentType: "application/json", + content: { + status: "ok" + } + } + } } ] diff --git a/api/settings.js b/api/settings.js index 860ceb9..653811c 100644 --- a/api/settings.js +++ b/api/settings.js @@ -10,7 +10,7 @@ module.exports = [ route: "/settings", methods: ["GET"], code: async ({req}) => { const user = getUser(req) const settings = user.getSettings() - return render(200, "pug/settings.pug", {constants, settings}) + return render(200, "pug/settings.pug", {constants, user, settings}) } }, { diff --git a/api/subscriptions.js b/api/subscriptions.js index a7b4294..a9c8aa3 100644 --- a/api/subscriptions.js +++ b/api/subscriptions.js @@ -31,7 +31,6 @@ module.exports = [ videos = db.prepare(`SELECT * FROM Videos WHERE authorId IN (${template}) ORDER BY published DESC LIMIT 60`).all(subscriptions) .map(video => { video.publishedText = timeToPastText(video.published * 1000) - console.log(watchedVideos, video.videoId) video.watched = watchedVideos.includes(video.videoId) return video }) diff --git a/pug/settings.pug b/pug/settings.pug index adec6c8..f62f8d2 100644 --- a/pug/settings.pug +++ b/pug/settings.pug @@ -49,3 +49,32 @@ block content .save-settings button.border-look Save + + details.data-management + summary Sync data + p Open this link elsewhere to import your current CloudTube session there. + p. + If you clear your cookies often, you can bookmark this link and open it + to restore your data, or if you have multiple devices, you can send this + link to them to import your session and automatically keep everything + in sync. + - let url = `/formapi/importsession/${user.token}` + a(href=url)= url + + details.data-management.delete-details + summary Delete data + p Press this button to erase all your data from CloudTube. + p. + Just the current session will be removed. If you lost access to a + previous session, you cannot touch it. + p. + You will lose your subscriptions, watch history, settings, and anything + else you stored on the server. The server will keep no record that they + ever existed. + p Deletion is instant and #[em cannot be undone.] + input(type="checkbox" id="delete-confirm") + .delete-confirm-container + label(for="delete-confirm").delete-confirm-label I understand the consequences + form(method="post" action="/formapi/erase") + input(type="hidden" name="token" value=user.token) + button.border-look#delete-button Permanently erase my data diff --git a/pug/subscriptions.pug b/pug/subscriptions.pug index a7a5fdd..c497951 100644 --- a/pug/subscriptions.pug +++ b/pug/subscriptions.pug @@ -26,6 +26,7 @@ block content - const notLoaded = channels.length - refreshed.count if notLoaded div #{notLoaded} subscriptions have not been refreshed at all + div Your subscriptions will be regularly refreshed in the background so long as you log in frequently. if settings.save_history input(type="checkbox" id="watched-videos-display") diff --git a/sass/includes/forms.sass b/sass/includes/forms.sass index c8dad07..9426dd5 100644 --- a/sass/includes/forms.sass +++ b/sass/includes/forms.sass @@ -79,3 +79,25 @@ fieldset &.checkbox:not(:disabled) + .pill @include acts-like-button cursor: pointer + +@mixin checkbox-hider($base) + ##{$base} + position: relative + left: 10px + display: block + z-index: 1 + height: 42px + margin: 0 + + .#{$base}-container + position: relative + display: grid // why does the default not work??? + top: -42px + background: c.$bg-accent-x + line-height: 1 + border-radius: 8px + margin-bottom: -18px + + .#{$base}-label + padding: 12px 0px 12px 32px + cursor: pointer diff --git a/sass/includes/settings-page.sass b/sass/includes/settings-page.sass index d7547bf..1ee836d 100644 --- a/sass/includes/settings-page.sass +++ b/sass/includes/settings-page.sass @@ -1,3 +1,6 @@ +@use "forms.sass" as forms +@use "colors.sass" as c + .settings-page padding: 40px 20px 20px max-width: 600px @@ -9,3 +12,18 @@ .border-look font-size: 22px padding: 7px 16px 8px + +.data-management + margin-top: 24px + + .delete-confirm-container + background: c.$bg-darker + margin-bottom: -36px + +@include forms.checkbox-hider("delete-confirm") + +#delete-confirm:not(:checked) ~ * #delete-button + visibility: hidden + +.delete-details[open] + padding-bottom: 40px diff --git a/sass/includes/subscriptions-page.sass b/sass/includes/subscriptions-page.sass index 2947aab..616acdb 100644 --- a/sass/includes/subscriptions-page.sass +++ b/sass/includes/subscriptions-page.sass @@ -1,5 +1,6 @@ @use "colors.sass" as c @use "video-list-item.sass" as * +@use "forms.sass" as forms .subscriptions-page padding: 40px 20px 20px @@ -34,27 +35,7 @@ font-size: 22px color: c.$fg-main - -#watched-videos-display - position: relative - left: 10px - display: block - z-index: 1 - height: 42px - margin: 0 - -.watched-videos-display-container - position: relative - display: grid // why does the default not work??? - top: -42px - background: c.$bg-accent-x - line-height: 1 - border-radius: 8px - margin-bottom: -18px - - .watched-videos-display-label - padding: 12px 0px 12px 32px - cursor: pointer +@include forms.checkbox-hider("watched-videos-display") #watched-videos-display:checked ~ .video-list-item--watched display: none diff --git a/utils/getuser.js b/utils/getuser.js index f358d7d..7442281 100644 --- a/utils/getuser.js +++ b/utils/getuser.js @@ -9,10 +9,7 @@ function getToken(req, responseHeaders) { let token = cookie.token if (!token) { if (responseHeaders) { // we should create a token - const setCookie = responseHeaders["set-cookie"] || [] - token = crypto.randomBytes(18).toString("base64").replace(/\W/g, "_") - setCookie.push(`token=${token}; Path=/; Max-Age=2147483648; HttpOnly; SameSite=Lax`) - responseHeaders["set-cookie"] = setCookie + setToken(responseHeaders) } else { return null } @@ -21,6 +18,14 @@ function getToken(req, responseHeaders) { return token } +function setToken(responseHeaders, token) { + const setCookie = responseHeaders["set-cookie"] || [] + if (!token) token = crypto.randomBytes(18).toString("base64").replace(/\W/g, "_") + setCookie.push(`token=${token}; Path=/; Max-Age=2147483648; HttpOnly; SameSite=Lax`) + responseHeaders["set-cookie"] = setCookie + return responseHeaders +} + class User { constructor(token) { this.token = token @@ -107,6 +112,7 @@ cleanCSRF() setInterval(cleanCSRF, constants.caching.csrf_time).unref() module.exports.getToken = getToken +module.exports.setToken = setToken module.exports.generateCSRF = generateCSRF module.exports.checkCSRF = checkCSRF module.exports.getUser = getUser diff --git a/utils/validate.js b/utils/validate.js index 3cf9962..176b6a3 100644 --- a/utils/validate.js +++ b/utils/validate.js @@ -78,6 +78,20 @@ function presetURLParamsBody() { ] } +function presetEnsureParams(list) { + return [ + state => { + return list.every(name => state.params.has(name)) + }, + () => ({ + statusCode: 400, + contentType: "application/json", + content: `Some required body parameters were missing. Required parameters: ${list.join(", ")}` + }) + ] +} + module.exports.V = V module.exports.presetLoad = presetLoad module.exports.presetURLParamsBody = presetURLParamsBody +module.exports.presetEnsureParams = presetEnsureParams