Allow data syncing and deletion

This commit is contained in:
Cadence Ember 2020-12-29 16:21:48 +13:00
parent e0bc0d2e81
commit 2faaa2e18b
No known key found for this signature in database
GPG Key ID: BC1C2C61CF521B17
10 changed files with 138 additions and 28 deletions

View File

@ -1,7 +1,7 @@
const {redirect} = require("pinski/plugins") const {redirect} = require("pinski/plugins")
const db = require("../utils/db") const db = require("../utils/db")
const constants = require("../utils/constants") const constants = require("../utils/constants")
const {getUser} = require("../utils/getuser") const {getUser, setToken} = require("../utils/getuser")
const validate = require("../utils/validate") const validate = require("../utils/validate")
const V = validate.V const V = validate.V
const {fetchChannel} = require("../utils/youtube") const {fetchChannel} = require("../utils/youtube")
@ -54,5 +54,45 @@ module.exports = [
}) })
.go() .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"
}
}
}
} }
] ]

View File

@ -10,7 +10,7 @@ module.exports = [
route: "/settings", methods: ["GET"], code: async ({req}) => { route: "/settings", methods: ["GET"], code: async ({req}) => {
const user = getUser(req) const user = getUser(req)
const settings = user.getSettings() const settings = user.getSettings()
return render(200, "pug/settings.pug", {constants, settings}) return render(200, "pug/settings.pug", {constants, user, settings})
} }
}, },
{ {

View File

@ -31,7 +31,6 @@ module.exports = [
videos = db.prepare(`SELECT * FROM Videos WHERE authorId IN (${template}) ORDER BY published DESC LIMIT 60`).all(subscriptions) videos = db.prepare(`SELECT * FROM Videos WHERE authorId IN (${template}) ORDER BY published DESC LIMIT 60`).all(subscriptions)
.map(video => { .map(video => {
video.publishedText = timeToPastText(video.published * 1000) video.publishedText = timeToPastText(video.published * 1000)
console.log(watchedVideos, video.videoId)
video.watched = watchedVideos.includes(video.videoId) video.watched = watchedVideos.includes(video.videoId)
return video return video
}) })

View File

@ -49,3 +49,32 @@ block content
.save-settings .save-settings
button.border-look Save 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

View File

@ -26,6 +26,7 @@ block content
- const notLoaded = channels.length - refreshed.count - const notLoaded = channels.length - refreshed.count
if notLoaded if notLoaded
div #{notLoaded} subscriptions have not been refreshed at all 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 if settings.save_history
input(type="checkbox" id="watched-videos-display") input(type="checkbox" id="watched-videos-display")

View File

@ -79,3 +79,25 @@ fieldset
&.checkbox:not(:disabled) + .pill &.checkbox:not(:disabled) + .pill
@include acts-like-button @include acts-like-button
cursor: pointer 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

View File

@ -1,3 +1,6 @@
@use "forms.sass" as forms
@use "colors.sass" as c
.settings-page .settings-page
padding: 40px 20px 20px padding: 40px 20px 20px
max-width: 600px max-width: 600px
@ -9,3 +12,18 @@
.border-look .border-look
font-size: 22px font-size: 22px
padding: 7px 16px 8px 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

View File

@ -1,5 +1,6 @@
@use "colors.sass" as c @use "colors.sass" as c
@use "video-list-item.sass" as * @use "video-list-item.sass" as *
@use "forms.sass" as forms
.subscriptions-page .subscriptions-page
padding: 40px 20px 20px padding: 40px 20px 20px
@ -34,27 +35,7 @@
font-size: 22px font-size: 22px
color: c.$fg-main color: c.$fg-main
@include forms.checkbox-hider("watched-videos-display")
#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
#watched-videos-display:checked ~ .video-list-item--watched #watched-videos-display:checked ~ .video-list-item--watched
display: none display: none

View File

@ -9,10 +9,7 @@ function getToken(req, responseHeaders) {
let token = cookie.token let token = cookie.token
if (!token) { if (!token) {
if (responseHeaders) { // we should create a token if (responseHeaders) { // we should create a token
const setCookie = responseHeaders["set-cookie"] || [] setToken(responseHeaders)
token = crypto.randomBytes(18).toString("base64").replace(/\W/g, "_")
setCookie.push(`token=${token}; Path=/; Max-Age=2147483648; HttpOnly; SameSite=Lax`)
responseHeaders["set-cookie"] = setCookie
} else { } else {
return null return null
} }
@ -21,6 +18,14 @@ function getToken(req, responseHeaders) {
return token 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 { class User {
constructor(token) { constructor(token) {
this.token = token this.token = token
@ -107,6 +112,7 @@ cleanCSRF()
setInterval(cleanCSRF, constants.caching.csrf_time).unref() setInterval(cleanCSRF, constants.caching.csrf_time).unref()
module.exports.getToken = getToken module.exports.getToken = getToken
module.exports.setToken = setToken
module.exports.generateCSRF = generateCSRF module.exports.generateCSRF = generateCSRF
module.exports.checkCSRF = checkCSRF module.exports.checkCSRF = checkCSRF
module.exports.getUser = getUser module.exports.getUser = getUser

View File

@ -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.V = V
module.exports.presetLoad = presetLoad module.exports.presetLoad = presetLoad
module.exports.presetURLParamsBody = presetURLParamsBody module.exports.presetURLParamsBody = presetURLParamsBody
module.exports.presetEnsureParams = presetEnsureParams