Merge branch 'backup' into 'main'

(feature)(profile) cards backup

See merge request PronounsPage/PronounsPage!375
This commit is contained in:
Andrea Vos 2023-10-10 20:49:42 +00:00
commit 84a798e497
10 changed files with 422 additions and 129 deletions

View File

@ -173,7 +173,9 @@
<CircleMentions/>
<section>
<CardsBackup v-if="!$user().bannedReason"/>
<section class="mt-5">
<a href="#" class="badge bg-light text-dark border" @click.prevent="logout">
<Icon v="sign-out"/>
<T>user.logout</T>
@ -406,7 +408,7 @@
this.$setToken(response.token);
}
}
},
}
</script>

View File

@ -0,0 +1,60 @@
<template>
<section>
<h3 class="h4"><T>profile.backup.header</T></h3>
<ul class="list-inline">
<li class="list-inline-item">
<a :href="exportLink"
:class="['btn btn-outline-primary', exportStarted ? 'disabled' : '']" :aria-disabled="exportStarted"
@click="startExport">
<Icon v="cloud-download"/>
<T>profile.backup.export.action</T>
</a>
</li>
<li class="list-inline-item">
<FileUploader :url="importLink" mime="application/gzip"
classes="btn btn-outline-primary"
@uploaded="importDone">
<Icon v="cloud-upload"/>
<T>profile.backup.import.action</T>
</FileUploader>
</li>
</ul>
<div class="alert alert-success" v-if="exportStarted">
<Icon v="check-circle"/>
<T>profile.backup.export.success</T>
</div>
<div class="alert alert-success" v-if="importSuccessful">
<Icon v="check-circle"/>
<T>profile.backup.import.success</T>
</div>
</section>
</template>
<script>
export default {
data() {
return {
exportStarted: false,
importSuccessful: false,
}
},
computed: {
exportLink() {
return `${process.env.BASE_URL}/api/profile/export`;
},
importLink() {
return `${process.env.BASE_URL}/api/profile/import`;
},
},
methods: {
startExport() {
this.exportStarted = true;
setTimeout(() => this.exportStarted = false, 5000);
},
importDone() {
this.importSuccessful = true;
setTimeout(() => window.location.reload(), 3000);
},
}
}
</script>

View File

