final updates before release

This commit is contained in:
msramalho
2025-02-17 16:21:44 +00:00
parent e45c4726a9
commit ebe7286ce2
18 changed files with 6884 additions and 6285 deletions

View File

@@ -1,96 +1,98 @@
/**
* Import function triggers from their respective submodules:
*
* const {onCall} = require("firebase-functions/v2/https");
* const {onDocumentWritten} = require("firebase-functions/v2/firestore");
*
* See a full list of supported triggers at https://firebase.google.com/docs/functions
*/
//NB: this code has been disabled since the cronjob is now handled by the API
const { onSchedule } = require("firebase-functions/v2/scheduler");
const logger = require("firebase-functions/logger");
// /**
// * Import function triggers from their respective submodules:
// *
// * const {onCall} = require("firebase-functions/v2/https");
// * const {onDocumentWritten} = require("firebase-functions/v2/firestore");
// *
// * See a full list of supported triggers at https://firebase.google.com/docs/functions
// */
// The Firebase Admin SDK to access Firestore.
const { initializeApp } = require("firebase-admin/app");
const { getFirestore } = require("firebase-admin/firestore");
// const { onSchedule } = require("firebase-functions/v2/scheduler");
// const logger = require("firebase-functions/logger");
const { defineSecret } = require('firebase-functions/params');
const API_TOKEN = defineSecret('API_SERVICE_PASSWORD');
const CLIENT_EMAIL = defineSecret('GOOGLE_API_CLIENT_EMAIL');
const PRIVATE_KEY = defineSecret('GOOGLE_API_PRIVATE_KEY');
// // The Firebase Admin SDK to access Firestore.
// const { initializeApp } = require("firebase-admin/app");
// const { getFirestore } = require("firebase-admin/firestore");
const { google } = require('googleapis');
// const { defineSecret } = require('firebase-functions/params');
// const API_TOKEN = defineSecret('API_SERVICE_PASSWORD');
// const CLIENT_EMAIL = defineSecret('GOOGLE_API_CLIENT_EMAIL');
// const PRIVATE_KEY = defineSecret('GOOGLE_API_PRIVATE_KEY');
initializeApp();
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// const { google } = require('googleapis');
String.prototype.hashCode = function () {
// https://stackoverflow.com/a/7616484/6196010
// Generating 1M random strings and applying this function shows it's very balanced for modulo 60
// 0 has double frequency of other numbers, but that's not a problem
var hash = 0,
i, chr;
if (this.length === 0) return hash;
for (i = 0; i < this.length; i++) {
chr = this.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0; // Convert to 32bit integer
}
return hash;
}
// initializeApp();
// const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
//TODO: disable the scheduler
exports.processSheetScheduler = onSchedule(
{ secrets: [API_TOKEN, CLIENT_EMAIL, PRIVATE_KEY], schedule: "* * * * *" },
async (event) => {
// authenticate the service account
const googleAuth = new google.auth.JWT(CLIENT_EMAIL.value(), null, PRIVATE_KEY.value().replace(/\\n/g, '\n'), 'https://www.googleapis.com/auth/spreadsheets');
const sheets = await google.sheets({ version: 'v4', auth: googleAuth });
// String.prototype.hashCode = function () {
// // https://stackoverflow.com/a/7616484/6196010
// // Generating 1M random strings and applying this function shows it's very balanced for modulo 60
// // 0 has double frequency of other numbers, but that's not a problem
// var hash = 0,
// i, chr;
// if (this.length === 0) return hash;
// for (i = 0; i < this.length; i++) {
// chr = this.charCodeAt(i);
// hash = ((hash << 5) - hash) + chr;
// hash |= 0; // Convert to 32bit integer
// }
// return hash;
// }
// get all documents from firestore sheets collection
const db = getFirestore();
// //TODO: disable the scheduler
// exports.processSheetScheduler = onSchedule(
// { secrets: [API_TOKEN, CLIENT_EMAIL, PRIVATE_KEY], schedule: "* * * * *" },
// async (event) => {
// // authenticate the service account
// const googleAuth = new google.auth.JWT(CLIENT_EMAIL.value(), null, PRIVATE_KEY.value().replace(/\\n/g, '\n'), 'https://www.googleapis.com/auth/spreadsheets');
// const sheets = await google.sheets({ version: 'v4', auth: googleAuth });
// each sheet runs once per hour, so we hash the sheet id and only process it if the hash % 60 matches the cron minute
const querySnapshot = await db.collection("sheets").get();
const eventDate = new Date(Date.parse(event.scheduleTime));
querySnapshot.forEach(async (doc) => {
const hashToSixty = Math.abs(doc.id.hashCode() % 60);
if (hashToSixty != eventDate.getMinutes()) {
return;
}
logger.log(`processing document ${doc.id}, its hash % 60 (${hashToSixty}) matches the cron minute (${eventDate.getMinutes()})`);
// // get all documents from firestore sheets collection
// const db = getFirestore();
try {
await sheets.spreadsheets.get({ spreadsheetId: doc.data().sheetId });
} catch (e) {
if (e.status == 404) {
await doc.ref.delete();
logger.log(`document ${doc.data().sheetId} not found, deleted`);
return;
}
}
// // each sheet runs once per hour, so we hash the sheet id and only process it if the hash % 60 matches the cron minute
// const querySnapshot = await db.collection("sheets").get();
// const eventDate = new Date(Date.parse(event.scheduleTime));
// querySnapshot.forEach(async (doc) => {
// const hashToSixty = Math.abs(doc.id.hashCode() % 60);
// if (hashToSixty != eventDate.getMinutes()) {
// return;
// }
// logger.log(`processing document ${doc.id}, its hash % 60 (${hashToSixty}) matches the cron minute (${eventDate.getMinutes()})`);
// send POST request with sheetID to trigger sheet processing
const url = "https://auto-archiver-api.bellingcat.com/sheet_service";
const data = {
sheet_id: doc.data().sheetId,
author_id: doc.data().email ?? doc.data().uid,
tags: ["setup-tool"]
};
const options = {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_TOKEN.value()}`,
},
body: JSON.stringify(data),
};
// try {
// await sheets.spreadsheets.get({ spreadsheetId: doc.data().sheetId });
// } catch (e) {
// if (e.status == 404) {
// await doc.ref.delete();
// logger.log(`document ${doc.data().sheetId} not found, deleted`);
// return;
// }
// }
const response = await fetch(url, options);
// // send POST request with sheetID to trigger sheet processing
// const url = "https://auto-archiver-api.bellingcat.com/sheet_service";
// const data = {
// sheet_id: doc.data().sheetId,
// author_id: doc.data().email ?? doc.data().uid,
// tags: ["setup-tool"]
// };
// const options = {
// method: "POST",
// headers: {
// "Content-Type": "application/json",
// Authorization: `Bearer ${API_TOKEN.value()}`,
// },
// body: JSON.stringify(data),
// };
await doc.ref.update({ lastArchived: Date.now() });
// const response = await fetch(url, options);
await sleep(100);
});
}
);
// await doc.ref.update({ lastArchived: Date.now() });
// await sleep(100);
// });
// }
// );

View File

@@ -4,7 +4,8 @@
"private": true,
"scripts": {
"serve": "vue-cli-service serve --port 8081 --skip-plugins @vue/cli-plugin-eslint",
"build": "vue-cli-service build",
"build": "VUE_APP_API_ENDPOINT=https://auto-archiver-api.bellingcat.com vue-cli-service build --skip-plugins @vue/cli-plugin-eslint",
"preview": "serve -s dist -l 8081",
"lint": "vue-cli-service lint"
},
"dependencies": {
@@ -35,6 +36,7 @@
"firebase-admin": "^11.11.1",
"firebase-functions": "^4.5.0",
"prettier": "^2.4.1",
"serve": "^14.2.4",
"vue-template-compiler": "^2.6.14"
}
}

View File

@@ -13,17 +13,25 @@
</div>
</v-col>
<v-col cols="12">
You can deploy your own version of this tool by hosting the <a href="https://github.com/bellingcat/auto-archiver-api">API</a> and the <a href="https://github.com/bellingcat/auto-archiver-api">UI</a>.
<br/>
This tool uses <a href="https://github.com/bellingcat/auto-archiver">Bellingcat's Auto Archiver</a> under the hood to archive online content.
<br/>
For more information about it see <a
href="https://www.bellingcat.com/resources/2022/09/22/preserve-vital-online-content-with-bellingcats-auto-archiver-tool/">associated
article</a>.
You can deploy your own version of this tool by hosting the
<a href="https://github.com/bellingcat/auto-archiver-api">API</a> and
the
<a href="https://github.com/bellingcat/auto-archiver-setup-tool">UI</a
>.
<br />
This tool uses
<a href="https://github.com/bellingcat/auto-archiver"
>Bellingcat's Auto Archiver</a
>
under the hood to archive online content.
<br />
For more information about it see
<a
href="https://www.bellingcat.com/resources/2022/09/22/preserve-vital-online-content-with-bellingcats-auto-archiver-tool/"
>associated article</a
>.
</v-col>
</v-row>
</v-footer>
</v-app>
</template>
@@ -96,14 +104,14 @@ p {
}
code {
background-color: rgba(0, 0, 0, .1);
padding: .2em .4em;
background-color: rgba(0, 0, 0, 0.1);
padding: 0.2em 0.4em;
}
.v-card .v-card-text {
font-size: 1.1rem;
font-weight: 400;
line-height: 1.7rem;
letter-spacing: .0092em;
letter-spacing: 0.0092em;
}
</style>

View File

@@ -1,210 +1,320 @@
<template>
<v-row class="my-2">
<v-col cols="12" sm="12" class="ma-0 pb-0">
<v-text-field label="Google Sheets document name" v-model="sheetName" required
density="comfortable"></v-text-field>
</v-col>
<v-col v-if="!actionIsCreate" cols="12" sm="12" class="ma-0 py-0">
<v-text-field label="Existing Google Sheet URL/ID" v-model="sheetUrlId" required density="comfortable">
</v-text-field>
</v-col>
<v-col cols="6" sm="6" class="ma-0 py-0">
<v-select v-model="group" label="Group" :items="availableGroups" required density="comfortable"></v-select>
</v-col>
<v-col cols="6" sm="6" class="ma-0 py-0">
<v-select v-model="frequency" label="Archive frequency" :items="availableFrequencies"
:disabled="!availableFrequencies?.length" required density="comfortable"></v-select>
</v-col>
<v-col cols="12" sm="12" class="text-right pt-0">
<small v-if="spreadsheetId">Detected Spreadsheet id: <code>{{ spreadsheetId }}</code></small>
</v-col>
<v-col cols="12" sm="12" class="text-right pt-0">
<v-progress-circular color="green" indeterminate class="mx-6" v-if="loading"></v-progress-circular>
<v-btn v-if="newSheetId" :href="`https://docs.google.com/spreadsheets/d/${newSheetId}`"
append-icon="mdi-open-in-new" :title="newSheetId" target="_blank" color="success" class="mx-2"
size="large">
open sheet
</v-btn>
<v-btn v-if="actionIsCreate" color="teal" size="large" :disabled="!requiredData"
@click="createSheet">Create</v-btn>
<v-btn v-if="!actionIsCreate" color="teal" size="large" :disabled="!requiredDataExisting"
@click="addExistingSheet">Add Existing Sheet</v-btn>
</v-col>
<v-col cols="12" sm="12" class="pt-0" v-if="group != 'please select'">
<span>
<span class="text-medium-emphasis mb-1">
<strong>{{ group }}</strong>: {{ groupPermissions.description }}
</span>
<ul>
<li>
Active sheets:
<strong>{{ groupUsage.total_sheets || 0 }}</strong> out of
<strong>{{ displayPermissionValue(groupPermissions?.max_sheets, "") }}</strong>
<v-chip v-if="maxedOutGroupQuota" label class="ml-2" color="red" density="comfortable"
size="small">maxed out</v-chip>
</li>
<li>Monthly URLs: <strong>{{ groupUsage.monthly_urls || 0 }}</strong> out of <strong>{{
displayPermissionValue(groupPermissions?.max_monthly_urls, " URLs") }}</strong></li>
<li>Monthly MBs: <strong>{{ groupUsage.monthly_mbs || 0 }}</strong> out of <strong>{{
displayPermissionValue(groupPermissions?.max_monthly_mbs, " MBs") }}</strong></li>
<li>We will store archives for: <strong>{{
displayPermissionValue(groupPermissions?.max_archive_lifespan_months, " months") }}</strong>
</li>
<li>You <strong>{{ groupPermissions?.manually_trigger_sheet ? "can" : "cannot" }}</strong> manually
trigger sheets in this group. </li>
</ul>
<p v-if="!actionIsCreate" class="text-medium-emphasis mt-2">
<strong>NOTE:</strong> invite <a :href="`mailto:${groupPermissions?.service_account_email}`">{{
groupPermissions?.service_account_email }}</a> to the sheet, see further instructions below.
</p>
</span>
</v-col>
<v-row class="my-2">
<v-col cols="12" sm="12" class="ma-0 pb-0">
<v-text-field
label="Google Sheets document name"
v-model="sheetName"
required
density="comfortable"
></v-text-field>
</v-col>
<v-col v-if="!actionIsCreate" cols="12" sm="12" class="ma-0 py-0">
<v-text-field
label="Existing Google Sheet URL/ID"
v-model="sheetUrlId"
required
density="comfortable"
>
</v-text-field>
</v-col>
<v-col cols="6" sm="6" class="ma-0 py-0">
<v-select
v-model="group"
label="Group"
:items="availableGroups"
required
density="comfortable"
></v-select>
</v-col>
<v-col cols="6" sm="6" class="ma-0 py-0">
<v-select
v-model="frequency"
label="Archive frequency"
:items="availableFrequencies"
:disabled="!availableFrequencies?.length"
required
density="comfortable"
></v-select>
</v-col>
<v-col cols="12" sm="12" class="text-right pt-0">
<small v-if="spreadsheetId"
>Detected Spreadsheet id: <code>{{ spreadsheetId }}</code></small
>
</v-col>
<v-col cols="12" sm="12" class="text-right pt-0">
<v-progress-circular
color="green"
indeterminate
class="mx-6"
v-if="loading"
></v-progress-circular>
<v-btn
v-if="newSheetId"
:href="`https://docs.google.com/spreadsheets/d/${newSheetId}`"
append-icon="mdi-open-in-new"
:title="newSheetId"
target="_blank"
color="success"
class="mx-2"
size="large"
>
open sheet
</v-btn>
<v-btn
v-if="actionIsCreate"
color="teal"
size="large"
:disabled="!requiredData"
@click="createSheet"
>Create</v-btn
>
<v-btn
v-if="!actionIsCreate"
color="teal"
size="large"
:disabled="!requiredDataExisting"
@click="addExistingSheet"
>Add Existing Sheet</v-btn
>
</v-col>
<v-col cols="12" sm="12" class="pt-0" v-if="group != 'please select'">
<span>
<span class="text-medium-emphasis mb-1">
<strong>{{ group }}</strong
>: {{ groupPermissions.description }}
</span>
<ul>
<li>
Active sheets:
<strong>{{ groupUsage.total_sheets || 0 }}</strong> out of
<strong>{{
displayPermissionValue(groupPermissions?.max_sheets, "")
}}</strong>
<v-chip
v-if="maxedOutGroupQuota"
label
class="ml-2"
color="red"
density="comfortable"
size="small"
>maxed out</v-chip
>
</li>
<li>
Monthly URLs:
<strong>{{ groupUsage.monthly_urls || 0 }}</strong> out of
<strong>{{
displayPermissionValue(
groupPermissions?.max_monthly_urls,
" URLs"
)
}}</strong>
</li>
<li>
Monthly MBs: <strong>{{ groupUsage.monthly_mbs || 0 }}</strong> out
of
<strong>{{
displayPermissionValue(groupPermissions?.max_monthly_mbs, " MBs")
}}</strong>
</li>
<li>
We will store archives for:
<strong>{{
displayPermissionValue(
groupPermissions?.max_archive_lifespan_months,
" months"
)
}}</strong>
</li>
<li>
You
<strong>{{
groupPermissions?.manually_trigger_sheet ? "can" : "cannot"
}}</strong>
manually trigger sheets in this group.
</li>
</ul>
<p v-if="!actionIsCreate" class="text-medium-emphasis mt-2">
<strong>NOTE:</strong> invite
<a :href="`mailto:${groupPermissions?.service_account_email}`">{{
groupPermissions?.service_account_email
}}</a>
to the sheet, see further instructions below.
</p>
</span>
</v-col>
</v-row>
</v-row>
<SnackBar :message="snackbarMessage" :show="snackbar" :color="snackbarColor" @update:show="snackbar = $event" />
<SnackBar
:message="snackbarMessage"
:show="snackbar"
:color="snackbarColor"
@update:show="snackbar = $event"
/>
</template>
<script>
import SnackBar from "@/components/SnackBar.vue";
export default {
name: "AddSheet",
components: {
SnackBar,
},
props: {
actionIsCreate: {
type: Boolean,
required: true,
default: true,
}
},
data() {
return {
snackbar: false,
snackbarMessage: "",
snackbarColor: "red",
loading: false,
name: "AddSheet",
components: {
SnackBar,
},
props: {
actionIsCreate: {
type: Boolean,
required: true,
default: true,
},
},
data() {
return {
snackbar: false,
snackbarMessage: "",
snackbarColor: "red",
loading: false,
sheetName: ``.trim(),
sheetUrlId: ``,
sheetName: ``.trim(),
sheetUrlId: ``,
group: "please select",
group: "please select",
frequency: "please select",
frequency: "please select",
newSheetId: "",
};
},
computed: {
user() {
return this.$store.state.user;
},
requiredData() {
return this.sheetName && this.availableGroups?.some(g => g.value === this.group) && this.availableFrequencies?.some(f => f === this.frequency) && !this.maxedOutGroupQuota;
},
requiredDataExisting() {
return this.sheetName && this.spreadsheetId && this.availableGroups?.some(g => g.value === this.group) && this.availableFrequencies?.some(f => f === this.frequency) && !this.maxedOutGroupQuota;
},
availableGroups() {
const permissions = this.$store.state.user?.permissions || {};
return Object.keys(permissions)
.filter(group => group !== "all" && permissions[group].archive_sheet)
.map(g => ({ title: g, value: g }));
},
availableFrequencies() {
return this.$store.state.user?.permissions?.[this.group]?.sheet_frequency || [];
},
groupPermissions() {
return this.$store.state.user?.permissions?.[this.group] || {};
},
groupUsage() {
return this.$store.state.user?.usage?.["groups"]?.[this.group] || {};
},
maxedOutGroupQuota() {
if (this.groupPermissions?.archive_sheet === false) return true;
if (this.groupPermissions.max_sheets === -1) return false;
return this.groupUsage.total_sheets >= this.groupPermissions.max_sheets;
},
spreadsheetId() {
if (
this.sheetUrlId.startsWith("http") &&
this.sheetUrlId.split("/").length >= 6
) {
return this.sheetUrlId.split("/")[5];
}
return this.sheetUrlId;
},
},
watch: {
},
methods: {
showSnackbar(message, color = "red") {
this.snackbarMessage = message;
this.snackbarColor = color;
this.snackbar = true;
},
createSheet() {
if (!this.requiredData) return;
if (this.loading) return;
newSheetId: "",
};
},
computed: {
user() {
return this.$store.state.user;
},
requiredData() {
return (
this.sheetName &&
this.availableGroups?.some((g) => g.value === this.group) &&
this.availableFrequencies?.some((f) => f === this.frequency) &&
!this.maxedOutGroupQuota
);
},
requiredDataExisting() {
return (
this.sheetName &&
this.spreadsheetId &&
this.availableGroups?.some((g) => g.value === this.group) &&
this.availableFrequencies?.some((f) => f === this.frequency) &&
!this.maxedOutGroupQuota
);
},
availableGroups() {
const permissions = this.$store.state.user?.permissions || {};
return Object.keys(permissions)
.filter((group) => group !== "all" && permissions[group].archive_sheet)
.map((g) => ({ title: g, value: g }));
},
availableFrequencies() {
return (
this.$store.state.user?.permissions?.[this.group]?.sheet_frequency || []
);
},
groupPermissions() {
return this.$store.state.user?.permissions?.[this.group] || {};
},
groupUsage() {
return this.$store.state.user?.usage?.["groups"]?.[this.group] || {};
},
maxedOutGroupQuota() {
if (this.groupPermissions?.archive_sheet === false) return true;
if (this.groupPermissions.max_sheets === -1) return false;
return this.groupUsage.total_sheets >= this.groupPermissions.max_sheets;
},
spreadsheetId() {
if (
this.sheetUrlId.startsWith("http") &&
this.sheetUrlId.split("/").length >= 6
) {
return this.sheetUrlId.split("/")[5];
}
return this.sheetUrlId;
},
},
watch: {},
methods: {
showSnackbar(message, color = "red") {
this.snackbarMessage = message;
this.snackbarColor = color;
this.snackbar = true;
},
createSheet() {
if (!this.requiredData) return;
if (this.loading) return;
this.loading = true;
this.newSheetId = "";
this.$store.dispatch("createSheet", { name: this.sheetName, service_account_email: this.groupPermissions.service_account_email }).then((res) => {
this.$store.dispatch("checkUserUsage");
if (!res.success) throw new Error(res.result);
this.newSheetId = res.result;
this.addSheetToAPI(this.newSheetId);
}).catch((error) => {
console.error(error);
this.showSnackbar(`Unable to create sheet: ${error.message}`);
this.loading = false;
});
},
addExistingSheet() {
if (!this.requiredDataExisting) return;
if (this.loading) return;
this.loading = true;
this.addSheetToAPI(this.spreadsheetId);
},
addSheetToAPI(sheetId) {
fetch(`${this.$store.state.API_ENDPOINT}/sheet/create`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.$store.state.access_token}`,
},
body: JSON.stringify({
id: sheetId,
name: this.sheetName,
group_id: this.group,
frequency: this.frequency,
})
}).then(async response => {
const j = await response.json();
if (response.status === 201) {
this.showSnackbar(`Sheet created successfully!`, "green");
this.$store.dispatch("getSheets");
this.$store.dispatch("checkUserUsage");
} else {
throw new Error(JSON.stringify(j));
}
}).catch(error => {
console.error("/sheet/create ", error);
this.showSnackbar(`Unable to save sheet to DB: ${error.message}`);
}).finally(() => {
this.loading = false;
this.sheetName = "";
this.sheetUrlId = "";
this.group = "please select";
});
},
displayPermissionValue(value, extraWord) {
if (value === undefined) { return "not set"; }
return value == -1 ? "no limit" : value + extraWord;
}
},
this.loading = true;
this.newSheetId = "";
this.$store
.dispatch("createSheet", {
name: this.sheetName,
service_account_email: this.groupPermissions.service_account_email,
})
.then((res) => {
this.$store.dispatch("checkUserUsage");
if (!res.success) throw new Error(res.result);
this.newSheetId = res.result;
this.addSheetToAPI(this.newSheetId);
})
.catch((error) => {
console.error(error);
this.showSnackbar(`Unable to create sheet: ${error.message}`);
this.loading = false;
});
},
addExistingSheet() {
if (!this.requiredDataExisting) return;
if (this.loading) return;
this.loading = true;
this.addSheetToAPI(this.spreadsheetId);
},
addSheetToAPI(sheetId) {
fetch(`${this.$store.state.API_ENDPOINT}/sheet/create`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.$store.state.access_token}`,
},
body: JSON.stringify({
id: sheetId,
name: this.sheetName,
group_id: this.group,
frequency: this.frequency,
}),
})
.then(async (response) => {
const j = await response.json();
if (response.status === 201) {
this.showSnackbar(`Sheet created successfully!`, "green");
this.$store.dispatch("getSheets");
this.$store.dispatch("checkUserUsage");
} else {
throw new Error(JSON.stringify(j));
}
})
.catch((error) => {
console.error("/sheet/create ", error);
this.showSnackbar(`Unable to save sheet to DB: ${error.message}`);
})
.finally(() => {
this.loading = false;
this.sheetName = "";
this.sheetUrlId = "";
this.group = "please select";
});
},
displayPermissionValue(value, extraWord) {
if (value === undefined) {
return "not set";
}
return value == -1 ? "no limit" : value + extraWord;
},
},
};
</script>
</script>

View File

@@ -1,9 +1,13 @@
<template>
<v-container class="pane">
<v-card class="pa-0">
<v-tabs v-model="tab" bg-color="teal" grow class="elevation-1">
<v-tab v-for="item in items" :key="item" :text="item" :value="item"></v-tab>
<v-tab
v-for="item in items"
:key="item"
:text="item"
:value="item"
></v-tab>
</v-tabs>
<v-tabs-window v-model="tab" class="elevation-1 rounded">
@@ -16,15 +20,16 @@
<v-expansion-panel-text>
<ol>
<li>Choose a sheet name</li>
<li>Choose a group: this will impact where/how to archive</li>
<li>
Choose a group: this will impact where/how to archive
</li>
<li>Choose a frequency: how often to archive</li>
<li>Press "create" and wait</li>
<li>Sheet will appear in "Your Sheets" below</li>
</ol>
<small>
<b>NB:</b> This new sheet will be shared with the
service account necessary for Bellingcat's archiving
server.
<b>NB:</b> This new sheet will be shared with the service
account necessary for Bellingcat's archiving server.
</small>
</v-expansion-panel-text>
</v-expansion-panel>
@@ -40,18 +45,21 @@
<v-expansion-panel-text>
<ol style="margin-bottom: 1em">
<li>Choose a group to associate with this Google Sheet</li>
<li>Invite the provided email as Editor to your Google Sheet</li>
<li>
Make sure you have the following <b>mandatory</b> column names:
Invite the provided email as Editor to your Google Sheet
</li>
<li>
Make sure you have the following <b>mandatory</b> column
names:
<ul>
<li><code>Link</code> where you will put the URLs</li>
<li>
<code>Archive Status</code> to monitor progress and success
of archiver
<code>Archive Status</code> to monitor progress and
success of archiver
</li>
<li>
<code>Archive location</code> where the link to the archived
content is added
<code>Archive location</code> where the link to the
archived content is added
</li>
</ul>
</li>
@@ -59,19 +67,23 @@
Add any of the following <b>optional</b> column names:
<ul>
<li>
<code>Archive date</code> info on when archiving occurred
<code>Archive date</code> info on when archiving
occurred
</li>
<li>
<code>Thumbnail</code> an image preview from archived media
<code>Thumbnail</code> an image preview from archived
media
</li>
<li>
<code>Upload timestamp</code> online content creation date
<code>Upload timestamp</code> online content creation
date
</li>
<li><code>Upload title</code> title</li>
<li><code>Textual content</code> text content</li>
<li><code>Screenshot</code> link to page screenshot</li>
<li>
<code>Hash</code> content hash (for integrity purposes)
<code>Hash</code> content hash (for integrity
purposes)
</li>
</ul>
</li>
@@ -79,8 +91,8 @@
<li>Paste the Google Sheet URL</li>
<li>Press "enable" to add the Google Sheet to your list</li>
<li>
Manually check archiving is working and re-check the steps above
if it is not
Manually check archiving is working and re-check the steps
above if it is not
</li>
</ol>
</v-expansion-panel-text>
@@ -91,7 +103,6 @@
</v-tabs-window>
</v-card>
</v-container>
</template>
<script>
@@ -100,12 +111,12 @@ import AddSheet from "@/components/AddSheet.vue";
export default {
name: "ArchiveSheet",
components: {
AddSheet
AddSheet,
},
data() {
return {
tab: '',
items: ['Create new Archiver Sheet', 'Add existing Sheet'],
tab: "",
items: ["Create new Archiver Sheet", "Add existing Sheet"],
};
},
computed: {
@@ -114,4 +125,4 @@ export default {
},
},
};
</script>
</script>

View File

@@ -1,139 +1,199 @@
<template>
<v-container class="pane-l mb-10">
<v-card class="pa-3">
<v-card-title class="text-center">
Your <u v-if="items">{{ items.length }}</u> active archiver sheets
</v-card-title>
<v-container class="pane-l mb-10">
<v-card class="pa-3">
<v-data-table
:headers="headers"
item-key="name"
no-data-text="No Active Sheets available"
:items="items"
:loading="loading"
items-per-page="25"
hover
>
<template v-slot:item.actions="{ item: data }">
<v-btn
:disabled="!canArchiveNow(data.group_id) || loading"
color="teal-lighten-1"
size="small"
icon
class="mx-2"
rounded
@click="archiveSheetNow(data.id)"
><v-icon>mdi-archive-outline</v-icon>
<v-card-title class="text-center">
Your <u v-if="items">{{ items.length }}</u> active archiver sheets
</v-card-title>
<v-tooltip activator="parent" location="left"
>Archive Now!</v-tooltip
>
</v-btn>
<v-btn
color="green-lighten-1"
size="small"
icon
class="mx-2"
rounded
:href="`https://docs.google.com/spreadsheets/d/${data.id}`"
:disabled="loading"
target="_blank"
><v-icon>mdi-open-in-new</v-icon>
<v-tooltip activator="parent" location="left"
>Open in new tab</v-tooltip
>
</v-btn>
<v-btn
color="red-lighten-2"
size="small"
icon
class="mx-2"
:disabled="loading"
rounded
@click="removeSheet(data.id)"
><v-icon>mdi-stop</v-icon>
<v-tooltip activator="parent" location="left"
>Stop archiving, does not delete the spreadsheet
itself.</v-tooltip
>
</v-btn>
</template>
<template v-slot:item.name="{ item: data }">
<strong :title="data.id">{{ data.name }}</strong>
</template>
<template v-slot:item.frequency="{ item: data }">
<v-chip
:color="
data.frequency == 'daily' ? 'teal-darken-3' : 'orange-darken-3'
"
class="bg-white"
prepend-icon="mdi-archive-clock-outline"
variant="outlined"
>
{{ data.frequency }}
</v-chip>
</template>
</v-data-table>
</v-card>
</v-container>
<v-data-table :headers="headers" item-key="name" no-data-text="No Active Sheets available" :items="items"
:loading="loading" items-per-page="25" hover>
<template v-slot:item.actions="{ item: data }">
<v-btn :disabled="!canArchiveNow(data.group_id) || loading" color="teal-lighten-1" size="small" icon class="mx-2" rounded
@click="archiveSheetNow(data.id)"><v-icon>mdi-archive-outline</v-icon>
<v-tooltip activator="parent" location="left">Archive Now!</v-tooltip>
</v-btn>
<v-btn color="green-lighten-1" size="small" icon class="mx-2" rounded
:href="`https://docs.google.com/spreadsheets/d/${data.id}`" :disabled="loading"
target="_blank"><v-icon>mdi-open-in-new</v-icon>
<v-tooltip activator="parent" location="left">Open in new tab</v-tooltip>
</v-btn>
<v-btn color="red-lighten-2" size="small" icon class="mx-2" :disabled="loading" rounded
@click="removeSheet(data.id)"><v-icon>mdi-stop</v-icon>
<v-tooltip activator="parent" location="left">Stop archiving, does not delete the spreadsheet
itself.</v-tooltip>
</v-btn>
</template>
<template v-slot:item.name="{ item: data }">
<strong :title="data.id">{{ data.name }}</strong>
</template>
<template v-slot:item.frequency="{ item: data }">
<v-chip :color="data.frequency == 'daily' ? 'teal-darken-3' : 'orange-darken-3'" class="bg-white"
prepend-icon="mdi-archive-clock-outline" variant="outlined">
{{ data.frequency }}
</v-chip>
</template>
</v-data-table>
</v-card>
</v-container>
<SnackBar :message="snackbarMessage" :show="snackbar" :color="snackbarColor" @update:show="snackbar = $event" />
<SnackBar
:message="snackbarMessage"
:show="snackbar"
:color="snackbarColor"
@update:show="snackbar = $event"
/>
</template>
<script>
import SnackBar from "@/components/SnackBar.vue";
export default {
name: "ManageSheets",
components: {
SnackBar,
},
props: {},
data() {
return {
snackbar: false,
snackbarMessage: "",
snackbarColor: "red",
name: "ManageSheets",
components: {
SnackBar,
},
props: {},
data() {
return {
snackbar: false,
snackbarMessage: "",
snackbarColor: "red",
loading: false,
loading: false,
headers: [
{ title: "Name", value: "name", sortable: true },
{ title: "Group", value: "group_id", sortable: true },
{ title: "Archived", value: "frequency", sortable: true },
{ title: "Created", value: "created_at", sortable: true },
{ title: "Last archived URL", value: "last_url_archived_at", sortable: true },
{ title: 'Actions', value: "actions", align: 'center' },
],
};
},
computed: {
user() {
return this.$store.state.user;
},
items() {
return this.$store.state.sheets;
},
},
methods: {
showSnackbar(message, color = "red") {
this.snackbarMessage = message;
this.snackbarColor = color;
this.snackbar = true;
},
canArchiveNow(group_id) {
return this.$store.state.user?.permissions?.[group_id]?.manually_trigger_sheet || false;
},
archiveSheetNow(sheetId) {
this.loading = true;
fetch(`${this.$store.state.API_ENDPOINT}/sheet/${sheetId}/archive`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.$store.state.access_token}`,
}
}).then(async response => {
const res = await response.json();
if (response.status === 201) {
this.showSnackbar(`Sheet ${sheetId} is being archived with task id ${res?.id}!`, "green");
this.$store.dispatch("getSheets");
} else {
throw new Error(JSON.stringify(res));
}
}).catch(error => {
console.error("/sheet/mine ", error);
this.showSnackbar(`Unable to trigger sheet archive: ${error.message}`);
}).finally(() => {
this.loading = false;
});
},
removeSheet(sheetId) {
this.loading = true;
fetch(`${this.$store.state.API_ENDPOINT}/sheet/${sheetId}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.$store.state.access_token}`,
}
}).then(async response => {
const res = await response.json();
if (response.status === 200 && res.deleted) {
this.showSnackbar(`Sheet ${sheetId} has been removed!`, "green");
this.$store.dispatch("getSheets");
this.$store.dispatch("checkUserUsage");
} else {
throw new Error(JSON.stringify(res));
}
}).catch(error => {
console.error("/sheet/mine ", error);
this.showSnackbar(`Unable to remove sheet: ${error.message}`);
}).finally(() => {
this.loading = false;
});
},
},
headers: [
{ title: "Name", value: "name", sortable: true },
{ title: "Group", value: "group_id", sortable: true },
{ title: "Archived", value: "frequency", sortable: true },
{ title: "Created", value: "created_at", sortable: true },
{
title: "Last archived URL",
value: "last_url_archived_at",
sortable: true,
},
{ title: "Actions", value: "actions", align: "center" },
],
};
},
computed: {
user() {
return this.$store.state.user;
},
items() {
return this.$store.state.sheets;
},
},
methods: {
showSnackbar(message, color = "red") {
this.snackbarMessage = message;
this.snackbarColor = color;
this.snackbar = true;
},
canArchiveNow(group_id) {
return (
this.$store.state.user?.permissions?.[group_id]
?.manually_trigger_sheet || false
);
},
archiveSheetNow(sheetId) {
this.loading = true;
fetch(`${this.$store.state.API_ENDPOINT}/sheet/${sheetId}/archive`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.$store.state.access_token}`,
},
})
.then(async (response) => {
const res = await response.json();
if (response.status === 201) {
this.showSnackbar(
`Sheet ${sheetId} is being archived with task id ${res?.id}!`,
"green"
);
this.$store.dispatch("getSheets");
} else {
throw new Error(JSON.stringify(res));
}
})
.catch((error) => {
console.error("/sheet/mine ", error);
this.showSnackbar(
`Unable to trigger sheet archive: ${error.message}`
);
})
.finally(() => {
this.loading = false;
});
},
removeSheet(sheetId) {
this.loading = true;
fetch(`${this.$store.state.API_ENDPOINT}/sheet/${sheetId}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.$store.state.access_token}`,
},
})
.then(async (response) => {
const res = await response.json();
if (response.status === 200 && res.deleted) {
this.showSnackbar(`Sheet ${sheetId} has been removed!`, "green");
this.$store.dispatch("getSheets");
this.$store.dispatch("checkUserUsage");
} else {
throw new Error(JSON.stringify(res));
}
})
.catch((error) => {
console.error("/sheet/mine ", error);
this.showSnackbar(`Unable to remove sheet: ${error.message}`);
})
.finally(() => {
this.loading = false;
});
},
},
};
</script>
</script>

