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 @@
/** //NB: this code has been disabled since the cronjob is now handled by the API
* 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
*/
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 { onSchedule } = require("firebase-functions/v2/scheduler");
const { initializeApp } = require("firebase-admin/app"); // const logger = require("firebase-functions/logger");
const { getFirestore } = require("firebase-admin/firestore");
const { defineSecret } = require('firebase-functions/params'); // // The Firebase Admin SDK to access Firestore.
const API_TOKEN = defineSecret('API_SERVICE_PASSWORD'); // const { initializeApp } = require("firebase-admin/app");
const CLIENT_EMAIL = defineSecret('GOOGLE_API_CLIENT_EMAIL'); // const { getFirestore } = require("firebase-admin/firestore");
const PRIVATE_KEY = defineSecret('GOOGLE_API_PRIVATE_KEY');
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 { google } = require('googleapis');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
String.prototype.hashCode = function () { // initializeApp();
// https://stackoverflow.com/a/7616484/6196010 // const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// 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;
}
//TODO: disable the scheduler // String.prototype.hashCode = function () {
exports.processSheetScheduler = onSchedule( // // https://stackoverflow.com/a/7616484/6196010
{ secrets: [API_TOKEN, CLIENT_EMAIL, PRIVATE_KEY], schedule: "* * * * *" }, // // Generating 1M random strings and applying this function shows it's very balanced for modulo 60
async (event) => { // // 0 has double frequency of other numbers, but that's not a problem
// authenticate the service account // var hash = 0,
const googleAuth = new google.auth.JWT(CLIENT_EMAIL.value(), null, PRIVATE_KEY.value().replace(/\\n/g, '\n'), 'https://www.googleapis.com/auth/spreadsheets'); // i, chr;
const sheets = await google.sheets({ version: 'v4', auth: googleAuth }); // 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 // //TODO: disable the scheduler
const db = getFirestore(); // 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 // // get all documents from firestore sheets collection
const querySnapshot = await db.collection("sheets").get(); // const db = getFirestore();
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()})`);
try { // // each sheet runs once per hour, so we hash the sheet id and only process it if the hash % 60 matches the cron minute
await sheets.spreadsheets.get({ spreadsheetId: doc.data().sheetId }); // const querySnapshot = await db.collection("sheets").get();
} catch (e) { // const eventDate = new Date(Date.parse(event.scheduleTime));
if (e.status == 404) { // querySnapshot.forEach(async (doc) => {
await doc.ref.delete(); // const hashToSixty = Math.abs(doc.id.hashCode() % 60);
logger.log(`document ${doc.data().sheetId} not found, deleted`); // if (hashToSixty != eventDate.getMinutes()) {
return; // 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 // try {
const url = "https://auto-archiver-api.bellingcat.com/sheet_service"; // await sheets.spreadsheets.get({ spreadsheetId: doc.data().sheetId });
const data = { // } catch (e) {
sheet_id: doc.data().sheetId, // if (e.status == 404) {
author_id: doc.data().email ?? doc.data().uid, // await doc.ref.delete();
tags: ["setup-tool"] // logger.log(`document ${doc.data().sheetId} not found, deleted`);
}; // return;
const options = { // }
method: "POST", // }
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_TOKEN.value()}`,
},
body: JSON.stringify(data),
};
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, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve --port 8081 --skip-plugins @vue/cli-plugin-eslint", "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" "lint": "vue-cli-service lint"
}, },
"dependencies": { "dependencies": {
@@ -35,6 +36,7 @@
"firebase-admin": "^11.11.1", "firebase-admin": "^11.11.1",
"firebase-functions": "^4.5.0", "firebase-functions": "^4.5.0",
"prettier": "^2.4.1", "prettier": "^2.4.1",
"serve": "^14.2.4",
"vue-template-compiler": "^2.6.14" "vue-template-compiler": "^2.6.14"
} }
} }

View File

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

View File

