mirror of
https://github.com/bellingcat/auto-archiver-setup-tool.git
synced 2026-06-07 19:18:36 +03:00
final updates before release
This commit is contained in:
@@ -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);
|
||||
// });
|
||||
// }
|
||||
// );
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
32
src/App.vue
32
src/App.vue
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user