View File

@@ -6,38 +6,75 @@
</router-link>
</v-toolbar-title>
<v-chip v-if="$store.state.errorMessage" :title="$store.state.errorMessage" color="red" variant="tonal" closable
class="mx-4">
<v-chip
v-if="$store.state.errorMessage"
:title="$store.state.errorMessage"
color="red"
variant="tonal"
closable
class="mx-4"
>
ERROR: {{ $store.state.errorMessage }}
</v-chip>
<v-spacer v-if="!smAndDown"></v-spacer>
<div v-if="user?.active && !smAndDown">
<template v-for="btn in btns">
<v-btn :to="btn.to" :prepend-icon="btn.icon" variant="text" class="nodecoration ml-2" size="large" active-color="teal">
<v-btn
:to="btn.to"
:prepend-icon="btn.icon"
variant="text"
class="nodecoration ml-2"
size="large"
active-color="teal"
>
{{ btn.text }}
<v-tooltip activator="parent" location="bottom">{{ btn.tooltip }}</v-tooltip>
<v-tooltip activator="parent" location="bottom">{{
btn.tooltip
}}</v-tooltip>
</v-btn>
</template>
</div>
<v-spacer v-if="!smAndDown"></v-spacer>
<span class="user mx-2 pa-2 bg-blue-grey-lighten-5 rounded elevation-2" v-if="user">
<span
class="user mx-2 pa-2 bg-blue-grey-lighten-5 rounded elevation-2"
v-if="user"
>
<span v-if="!loadingUserState">
<v-chip v-if="user.active" color="green" class="bg-white" prepend-icon="mdi-checkbox-marked-circle"
variant="outlined">
<v-chip
v-if="user.active"
color="green"
class="bg-white"
prepend-icon="mdi-checkbox-marked-circle"
variant="outlined"
>
active
</v-chip>
<v-chip v-if="!user.active" color="red" class="bg-white" prepend-icon="mdi-account-cancel" variant="outlined">
<v-chip
v-if="!user.active"
color="red"
class="bg-white"
prepend-icon="mdi-account-cancel"
variant="outlined"
>
inactive
</v-chip>
<v-tooltip activator="parent" location="bottom">{{ activeUserMessage }}</v-tooltip>
<v-tooltip activator="parent" location="bottom">{{
activeUserMessage
}}</v-tooltip>
</span>
<span class="ms-2">{{ user.email }}</span>
<v-btn v-if="!smAndDown" prepend-icon="mdi-logout" variant="text" class="mx-2 elevation-2 bg-white" size="small"
@click="$store.dispatch('signout')">Sign Out</v-btn>
<v-btn
v-if="!smAndDown"
prepend-icon="mdi-logout"
variant="text"
class="mx-2 elevation-2 bg-white"
size="small"
@click="$store.dispatch('signout')"
>Sign Out</v-btn
>
</span>
<v-btn v-if="!user" @click="$store.dispatch('signin')">Sign In</v-btn>
@@ -48,21 +85,33 @@
</template>
<v-list>
<v-list-item v-for="btn in btns" :key="btn.to" :to="btn.to">
<v-btn :prepend-icon="btn.icon" variant="plain" class="nodecoration" size="large">
<v-btn
:prepend-icon="btn.icon"
variant="plain"
class="nodecoration"
size="large"
>
{{ btn.text }}
</v-btn>
<v-tooltip activator="parent" location="left">{{ btn.tooltip }}</v-tooltip>
<v-tooltip activator="parent" location="left">{{
btn.tooltip
}}</v-tooltip>
</v-list-item>
<v-list-item @click="$store.dispatch('signout')">
<v-btn prepend-icon="mdi-logout" variant="plain" class="nodecoration" size="large">Sign Out</v-btn>
<v-btn
prepend-icon="mdi-logout"
variant="plain"
class="nodecoration"
size="large"
>Sign Out</v-btn
>
</v-list-item>
</v-list>
</v-menu>
</v-app-bar>
</template>
<script setup>
import { useDisplay } from 'vuetify'
import { useDisplay } from "vuetify";
const { smAndDown } = useDisplay();
</script>
@@ -73,10 +122,25 @@ export default {
return {
drawer: false,
btns: [
{ to: "/", icon: "mdi-table-large", text: "Sheets", tooltip: "Create, manage, and archive Google Sheets." },
{ to: "/url", icon: "mdi-cloud-download-outline", text: "URL", tooltip: "Archive a single URL." },
{ to: "/archives", icon: "mdi-magnify", text: "Archives", tooltip: "Search for archived URLs." },
]
{
to: "/",
icon: "mdi-table-large",
text: "Sheets",
tooltip: "Create, manage, and archive Google Sheets.",
},
{
to: "/url",
icon: "mdi-cloud-download-outline",
text: "URL",
tooltip: "Archive a single URL.",
},
{
to: "/archives",
icon: "mdi-magnify",
text: "Archives",
tooltip: "Search for archived URLs.",
},
],
};
},
computed: {
@@ -89,9 +153,9 @@ export default {
}
return "This account is inactive, please reach out to the Bellingcat team for access.";
},
loadingUserState() {
return this.$store.state?.loadingUserState;
}
loadingUserState() {
return this.$store.state?.loadingUserState;
},
},
};
</script>

