Merge pull request #10 from bellingcat/dev

major refactor completed
This commit is contained in:
Miguel Sozinho Ramalho
2025-02-19 18:39:17 +00:00
committed by GitHub
29 changed files with 8512 additions and 3211 deletions

View File

@@ -4,7 +4,7 @@ module.exports = {
node: true,
},
extends: [
"plugin:vue/essential",
"plugin:vue/base",
"eslint:recommended",
"plugin:prettier/recommended",
],

1
.gitignore vendored
View File

@@ -22,3 +22,4 @@ pnpm-debug.log*
*.sln
*.sw?
firebase-debug.log
ui.debug.log

View File

@@ -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
```
![UI preview](docs/image.png)
## 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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
View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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");

View File

@@ -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,
});

View File

@@ -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
View 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
View 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;
}
}

View 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>

View 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>

View File

@@ -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>

View File

@@ -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&#39;s policy to respect

View File

@@ -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

View File

@@ -1,2 +0,0 @@
Web / API server started at 127.0.0.1:4000
Web / API server started at ::1:4000

2329
yarn.lock

File diff suppressed because it is too large Load Diff