mirror of
https://github.com/bellingcat/auto-archiver-setup-tool.git
synced 2026-06-11 21:18:38 +03:00
@@ -4,7 +4,7 @@ module.exports = {
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
"plugin:vue/essential",
|
||||
"plugin:vue/base",
|
||||
"eslint:recommended",
|
||||
"plugin:prettier/recommended",
|
||||
],
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,3 +22,4 @@ pnpm-debug.log*
|
||||
*.sln
|
||||
*.sw?
|
||||
firebase-debug.log
|
||||
ui.debug.log
|
||||
40
README.md
40
README.md
@@ -1,36 +1,34 @@
|
||||
# firebase-archiver-2
|
||||
# Aut Archiver Setup Tool
|
||||
This project is a firebase/vue app that can connect to an instance of the [Auto Archive API](https://github.com/bellingcat/auto-archiver-api).
|
||||
|
||||
## Project setup
|
||||
```
|
||||

|
||||
|
||||
## development with yarn
|
||||
```bash
|
||||
# setup
|
||||
yarn install
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
```
|
||||
# Compiles and hot-reloads for development
|
||||
yarn serve
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
```
|
||||
# Compiles and minifies for production
|
||||
yarn build
|
||||
```
|
||||
# Previews the production build
|
||||
yarn preview
|
||||
|
||||
### Lints and fixes files
|
||||
```
|
||||
# Lints and fixes files
|
||||
yarn lint
|
||||
```
|
||||
|
||||
### Customize configuration
|
||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||
### Release/development process
|
||||
> You need a [firebase APP](https://firebase.google.com/) and a Google project, and as it stands you need to update the hardcoded APP identifiers ([here](src/store/index.js) and [here](firebase.json)); a PR on isolating that to .env is welcome.
|
||||
|
||||
|
||||
### Release process
|
||||
1. install firebase locally
|
||||
2. login to your firebase account with `firebase login`
|
||||
3. make sure you have access to the project `firebase projects:list`
|
||||
4. build `yarn build` and then release `firebase deploy --only hosting`
|
||||
5. to update schedule functions `firebase deploy --only functions`
|
||||
4. build `yarn build` and check with `yarn preview`, once all is good release `firebase deploy --only hosting`
|
||||
5. to update schedule functions `firebase deploy --only functions` currently these are disabled
|
||||
6. if you add any library to a function, install it inside the `/functions` folder and not in the root folder
|
||||
7. to update secrets use `firebase functions:secrets:set SECRET_NAME` more info [here](https://firebase.google.com/docs/functions/config-env?gen=2nd#managing_secrets)
|
||||
1. `API_SERVICE_PASSWORD` for the auto-archiver-api
|
||||
2. `GOOGLE_API_CLIENT_EMAIL` and `GOOGLE_API_PRIVATE_KEY` for the scheduled function to validate sheets exist
|
||||
<!-- 7. to update secrets use `firebase functions:secrets:set SECRET_NAME` more info [here](https://firebase.google.com/docs/functions/config-env?gen=2nd#managing_secrets) -->
|
||||
<!-- 1. `API_SERVICE_PASSWORD` for the auto-archiver-api -->
|
||||
<!-- 2. `GOOGLE_API_CLIENT_EMAIL` and `GOOGLE_API_PRIVATE_KEY` for the scheduled function to validate sheets exist -->
|
||||
BIN
docs/image.png
Normal file
BIN
docs/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
@@ -1,97 +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 { google } = require('googleapis');
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
// initializeApp();
|
||||
// const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
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;
|
||||
}
|
||||
// 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;
|
||||
// }
|
||||
|
||||
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 });
|
||||
// //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 });
|
||||
|
||||
// get all documents from firestore sheets collection
|
||||
const db = getFirestore();
|
||||
// // get all documents from firestore sheets collection
|
||||
// const db = getFirestore();
|
||||
|
||||
// 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()})`);
|
||||
// // 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()})`);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
// }
|
||||
// }
|
||||
|
||||
// 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),
|
||||
};
|
||||
// // 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),
|
||||
// };
|
||||
|
||||
const response = await fetch(url, options);
|
||||
console.log(response);
|
||||
// const response = await fetch(url, options);
|
||||
|
||||
await doc.ref.update({ lastArchived: Date.now() });
|
||||
// await doc.ref.update({ lastArchived: Date.now() });
|
||||
|
||||
await sleep(100);
|
||||
});
|
||||
}
|
||||
);
|
||||
// await sleep(100);
|
||||
// });
|
||||
// }
|
||||
// );
|
||||
|
||||
6342
package-lock.json
generated
6342
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "firebase-archiver-2",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve --port 8081",
|
||||
"build": "vue-cli-service build",
|
||||
"serve": "vue-cli-service serve --port 8081 --skip-plugins @vue/cli-plugin-eslint",
|
||||
"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": {
|
||||
@@ -14,10 +15,11 @@
|
||||
"firebaseui": "^6.0.2",
|
||||
"gapi-script": "^1.2.0",
|
||||
"googleapis": "^134.0.0",
|
||||
"vue": "^2.6.14",
|
||||
"vue-router": "^3.5.1",
|
||||
"vuetify": "^2.6.15",
|
||||
"vuex": "^3.6.2",
|
||||
"moment": "^2.30.1",
|
||||
"vue": "^3",
|
||||
"vue-router": "^4",
|
||||
"vuetify": "^3",
|
||||
"vuex": "^4",
|
||||
"vuex-easy-firestore": "^1.37.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -34,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"
|
||||
}
|
||||
}
|
||||
|
||||
100
src/App.vue
100
src/App.vue
@@ -1,16 +1,39 @@
|
||||
<template>
|
||||
<div class="pane">
|
||||
<v-app class="bg">
|
||||
<NavBar />
|
||||
<v-main>
|
||||
<router-view />
|
||||
</v-main>
|
||||
<v-footer class="legal">
|
||||
<router-link to="/privacy">Privacy Policy</router-link>
|
||||
<router-link to="/tos">Terms of Service</router-link>
|
||||
</v-footer>
|
||||
</v-app>
|
||||
</div>
|
||||
<v-app class="bg">
|
||||
<NavBar />
|
||||
<v-main>
|
||||
<router-view />
|
||||
</v-main>
|
||||
<v-footer class="text-center py-6">
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<div class="legal py-2">
|
||||
<router-link to="/privacy">Privacy Policy</router-link>
|
||||
<router-link to="/tos">Terms of Service</router-link>
|
||||
</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-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>
|
||||
|
||||
<script>
|
||||
@@ -25,29 +48,70 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--v-card-text-opacity: 0.85;
|
||||
}
|
||||
|
||||
.pane {
|
||||
max-width: 800px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
height: 100vh;
|
||||
background-color: #d6e8de !important;
|
||||
}
|
||||
|
||||
.pane-l {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.bg {
|
||||
background-color: #d6e8de !important;
|
||||
background-color: #b2dfdb !important;
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: #d6e8de;
|
||||
background-color: #b2dfdb;
|
||||
}
|
||||
|
||||
.legal a {
|
||||
margin-left: 2em;
|
||||
margin-right: 2em;
|
||||
color: inherit !important;
|
||||
font-weight: 500;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
padding: 0.2em 0.4em;
|
||||
}
|
||||
|
||||
.legal {
|
||||
justify-content: center;
|
||||
footer,
|
||||
.v-footer,
|
||||
.v-footer div {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.v-footer {
|
||||
max-height: 125px;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.v-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
code {
|
||||
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: 0.0092em;
|
||||
}
|
||||
</style>
|
||||
|
||||
320
src/components/AddSheet.vue
Normal file
320
src/components/AddSheet.vue
Normal file
@@ -0,0 +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>
|
||||
|
||||
<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,
|
||||
|
||||
sheetName: ``.trim(),
|
||||
sheetUrlId: ``,
|
||||
|
||||
group: "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;
|
||||
|
||||
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>
|
||||
128
src/components/ArchiveSheet.vue
Normal file
128
src/components/ArchiveSheet.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<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-tabs>
|
||||
|
||||
<v-tabs-window v-model="tab" class="elevation-1 rounded">
|
||||
<v-tabs-window-item :value="items[0]">
|
||||
<v-card-text>
|
||||
<AddSheet :actionIsCreate="true" />
|
||||
<v-expansion-panels elevation="0">
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title>Instructions</v-expansion-panel-title>
|
||||
<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 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.
|
||||
</small>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card-text>
|
||||
</v-tabs-window-item>
|
||||
<v-tabs-window-item :value="items[1]">
|
||||
<v-card-text>
|
||||
<AddSheet :actionIsCreate="false" />
|
||||
<v-expansion-panels elevation="0">
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title>Instructions</v-expansion-panel-title>
|
||||
<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:
|
||||
<ul>
|
||||
<li><code>Link</code> where you will put the URLs</li>
|
||||
<li>
|
||||
<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
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
Add any of the following <b>optional</b> column names:
|
||||
<ul>
|
||||
<li>
|
||||
<code>Archive date</code> info on when archiving
|
||||
occurred
|
||||
</li>
|
||||
<li>
|
||||
<code>Thumbnail</code> an image preview from archived
|
||||
media
|
||||
</li>
|
||||
<li>
|
||||
<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)
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Choose a name to associate with this Sheet</li>
|
||||
<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
|
||||
</li>
|
||||
</ol>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card-text>
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AddSheet from "@/components/AddSheet.vue";
|
||||
|
||||
export default {
|
||||
name: "ArchiveSheet",
|
||||
components: {
|
||||
AddSheet,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tab: "",
|
||||
items: ["Create new Archiver Sheet", "Add existing Sheet"],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,77 +0,0 @@
|
||||
<template>
|
||||
<v-card class="doc">
|
||||
<v-card-title>{{ doc.name }}</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="doc-timestamp">
|
||||
Created: {{ new Date(doc.timestamp).toLocaleString() }}
|
||||
</div>
|
||||
<div class="doc-archived">
|
||||
Last archived:
|
||||
{{
|
||||
doc.lastArchived
|
||||
? new Date(doc.lastArchived).toLocaleString()
|
||||
: "never"
|
||||
}}
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-btn :href="doc.url" target="_blank"
|
||||
>Open sheet
|
||||
<v-icon small style="margin-left: 1em">mdi-open-in-new</v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-btn @click="$store.dispatch('archive', doc)">Archive now</v-btn>
|
||||
</v-col>
|
||||
<v-dialog width="500" v-model="dialog" persistent :retain-focus="false">
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-col class="text-right">
|
||||
<v-btn color="#f2d97c" right v-bind="attrs" v-on="on"
|
||||
>Stop archiving</v-btn
|
||||
>
|
||||
</v-col>
|
||||
</template>
|
||||
|
||||
<v-card>
|
||||
<v-card-title>Stop archiving "{{ doc.name }}"? </v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
This will stop archiving the sheet, but will not delete the sheet
|
||||
or any of its data from your Google Drive.
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions>
|
||||
<v-btn @click="dialog = false" color="primary">Cancel</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="red" text @click="remove"> Stop archiving </v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-row>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "DocCard",
|
||||
props: {
|
||||
doc: Object,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dialog: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
remove() {
|
||||
this.dialog = false;
|
||||
this.$store.dispatch("removeDoc", this.doc.id);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,30 +0,0 @@
|
||||
<template>
|
||||
<div style="margin-bottom: 1em">
|
||||
<div class="text-h5 mt-5 mb-3" v-if="docs.length > 0">
|
||||
Your auto-archiver sheets
|
||||
</div>
|
||||
<v-row v-for="doc in docs" :key="doc.sheetId">
|
||||
<v-col>
|
||||
<DocCard :doc="doc" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DocCard from "@/components/DocCard.vue";
|
||||
|
||||
export default {
|
||||
name: "DocList",
|
||||
components: {
|
||||
DocCard,
|
||||
},
|
||||
computed: {
|
||||
docs() {
|
||||
return this.$store.state.docs;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
199
src/components/ManageSheets.vue
Normal file
199
src/components/ManageSheets.vue
Normal file
@@ -0,0 +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-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"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SnackBar from "@/components/SnackBar.vue";
|
||||
|
||||
export default {
|
||||
name: "ManageSheets",
|
||||
components: {
|
||||
SnackBar,
|
||||
},
|
||||
props: {},
|
||||
data() {
|
||||
return {
|
||||
snackbar: false,
|
||||
snackbarMessage: "",
|
||||
snackbarColor: "red",
|
||||
|
||||
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>
|
||||
@@ -1,28 +1,161 @@
|
||||
<template>
|
||||
<v-app-bar style="flex-grow: 0" class="text-no-wrap">
|
||||
<v-toolbar-title
|
||||
><router-link to="/" class="nodecoration"
|
||||
>Bellingcat Auto Archiver demo tool</router-link
|
||||
></v-toolbar-title
|
||||
<v-toolbar-title title="Bellingcat Auto Archiver prototype">
|
||||
<router-link to="/" class="nodecoration">
|
||||
Bellingcat Auto Archiver prototype
|
||||
</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-spacer></v-spacer>
|
||||
<v-btn v-if="!user" @click="$store.dispatch('signin')">Sign In</v-btn>
|
||||
<span class="user" v-if="user">
|
||||
{{ user.email }}
|
||||
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"
|
||||
>
|
||||
{{ btn.text }}
|
||||
<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 v-if="!loadingUserState">
|
||||
<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"
|
||||
>
|
||||
inactive
|
||||
</v-chip>
|
||||
|
||||
<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
|
||||
>
|
||||
</span>
|
||||
<v-btn v-if="user" href="#" @click="$store.dispatch('signout')"
|
||||
>Sign Out</v-btn
|
||||
>
|
||||
|
||||
<v-btn v-if="!user" @click="$store.dispatch('signin')">Sign In</v-btn>
|
||||
|
||||
<v-menu v-if="user?.active && smAndDown">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-app-bar-nav-icon v-bind="props"></v-app-bar-nav-icon>
|
||||
</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"
|
||||
>
|
||||
{{ btn.text }}
|
||||
</v-btn>
|
||||
<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-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-app-bar>
|
||||
</template>
|
||||
<script setup>
|
||||
import { useDisplay } from "vuetify";
|
||||
const { smAndDown } = useDisplay();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "NavBar",
|
||||
data() {
|
||||
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.",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user;
|
||||
},
|
||||
activeUserMessage() {
|
||||
if (this.user && this.user.active) {
|
||||
return "This account has access to at least one feature.";
|
||||
}
|
||||
return "This account is inactive, please reach out to the Bellingcat team for access.";
|
||||
},
|
||||
loadingUserState() {
|
||||
return this.$store.state?.loadingUserState;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
39
src/components/PermissionNeeded.vue
Normal file
39
src/components/PermissionNeeded.vue
Normal file
@@ -0,0 +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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "PermissionNeeded",
|
||||
props: {
|
||||
feature: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
loadingUserState() {
|
||||
return this.$store.state?.loadingUserState;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
63
src/components/SnackBar.vue
Normal file
63
src/components/SnackBar.vue
Normal file
@@ -0,0 +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>
|
||||
</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);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
67
src/components/WelcomeCard.vue
Normal file
67
src/components/WelcomeCard.vue
Normal file
@@ -0,0 +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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "WelcomeCard",
|
||||
props: {},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user;
|
||||
},
|
||||
loadingUserState() {
|
||||
return this.$store.state?.loadingUserState;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
30
src/main.js
30
src/main.js
@@ -1,18 +1,26 @@
|
||||
import Vue from "vue";
|
||||
import Vuetify from "vuetify";
|
||||
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 App from "./App.vue";
|
||||
import router from "./router";
|
||||
import store from "./store";
|
||||
import "vuetify/dist/vuetify.min.css";
|
||||
import moment from "moment";
|
||||
import "vuetify/styles";
|
||||
import "@mdi/font/css/materialdesignicons.css";
|
||||
import "./styles/global.css";
|
||||
|
||||
Vue.use(Vuetify);
|
||||
const vuetify = createVuetify({
|
||||
components: { ...components, VDateInput },
|
||||
directives,
|
||||
});
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
const app = createApp(App);
|
||||
|
||||
new Vue({
|
||||
router,
|
||||
store,
|
||||
vuetify: new Vuetify(),
|
||||
render: (h) => h(App),
|
||||
}).$mount("#app");
|
||||
app.use(router);
|
||||
app.use(store);
|
||||
app.use(vuetify);
|
||||
app.config.globalProperties.$moment = moment;
|
||||
|
||||
app.mount("#app");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Vue from "vue";
|
||||
import VueRouter from "vue-router";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import HomeView from "../views/HomeView.vue";
|
||||
import ArchiveSearchView from "../views/ArchiveSearchView.vue";
|
||||
|
||||
Vue.use(VueRouter);
|
||||
import ArchiveUrlView from "../views/ArchiveUrlView.vue";
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -10,6 +10,16 @@ const routes = [
|
||||
name: "home",
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: "/url",
|
||||
name: "URL Archiving",
|
||||
component: ArchiveUrlView,
|
||||
},
|
||||
{
|
||||
path: "/archives",
|
||||
name: "Archives search",
|
||||
component: ArchiveSearchView,
|
||||
},
|
||||
{
|
||||
path: "/privacy",
|
||||
name: "Privacy Policy",
|
||||
@@ -22,11 +32,14 @@ const routes = [
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "tos" */ "../views/TOSView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
redirect: "/",
|
||||
},
|
||||
];
|
||||
|
||||
const router = new VueRouter({
|
||||
mode: "history",
|
||||
base: process.env.BASE_URL,
|
||||
const router = createRouter({
|
||||
history: createWebHistory(process.env.BASE_URL),
|
||||
routes,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,49 +1,84 @@
|
||||
import Vue from "vue";
|
||||
import Vuex from "vuex";
|
||||
|
||||
/* eslint-disable */
|
||||
// eslint-disable-next-line
|
||||
import { gapi, client } from "@/gapi";
|
||||
import { createStore } from "vuex";
|
||||
import { gapi } from "@/gapi";
|
||||
import {
|
||||
signOut,
|
||||
GoogleAuthProvider,
|
||||
signInWithCredential,
|
||||
browserLocalPersistence,
|
||||
setPersistence,
|
||||
} from "firebase/auth";
|
||||
import {
|
||||
collection,
|
||||
addDoc,
|
||||
query,
|
||||
where,
|
||||
limit,
|
||||
getDocs,
|
||||
doc,
|
||||
deleteDoc,
|
||||
updateDoc,
|
||||
} from "firebase/firestore";
|
||||
import { firebaseAuth, firebaseFirestore } from "@/firebase.js";
|
||||
import { firebaseAuth } from "@/firebase.js";
|
||||
|
||||
Vue.use(Vuex);
|
||||
function saveToLocalStorage(state) {
|
||||
localStorage.setItem("user", JSON.stringify(state.user));
|
||||
localStorage.setItem("access_token", state.access_token);
|
||||
}
|
||||
|
||||
export default new Vuex.Store({
|
||||
function loadFromLocalStorage() {
|
||||
const user = JSON.parse(localStorage.getItem("user"));
|
||||
const access_token = localStorage.getItem("access_token");
|
||||
return { user, access_token };
|
||||
}
|
||||
|
||||
function clearLocalStorage() {
|
||||
localStorage.removeItem("user");
|
||||
localStorage.removeItem("access_token");
|
||||
}
|
||||
|
||||
async function waitForGapiAuth2() {
|
||||
return new Promise((resolve, _reject) => {
|
||||
const checkGapiAuth2 = () => {
|
||||
if (gapi.auth2 && gapi.auth2.getAuthInstance()) {
|
||||
resolve(gapi.auth2.getAuthInstance());
|
||||
} else {
|
||||
setTimeout(checkGapiAuth2, 100);
|
||||
}
|
||||
};
|
||||
checkGapiAuth2();
|
||||
});
|
||||
}
|
||||
|
||||
export default createStore({
|
||||
state: {
|
||||
user: null,
|
||||
active: false,
|
||||
access_token: null,
|
||||
docs: [],
|
||||
loading: false,
|
||||
sheets: [],
|
||||
loadingUserState: false,
|
||||
errorMessage: "",
|
||||
// API_ENDPOINT: "https://auto-archiver-api.bellingcat.com"
|
||||
API_ENDPOINT: process.env.VUE_APP_API_ENDPOINT || "http://localhost:8004",
|
||||
},
|
||||
mutations: {
|
||||
setUser(state, user) {
|
||||
state.user = user;
|
||||
saveToLocalStorage(state);
|
||||
},
|
||||
setDocs(state, docs) {
|
||||
state.docs = docs;
|
||||
setUserActiveState(state, active) {
|
||||
state.user.active = active;
|
||||
saveToLocalStorage(state);
|
||||
},
|
||||
setLoading(state, loading) {
|
||||
state.loading = loading;
|
||||
setUserPermissions(state, permissions) {
|
||||
state.user.permissions = permissions;
|
||||
state.user.groups = Object.keys(permissions).filter(
|
||||
(key) => key !== "all"
|
||||
);
|
||||
state.loadingUserState = false;
|
||||
saveToLocalStorage(state);
|
||||
},
|
||||
setUserUsage(state, usage) {
|
||||
state.user.usage = usage;
|
||||
},
|
||||
setSheets(state, sheets) {
|
||||
state.sheets = sheets;
|
||||
},
|
||||
setLoadingUserState(state, loadingUserState) {
|
||||
state.loadingUserState = loadingUserState;
|
||||
saveToLocalStorage(state);
|
||||
},
|
||||
setAccessToken(state, access_token) {
|
||||
state.access_token = access_token;
|
||||
saveToLocalStorage(state);
|
||||
},
|
||||
setErrorMessage(state, errorMessage) {
|
||||
state.errorMessage = errorMessage;
|
||||
@@ -51,20 +86,26 @@ export default new Vuex.Store({
|
||||
},
|
||||
actions: {
|
||||
async signin({ commit, dispatch }) {
|
||||
commit("setLoadingUserState", true);
|
||||
async function callback(tokenResponse) {
|
||||
let access_token = tokenResponse.access_token;
|
||||
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);
|
||||
dispatch("getDocs");
|
||||
dispatch("checkActiveUser");
|
||||
dispatch("checkUserPermissions");
|
||||
dispatch("checkUserUsage");
|
||||
}
|
||||
|
||||
commit("setUser", null);
|
||||
|
||||
// eslint-disable-next-line
|
||||
const client = google.accounts.oauth2.initTokenClient({
|
||||
client_id:
|
||||
"406209235111-r1mpkvkfaqc2jg5iqbvffl2b0rf4clbo.apps.googleusercontent.com",
|
||||
@@ -73,296 +114,314 @@ export default new Vuex.Store({
|
||||
callback,
|
||||
});
|
||||
|
||||
client.requestAccessToken();
|
||||
await client.requestAccessToken();
|
||||
},
|
||||
|
||||
async signout({ commit }) {
|
||||
console.log("sign out");
|
||||
try {
|
||||
await gapi.auth2.getAuthInstance().signOut();
|
||||
console.log("User is signed out from gapi.");
|
||||
const authInstance = await waitForGapiAuth2();
|
||||
if (authInstance) {
|
||||
await authInstance.signOut();
|
||||
console.log("User is signed out from gapi.");
|
||||
} else {
|
||||
console.warn("gapi.auth2 is not initialized.");
|
||||
}
|
||||
|
||||
await signOut(firebaseAuth);
|
||||
console.log("User is signed out from firebase.");
|
||||
|
||||
// clean user from store
|
||||
// clean user from store and local storage
|
||||
commit("setUser", null);
|
||||
commit("setDocs", []);
|
||||
commit("setSheets", []);
|
||||
clearLocalStorage();
|
||||
} catch (error) {
|
||||
console.error("signOutUser (firebase/auth.js): ", error);
|
||||
} finally {
|
||||
commit("setLoadingUserState", false);
|
||||
}
|
||||
},
|
||||
|
||||
async getDocs({ state, commit }) {
|
||||
async checkActiveUser({ state, dispatch, commit }) {
|
||||
try {
|
||||
// get documents where uid matches user
|
||||
|
||||
const q = query(
|
||||
collection(firebaseFirestore, "sheets"),
|
||||
where("uid", "==", state.user.uid)
|
||||
);
|
||||
|
||||
const response = await getDocs(q);
|
||||
|
||||
const docs = response.docs.map((d) => ({ id: d.id, ...d.data() }));
|
||||
commit("setDocs", docs);
|
||||
commit("setLoading", false);
|
||||
} catch (error) {
|
||||
console.error("getDocs (firebase.js): ", error);
|
||||
}
|
||||
},
|
||||
|
||||
async removeDoc({ dispatch }, id) {
|
||||
try {
|
||||
await deleteDoc(doc(firebaseFirestore, "sheets", id));
|
||||
|
||||
dispatch("getDocs");
|
||||
} catch (error) {
|
||||
console.error("removeDocs (firebase.js): ", error);
|
||||
}
|
||||
},
|
||||
|
||||
async archive({ state, dispatch }, sheet) {
|
||||
try {
|
||||
// send a post request to the API with the sheet ID in the body
|
||||
// and a bearer auth token in the header
|
||||
await fetch(
|
||||
"https://auto-archiver-api.bellingcat.com/sheet",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${state.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sheet_id: sheet.sheetId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// update firestore with the archive status
|
||||
const docRef = doc(firebaseFirestore, "sheets", sheet.id);
|
||||
|
||||
await updateDoc(docRef, {
|
||||
lastArchived: Date.now(),
|
||||
});
|
||||
|
||||
// update the store
|
||||
dispatch("getDocs");
|
||||
} catch (error) {
|
||||
console.error("archive (firebase.js): ", error);
|
||||
}
|
||||
},
|
||||
|
||||
async add({ state, dispatch, commit }, { name }) {
|
||||
commit("setLoading", true);
|
||||
|
||||
try {
|
||||
// create new sheet
|
||||
const newSheet = await gapi.client.sheets.spreadsheets.create({
|
||||
properties: {
|
||||
title: name,
|
||||
commit("setErrorMessage", "");
|
||||
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 spreadsheetId = newSheet.result.spreadsheetId;
|
||||
|
||||
const userEnteredFormat = {
|
||||
textFormat: {
|
||||
bold: true,
|
||||
},
|
||||
};
|
||||
|
||||
// add header row
|
||||
await gapi.client.sheets.spreadsheets.batchUpdate(
|
||||
{
|
||||
spreadsheetId: spreadsheetId,
|
||||
},
|
||||
{
|
||||
requests: [
|
||||
{
|
||||
updateCells: {
|
||||
rows: [
|
||||
{
|
||||
values: [
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Link",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Archive status",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Destination folder",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Archive location",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Archive date",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Thumbnail",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Upload timestamp",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Upload title",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Textual content",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Screenshot",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Hash",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
// {
|
||||
// userEnteredValue: {
|
||||
// stringValue: "WACZ",
|
||||
// },
|
||||
// userEnteredFormat,
|
||||
// },
|
||||
// {
|
||||
// userEnteredValue: {
|
||||
// stringValue: "Replaywebpage",
|
||||
// },
|
||||
// userEnteredFormat,
|
||||
// },
|
||||
],
|
||||
},
|
||||
],
|
||||
fields:
|
||||
"userEnteredValue.stringValue,userEnteredFormat.textFormat.bold",
|
||||
start: {
|
||||
sheetId: 0,
|
||||
rowIndex: 0,
|
||||
columnIndex: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
addProtectedRange: {
|
||||
protectedRange: {
|
||||
range: {
|
||||
sheetId: 0,
|
||||
startRowIndex: 0,
|
||||
endRowIndex: 1,
|
||||
startColumnIndex: 0,
|
||||
endColumnIndex: 11,
|
||||
},
|
||||
description:
|
||||
"Protecting header row (needed for auto-archiver), do not modify archiving column names, you can add and move columns around when no 'Archive in Progress' is present in the 'Archive status' column.",
|
||||
warningOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
// add permissions
|
||||
await gapi.client.drive.permissions.create({
|
||||
fileId: spreadsheetId,
|
||||
resource: {
|
||||
role: "writer",
|
||||
type: "user",
|
||||
emailAddress:
|
||||
"bellingcat-auto-archiver-api@bellingcat-auto-archiver-b85db.iam.gserviceaccount.com",
|
||||
},
|
||||
});
|
||||
|
||||
const col = await collection(firebaseFirestore, "sheets");
|
||||
await addDoc(col, {
|
||||
sheetId: spreadsheetId,
|
||||
url: newSheet.result.spreadsheetUrl,
|
||||
timestamp: Date.now(),
|
||||
uid: state.user.uid,
|
||||
email: state.user.email,
|
||||
lastArchived: null,
|
||||
name: name,
|
||||
});
|
||||
|
||||
dispatch("getDocs");
|
||||
} catch (error) {
|
||||
console.error("add (firebase.js): ", error);
|
||||
}
|
||||
},
|
||||
|
||||
async enable({ state, dispatch, commit }, { spreadsheetId }) {
|
||||
commit("setLoading", true);
|
||||
commit("setErrorMessage", "");
|
||||
|
||||
try {
|
||||
// fetch existing sheet
|
||||
const sheetToEnable = await gapi.client.sheets.spreadsheets.get({
|
||||
spreadsheetId: spreadsheetId,
|
||||
});
|
||||
|
||||
const q = query(
|
||||
collection(firebaseFirestore, "sheets"),
|
||||
where("uid", "==", state.user.uid),
|
||||
where("sheetId", "==", spreadsheetId),
|
||||
limit(1)
|
||||
);
|
||||
|
||||
const response = await getDocs(q);
|
||||
if (response.docs.length > 0) {
|
||||
throw "Sheet already enabled";
|
||||
const response = await r.json();
|
||||
commit("setUserActiveState", response.active);
|
||||
if (response.active === true) {
|
||||
dispatch("getSheets");
|
||||
}
|
||||
|
||||
const col = await collection(firebaseFirestore, "sheets");
|
||||
await addDoc(col, {
|
||||
sheetId: spreadsheetId,
|
||||
url: sheetToEnable.result.spreadsheetUrl,
|
||||
timestamp: Date.now(),
|
||||
uid: state.user.uid,
|
||||
email: state.user.email,
|
||||
lastArchived: null,
|
||||
name: sheetToEnable.result.properties.title,
|
||||
});
|
||||
|
||||
dispatch("getDocs");
|
||||
} catch (error) {
|
||||
commit("setErrorMessage", `Unable to add sheet: ${JSON.stringify(error)}`);
|
||||
commit("setLoading", false);
|
||||
console.error("add (firebase.js): ", error);
|
||||
console.error("checkActiveUser (firebase.js): ", error);
|
||||
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 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"
|
||||
);
|
||||
}
|
||||
},
|
||||
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 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"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async getSheets({ state, commit }) {
|
||||
try {
|
||||
commit("setErrorMessage", "");
|
||||
if (state.user?.active === false) return;
|
||||
|
||||
fetch(`${state.API_ENDPOINT}/sheet/mine`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${state.access_token}`,
|
||||
},
|
||||
}).then(async (response) => {
|
||||
const res = await response.json();
|
||||
if (response.status === 200) {
|
||||
commit("setSheets", res);
|
||||
} else {
|
||||
throw new Error(JSON.stringify(res));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("getSheets (firebase.js): ", error);
|
||||
}
|
||||
},
|
||||
async createSheet(
|
||||
{ _state, dispatch, _commit },
|
||||
{ name, service_account_email }
|
||||
) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
// create new sheet
|
||||
const newSheet = await gapi.client.sheets.spreadsheets.create({
|
||||
properties: {
|
||||
title: name,
|
||||
},
|
||||
});
|
||||
|
||||
const spreadsheetId = newSheet.result.spreadsheetId;
|
||||
|
||||
const userEnteredFormat = {
|
||||
textFormat: {
|
||||
bold: true,
|
||||
},
|
||||
};
|
||||
|
||||
// add header row
|
||||
await gapi.client.sheets.spreadsheets.batchUpdate(
|
||||
{
|
||||
spreadsheetId: spreadsheetId,
|
||||
},
|
||||
{
|
||||
requests: [
|
||||
{
|
||||
updateCells: {
|
||||
rows: [
|
||||
{
|
||||
values: [
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Link",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Archive status",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Archive location",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Archive date",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Thumbnail",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Upload timestamp",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Upload title",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Textual content",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Screenshot",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
{
|
||||
userEnteredValue: {
|
||||
stringValue: "Hash",
|
||||
},
|
||||
userEnteredFormat,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
fields:
|
||||
"userEnteredValue.stringValue,userEnteredFormat.textFormat.bold",
|
||||
start: {
|
||||
sheetId: 0,
|
||||
rowIndex: 0,
|
||||
columnIndex: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
addProtectedRange: {
|
||||
protectedRange: {
|
||||
range: {
|
||||
sheetId: 0,
|
||||
startRowIndex: 0,
|
||||
endRowIndex: 1,
|
||||
startColumnIndex: 0,
|
||||
endColumnIndex: 11,
|
||||
},
|
||||
description:
|
||||
"Protecting header row (needed for auto-archiver), do not modify archiving column names, you can add and move columns around when no 'Archive in Progress' is present in the 'Archive status' column.",
|
||||
warningOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
// add permissions
|
||||
// TODO: make sure this emailAdress is used according to the group
|
||||
await gapi.client.drive.permissions.create({
|
||||
fileId: spreadsheetId,
|
||||
resource: {
|
||||
role: "writer",
|
||||
type: "user",
|
||||
emailAddress: service_account_email,
|
||||
},
|
||||
});
|
||||
|
||||
resolve({ success: true, result: spreadsheetId });
|
||||
} catch (error) {
|
||||
console.error("add (firebase.js): ", error);
|
||||
if (error.status === 401) {
|
||||
await dispatch("signout");
|
||||
}
|
||||
reject({ success: false, result: error });
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
isTokenExpired: async (state) => {
|
||||
if (!state.access_token) return true;
|
||||
try {
|
||||
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;
|
||||
} catch (error) {
|
||||
console.error("Error checking token expiration:", error);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
},
|
||||
modules: {},
|
||||
plugins: [
|
||||
(store) => {
|
||||
store.subscribe((mutation, state) => {
|
||||
if (mutation.type === "setUser" || mutation.type === "setAccessToken") {
|
||||
saveToLocalStorage(state);
|
||||
}
|
||||
});
|
||||
|
||||
const { user, access_token } = loadFromLocalStorage();
|
||||
if (user && access_token) {
|
||||
store.commit("setLoadingUserState", true);
|
||||
store.commit("setUser", user);
|
||||
store.commit("setAccessToken", access_token);
|
||||
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");
|
||||
});
|
||||
}
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
13
src/styles/global.css
Normal file
13
src/styles/global.css
Normal file
@@ -0,0 +1,13 @@
|
||||
html {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #1a73e8;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #0c47a1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
19
src/utils/misc.js
Normal file
19
src/utils/misc.js
Normal file
@@ -0,0 +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";
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
355
src/views/ArchiveSearchView.vue
Normal file
355
src/views/ArchiveSearchView.vue
Normal file
@@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<PermissionNeeded v-if="user && !featureEnabled" feature="Search Archives" />
|
||||
<WelcomeCard />
|
||||
<v-container class="pane-l" v-if="user?.active && featureEnabled">
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-card elevation="12">
|
||||
<v-card-title class="text-center my-3">
|
||||
Search archives by URL
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<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-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-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-row>
|
||||
<v-col cols="12" class="text-right">
|
||||
<v-btn
|
||||
@click="searchForArchives"
|
||||
color="teal"
|
||||
class="mt-4"
|
||||
size="large"
|
||||
:disabled="!validUrl"
|
||||
>
|
||||
Search
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<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"
|
||||
>
|
||||
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
|
||||
>
|
||||
<template v-slot:item.result="{ item }">
|
||||
<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
|
||||
>
|
||||
</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
|
||||
>
|
||||
</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
|
||||
>
|
||||
</template>
|
||||
<template v-slot:item.size="{ item }">
|
||||
{{
|
||||
(
|
||||
(item?.result?.metadata?.total_bytes || 0) /
|
||||
(1024 * 1024)
|
||||
).toFixed(2)
|
||||
}}
|
||||
</template>
|
||||
<template v-slot:item.files="{ item }">
|
||||
{{ item?.result?.media?.length }}
|
||||
</template>
|
||||
|
||||
<!-- EXPANDED ROW WITH SUBTABLE -->
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
</a>
|
||||
</template>
|
||||
<template v-slot:item.hash="{ item: media }">
|
||||
<span style="font-size: small">{{
|
||||
media?.properties?.hash
|
||||
}}</span>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</v-data-table-server>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PermissionNeeded from "@/components/PermissionNeeded.vue";
|
||||
import { urlValidator, getUrlFromResult } from "@/utils/misc.js";
|
||||
import WelcomeCard from "@/components/WelcomeCard.vue";
|
||||
|
||||
export default {
|
||||
name: "ArchiveSearchView",
|
||||
components: {
|
||||
PermissionNeeded,
|
||||
WelcomeCard,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
today: new Date().toISOString().substring(0, 10),
|
||||
queryAfter: null,
|
||||
queryBefore: null,
|
||||
queryUrl: this.$route.query.url || "",
|
||||
tableSearch: "", // used to retrigger the search
|
||||
loading: false,
|
||||
itemsPerPage: 5,
|
||||
totalItems: 0,
|
||||
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" },
|
||||
{ title: "Archived", value: "created_at" },
|
||||
{ title: "Deleted", value: "store_until", width: "150px" },
|
||||
{ title: "Size (MB)", value: "size" },
|
||||
{ title: "Files", value: "files" },
|
||||
{ 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",
|
||||
},
|
||||
],
|
||||
serverItems: [],
|
||||
snackbar: false,
|
||||
snackbarMessage: "",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user;
|
||||
},
|
||||
featureEnabled() {
|
||||
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;
|
||||
},
|
||||
validUrl() {
|
||||
return this.queryUrl && this.urlValidator(this.queryUrl) === true;
|
||||
},
|
||||
urlValidator() {
|
||||
return urlValidator;
|
||||
},
|
||||
getUrlFromResult() {
|
||||
return getUrlFromResult;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
searchForArchives() {
|
||||
if (!this.validUrl) return;
|
||||
this.tableSearch = `${this.queryUrl}${this.queryAfter}${this.queryBefore}`;
|
||||
},
|
||||
loadItems({ page, itemsPerPage, _sortBy }) {
|
||||
if (!this.validUrl || this.loading === true) return;
|
||||
this.loading = true;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
url: this.queryUrl,
|
||||
limit: itemsPerPage,
|
||||
skip: (page - 1) * itemsPerPage,
|
||||
});
|
||||
if (this.queryAfter) {
|
||||
params.append("archived_after", this.queryAfter.toISOString());
|
||||
}
|
||||
if (this.queryBefore) {
|
||||
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) => {
|
||||
if (!Array.isArray(items)) {
|
||||
throw `Unexpected response format from API`;
|
||||
}
|
||||
|
||||
// Estimate totalItems if not provided by the API
|
||||
this.serverItems = items;
|
||||
if (items.length < itemsPerPage) {
|
||||
this.totalItems = (page - 1) * itemsPerPage + items.length;
|
||||
} else {
|
||||
this.totalItems = (page + 1) * itemsPerPage; // Assume there are more items
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("/url/search", error);
|
||||
this.snackbarMessage = `Error searching for archives: ${error}`;
|
||||
this.snackbar = true;
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
img,
|
||||
video {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
img:hover,
|
||||
video:hover {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
td {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
346
src/views/ArchiveUrlView.vue
Normal file
346
src/views/ArchiveUrlView.vue
Normal file
@@ -0,0 +1,346 @@
|
||||
<template>
|
||||
<PermissionNeeded v-if="user && !featureEnabled" feature="Archive URL" />
|
||||
<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-text>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<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>
|
||||
<v-radio label="Public" :value="true"></v-radio>
|
||||
<v-radio label="Private" :value="false"></v-radio>
|
||||
</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-col>
|
||||
<v-col cols="12" md="4" class="text-right">
|
||||
<v-btn
|
||||
@click="archiveUrl"
|
||||
color="teal"
|
||||
:disabled="
|
||||
!validUrl ||
|
||||
loadingArchive ||
|
||||
group == 'please select' ||
|
||||
maxedOutMBs ||
|
||||
maxedOutURLs
|
||||
"
|
||||
>
|
||||
Archive
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<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
|
||||
>
|
||||
</p>
|
||||
<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">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.
|
||||
</p>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="12" class="pt-0" v-if="group != 'please select'">
|
||||
<span>
|
||||
<code>{{ group }}</code
|
||||
><br />
|
||||
<span class="text-medium-emphasis mb-1">
|
||||
{{ groupPermissions.description }}
|
||||
</span>
|
||||
<ul>
|
||||
<li>
|
||||
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
|
||||
>
|
||||
</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
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
We will store archives for:
|
||||
<strong>{{
|
||||
displayPermissionValue(
|
||||
groupPermissions?.max_archive_lifespan_months,
|
||||
" months"
|
||||
)
|
||||
}}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<SnackBar
|
||||
:message="snackbarMessage"
|
||||
:show="snackbar"
|
||||
:color="snackbarColor"
|
||||
@update:show="snackbar = $event"
|
||||
/>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { urlValidator, getUrlFromResult } from "@/utils/misc";
|
||||
import SnackBar from "@/components/SnackBar.vue";
|
||||
import PermissionNeeded from "@/components/PermissionNeeded.vue";
|
||||
import WelcomeCard from "@/components/WelcomeCard.vue";
|
||||
|
||||
export default {
|
||||
name: "ArchiveUrlView",
|
||||
components: {
|
||||
SnackBar,
|
||||
PermissionNeeded,
|
||||
WelcomeCard,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
url: this.$route.query.url || "",
|
||||
public: false,
|
||||
group: "please select",
|
||||
loadingArchive: false,
|
||||
|
||||
taskId: null,
|
||||
archiveResult: null,
|
||||
archiveFailure: null,
|
||||
|
||||
snackbar: false,
|
||||
snackbarMessage: "",
|
||||
snackbarColor: "red",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user;
|
||||
},
|
||||
featureEnabled() {
|
||||
return this.user?.permissions?.["all"]?.archive_url;
|
||||
},
|
||||
urlValidator() {
|
||||
return urlValidator;
|
||||
},
|
||||
urlFromResult() {
|
||||
if (!this.archiveResult) return null;
|
||||
return getUrlFromResult(this.archiveResult);
|
||||
},
|
||||
validUrl() {
|
||||
return this.url && this.urlValidator(this.url) === true;
|
||||
},
|
||||
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 }));
|
||||
},
|
||||
globalUsage() {
|
||||
return this.$store.state.user?.usage || {};
|
||||
},
|
||||
groupUsage() {
|
||||
if (this.group == "") {
|
||||
return this.$store.state.user?.usage || {};
|
||||
}
|
||||
return this.$store.state.user?.usage?.["groups"]?.[this.group] || {};
|
||||
},
|
||||
groupPermissions() {
|
||||
if (this.group == "") {
|
||||
return this.$store.state.user?.permissions?.["all"] || {};
|
||||
}
|
||||
return this.$store.state.user?.permissions?.[this.group] || {};
|
||||
},
|
||||
maxedOutMBs() {
|
||||
if (this.groupPermissions.max_monthly_mbs === -1) return false;
|
||||
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
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
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"
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
showSnackbar(message, color = "red") {
|
||||
this.snackbarMessage = message;
|
||||
this.snackbarColor = color;
|
||||
this.snackbar = true;
|
||||
},
|
||||
archiveUrl() {
|
||||
if (this.loadingArchive) return;
|
||||
this.loadingArchive = true;
|
||||
this.archiveResult = null;
|
||||
this.archiveFailure = null;
|
||||
|
||||
fetch(`${this.$store.state.API_ENDPOINT}/url/archive`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.$store.state.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: this.url,
|
||||
group_id: this.group,
|
||||
public: this.public,
|
||||
tags: [],
|
||||
}),
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((res) => {
|
||||
this.taskId = res.id;
|
||||
this.showSnackbar(
|
||||
`Your URL is being archived with id ${this.taskId}!`,
|
||||
"green"
|
||||
);
|
||||
this.pollForArchiveResults();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("/archive ", error);
|
||||
this.showSnackbar(`Unable to archive URL: ${error.message}`);
|
||||
this.loadingArchive = false;
|
||||
});
|
||||
},
|
||||
pollForArchiveResults() {
|
||||
this.loadingArchive = true;
|
||||
const poll = () => {
|
||||
if (!this.loadingArchive) {
|
||||
return;
|
||||
}
|
||||
fetch(`${this.$store.state.API_ENDPOINT}/task/${this.taskId}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.$store.state.access_token}`,
|
||||
},
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((task) => {
|
||||
if (task.status === "SUCCESS") {
|
||||
this.showSnackbar(
|
||||
`URL archived successfully with id ${task.id}!`,
|
||||
"green"
|
||||
);
|
||||
this.loadingArchive = false;
|
||||
this.archiveResult = task;
|
||||
this.taskId = task.id;
|
||||
} else if (task.status === "FAILURE") {
|
||||
this.showSnackbar(`Failed to archive URL: ${task.error}`);
|
||||
this.loadingArchive = false;
|
||||
this.taskId = null;
|
||||
this.archiveFailure = task.error;
|
||||
} else {
|
||||
setTimeout(poll, 5000); // Poll every 5 seconds
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("/task ", error);
|
||||
this.showSnackbar(
|
||||
`Error checking archive status: ${error.message}`
|
||||
);
|
||||
this.loadingArchive = false;
|
||||
});
|
||||
};
|
||||
poll();
|
||||
},
|
||||
displayPermissionValue(value, extraWord) {
|
||||
if (value === undefined) {
|
||||
return "not set";
|
||||
}
|
||||
return value == -1 ? "no limit" : value + extraWord;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,200 +1,34 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-card style="margin-bottom: 1em">
|
||||
<v-card-text>
|
||||
<v-alert color="#f2d97c" light icon="mdi-alert">
|
||||
This is a pre-release prototype demo service provided on a
|
||||
best-effort basis. Do not use for mission critical or sensitive
|
||||
data.
|
||||
</v-alert>
|
||||
<p>
|
||||
This tool will configure a Google Sheet on your Google account for
|
||||
use with
|
||||
<a href="https://github.com/bellingcat/auto-archiver"
|
||||
>Bellingcat's Auto Archiver</a
|
||||
>. For more information about the Auto Archiver and how to use it,
|
||||
see
|
||||
<a href="https://github.com/bellingcat/auto-archiver"
|
||||
>our Github repository</a
|
||||
>
|
||||
and the
|
||||
<a
|
||||
href="https://www.bellingcat.com/resources/2022/09/22/preserve-vital-online-content-with-bellingcats-auto-archiver-tool/"
|
||||
>associated article</a
|
||||
>.
|
||||
</p>
|
||||
<h4>How archiving a Google Spreadsheet works</h4>
|
||||
<ul>
|
||||
<li>Add links to the <code>Link</code> column</li>
|
||||
<li>
|
||||
Links are archived
|
||||
<b>every 60 minutes</b>, or you can trigger a manual archive
|
||||
below
|
||||
</li>
|
||||
<li>
|
||||
You can modify and share the Google Sheet subsequently, but do
|
||||
not edit the auto archiver column names in the header row or
|
||||
remove the service account from the shared users
|
||||
</li>
|
||||
</ul>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<DocList v-if="user" />
|
||||
|
||||
<div class="text-h5 mt-5 mb-3">Manage new auto-archiver sheets</div>
|
||||
|
||||
<v-card style="margin-bottom: 1em">
|
||||
<v-card-title>Create a new auto-archiver sheet</v-card-title>
|
||||
<v-card-text>
|
||||
<ol style="margin-bottom: 1em">
|
||||
<li>Press "create" to create a new archiving Google Sheet</li>
|
||||
<li>
|
||||
This sheet will be shared with the service account necessary for
|
||||
Bellingcat's archiving server
|
||||
</li>
|
||||
<li>The sheet will appear in your list</li>
|
||||
</ol>
|
||||
<v-text-field
|
||||
label="Document name"
|
||||
v-model="docName"
|
||||
v-if="user"
|
||||
></v-text-field>
|
||||
<v-btn
|
||||
@click="$store.dispatch('add', { name: docName })"
|
||||
:loading="$store.state.loading"
|
||||
v-if="user"
|
||||
>Create</v-btn
|
||||
>
|
||||
<v-alert v-if="!user" color="#f2d97c" light icon="mdi-alert"
|
||||
><a href="#!" @click="$store.dispatch('signin')"
|
||||
>Sign in with a Google account</a
|
||||
>
|
||||
to continue</v-alert
|
||||
>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card>
|
||||
<v-card-title
|
||||
>Enable the auto-archiver in an existing sheet</v-card-title
|
||||
>
|
||||
<v-card-text>
|
||||
<ol style="margin-bottom: 1em">
|
||||
<li>
|
||||
Invite
|
||||
<code
|
||||
>bellingcat-auto-archiver-api@bellingcat-auto-archiver-b85db.iam.gserviceaccount.com</code
|
||||
>
|
||||
into your spreadsheet
|
||||
</li>
|
||||
<!-- Link Archive status Destination folder Archive location Archive date Thumbnail Upload timestamp Upload title Textual content Screenshot Hash -->
|
||||
<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
|
||||
</li>
|
||||
<li>
|
||||
<code>Archive location</code> where the link to the archived
|
||||
content is added
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
Add any of the following <b>optional</b> column names:
|
||||
<ul>
|
||||
<li>
|
||||
<code>Archive date</code> info on when archiving occurred
|
||||
</li>
|
||||
<li>
|
||||
<code>Thumbnail</code> an image preview from archived media
|
||||
</li>
|
||||
<li>
|
||||
<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)
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<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
|
||||
</li>
|
||||
</ol>
|
||||
<v-alert
|
||||
v-if="$store.state.errorMessage"
|
||||
title="Error"
|
||||
text
|
||||
type="error"
|
||||
variant="outlined"
|
||||
closable
|
||||
>{{ $store.state.errorMessage }}</v-alert
|
||||
>
|
||||
<v-text-field
|
||||
label="Google Sheet URL"
|
||||
v-model="spreadsheetUrl"
|
||||
:hint="spreadsheetId ? 'Detected id: ' + spreadsheetId : ''"
|
||||
persistent-hint
|
||||
v-if="user"
|
||||
></v-text-field>
|
||||
<v-btn
|
||||
@click="$store.dispatch('enable', { spreadsheetId })"
|
||||
:loading="$store.state.loading"
|
||||
v-if="user"
|
||||
>Enable</v-btn
|
||||
>
|
||||
<v-alert v-if="!user" color="#f2d97c" light icon="mdi-alert"
|
||||
><a href="#!" @click="$store.dispatch('signin')"
|
||||
>Sign in with a Google account</a
|
||||
>
|
||||
to continue</v-alert
|
||||
>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
<PermissionNeeded
|
||||
v-if="user && !featureEnabled"
|
||||
feature="Archive Spreadsheets"
|
||||
/>
|
||||
<WelcomeCard />
|
||||
<ArchiveSheet v-if="user?.active && featureEnabled" />
|
||||
<ManageSheets v-if="user?.active && featureEnabled" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DocList from "@/components/DocList.vue";
|
||||
import ArchiveSheet from "@/components/ArchiveSheet.vue";
|
||||
import ManageSheets from "@/components/ManageSheets.vue";
|
||||
import PermissionNeeded from "@/components/PermissionNeeded.vue";
|
||||
import WelcomeCard from "@/components/WelcomeCard.vue";
|
||||
|
||||
export default {
|
||||
name: "HomeView",
|
||||
components: {
|
||||
DocList,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
docName: "Auto archiver sheet",
|
||||
spreadsheetUrl: "",
|
||||
};
|
||||
ArchiveSheet,
|
||||
ManageSheets,
|
||||
PermissionNeeded,
|
||||
WelcomeCard,
|
||||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user;
|
||||
},
|
||||
spreadsheetId() {
|
||||
if (
|
||||
this.spreadsheetUrl.startsWith("http") &&
|
||||
this.spreadsheetUrl.split("/").length >= 6
|
||||
) {
|
||||
return this.spreadsheetUrl.split("/")[5];
|
||||
}
|
||||
return this.spreadsheetUrl;
|
||||
featureEnabled() {
|
||||
return this.user?.permissions?.["all"]?.archive_sheet;
|
||||
},
|
||||
},
|
||||
methods: {},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<v-container class="pane">
|
||||
<h2>Bellingcat Privacy Policy</h2>
|
||||
<p>
|
||||
Your privacy is important to us. It is Bellingcat's policy to respect
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<v-container class="pane">
|
||||
<h2>Bellingcat Terms of Service</h2>
|
||||
<p>
|
||||
These Terms of Service govern your use of the website located at
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
Web / API server started at 127.0.0.1:4000
|
||||
Web / API server started at ::1:4000
|
||||
Reference in New Issue
Block a user