@@ -1,210 +1,320 @@
<template> <template>
<v-row class="my-2"> <v-row class="my-2">
<v-col cols="12" sm="12" class="ma-0 pb-0"> <v-col cols="12" sm="12" class="ma-0 pb-0">
<v-text-field label="Google Sheets document name" v-model="sheetName" required <v-text-field
density="comfortable"></v-text-field> label="Google Sheets document name"
</v-col> v-model="sheetName"
<v-col v-if="!actionIsCreate" cols="12" sm="12" class="ma-0 py-0"> required
<v-text-field label="Existing Google Sheet URL/ID" v-model="sheetUrlId" required density="comfortable"> density="comfortable"
</v-text-field> ></v-text-field>
</v-col> </v-col>
<v-col cols="6" sm="6" class="ma-0 py-0"> <v-col v-if="!actionIsCreate" cols="12" sm="12" class="ma-0 py-0">
<v-select v-model="group" label="Group" :items="availableGroups" required density="comfortable"></v-select> <v-text-field
</v-col> label="Existing Google Sheet URL/ID"
<v-col cols="6" sm="6" class="ma-0 py-0"> v-model="sheetUrlId"
<v-select v-model="frequency" label="Archive frequency" :items="availableFrequencies" required
:disabled="!availableFrequencies?.length" required density="comfortable"></v-select> density="comfortable"
</v-col> >
<v-col cols="12" sm="12" class="text-right pt-0"> </v-text-field>
<small v-if="spreadsheetId">Detected Spreadsheet id: <code>{{ spreadsheetId }}</code></small> </v-col>
</v-col> <v-col cols="6" sm="6" class="ma-0 py-0">
<v-col cols="12" sm="12" class="text-right pt-0"> <v-select
<v-progress-circular color="green" indeterminate class="mx-6" v-if="loading"></v-progress-circular> v-model="group"
<v-btn v-if="newSheetId" :href="`https://docs.google.com/spreadsheets/d/${newSheetId}`" label="Group"
append-icon="mdi-open-in-new" :title="newSheetId" target="_blank" color="success" class="mx-2" :items="availableGroups"
size="large"> required
open sheet density="comfortable"
</v-btn> ></v-select>
<v-btn v-if="actionIsCreate" color="teal" size="large" :disabled="!requiredData" </v-col>
@click="createSheet">Create</v-btn> <v-col cols="6" sm="6" class="ma-0 py-0">
<v-btn v-if="!actionIsCreate" color="teal" size="large" :disabled="!requiredDataExisting" <v-select
@click="addExistingSheet">Add Existing Sheet</v-btn> v-model="frequency"
</v-col> label="Archive frequency"
<v-col cols="12" sm="12" class="pt-0" v-if="group != 'please select'"> :items="availableFrequencies"
<span> :disabled="!availableFrequencies?.length"
<span class="text-medium-emphasis mb-1"> required
<strong>{{ group }}</strong>: {{ groupPermissions.description }} density="comfortable"
</span> ></v-select>
<ul> </v-col>
<li> <v-col cols="12" sm="12" class="text-right pt-0">
Active sheets: <small v-if="spreadsheetId"
<strong>{{ groupUsage.total_sheets || 0 }}</strong> out of >Detected Spreadsheet id: <code>{{ spreadsheetId }}</code></small
<strong>{{ displayPermissionValue(groupPermissions?.max_sheets, "") }}</strong> >
<v-chip v-if="maxedOutGroupQuota" label class="ml-2" color="red" density="comfortable" </v-col>
size="small">maxed out</v-chip> <v-col cols="12" sm="12" class="text-right pt-0">
</li> <v-progress-circular
<li>Monthly URLs: <strong>{{ groupUsage.monthly_urls || 0 }}</strong> out of <strong>{{ color="green"
displayPermissionValue(groupPermissions?.max_monthly_urls, " URLs") }}</strong></li> indeterminate
<li>Monthly MBs: <strong>{{ groupUsage.monthly_mbs || 0 }}</strong> out of <strong>{{ class="mx-6"
displayPermissionValue(groupPermissions?.max_monthly_mbs, " MBs") }}</strong></li> v-if="loading"
<li>We will store archives for: <strong>{{ ></v-progress-circular>
displayPermissionValue(groupPermissions?.max_archive_lifespan_months, " months") }}</strong> <v-btn
</li> v-if="newSheetId"
<li>You <strong>{{ groupPermissions?.manually_trigger_sheet ? "can" : "cannot" }}</strong> manually :href="`https://docs.google.com/spreadsheets/d/${newSheetId}`"
trigger sheets in this group. </li> append-icon="mdi-open-in-new"
</ul> :title="newSheetId"
<p v-if="!actionIsCreate" class="text-medium-emphasis mt-2"> target="_blank"
<strong>NOTE:</strong> invite <a :href="`mailto:${groupPermissions?.service_account_email}`">{{ color="success"
groupPermissions?.service_account_email }}</a> to the sheet, see further instructions below. class="mx-2"
</p> size="large"
</span> >
</v-col> 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>
<SnackBar
</v-row> :message="snackbarMessage"
:show="snackbar"
<SnackBar :message="snackbarMessage" :show="snackbar" :color="snackbarColor" @update:show="snackbar = $event" /> :color="snackbarColor"
@update:show="snackbar = $event"
/>
</template> </template>
<script> <script>
import SnackBar from "@/components/SnackBar.vue"; import SnackBar from "@/components/SnackBar.vue";
export default { export default {
name: "AddSheet", name: "AddSheet",
components: { components: {
SnackBar, SnackBar,
}, },
props: { props: {
actionIsCreate: { actionIsCreate: {
type: Boolean, type: Boolean,
required: true, required: true,
default: true, default: true,
} },
}, },
data() { data() {
return { return {
snackbar: false, snackbar: false,
snackbarMessage: "", snackbarMessage: "",
snackbarColor: "red", snackbarColor: "red",
loading: false, loading: false,
sheetName: ``.trim(), sheetName: ``.trim(),
sheetUrlId: ``, sheetUrlId: ``,
group: "please select", group: "please select",
frequency: "please select", frequency: "please select",
newSheetId: "", newSheetId: "",
}; };
}, },
computed: { computed: {
user() { user() {
return this.$store.state.user; return this.$store.state.user;
}, },
requiredData() { requiredData() {
return this.sheetName && this.availableGroups?.some(g => g.value === this.group) && this.availableFrequencies?.some(f => f === this.frequency) && !this.maxedOutGroupQuota; return (
}, this.sheetName &&
requiredDataExisting() { this.availableGroups?.some((g) => g.value === this.group) &&
return this.sheetName && this.spreadsheetId && this.availableGroups?.some(g => g.value === this.group) && this.availableFrequencies?.some(f => f === this.frequency) && !this.maxedOutGroupQuota; this.availableFrequencies?.some((f) => f === this.frequency) &&
}, !this.maxedOutGroupQuota
availableGroups() { );
const permissions = this.$store.state.user?.permissions || {}; },
return Object.keys(permissions) requiredDataExisting() {
.filter(group => group !== "all" && permissions[group].archive_sheet) return (
.map(g => ({ title: g, value: g })); this.sheetName &&
}, this.spreadsheetId &&
availableFrequencies() { this.availableGroups?.some((g) => g.value === this.group) &&
return this.$store.state.user?.permissions?.[this.group]?.sheet_frequency || []; this.availableFrequencies?.some((f) => f === this.frequency) &&
}, !this.maxedOutGroupQuota
groupPermissions() { );
return this.$store.state.user?.permissions?.[this.group] || {}; },
}, availableGroups() {
groupUsage() { const permissions = this.$store.state.user?.permissions || {};
return this.$store.state.user?.usage?.["groups"]?.[this.group] || {}; return Object.keys(permissions)
}, .filter((group) => group !== "all" && permissions[group].archive_sheet)
maxedOutGroupQuota() { .map((g) => ({ title: g, value: g }));
if (this.groupPermissions?.archive_sheet === false) return true; },
if (this.groupPermissions.max_sheets === -1) return false; availableFrequencies() {
return this.groupUsage.total_sheets >= this.groupPermissions.max_sheets; return (
}, this.$store.state.user?.permissions?.[this.group]?.sheet_frequency || []
spreadsheetId() { );
if ( },
this.sheetUrlId.startsWith("http") && groupPermissions() {
this.sheetUrlId.split("/").length >= 6 return this.$store.state.user?.permissions?.[this.group] || {};
) { },
return this.sheetUrlId.split("/")[5]; groupUsage() {
} return this.$store.state.user?.usage?.["groups"]?.[this.group] || {};
return this.sheetUrlId; },
}, maxedOutGroupQuota() {
}, if (this.groupPermissions?.archive_sheet === false) return true;
watch: { if (this.groupPermissions.max_sheets === -1) return false;
}, return this.groupUsage.total_sheets >= this.groupPermissions.max_sheets;
methods: { },
showSnackbar(message, color = "red") { spreadsheetId() {
this.snackbarMessage = message; if (
this.snackbarColor = color; this.sheetUrlId.startsWith("http") &&
this.snackbar = true; this.sheetUrlId.split("/").length >= 6
}, ) {
createSheet() { return this.sheetUrlId.split("/")[5];
if (!this.requiredData) return; }
if (this.loading) return; 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.loading = true;
this.newSheetId = ""; this.newSheetId = "";
this.$store.dispatch("createSheet", { name: this.sheetName, service_account_email: this.groupPermissions.service_account_email }).then((res) => { this.$store
this.$store.dispatch("checkUserUsage"); .dispatch("createSheet", {
if (!res.success) throw new Error(res.result); name: this.sheetName,
this.newSheetId = res.result; service_account_email: this.groupPermissions.service_account_email,
this.addSheetToAPI(this.newSheetId); })
}).catch((error) => { .then((res) => {
console.error(error); this.$store.dispatch("checkUserUsage");
this.showSnackbar(`Unable to create sheet: ${error.message}`); if (!res.success) throw new Error(res.result);
this.loading = false; this.newSheetId = res.result;
}); this.addSheetToAPI(this.newSheetId);
}, })
addExistingSheet() { .catch((error) => {
if (!this.requiredDataExisting) return; console.error(error);
if (this.loading) return; this.showSnackbar(`Unable to create sheet: ${error.message}`);
this.loading = true; this.loading = false;
this.addSheetToAPI(this.spreadsheetId); });
}, },
addSheetToAPI(sheetId) { addExistingSheet() {
fetch(`${this.$store.state.API_ENDPOINT}/sheet/create`, { if (!this.requiredDataExisting) return;
method: "POST", if (this.loading) return;
headers: { this.loading = true;
"Content-Type": "application/json", this.addSheetToAPI(this.spreadsheetId);
Authorization: `Bearer ${this.$store.state.access_token}`, },
}, addSheetToAPI(sheetId) {
body: JSON.stringify({ fetch(`${this.$store.state.API_ENDPOINT}/sheet/create`, {
id: sheetId, method: "POST",
name: this.sheetName, headers: {
group_id: this.group, "Content-Type": "application/json",
frequency: this.frequency, Authorization: `Bearer ${this.$store.state.access_token}`,
}) },
}).then(async response => { body: JSON.stringify({
const j = await response.json(); id: sheetId,
if (response.status === 201) { name: this.sheetName,
this.showSnackbar(`Sheet created successfully!`, "green"); group_id: this.group,
this.$store.dispatch("getSheets"); frequency: this.frequency,
this.$store.dispatch("checkUserUsage"); }),
} else { })
throw new Error(JSON.stringify(j)); .then(async (response) => {
} const j = await response.json();
}).catch(error => { if (response.status === 201) {
console.error("/sheet/create ", error); this.showSnackbar(`Sheet created successfully!`, "green");
this.showSnackbar(`Unable to save sheet to DB: ${error.message}`); this.$store.dispatch("getSheets");
}).finally(() => { this.$store.dispatch("checkUserUsage");
this.loading = false; } else {
this.sheetName = ""; throw new Error(JSON.stringify(j));
this.sheetUrlId = ""; }
this.group = "please select"; })
}); .catch((error) => {
}, console.error("/sheet/create ", error);
displayPermissionValue(value, extraWord) { this.showSnackbar(`Unable to save sheet to DB: ${error.message}`);
if (value === undefined) { return "not set"; } })
return value == -1 ? "no limit" : value + extraWord; .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> <template>
<v-container class="pane"> <v-container class="pane">
<v-card class="pa-0"> <v-card class="pa-0">
<v-tabs v-model="tab" bg-color="teal" grow class="elevation-1"> <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>
<v-tabs-window v-model="tab" class="elevation-1 rounded"> <v-tabs-window v-model="tab" class="elevation-1 rounded">
@@ -16,15 +20,16 @@
<v-expansion-panel-text> <v-expansion-panel-text>
<ol> <ol>
<li>Choose a sheet name</li> <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>Choose a frequency: how often to archive</li>
<li>Press "create" and wait</li> <li>Press "create" and wait</li>
<li>Sheet will appear in "Your Sheets" below</li> <li>Sheet will appear in "Your Sheets" below</li>
</ol> </ol>
<small> <small>
<b>NB:</b> This new sheet will be shared with the <b>NB:</b> This new sheet will be shared with the service
service account necessary for Bellingcat's archiving account necessary for Bellingcat's archiving server.
server.
</small> </small>
</v-expansion-panel-text> </v-expansion-panel-text>
</v-expansion-panel> </v-expansion-panel>
@@ -40,18 +45,21 @@
<v-expansion-panel-text> <v-expansion-panel-text>
<ol style="margin-bottom: 1em"> <ol style="margin-bottom: 1em">
<li>Choose a group to associate with this Google Sheet</li> <li>Choose a group to associate with this Google Sheet</li>
<li>Invite the provided email as Editor to your Google Sheet</li>
<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> <ul>
<li><code>Link</code> where you will put the URLs</li> <li><code>Link</code> where you will put the URLs</li>
<li> <li>
<code>Archive Status</code> to monitor progress and success <code>Archive Status</code> to monitor progress and
of archiver success of archiver
</li> </li>
<li> <li>
<code>Archive location</code> where the link to the archived <code>Archive location</code> where the link to the
content is added archived content is added
</li> </li>
</ul> </ul>
</li> </li>
@@ -59,19 +67,23 @@
Add any of the following <b>optional</b> column names: Add any of the following <b>optional</b> column names:
<ul> <ul>
<li> <li>
<code>Archive date</code> info on when archiving occurred <code>Archive date</code> info on when archiving
occurred
</li> </li>
<li> <li>
<code>Thumbnail</code> an image preview from archived media <code>Thumbnail</code> an image preview from archived
media
</li> </li>
<li> <li>
<code>Upload timestamp</code> online content creation date <code>Upload timestamp</code> online content creation
date
</li> </li>
<li><code>Upload title</code> title</li> <li><code>Upload title</code> title</li>
<li><code>Textual content</code> text content</li> <li><code>Textual content</code> text content</li>
<li><code>Screenshot</code> link to page screenshot</li> <li><code>Screenshot</code> link to page screenshot</li>
<li> <li>
<code>Hash</code> content hash (for integrity purposes) <code>Hash</code> content hash (for integrity
purposes)
</li> </li>
</ul> </ul>
</li> </li>
@@ -79,8 +91,8 @@
<li>Paste the Google Sheet URL</li> <li>Paste the Google Sheet URL</li>
<li>Press "enable" to add the Google Sheet to your list</li> <li>Press "enable" to add the Google Sheet to your list</li>
<li> <li>
Manually check archiving is working and re-check the steps above Manually check archiving is working and re-check the steps
if it is not above if it is not
</li> </li>
</ol> </ol>
</v-expansion-panel-text> </v-expansion-panel-text>
@@ -91,7 +103,6 @@
</v-tabs-window> </v-tabs-window>
</v-card> </v-card>
</v-container> </v-container>
</template> </template>
<script> <script>
@@ -100,12 +111,12 @@ import AddSheet from "@/components/AddSheet.vue";
export default { export default {
name: "ArchiveSheet", name: "ArchiveSheet",
components: { components: {
AddSheet AddSheet,
}, },
data() { data() {
return { return {
tab: '', tab: "",
items: ['Create new Archiver Sheet', 'Add existing Sheet'], items: ["Create new Archiver Sheet", "Add existing Sheet"],
}; };
}, },
computed: { computed: {
@@ -114,4 +125,4 @@ export default {
}, },
}, },
}; };
</script> </script>

View File

@@ -1,139 +1,199 @@
<template> <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-data-table
<v-card class="pa-3"> :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"> <v-tooltip activator="parent" location="left"
Your <u v-if="items">{{ items.length }}</u> active archiver sheets >Archive Now!</v-tooltip
</v-card-title> >
</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" <SnackBar
:loading="loading" items-per-page="25" hover> :message="snackbarMessage"
<template v-slot:item.actions="{ item: data }"> :show="snackbar"
<v-btn :disabled="!canArchiveNow(data.group_id) || loading" color="teal-lighten-1" size="small" icon class="mx-2" rounded :color="snackbarColor"
@click="archiveSheetNow(data.id)"><v-icon>mdi-archive-outline</v-icon> @update:show="snackbar = $event"
/>
<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" />
</template> </template>
<script> <script>
import SnackBar from "@/components/SnackBar.vue"; import SnackBar from "@/components/SnackBar.vue";
export default { export default {
name: "ManageSheets", name: "ManageSheets",
components: { components: {
SnackBar, SnackBar,
}, },
props: {}, props: {},
data() { data() {
return { return {
snackbar: false, snackbar: false,
snackbarMessage: "", snackbarMessage: "",
snackbarColor: "red", snackbarColor: "red",
loading: false, loading: false,
headers: [ headers: [
{ title: "Name", value: "name", sortable: true }, { title: "Name", value: "name", sortable: true },
{ title: "Group", value: "group_id", sortable: true }, { title: "Group", value: "group_id", sortable: true },
{ title: "Archived", value: "frequency", sortable: true }, { title: "Archived", value: "frequency", sortable: true },
{ title: "Created", value: "created_at", 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' }, title: "Last archived URL",
], value: "last_url_archived_at",
sortable: true,
}; },
}, { title: "Actions", value: "actions", align: "center" },
computed: { ],
user() { };
return this.$store.state.user; },
}, computed: {
items() { user() {
return this.$store.state.sheets; return this.$store.state.user;
}, },
}, items() {
methods: { return this.$store.state.sheets;
showSnackbar(message, color = "red") { },
this.snackbarMessage = message; },
this.snackbarColor = color; methods: {
this.snackbar = true; showSnackbar(message, color = "red") {
}, this.snackbarMessage = message;
canArchiveNow(group_id) { this.snackbarColor = color;
return this.$store.state.user?.permissions?.[group_id]?.manually_trigger_sheet || false; this.snackbar = true;
}, },
archiveSheetNow(sheetId) { canArchiveNow(group_id) {
this.loading = true; return (
fetch(`${this.$store.state.API_ENDPOINT}/sheet/${sheetId}/archive`, { this.$store.state.user?.permissions?.[group_id]
method: "POST", ?.manually_trigger_sheet || false
headers: { );
"Content-Type": "application/json", },
Authorization: `Bearer ${this.$store.state.access_token}`, archiveSheetNow(sheetId) {
} this.loading = true;
}).then(async response => { fetch(`${this.$store.state.API_ENDPOINT}/sheet/${sheetId}/archive`, {
const res = await response.json(); method: "POST",
if (response.status === 201) { headers: {
this.showSnackbar(`Sheet ${sheetId} is being archived with task id ${res?.id}!`, "green"); "Content-Type": "application/json",
this.$store.dispatch("getSheets"); Authorization: `Bearer ${this.$store.state.access_token}`,
} else { },
throw new Error(JSON.stringify(res)); })
} .then(async (response) => {
}).catch(error => { const res = await response.json();
console.error("/sheet/mine ", error); if (response.status === 201) {
this.showSnackbar(`Unable to trigger sheet archive: ${error.message}`); this.showSnackbar(
}).finally(() => { `Sheet ${sheetId} is being archived with task id ${res?.id}!`,
this.loading = false; "green"
}); );
}, this.$store.dispatch("getSheets");
removeSheet(sheetId) { } else {
this.loading = true; throw new Error(JSON.stringify(res));
fetch(`${this.$store.state.API_ENDPOINT}/sheet/${sheetId}`, { }
method: "DELETE", })
headers: { .catch((error) => {
"Content-Type": "application/json", console.error("/sheet/mine ", error);
Authorization: `Bearer ${this.$store.state.access_token}`, this.showSnackbar(
} `Unable to trigger sheet archive: ${error.message}`
}).then(async response => { );
const res = await response.json(); })
if (response.status === 200 && res.deleted) { .finally(() => {
this.showSnackbar(`Sheet ${sheetId} has been removed!`, "green"); this.loading = false;
this.$store.dispatch("getSheets"); });
this.$store.dispatch("checkUserUsage"); },
} else { removeSheet(sheetId) {
throw new Error(JSON.stringify(res)); this.loading = true;
} fetch(`${this.$store.state.API_ENDPOINT}/sheet/${sheetId}`, {
}).catch(error => { method: "DELETE",
console.error("/sheet/mine ", error); headers: {
this.showSnackbar(`Unable to remove sheet: ${error.message}`); "Content-Type": "application/json",
}).finally(() => { Authorization: `Bearer ${this.$store.state.access_token}`,
this.loading = false; },
}); })
}, .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> </router-link>
</v-toolbar-title> </v-toolbar-title>
<v-chip
<v-chip v-if="$store.state.errorMessage" :title="$store.state.errorMessage" color="red" variant="tonal" closable v-if="$store.state.errorMessage"
class="mx-4"> :title="$store.state.errorMessage"
color="red"
variant="tonal"
closable
class="mx-4"
>
ERROR: {{ $store.state.errorMessage }} ERROR: {{ $store.state.errorMessage }}
</v-chip> </v-chip>
<v-spacer v-if="!smAndDown"></v-spacer> <v-spacer v-if="!smAndDown"></v-spacer>
<div v-if="user?.active && !smAndDown"> <div v-if="user?.active && !smAndDown">
<template v-for="btn in btns"> <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 }} {{ btn.text }}
<v-tooltip activator="parent" location="bottom">{{ btn.tooltip }}</v-tooltip> <v-tooltip activator="parent" location="bottom">{{
btn.tooltip
}}</v-tooltip>
</v-btn> </v-btn>
</template> </template>
</div> </div>
<v-spacer v-if="!smAndDown"></v-spacer> <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"> <span v-if="!loadingUserState">
<v-chip v-if="user.active" color="green" class="bg-white" prepend-icon="mdi-checkbox-marked-circle" <v-chip
variant="outlined"> v-if="user.active"
color="green"
class="bg-white"
prepend-icon="mdi-checkbox-marked-circle"
variant="outlined"
>
active active
</v-chip> </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 inactive
</v-chip> </v-chip>
<v-tooltip activator="parent" location="bottom">{{ activeUserMessage }}</v-tooltip> <v-tooltip activator="parent" location="bottom">{{
activeUserMessage
}}</v-tooltip>
</span> </span>
<span class="ms-2">{{ user.email }}</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" <v-btn
@click="$store.dispatch('signout')">Sign Out</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> </span>
<v-btn v-if="!user" @click="$store.dispatch('signin')">Sign In</v-btn> <v-btn v-if="!user" @click="$store.dispatch('signin')">Sign In</v-btn>
@@ -48,21 +85,33 @@
</template> </template>
<v-list> <v-list>
<v-list-item v-for="btn in btns" :key="btn.to" :to="btn.to"> <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 }} {{ btn.text }}
</v-btn> </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>
<v-list-item @click="$store.dispatch('signout')"> <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-item>
</v-list> </v-list>
</v-menu> </v-menu>
</v-app-bar> </v-app-bar>
</template> </template>
<script setup> <script setup>
import { useDisplay } from 'vuetify' import { useDisplay } from "vuetify";
const { smAndDown } = useDisplay(); const { smAndDown } = useDisplay();
</script> </script>
@@ -73,10 +122,25 @@ export default {
return { return {
drawer: false, drawer: false,
btns: [ 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: "/",
{ to: "/archives", icon: "mdi-magnify", text: "Archives", tooltip: "Search for archived URLs." }, 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: { computed: {
@@ -89,9 +153,9 @@ export default {
} }
return "This account is inactive, please reach out to the Bellingcat team for access."; return "This account is inactive, please reach out to the Bellingcat team for access.";
}, },
loadingUserState() { loadingUserState() {
return this.$store.state?.loadingUserState; return this.$store.state?.loadingUserState;
} },
}, },
}; };
</script> </script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
import { createStore } from "vuex"; import { createStore } from "vuex";
import { gapi, client } from "@/gapi"; import { gapi } from "@/gapi";
import { import {
signOut, signOut,
GoogleAuthProvider, GoogleAuthProvider,
signInWithCredential, signInWithCredential,
browserLocalPersistence,
setPersistence,
} from "firebase/auth"; } from "firebase/auth";
import { collection, } from "firebase/firestore"; import { firebaseAuth } from "@/firebase.js";
import { firebaseAuth, firebaseFirestore } from "@/firebase.js";
function saveToLocalStorage(state) { function saveToLocalStorage(state) {
localStorage.setItem("user", JSON.stringify(state.user)); localStorage.setItem("user", JSON.stringify(state.user));
@@ -25,7 +26,7 @@ function clearLocalStorage() {
} }
async function waitForGapiAuth2() { async function waitForGapiAuth2() {
return new Promise((resolve, reject) => { return new Promise((resolve, _reject) => {
const checkGapiAuth2 = () => { const checkGapiAuth2 = () => {
if (gapi.auth2 && gapi.auth2.getAuthInstance()) { if (gapi.auth2 && gapi.auth2.getAuthInstance()) {
resolve(gapi.auth2.getAuthInstance()); resolve(gapi.auth2.getAuthInstance());
@@ -45,9 +46,8 @@ export default createStore({
sheets: [], sheets: [],
loadingUserState: false, loadingUserState: false,
errorMessage: "", errorMessage: "",
// # TODO: reenable production API endpoint
// API_ENDPOINT: "https://auto-archiver-api.bellingcat.com" // 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: { mutations: {
setUser(state, user) { setUser(state, user) {
@@ -60,7 +60,9 @@ export default createStore({
}, },
setUserPermissions(state, permissions) { setUserPermissions(state, permissions) {
state.user.permissions = 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; state.loadingUserState = false;
saveToLocalStorage(state); saveToLocalStorage(state);
}, },
@@ -90,6 +92,10 @@ export default createStore({
commit("setAccessToken", access_token); commit("setAccessToken", access_token);
const credential = GoogleAuthProvider.credential(null, 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); const response = await signInWithCredential(firebaseAuth, credential);
commit("setUser", response.user); commit("setUser", response.user);
@@ -108,7 +114,7 @@ export default createStore({
callback, callback,
}); });
client.requestAccessToken(); await client.requestAccessToken();
}, },
async signout({ commit }) { async signout({ commit }) {
@@ -138,16 +144,14 @@ export default createStore({
async checkActiveUser({ state, dispatch, commit }) { async checkActiveUser({ state, dispatch, commit }) {
try { try {
commit("setErrorMessage", ""); commit("setErrorMessage", "");
const r = await fetch( console.log(`${state.API_ENDPOINT}/user/active`);
`${state.API_ENDPOINT}/user/active`, const r = await fetch(`${state.API_ENDPOINT}/user/active`, {
{ method: "GET",
method: "GET", headers: {
headers: { "Content-Type": "application/json",
"Content-Type": "application/json", Authorization: `Bearer ${state.access_token}`,
Authorization: `Bearer ${state.access_token}`, },
}, });
}
)
const response = await r.json(); const response = await r.json();
commit("setUserActiveState", response.active); commit("setUserActiveState", response.active);
if (response.active === true) { if (response.active === true) {
@@ -155,48 +159,51 @@ export default createStore({
} }
} catch (error) { } catch (error) {
console.error("checkActiveUser (firebase.js): ", 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 }) { async checkUserPermissions({ state, commit }) {
try { try {
commit("setErrorMessage", ""); commit("setErrorMessage", "");
const r = await fetch( const r = await fetch(`${state.API_ENDPOINT}/user/permissions`, {
`${state.API_ENDPOINT}/user/permissions`, method: "GET",
{ headers: {
method: "GET", "Content-Type": "application/json",
headers: { Authorization: `Bearer ${state.access_token}`,
"Content-Type": "application/json", },
Authorization: `Bearer ${state.access_token}`, });
},
}
);
const response = await r.json(); const response = await r.json();
commit("setUserPermissions", response); commit("setUserPermissions", response);
} catch (error) { } catch (error) {
console.error("checkUserPermissions (firebase.js): ", 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 }) { async checkUserUsage({ state, commit }) {
try { try {
commit("setErrorMessage", ""); commit("setErrorMessage", "");
const r = await fetch( const r = await fetch(`${state.API_ENDPOINT}/user/usage`, {
`${state.API_ENDPOINT}/user/usage`, method: "GET",
{ headers: {
method: "GET", "Content-Type": "application/json",
headers: { Authorization: `Bearer ${state.access_token}`,
"Content-Type": "application/json", },
Authorization: `Bearer ${state.access_token}`, });
},
}
);
const response = await r.json(); const response = await r.json();
commit("setUserUsage", response); commit("setUserUsage", response);
} catch (error) { } catch (error) {
console.error("checkUserUsage (firebase.js): ", 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: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${state.access_token}`, Authorization: `Bearer ${state.access_token}`,
} },
}).then(async response => { }).then(async (response) => {
const res = await response.json(); const res = await response.json();
if (response.status === 200) { if (response.status === 200) {
commit("setSheets", res); commit("setSheets", res);
@@ -219,13 +226,14 @@ export default createStore({
throw new Error(JSON.stringify(res)); throw new Error(JSON.stringify(res));
} }
}); });
} catch (error) { } catch (error) {
console.error("getSheets (firebase.js): ", 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) => { return new Promise(async (resolve, reject) => {
try { try {
// create new sheet // create new sheet
@@ -354,8 +362,7 @@ export default createStore({
resource: { resource: {
role: "writer", role: "writer",
type: "user", type: "user",
emailAddress: emailAddress: service_account_email,
service_account_email,
}, },
}); });
@@ -374,7 +381,9 @@ export default createStore({
isTokenExpired: async (state) => { isTokenExpired: async (state) => {
if (!state.access_token) return true; if (!state.access_token) return true;
try { 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; if (response.status !== 200) return true;
const data = await response.json(); const data = await response.json();
if (data.expires_in > 0) return false; if (data.expires_in > 0) return false;
@@ -398,18 +407,20 @@ export default createStore({
store.commit("setLoadingUserState", true); store.commit("setLoadingUserState", true);
store.commit("setUser", user); store.commit("setUser", user);
store.commit("setAccessToken", access_token); store.commit("setAccessToken", access_token);
store.getters.isTokenExpired.then((expired) => { store.getters.isTokenExpired
if (expired) { .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"); 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) { export function urlValidator(url) {
if (!url) return true; if (!url) return true;
if (url.length < 10) return "URL is too short"; if (url.length < 10) return "URL is too short";
try { try {
new URL(url); new URL(url);
return true; return true;
} catch (_) { } catch (_) {
return "Not a valid URL"; return "Not a valid URL";
} }
} }
export function getUrlFromResult(item) { export function getUrlFromResult(item) {
const final_media = item.result?.media?.filter(m => m?.properties?.id == '_final_media'); const final_media = item.result?.media?.filter(
if (final_media && final_media.length > 0) { (m) => m?.properties?.id == "_final_media"
return final_media[0].urls; );
} if (final_media && final_media.length > 0) {
}; return final_media[0].urls;
}
}

View File

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

View File

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

View File

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

11087
yarn.lock

File diff suppressed because it is too large Load Diff