View File

@@ -1,32 +1,39 @@
<template>
<v-alert v-if="!loadingUserState" color="orange" icon="mdi-information" class="text-center"
style="font-size:x-large">
To use the <strong>{{ feature }}</strong> feature, you need <strong>permission from Bellingcat's tech
team</strong>.
<br />
You can ask for access via <a href="https://forms.gle/crqBXUtyZcbLhiRQ9" target="_blank">this form</a>.
<br />
<small>
<strong>NB: </strong>We do not allow law enforcement, military or intelligence agencies to use this tool.
</small>
</v-alert>
<v-alert
v-if="!loadingUserState"
color="orange"
icon="mdi-information"
class="text-center"
style="font-size: x-large"
>
To use the <strong>{{ feature }}</strong> feature, you need
<strong>permission from Bellingcat's tech team</strong>.
<br />
You can ask for access via
<a href="https://forms.gle/crqBXUtyZcbLhiRQ9" target="_blank">this form</a>.
<br />
<small>
<strong>NB: </strong>We do not allow law enforcement, military or
intelligence agencies to use this tool.
</small>
</v-alert>
</template>
<script>
export default {
name: 'PermissionNeeded',
props: {
feature: {
type: String,
required: false
}
},
computed: {
loadingUserState() {
return this.$store.state?.loadingUserState;
}
}
}
name: "PermissionNeeded",
props: {
feature: {
type: String,
required: false,
},
},
computed: {
loadingUserState() {
return this.$store.state?.loadingUserState;
},
},
};
</script>
<style scoped></style>
<style scoped></style>