@ -0,0 +1,98 @@
<template>
<div
:class="['uploader-container', form ? 'form-control p-2' : classes, drag ? 'drag' : '']"
@dragover="drag=true" @dragleave="drag=false"
>
<input type="file"
:name="name + (multiple ? '[]' : '')"
:multiple="multiple"
:disabled="uploading"
@change="filesChange($event.target.name, $event.target.files)"
:accept="mime"/>
<p v-if="errorMessage" class="text-danger mb-0">
<Icon v="exclamation-circle"/>
<T>{{errorMessage}}</T>
</p>
<p v-else-if="uploading" class="mb-0">
<Spinner/>
</p>
<p v-else class="mb-0">
<slot>
<Icon v="upload"/>
<T>images.upload.instructionShort</T>
</slot>
</p>
</div>
</template>
<script>
export default {
props: {
url: {required: true},
multiple: {type: Boolean},
mime: {'default': '*/*'},
name: {'default': 'files'},
form: {type: Boolean},
classes: {'default': 'btn btn-outline-primary btn-sm'},
},
data() {
return {
uploading: false,
drag: false,
errorMessage: '',
}
},
methods: {
async filesChange(fieldName, fileList) {
if (!fileList.length) {
return;
}
this.drag = false;
const formData = new FormData();
for (let file of fileList) {
formData.append(fieldName, file, file.name);
}
await this.save(formData);
},
async save(formData) {
this.uploading = true;
this.errorMessage = '';
try {
const ids = await this.$axios.$post(this.url, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
this.$emit('uploaded', ids);
} catch (e) {
this.errorMessage = e?.response?.data?.error || 'error.invalidImage';
}
this.uploading = false;
},
},
}
</script>
<style lang="scss" scoped>
@import "../assets/style";
.uploader-container {
position: relative;
cursor: pointer;
&.form-control {
&:hover, &.drag {
background: lighten($primary, 50%);
}
}
}
input[type="file"] {
opacity: 0;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
</style>

View File

@ -1,26 +1,10 @@
<template>
<div
:class="['uploader-container', form ? 'form-control p-2' : 'btn btn-outline-primary btn-sm', drag ? 'drag' : '']"
@dragover="drag=true" @dragleave="drag=false"
<FileUploader :url="'/images/upload?sizes=' + sizes" :multiple="multiple" mime="image/*" :name="name" :form="form"
@uploaded="uploaded"
>
<input type="file"
:name="name + (multiple ? '[]' : '')"
:multiple="multiple"
:disabled="uploading"
@change="filesChange($event.target.name, $event.target.files)"
accept="image/*">
<p v-if="errorMessage" class="text-danger mb-0">
<Icon v="exclamation-circle"/>
<T>{{errorMessage}}</T>
</p>
<p v-else-if="uploading" class="mb-0">
<Spinner/>
</p>
<p v-else class="mb-0">
<Icon v="upload"/>
<T>images.upload.instruction{{small ? 'Short' : ''}}</T>
</p>
</div>
<Icon v="upload"/>
<T>images.upload.instruction{{small ? 'Short' : ''}}</T>
</FileUploader>
</template>
<script>
@ -32,64 +16,10 @@
small: {type: Boolean},
sizes: {'default': 'all'},
},
data() {
return {
uploading: false,
drag: false,
errorMessage: '',
}
},
methods: {
async filesChange(fieldName, fileList) {
if (!fileList.length) {
return;
}
this.drag = false;
const formData = new FormData();
for (let file of fileList) {
formData.append(fieldName, file, file.name);
}
await this.save(formData);
},
async save(formData) {
this.uploading = true;
this.errorMessage = '';
try {
const ids = await this.$axios.$post('/images/upload?sizes=' + this.sizes, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
this.$emit('uploaded', ids);
} catch (e) {
this.errorMessage = e?.response?.data?.error || 'error.invalidImage';
}
this.uploading = false;
},
},
}
</script>
<style lang="scss" scoped>
@import "../assets/style";
.uploader-container {
position: relative;
cursor: pointer;
&.form-control {
&:hover, &.drag {
background: lighten($primary, 50%);
uploaded(ids) {
this.$emit('uploaded', ids);
}
}
}
input[type="file"] {
opacity: 0;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
</style>
</script>

View File

@ -797,6 +797,16 @@ profile:
local: 'Link to this language version'
atAlternative: 'Use the /u/ notation'
pronouns: 'Include pronouns'
backup:
header: 'Cards backup'
export:
action: 'Generate backup'
success: 'A backup file is being generated, download should begin shortly'
import:
action: 'Restore backup'
success: 'Your backup was successfully restored! The page will be refreshed now.'
error:
signature: 'Invalid signature, can''t verify file integrity'
share: 'Share'

View File

@ -966,6 +966,16 @@ profile:
local: 'Link to this language version'
atAlternative: 'Use the /u/ notation'
pronouns: 'Include pronouns'
backup:
header: 'Cards backup'
export:
action: 'Generate backup'
success: 'A backup file is being generated, download should begin shortly'
import:
action: 'Restore backup'
success: 'Your backup was successfully restored! The page will be refreshed now.'
error:
signature: 'Invalid signature, can''t verify file integrity'
share: 'Share'

View File

@ -130,4 +130,10 @@ module.exports = [
'calendar.onlyFirstDays',
'calendar.start',
'calendar.events.nonmonogamy_visibility_day',
'profile.backup.header',
'profile.backup.export.action',
'profile.backup.export.success',
'profile.backup.import.action',
'profile.backup.import.success',
'profile.backup.error.signature',
];

View File

@ -1513,6 +1513,16 @@ profile:
local: 'Podlinkuj do tej wersji językowej'
atAlternative: 'Użyj notacji z /u/'
pronouns: 'Zamieść zaimki bezpośrednio w linku'
backup:
header: 'Kopia zapasowa wizytówek'
export:
action: 'Ściągnij kopię'
success: 'Plik kopii zapasowej jest generowany, zaraz zacznie się ściąganie pliku'
import:
action: 'Przywróć kopię'
success: 'Kopia zapasowa została przywrócona! Strona zostanie teraz odświeżona.'
error:
signature: 'Nieprawidłowa sygnatura pliku, nie możemy potwierdzić jego autentyczności'
census:
header: 'Spis'

View File

@ -13,6 +13,11 @@ import { colours, styles } from '../../src/styling';
import {normaliseUrl} from "../../src/links";
import allLocales from '../../locale/locales';
import auditLog from '../audit';
import crypto from '../../src/crypto';
import awsConfig from '../aws';
import S3 from 'aws-sdk/clients/s3';
import zlib from 'zlib';
import multer from "multer";
const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // https://stackoverflow.com/a/6969486/3297012
const normalise = s => decodeURIComponent(s.trim().toLowerCase());
@ -522,6 +527,63 @@ const cleanupOpinions = (opinions) => {
return cleanOpinions;
}
const saveProfile = async (req, locale, {
opinions, names, pronouns, description, birthday, timezone, links, flags, customFlags, words, sensitive,
teamName, footerName, footerAreas, credentials, credentialsLevel, credentialsName,
}) => {
// TODO just make it a transaction...
const ids = (await req.db.all(SQL`SELECT * FROM profiles WHERE userId = ${req.user.id} AND locale = ${locale}`)).map(row => row.id);
let profileId;
if (ids.length) {
profileId = ids[0];
await req.db.get(SQL`UPDATE profiles
SET
opinions = ${JSON.stringify(opinions)},
names = ${JSON.stringify(names)},
pronouns = ${JSON.stringify(pronouns)},
description = ${description},
birthday = ${birthday},
timezone = ${JSON.stringify(timezone)},
links = ${JSON.stringify(links)},
flags = ${JSON.stringify(flags)},
customFlags = ${JSON.stringify(customFlags)},
words = ${JSON.stringify(words)},
sensitive = ${JSON.stringify(sensitive)},
teamName = ${req.isGranted() ? teamName || null : ''},
footerName = ${req.isGranted() ? footerName || null : ''},
footerAreas = ${req.isGranted() ? footerAreas || null : ''},
credentials = ${req.isGranted() ? credentials || null : null},
credentialsLevel = ${req.isGranted() ? credentialsLevel || null : null},
credentialsName = ${req.isGranted() ? credentialsName || null : null},
card = NULL,
cardDark = NULL
WHERE id = ${profileId}
`);
} else {
profileId = ulid();
await req.db.get(SQL`INSERT INTO profiles (id, userId, locale, opinions, names, pronouns, description, birthday, timezone, links, flags, customFlags, words, sensitive, active, teamName, footerName, footerAreas)
VALUES (${profileId}, ${req.user.id}, ${locale},
${JSON.stringify(opinions)},
${JSON.stringify(names)},
${JSON.stringify(pronouns)},
${description},
${birthday},
${JSON.stringify(timezone)},
${JSON.stringify(links)},
${JSON.stringify(flags)},
${JSON.stringify(customFlags)},
${JSON.stringify(words)},
${JSON.stringify(sensitive)},
1,
${req.isGranted() ? teamName || null : ''},
${req.isGranted() ? footerName || null : ''},
${req.isGranted() ? footerAreas || null : ''}
)`);
}
return profileId;
}
router.post('/profile/save', handleErrorAsync(async (req, res) => {
if (!req.user) {
return res.status(401).json({error: 'Unauthorised'});
@ -578,55 +640,17 @@ router.post('/profile/save', handleErrorAsync(async (req, res) => {
loc: req.body.timezone.loc,
} : null;
// TODO just make it a transaction...
const ids = (await req.db.all(SQL`SELECT * FROM profiles WHERE userId = ${req.user.id} AND locale = ${global.config.locale}`)).map(row => row.id);
let profileId;
if (ids.length) {
profileId = ids[0];
await req.db.get(SQL`UPDATE profiles
SET
opinions = ${JSON.stringify(opinions)},
names = ${JSON.stringify(names)},
pronouns = ${JSON.stringify(pronouns)},
description = ${description},
birthday = ${birthday},
timezone = ${JSON.stringify(timezone)},
links = ${JSON.stringify(links)},
flags = ${JSON.stringify(req.body.flags)},
customFlags = ${JSON.stringify(customFlags)},
words = ${JSON.stringify(words)},
sensitive = ${JSON.stringify(sensitive)},
teamName = ${req.isGranted() ? req.body.teamName || null : ''},
footerName = ${req.isGranted() ? req.body.footerName || null : ''},
footerAreas = ${req.isGranted() ? req.body.footerAreas.join(',') || null : ''},
credentials = ${req.isGranted() ? req.body.credentials.join('|') || null : null},
credentialsLevel = ${req.isGranted() ? req.body.credentialsLevel || null : null},
credentialsName = ${req.isGranted() ? req.body.credentialsName || null : null},
card = NULL,
cardDark = NULL
WHERE id = ${profileId}
`);
} else {
profileId = ulid();
await req.db.get(SQL`INSERT INTO profiles (id, userId, locale, opinions, names, pronouns, description, birthday, timezone, links, flags, customFlags, words, sensitive, active, teamName, footerName, footerAreas)
VALUES (${profileId}, ${req.user.id}, ${global.config.locale},
${JSON.stringify(opinions)},
${JSON.stringify(names)},
${JSON.stringify(pronouns)},
${description},
${birthday},
${JSON.stringify(timezone)},
${JSON.stringify(links)},
${JSON.stringify(req.body.flags)},
${JSON.stringify(customFlags)},
${JSON.stringify(words)},
${JSON.stringify(sensitive)},
1,
${req.isGranted() ? req.body.teamName || null : ''},
${req.isGranted() ? req.body.footerName || null : ''},
${req.isGranted() ? req.body.footerAreas.join(',') || null : ''}
)`);
}
const profileId = await saveProfile(req, global.config.locale, {
opinions, names, pronouns, description, birthday, timezone, links,
flags: req.body.flags,
customFlags, words, sensitive,
teamName: req.body.teamName,
footerName: req.body.footerName,
footerAreas: (req.body.footerAreas || []).join(','),
credentials: (req.body.credentials || []).join('|'),
credentialsLevel: req.body.credentialsLevel,
credentialsName: req.body.credentialsName,
});
await req.db.get(SQL`DELETE FROM user_connections WHERE from_profileId = ${profileId}`);
const usernameIdMap = await usernamesToIds(req.db, req.body.circle.map(r => r.username));
@ -832,4 +856,124 @@ router.post('/profile/remove-self-circle/:username', handleErrorAsync(async (req
return res.json('OK');
}));
router.get('/profile/export', handleErrorAsync(async (req, res) => {
if (!req.user || req.user.bannedReason) {
return res.status(401).json({error: 'Unauthorised'});
}
const profiles = {};
const customFlagIds = new Set();
for (let profile of await req.db.all(SQL`SELECT * FROM profiles WHERE userId = ${req.user.id}`)) {
const exportProfile = {
names: JSON.parse(profile['names']),
pronouns: JSON.parse(profile['pronouns']),
description: profile['description'],
birthday: profile['birthday'],
links: JSON.parse(profile['links']),
flags: JSON.parse(profile['flags']),
words: JSON.parse(profile['words']),
customFlags: JSON.parse(profile['customFlags']),
opinions: JSON.parse(profile['opinions']),
timezone: JSON.parse(profile['timezone']),
sensitive: JSON.parse(profile['sensitive']),
teamName: profile['teamName'],
footerName: profile['footerName'],
footerAreas: profile['footerAreas'],
credentials: profile['credentials'],
credentialsLevel: profile['credentialsLevel'],
credentialsName: profile['credentialsName'],
};
for (let customFlag of exportProfile.customFlags) {
customFlagIds.add(customFlag['value']);
}
profiles[profile['locale']] = exportProfile;
}
const s3 = new S3(awsConfig);
const images = {};
for (let id of customFlagIds) {
try {
const data = await s3.getObject({
Key: `images/${id}-flag.png`,
}).promise();
images[id] = {
flag: data.Body.toString('base64'),
}
} catch (error) {
console.error('Error fetching object from S3:', error);
}
}
await auditLog(req, 'profile/exported', {
profiles,
});
const payload = Buffer.from(JSON.stringify({
version: 1,
profiles,
images,
})).toString('base64');
const signature = crypto.sign(payload);
res.setHeader('Content-disposition', `attachment; filename=pronounspage-${req.user.username}-${+new Date}.card.gz`);
res.setHeader('Content-type', 'application/gzip');
res.end(
zlib.gzipSync(
payload + '\n' +
signature
)
);
}));
router.post('/profile/import', multer({limits: {fileSize: 10 * 1024 * 1024}}).any('files[]', 1), handleErrorAsync(async (req, res) => {
if (!req.user) {
return res.status(401).json({error: 'Unauthorised'});
}
if (req.files.length !== 1) {
return res.status(401).json({error: 'One file expected'});
}
const contentParts = zlib.gunzipSync(req.files[0].buffer).toString('utf-8').split('\n');
if (contentParts.length !== 2) {
return res.status(401).json({error: 'profile.backup.error.signature'});
}
const [payload, signature] = contentParts;
if (!crypto.validate(payload, signature)) {
return res.status(400).json({error: 'profile.backup.error.signature'});
}
const {version, profiles, images} = JSON.parse(Buffer.from(payload, 'base64').toString('utf-8'));
const s3 = new S3(awsConfig);
for (let [id, sizes] of Object.entries(images)) {
for (let [size, content] of Object.entries(sizes)) {
try {
const data = await s3.headObject({
Key: `images/${id}-${size}.png`,
}).promise();
continue;
} catch (error) {}
await s3.putObject({
Key: `images/${id}-${size}.png`,
Body: Buffer.from(content, 'base64'),
ContentType: 'image/png',
ACL: 'public-read',
}).promise();
}
}
for (let [locale, profile] of Object.entries(profiles)) {
await saveProfile(req, locale, profile);
}
return res.json('OK');
}));
export default router;

23
src/crypto.js Normal file
View File

@ -0,0 +1,23 @@
import crypto from 'crypto';
import fs from 'fs'
class Crypto {
constructor(privateKey, publicKey) {
this.privateKey = crypto.createPrivateKey(fs.readFileSync(privateKey));
this.publicKey = crypto.createPublicKey(fs.readFileSync(publicKey));
}
sign(payload) {
const sign = crypto.createSign('SHA256');
sign.update(payload);
return sign.sign(this.privateKey, 'hex');
}
validate(payload, signature) {
const verify = crypto.createVerify('SHA256');
verify.update(payload);
return verify.verify(this.publicKey, signature, 'hex');
}
}
export default new Crypto(__dirname + '/../keys/private.pem', __dirname + '/../keys/public.pem');