View File

@@ -1,57 +1,63 @@
<template>
<v-snackbar v-model="visible" :timeout="timeout" :top="top" :bottom="bottom" close-on-content-click>
{{ message }}
<template v-slot:actions>
<v-btn :color="color" variant="text" @click="visible = false">
Close
</v-btn>
</template>
</v-snackbar>
<v-snackbar
v-model="visible"
:timeout="timeout"
:top="top"
:bottom="bottom"
close-on-content-click
>
{{ message }}
<template v-slot:actions>
<v-btn :color="color" variant="text" @click="visible = false">
Close
</v-btn>
</template>
</v-snackbar>
</template>
<script>
export default {
name: 'MySnackBar',
props: {
message: {
type: String,
required: true
},
timeout: {
type: Number,
default: 5000
},
color: {
type: String,
default: 'orange'
},
top: {
type: Boolean,
default: false
},
bottom: {
type: Boolean,
default: true
},
show: {
type: Boolean,
required: true
}
},
data() {
return {
visible: this.show
};
},
watch: {
show(val) {
this.visible = val;
},
visible(val) {
if (!val) {
this.$emit('update:show', false);
}
}
}
name: "MySnackBar",
props: {
message: {
type: String,
required: true,
},
timeout: {
type: Number,
default: 5000,
},
color: {
type: String,
default: "orange",
},
top: {
type: Boolean,
default: false,
},
bottom: {
type: Boolean,
default: true,
},
show: {
type: Boolean,
required: true,
},
},
data() {
return {
visible: this.show,
};
},
watch: {
show(val) {
this.visible = val;
},
visible(val) {
if (!val) {
this.$emit("update:show", false);
}
},
},
};
</script>
</script>

View File

@@ -1,57 +1,67 @@
<template>
<v-container class="pane" fluid v-if="!user || !user.active">
<v-row>
<v-col>
<v-card>
<v-card-text>
<v-card-title class="text-center">
Welcome to the Auto Archiver Setup Tool
</v-card-title>
<v-alert color="#f2d97c" icon="mdi-alert">
This is a prototype demo service provided on a
best-effort basis. <br />Do not use for mission critical or sensitive
data.
</v-alert>
<p>
This tool can be used to archive digital content via single URL or Google Sheets, you can
also search for
archived content.
</p>
<div class="text-center">
<v-btn v-if="!user && !loadingUserState" @click="$store.dispatch('signin')" size="large">Sign In</v-btn>
</div>
<v-container v-if="loadingUserState" class="pane" style="text-align: center;">
<v-row justify="center">
<v-col cols="12">
<v-progress-circular color="teal" indeterminate :size="82"
:width="7"></v-progress-circular>
</v-col>
<v-col cols="12">
<h4>loading...</h4>
</v-col>
</v-row>
</v-container>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
<v-container class="pane" fluid v-if="!user || !user.active">
<v-row>
<v-col>
<v-card>
<v-card-text>
<v-card-title class="text-center">
Welcome to the Auto Archiver Setup Tool
</v-card-title>
<v-alert color="#f2d97c" icon="mdi-alert">
This is a prototype demo service provided on a best-effort basis.
<br />Do not use for mission critical or sensitive data.
</v-alert>
<p>
This tool can be used to archive digital content via single URL or
Google Sheets, you can also search for archived content.
</p>
<div class="text-center">
<v-btn
v-if="!user && !loadingUserState"
@click="$store.dispatch('signin')"
size="large"
>Sign In</v-btn
>
</div>
<v-container
v-if="loadingUserState"
class="pane"
style="text-align: center"
>
<v-row justify="center">
<v-col cols="12">
<v-progress-circular
color="teal"
indeterminate
:size="82"
:width="7"
></v-progress-circular>
</v-col>
<v-col cols="12">
<h4>loading...</h4>
</v-col>
</v-row>
</v-container>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script>
export default {
name: 'WelcomeCard',
props: {
},
computed: {
user() {
return this.$store.state.user;
},
loadingUserState() {
return this.$store.state?.loadingUserState;
}
}
}
name: "WelcomeCard",
props: {},
computed: {
user() {
return this.$store.state.user;
},
loadingUserState() {
return this.$store.state?.loadingUserState;
},
},
};
</script>
<style scoped></style>
<style scoped></style>

View File

@@ -1,8 +1,8 @@
import { createApp } from "vue";
import { createVuetify } from "vuetify";
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import { VDateInput } from 'vuetify/labs/VDateInput';
import * as components from "vuetify/components";
import * as directives from "vuetify/directives";
import { VDateInput } from "vuetify/labs/VDateInput";
import App from "./App.vue";
import router from "./router";
import store from "./store";
@@ -12,7 +12,7 @@ import "@mdi/font/css/materialdesignicons.css";
import "./styles/global.css";
const vuetify = createVuetify({
components: { ...components, VDateInput, },
components: { ...components, VDateInput },
directives,
});

View File

@@ -1,41 +1,41 @@
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
import ArchiveSearchView from '../views/ArchiveSearchView.vue';
import { createRouter, createWebHistory } from "vue-router";
import HomeView from "../views/HomeView.vue";
import ArchiveSearchView from "../views/ArchiveSearchView.vue";
import ArchiveUrlView from "../views/ArchiveUrlView.vue";
const routes = [
{
path: '/',
name: 'home',
path: "/",
name: "home",
component: HomeView,
},
{
path: '/url',
name: 'URL Archiving',
path: "/url",
name: "URL Archiving",
component: ArchiveUrlView,
},
{
path: '/archives',
name: 'Archives search',
path: "/archives",
name: "Archives search",
component: ArchiveSearchView,
},
{
path: '/privacy',
name: 'Privacy Policy',
path: "/privacy",
name: "Privacy Policy",
component: () =>
import(/* webpackChunkName: "privacy" */ '../views/PrivacyView.vue'),
import(/* webpackChunkName: "privacy" */ "../views/PrivacyView.vue"),
},
{
path: '/tos',
name: 'Terms of Use',
path: "/tos",
name: "Terms of Use",
component: () =>
import(/* webpackChunkName: "tos" */ '../views/TOSView.vue'),
import(/* webpackChunkName: "tos" */ "../views/TOSView.vue"),
},
{
path: '/:pathMatch(.*)*',
redirect: '/',
}
path: "/:pathMatch(.*)*",
redirect: "/",
},
];
const router = createRouter({

View File

@@ -1,12 +1,13 @@
import { createStore } from "vuex";
import { gapi, client } from "@/gapi";
import { gapi } from "@/gapi";
import {
signOut,
GoogleAuthProvider,
signInWithCredential,
browserLocalPersistence,
setPersistence,
} from "firebase/auth";
import { collection, } from "firebase/firestore";
import { firebaseAuth, firebaseFirestore } from "@/firebase.js";
import { firebaseAuth } from "@/firebase.js";
function saveToLocalStorage(state) {
localStorage.setItem("user", JSON.stringify(state.user));
@@ -25,7 +26,7 @@ function clearLocalStorage() {
}
async function waitForGapiAuth2() {
return new Promise((resolve, reject) => {
return new Promise((resolve, _reject) => {
const checkGapiAuth2 = () => {
if (gapi.auth2 && gapi.auth2.getAuthInstance()) {
resolve(gapi.auth2.getAuthInstance());
@@ -45,9 +46,8 @@ export default createStore({
sheets: [],
loadingUserState: false,
errorMessage: "",
// # TODO: reenable production API endpoint
// API_ENDPOINT: "https://auto-archiver-api.bellingcat.com"
API_ENDPOINT: "http://localhost:8004"
API_ENDPOINT: process.env.VUE_APP_API_ENDPOINT || "http://localhost:8004",
},
mutations: {
setUser(state, user) {
@@ -60,7 +60,9 @@ export default createStore({
},
setUserPermissions(state, permissions) {
state.user.permissions = permissions;
state.user.groups = Object.keys(permissions).filter(key => key !== "all");
state.user.groups = Object.keys(permissions).filter(
(key) => key !== "all"
);
state.loadingUserState = false;
saveToLocalStorage(state);
},
@@ -90,6 +92,10 @@ export default createStore({
commit("setAccessToken", access_token);
const credential = GoogleAuthProvider.credential(null, access_token);
// Set persistence before signing in
await setPersistence(firebaseAuth, browserLocalPersistence);
// Sign in with the provided credential
const response = await signInWithCredential(firebaseAuth, credential);
commit("setUser", response.user);
@@ -108,7 +114,7 @@ export default createStore({
callback,
});
client.requestAccessToken();
await client.requestAccessToken();
},
async signout({ commit }) {
@@ -138,16 +144,14 @@ export default createStore({
async checkActiveUser({ state, dispatch, commit }) {
try {
commit("setErrorMessage", "");
const r = await fetch(
`${state.API_ENDPOINT}/user/active`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${state.access_token}`,
},
}
)
console.log(`${state.API_ENDPOINT}/user/active`);
const r = await fetch(`${state.API_ENDPOINT}/user/active`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${state.access_token}`,
},
});
const response = await r.json();
commit("setUserActiveState", response.active);
if (response.active === true) {
@@ -155,48 +159,51 @@ export default createStore({
}
} catch (error) {
console.error("checkActiveUser (firebase.js): ", error);
commit("setErrorMessage", "Unable to check user status against the API");
commit(
"setErrorMessage",
"Unable to check user status against the API"
);
}
},
async checkUserPermissions({ state, commit }) {
try {
commit("setErrorMessage", "");
const r = await fetch(
`${state.API_ENDPOINT}/user/permissions`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${state.access_token}`,
},
}
);
const r = await fetch(`${state.API_ENDPOINT}/user/permissions`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${state.access_token}`,
},
});
const response = await r.json();
commit("setUserPermissions", response);
} catch (error) {
console.error("checkUserPermissions (firebase.js): ", error);
commit("setErrorMessage", "Unable to fetch user permissions from the API");
commit(
"setErrorMessage",
"Unable to fetch user permissions from the API"
);
}
},
async checkUserUsage({ state, commit }) {
try {
commit("setErrorMessage", "");
const r = await fetch(
`${state.API_ENDPOINT}/user/usage`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${state.access_token}`,
},
}
);
const r = await fetch(`${state.API_ENDPOINT}/user/usage`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${state.access_token}`,
},
});
const response = await r.json();
commit("setUserUsage", response);
} catch (error) {
console.error("checkUserUsage (firebase.js): ", error);
commit("setErrorMessage", "Unable to fetch user usage quota from the API");
commit(
"setErrorMessage",
"Unable to fetch user usage quota from the API"
);
}
},
@@ -210,8 +217,8 @@ export default createStore({
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${state.access_token}`,
}
}).then(async response => {
},
}).then(async (response) => {
const res = await response.json();
if (response.status === 200) {
commit("setSheets", res);
@@ -219,13 +226,14 @@ export default createStore({
throw new Error(JSON.stringify(res));
}
});
} catch (error) {
console.error("getSheets (firebase.js): ", error);
}
},
async createSheet({ state, dispatch, commit }, {name, service_account_email}) {
async createSheet(
{ _state, dispatch, _commit },
{ name, service_account_email }
) {
return new Promise(async (resolve, reject) => {
try {
// create new sheet
@@ -354,8 +362,7 @@ export default createStore({
resource: {
role: "writer",
type: "user",
emailAddress:
service_account_email,
emailAddress: service_account_email,
},
});
@@ -374,7 +381,9 @@ export default createStore({
isTokenExpired: async (state) => {
if (!state.access_token) return true;
try {
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${state.access_token}`);
const response = await fetch(
`https://oauth2.googleapis.com/tokeninfo?access_token=${state.access_token}`
);
if (response.status !== 200) return true;
const data = await response.json();
if (data.expires_in > 0) return false;
@@ -398,18 +407,20 @@ export default createStore({
store.commit("setLoadingUserState", true);
store.commit("setUser", user);
store.commit("setAccessToken", access_token);
store.getters.isTokenExpired.then((expired) => {
if (expired) {
store.getters.isTokenExpired
.then((expired) => {
if (expired) {
store.dispatch("signout");
} else {
store.dispatch("checkActiveUser");
store.dispatch("checkUserPermissions");
store.dispatch("checkUserUsage");
}
})
.catch((error) => {
console.error("Error checking token expiration:", error);
store.dispatch("signout");
} else {
store.dispatch("checkActiveUser");
store.dispatch("checkUserPermissions");
store.dispatch("checkUserUsage");
}
}).catch((error) => {
console.error("Error checking token expiration:", error);
store.dispatch("signout");
});
});
}
},
],

View File

@@ -1,19 +1,19 @@
export function urlValidator(url) {
if (!url) return true;
if (url.length < 10) return "URL is too short";
try {
new URL(url);
return true;
} catch (_) {
return "Not a valid URL";
}
if (!url) return true;
if (url.length < 10) return "URL is too short";
try {
new URL(url);
return true;
} catch (_) {
return "Not a valid URL";
}
}
export function getUrlFromResult(item) {
const final_media = item.result?.media?.filter(m => m?.properties?.id == '_final_media');
if (final_media && final_media.length > 0) {
return final_media[0].urls;
}
};
const final_media = item.result?.media?.filter(
(m) => m?.properties?.id == "_final_media"
);
if (final_media && final_media.length > 0) {
return final_media[0].urls;
}
}

View File

@@ -1,6 +1,6 @@
<template>
<PermissionNeeded v-if="user && !featureEnabled" feature="Search Archives" />
<WelcomeCard/>
<WelcomeCard />
<v-container class="pane-l" v-if="user?.active && featureEnabled">
<v-row>
<v-col>
@@ -12,19 +12,43 @@
<v-form>
<v-row>
<v-col cols="12" md="6">
<v-date-input v-model="queryAfter" label="Archived After" variant="outlined" min="2022-01-01"
:max="queryBefore || today"></v-date-input>
<v-date-input
v-model="queryAfter"
label="Archived After"
variant="outlined"
min="2022-01-01"
:max="queryBefore || today"
></v-date-input>
</v-col>
<v-col cols="12" md="6">
<v-date-input v-model="queryBefore" label="Archived Before" variant="outlined"
:min="queryAfter || '2022-01-01'" :max="today"></v-date-input>
<v-date-input
v-model="queryBefore"
label="Archived Before"
variant="outlined"
:min="queryAfter || '2022-01-01'"
:max="today"
></v-date-input>
</v-col>
</v-row>
<v-text-field ref="searchInput" v-model="queryUrl" label="Search for this URL" prepend-icon="mdi-web"
variant="outlined" :rules="[urlValidator]" required @keyup.enter="searchForArchives"></v-text-field>
<v-text-field
ref="searchInput"
v-model="queryUrl"
label="Search for this URL"
prepend-icon="mdi-web"
variant="outlined"
:rules="[urlValidator]"
required
@keyup.enter="searchForArchives"
></v-text-field>
<v-row>
<v-col cols="12" class="text-right">
<v-btn @click="searchForArchives" color="teal" class="mt-4" size="large" :disabled="!validUrl">
<v-btn
@click="searchForArchives"
color="teal"
class="mt-4"
size="large"
:disabled="!validUrl"
>
Search
</v-btn>
</v-col>
@@ -32,39 +56,93 @@
</v-form>
<v-row>
<v-col>
<v-snackbar v-model="snackbar" :timeout="4000" top right close-on-content-click>
<v-snackbar
v-model="snackbar"
:timeout="4000"
top
right
close-on-content-click
>
{{ snackbarMessage }}
<template v-slot:actions>
<v-btn color="orange" variant="text" @click="snackbar = false">
<v-btn
color="orange"
variant="text"
@click="snackbar = false"
>
Close
</v-btn>
</template>
</v-snackbar>
<v-data-table-server density="compact" loading-text="Loading... Please wait"
no-data-text="Nothing found" v-model:items-per-page="itemsPerPage" :headers="headers"
:items="serverItems" :items-length="totalItems" :loading="loading" :search="tableSearch"
@update:options="loadItems" :items-per-page-options="pageOptions" show-expand item-value="id"
fixed-header>
<v-data-table-server
density="compact"
loading-text="Loading... Please wait"
no-data-text="Nothing found"
v-model:items-per-page="itemsPerPage"
:headers="headers"
:items="serverItems"
:items-length="totalItems"
:loading="loading"
:search="tableSearch"
@update:options="loadItems"
:items-per-page-options="pageOptions"
show-expand
item-value="id"
fixed-header
>
<template v-slot:item.result="{ item }">
<a :href="getUrlFromResult(item)" target="_blank" rel="noopener noreferrer">{{ item.result?.status
}}</a>
<a
:href="getUrlFromResult(item)"
target="_blank"
rel="noopener noreferrer"
>{{ item.result?.status }}</a
>
</template>
<template v-slot:item.url="{ item }">
<a :href="item.url" target="_blank" rel="noopener noreferrer">{{ item.url }}</a>
<a
:href="item.url"
target="_blank"
rel="noopener noreferrer"
>{{ item.url }}</a
>
</template>
<template v-slot:item.created_at="{ item }">
<time :datetime="item?.created_at"
:title="$moment(item?.created_at).format(`MMMM Do YYYY, k:mm:ss`)">{{
$moment(item?.created_at).fromNow() }}</time>
<time
:datetime="item?.created_at"
:title="
$moment(item?.created_at).format(
`MMMM Do YYYY, k:mm:ss`
)
"
>{{ $moment(item?.created_at).fromNow() }}</time
>
</template>
<template v-slot:item.store_until="{ item }">
<time :datetime="item?.store_until"
:title="`this archive will be deleted on: ${$moment(item?.store_until).format(`MMMM Do YYYY, k:mm:ss`)}`"
:style="{ color: $moment().diff(item?.store_until, 'days') > -31 ? 'red' : 'inherit' }">{{
item?.store_until ? $moment(item?.store_until).fromNow() : "never" }}</time>
<time
:datetime="item?.store_until"
:title="`this archive will be deleted on: ${$moment(
item?.store_until
).format(`MMMM Do YYYY, k:mm:ss`)}`"
:style="{
color:
$moment().diff(item?.store_until, 'days') > -31
? 'red'
: 'inherit',
}"
>{{
item?.store_until
? $moment(item?.store_until).fromNow()
: "never"
}}</time
>
</template>
<template v-slot:item.size="{ item }">
{{ ((item?.result?.metadata?.total_bytes || 0) / (1024 * 1024)).toFixed(2) }}
{{
(
(item?.result?.metadata?.total_bytes || 0) /
(1024 * 1024)
).toFixed(2)
}}
</template>
<template v-slot:item.files="{ item }">
{{ item?.result?.media?.length }}
@@ -74,17 +152,37 @@
<template v-slot:expanded-row="{ columns, item }">
<tr>
<td :colspan="columns.length" class="pa-0">
<v-data-table density="compact" class="sub-table elevation-0 bg-blue-grey-lighten-5"
:items="item?.result?.media" item-key="key" hide-default-footer :headers="fileHeaders">
<v-data-table
density="compact"
class="sub-table elevation-0 bg-blue-grey-lighten-5"
:items="item?.result?.media"
item-key="key"
hide-default-footer
:headers="fileHeaders"
>
<template v-slot:item.preview="{ item: media }">
<a :href="media.urls[0]" target="_blank">
<template v-if="media._mimetype?.startsWith('image/')">
<v-img :src="media.urls[0]" max-width="150" max-height="250" class="mx-auto"></v-img>
<template
v-if="media._mimetype?.startsWith('image/')"
>
<v-img
:src="media.urls[0]"
max-width="150"
max-height="250"
class="mx-auto"
></v-img>
</template>
<template v-else-if="media._mimetype?.startsWith('video/')">
<video :src="media.urls[0]" controls style="max-width: 150px; max-height: 200px;"
class="mx-auto"></video>
<template
v-else-if="
media._mimetype?.startsWith('video/')
"
>
<video
:src="media.urls[0]"
controls
style="max-width: 150px; max-height: 200px"
class="mx-auto"
></video>
</template>
<template v-else>
<span>{{ media?.properties?.id }}</span>
@@ -92,7 +190,9 @@
</a>
</template>
<template v-slot:item.hash="{ item: media }">
<span style="font-size: small;">{{ media?.properties?.hash }}</span>
<span style="font-size: small">{{
media?.properties?.hash
}}</span>
</template>
</v-data-table>
</td>
@@ -102,7 +202,6 @@
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
@@ -117,7 +216,8 @@ import WelcomeCard from "@/components/WelcomeCard.vue";
export default {
name: "ArchiveSearchView",
components: {
PermissionNeeded, WelcomeCard
PermissionNeeded,
WelcomeCard,
},
data() {
return {
@@ -129,7 +229,12 @@ export default {
loading: false,
itemsPerPage: 5,
totalItems: 0,
pageOptions: [{ value: 5, title: '5' }, { value: 10, title: '10' }, { value: 25, title: '25' }, { value: 50, title: '50' }],
pageOptions: [
{ value: 5, title: "5" },
{ value: 10, title: "10" },
{ value: 25, title: "25" },
{ value: 50, title: "50" },
],
headers: [
{ title: "URL", value: "url" },
{ title: "Result", value: "result" },
@@ -137,12 +242,17 @@ export default {
{ title: "Deleted", value: "store_until", width: "150px" },
{ title: "Size (MB)", value: "size" },
{ title: "Files", value: "files" },
{ title: '', key: 'data-table-expand' },
{ title: "", key: "data-table-expand" },
],
fileHeaders: [
{ title: 'Preview', value: 'preview', align: 'center' },
{ title: 'Hash', value: 'hash', align: 'end', width: '150px' },
{ title: 'Size', value: 'properties.size', align: 'end', width: '150px' }
{ title: "Preview", value: "preview", align: "center" },
{ title: "Hash", value: "hash", align: "end", width: "150px" },
{
title: "Size",
value: "properties.size",
align: "end",
width: "150px",
},
],
serverItems: [],
snackbar: false,
@@ -154,14 +264,14 @@ export default {
return this.$store.state.user;
},
featureEnabled() {
const read = this.user?.permissions?.['all']?.read
const read = this.user?.permissions?.["all"]?.read;
if (read === true) {
return true;
}
if (Array.isArray(read) && read.length > 0) {
return true;
}
return this.user?.permissions?.['all']?.read_public
return this.user?.permissions?.["all"]?.read_public;
},
validUrl() {
return this.queryUrl && this.urlValidator(this.queryUrl) === true;
@@ -178,7 +288,7 @@ export default {
if (!this.validUrl) return;
this.tableSearch = `${this.queryUrl}${this.queryAfter}${this.queryBefore}`;
},
loadItems({ page, itemsPerPage, sortBy }) {
loadItems({ page, itemsPerPage, _sortBy }) {
if (!this.validUrl || this.loading === true) return;
this.loading = true;
@@ -194,17 +304,17 @@ export default {
params.append("archived_before", this.queryBefore.toISOString());
}
fetch(`${this.$store.state.API_ENDPOINT}/url/search?${params}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.$store.state.access_token}`,
}
}).then(response => response.json())
.then(items => {
},
})
.then((response) => response.json())
.then((items) => {
if (!Array.isArray(items)) {
throw (`Unexpected response format from API`);
throw `Unexpected response format from API`;
}
// Estimate totalItems if not provided by the API
@@ -215,7 +325,7 @@ export default {
this.totalItems = (page + 1) * itemsPerPage; // Assume there are more items
}
})
.catch(error => {
.catch((error) => {
console.error("/url/search", error);
this.snackbarMessage = `Error searching for archives: ${error}`;
this.snackbar = true;
@@ -223,7 +333,7 @@ export default {
.finally(() => {
this.loading = false;
});
}
},
},
};
</script>

View File

@@ -3,13 +3,16 @@
<WelcomeCard />
<v-container class="pane" v-if="user?.active && featureEnabled">
<v-card>
<v-card-title class="text-center">
Archive a single URL
</v-card-title>
<v-card-title class="text-center"> Archive a single URL </v-card-title>
<v-card-text>
<v-row>
<v-col cols="12">
<v-text-field v-model="url" label="URL" required :rules="[urlValidator]"></v-text-field>
<v-text-field
v-model="url"
label="URL"
required
:rules="[urlValidator]"
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-radio-group v-model="public" inline>
@@ -18,11 +21,25 @@
</v-radio-group>
</v-col>
<v-col cols="12" md="4">
<v-select v-model="group" label="Group" :items="availableGroups" density="compact"></v-select>
<v-select
v-model="group"
label="Group"
:items="availableGroups"
density="compact"
></v-select>
</v-col>
<v-col cols="12" md="4" class="text-right">
<v-btn @click="archiveUrl" color="teal"
:disabled="!validUrl || loadingArchive || (group == 'please select') || maxedOutMBs || maxedOutURLs">
<v-btn
@click="archiveUrl"
color="teal"
:disabled="
!validUrl ||
loadingArchive ||
group == 'please select' ||
maxedOutMBs ||
maxedOutURLs
"
>
Archive
</v-btn>
</v-col>
@@ -30,49 +47,97 @@
<v-row>
<v-col cols="12">
<p v-if="loadingArchive">
<v-progress-circular color="teal" indeterminate></v-progress-circular>
Archive in progress <span v-if="taskId">task id = <code>{{ taskId }}</code></span>
<v-progress-circular
color="teal"
indeterminate
></v-progress-circular>
Archive in progress
<span v-if="taskId"
>task id = <code>{{ taskId }}</code></span
>
</p>
<v-alert color="success" icon="mdi-information" v-if="archiveResult">
<v-alert
color="success"
icon="mdi-information"
v-if="archiveResult"
>
Archived successfully with id {{ archiveResult.id }}
<span v-if="urlFromResult"> available <a :href="urlFromResult" target="_blank">here</a>.</span>
<span v-if="urlFromResult">
available
<a :href="urlFromResult" target="_blank">here</a>.</span
>
<span v-if="!urlFromResult">no archived content to show.</span>
</v-alert>
<v-alert color="warning" icon="mdi-alert" v-if="archiveFailure">
Failure: {{ archiveFailure }}
</v-alert>
<p v-if="validUrl">
You can <strong v-if="archiveFailure">still</strong> <router-link
:to="`/archives?url=${encodeURIComponent(url)}`" target="_blank"><v-icon>mdi-open-in-new</v-icon> search
for
archives</router-link> of
this URL.
You can <strong v-if="archiveFailure">still</strong>
<router-link
:to="`/archives?url=${encodeURIComponent(url)}`"
target="_blank"
><v-icon>mdi-open-in-new</v-icon> search for
archives</router-link
>
of this URL.
</p>
</v-col>
<v-col cols="12" sm="12" class="pt-0" v-if="group != 'please select'">
<span>
<code>{{ group }}</code><br />
<code>{{ group }}</code
><br />
<span class="text-medium-emphasis mb-1">
{{ groupPermissions.description }}
</span>
<ul>
<li>
Monthly URLs: <strong>{{ groupUsage.monthly_urls || 0 }}</strong>
Monthly URLs:
<strong>{{ groupUsage.monthly_urls || 0 }}</strong>
out of
<strong>{{ displayPermissionValue(groupPermissions?.max_monthly_urls, " URLs") }}</strong>
<v-chip v-if="maxedOutURLs" label class="ml-2" color="red" density="comfortable" size="small">maxed
out</v-chip>
<strong>{{
displayPermissionValue(
groupPermissions?.max_monthly_urls,
" URLs"
)
}}</strong>
<v-chip
v-if="maxedOutURLs"
label
class="ml-2"
color="red"
density="comfortable"
size="small"
>maxed out</v-chip
>
</li>
<li>
Monthly MBs:
<strong>{{ groupUsage.monthly_mbs || 0 }}</strong>
out of
<strong>{{ displayPermissionValue(groupPermissions?.max_monthly_mbs, " MBs") }}</strong>
<v-chip v-if="maxedOutMBs" label class="ml-2" color="red" density="comfortable" size="small">maxed
out</v-chip>
<strong>{{
displayPermissionValue(
groupPermissions?.max_monthly_mbs,
" MBs"
)
}}</strong>
<v-chip
v-if="maxedOutMBs"
label
class="ml-2"
color="red"
density="comfortable"
size="small"
>maxed out</v-chip
>
</li>
<li>We will store archives for: <strong>{{
displayPermissionValue(groupPermissions?.max_archive_lifespan_months, " months") }}</strong>
<li>
We will store archives for:
<strong>{{
displayPermissionValue(
groupPermissions?.max_archive_lifespan_months,
" months"
)
}}</strong>
</li>
</ul>
</span>
@@ -80,7 +145,12 @@
</v-row>
</v-card-text>
</v-card>
<SnackBar :message="snackbarMessage" :show="snackbar" :color="snackbarColor" @update:show="snackbar = $event" />
<SnackBar
:message="snackbarMessage"
:show="snackbar"
:color="snackbarColor"
@update:show="snackbar = $event"
/>
</v-container>
</template>
@@ -93,11 +163,13 @@ import WelcomeCard from "@/components/WelcomeCard.vue";
export default {
name: "ArchiveUrlView",
components: {
SnackBar, PermissionNeeded, WelcomeCard
SnackBar,
PermissionNeeded,
WelcomeCard,
},
data() {
return {
url: "",
url: this.$route.query.url || "",
public: false,
group: "please select",
loadingArchive: false,
@@ -131,8 +203,8 @@ export default {
availableGroups() {
const permissions = this.$store.state.user?.permissions || {};
return Object.keys(permissions)
.filter(group => group !== "all" && permissions[group].archive_url)
.map(g => ({ title: g, value: g }));
.filter((group) => group !== "all" && permissions[group].archive_url)
.map((g) => ({ title: g, value: g }));
},
globalUsage() {
return this.$store.state.user?.usage || {};
@@ -151,21 +223,28 @@ export default {
},
maxedOutMBs() {
if (this.groupPermissions.max_monthly_mbs === -1) return false;
return this.groupUsage.monthly_mbs >= this.groupPermissions.max_monthly_mbs;
return (
this.groupUsage.monthly_mbs >= this.groupPermissions.max_monthly_mbs
);
},
maxedOutURLs() {
if (this.groupPermissions.max_monthly_urls === -1) return false;
return this.groupUsage.monthly_urls >= this.groupPermissions.max_monthly_urls;
return (
this.groupUsage.monthly_urls >= this.groupPermissions.max_monthly_urls
);
},
},
watch: {
url(val) {
url(_val) {
this.archiveResult = null;
this.archiveFailure = null;
this.taskId = null;
if (this.loadingArchive) {
this.loadingArchive = false;
this.showSnackbar("Your previous archive will run in the background.", "yellow");
this.showSnackbar(
"Your previous archive will run in the background.",
"yellow"
);
}
},
},
@@ -192,20 +271,23 @@ export default {
group_id: this.group,
public: this.public,
tags: [],
})
}),
})
.then(response => {
.then((response) => {
if (!response.ok) {
throw new Error(`API error: ${response.statusText}`);
}
return response.json();
})
.then(res => {
.then((res) => {
this.taskId = res.id;
this.showSnackbar(`Your URL is being archived with id ${this.taskId}!`, "green");
this.showSnackbar(
`Your URL is being archived with id ${this.taskId}!`,
"green"
);
this.pollForArchiveResults();
})
.catch(error => {
.catch((error) => {
console.error("/archive ", error);
this.showSnackbar(`Unable to archive URL: ${error.message}`);
this.loadingArchive = false;
@@ -222,12 +304,15 @@ export default {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.$store.state.access_token}`,
}
},
})
.then(response => response.json())
.then(task => {
.then((response) => response.json())
.then((task) => {
if (task.status === "SUCCESS") {
this.showSnackbar(`URL archived successfully with id ${task.id}!`, "green");
this.showSnackbar(
`URL archived successfully with id ${task.id}!`,
"green"
);
this.loadingArchive = false;
this.archiveResult = task;
this.taskId = task.id;
@@ -240,18 +325,22 @@ export default {
setTimeout(poll, 5000); // Poll every 5 seconds
}
})
.catch(error => {
.catch((error) => {
console.error("/task ", error);
this.showSnackbar(`Error checking archive status: ${error.message}`);
this.showSnackbar(
`Error checking archive status: ${error.message}`
);
this.loadingArchive = false;
});
};
poll();
},
displayPermissionValue(value, extraWord) {
if (value === undefined) { return "not set"; }
if (value === undefined) {
return "not set";
}
return value == -1 ? "no limit" : value + extraWord;
}
},
},
};
</script>
</script>

View File

@@ -1,6 +1,9 @@
<template>
<PermissionNeeded v-if="user && !featureEnabled" feature="Archive Spreadsheets" />
<WelcomeCard/>
<PermissionNeeded
v-if="user && !featureEnabled"
feature="Archive Spreadsheets"
/>
<WelcomeCard />
<ArchiveSheet v-if="user?.active && featureEnabled" />
<ManageSheets v-if="user?.active && featureEnabled" />
</template>
@@ -14,7 +17,10 @@ import WelcomeCard from "@/components/WelcomeCard.vue";
export default {
name: "HomeView",
components: {
ArchiveSheet, ManageSheets, PermissionNeeded, WelcomeCard
ArchiveSheet,
ManageSheets,
PermissionNeeded,
WelcomeCard,
},
computed: {
user() {
@@ -22,7 +28,7 @@ export default {
},
featureEnabled() {
return this.user?.permissions?.["all"]?.archive_sheet;
}
}
},
},
};
</script>

11087
yarn.lock

File diff suppressed because it is too large Load Diff