introduces 1st batch of permissions and usage quota control

This commit is contained in:
msramalho
2025-02-06 19:04:30 +00:00
parent 531e7bf63a
commit ddb803a82f
6 changed files with 159 additions and 31 deletions

View File

@@ -4,7 +4,7 @@
<v-main>
<router-view />
</v-main>
<v-footer class="text-center">
<v-footer class="text-center py-6">
<v-row>
<v-col cols="12">
<div class="legal py-2">
@@ -13,13 +13,14 @@
</div>
</v-col>
<v-col cols="12">
This tool uses <a href="https://github.com/bellingcat/auto-archiver">Bellingcat's Auto Archiver</a> to
archive online content.
<br/>For more information see
<a href="https://github.com/bellingcat/auto-archiver">our Github repository</a>
and the <a
You can deploy your own version of this tool by hosting the <a href="https://github.com/bellingcat/auto-archiver-api">API</a> and the <a href="https://github.com/bellingcat/auto-archiver-api">UI</a>.
<br/>
This tool uses <a href="https://github.com/bellingcat/auto-archiver">Bellingcat's Auto Archiver</a> under the hood to archive online content.
<br/>
For more information about it see <a
href="https://www.bellingcat.com/resources/2022/09/22/preserve-vital-online-content-with-bellingcats-auto-archiver-tool/">associated
article</a>.
</v-col>
</v-row>
@@ -70,10 +71,13 @@ html {
padding: 0.2em 0.4em;
}
footer, .v-footer, .v-footer div {
footer,
.v-footer,
.v-footer div {
margin: 0px;
padding: 0px;
}
.v-footer {
max-height: 125px;
}

View File

@@ -12,8 +12,8 @@
<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" required
density="comfortable"></v-select>
<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>
@@ -30,6 +30,28 @@
<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>
Quota and rules for group <code>{{ group }}</code>:
<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>How long will we store these archives: <strong>{{
displayPermissionValue(groupPermissions?.max_archive_lifespan_months, " months") }}</strong>
</li>
<li>You <strong>{{ groupPermissions?.manually_trigger_sheet?"can":"cannot" }}</strong> manually trigger sheeets in this group. </li>
</ul>
</span>
</v-col>
</v-row>
@@ -63,8 +85,7 @@ export default {
group: "please select",
availableFrequencies: ["daily", "hourly"].map(f => ({ title: f, value: f })),
frequency: "daily",
frequency: "please select group",
newSheetId: "",
};
@@ -74,14 +95,27 @@ export default {
return this.$store.state.user;
},
requiredData() {
return this.sheetName && this.availableGroups?.some(g => g.value === this.group) && this.availableFrequencies?.some(f => f.value === this.frequency);
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.value === this.frequency);
return this.sheetName && this.spreadsheetId && this.availableGroups?.some(g => g.value === this.group) && this.availableFrequencies?.some(f => f === this.frequency) && !this.maxedOutGroupQuota;
},
availableGroups() {
return (this.$store.state.user?.groups || []).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.max_sheets === -1) return false;
return this.groupUsage.total_sheets >= this.groupPermissions.max_sheets;
},
spreadsheetId() {
if (
this.sheetUrlId.startsWith("http") &&
@@ -107,6 +141,7 @@ export default {
this.loading = true;
this.newSheetId = "";
this.$store.dispatch("createSheet", this.sheetName).then((res) => {
this.$store.dispatch("checkUserUsage");
if (!res.success) throw new Error(res.result);
this.newSheetId = res.result;
this.addSheetToAPI(this.newSheetId);
@@ -140,6 +175,7 @@ export default {
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));
}
@@ -152,6 +188,10 @@ export default {
this.sheetUrlId = "";
this.group = "please select";
});
},
displayPermissionValue(value, extraWord) {
if (value === undefined) { return "not set"; }
return value == -1 ? "no limit" : value + extraWord;
}
},
};

View File

@@ -1,5 +1,5 @@
<template>
<v-container class="pane" v-if="user?.active" >
<v-container class="pane" v-if="user?.active">
<v-card :loading="loadingArchive">
<v-card-title class="text-center">
Archive a single URL
@@ -21,7 +21,7 @@
</v-col>
<v-col cols="12" md="4" class="text-right">
<v-btn @click="archiveUrl" color="teal"
:disabled="!validUrl || loadingArchive || (!public && group == -1)">
:disabled="!validUrl || loadingArchive || (!public && group == -1) || maxedOutMBs || maxedOutURLs">
Archive
</v-btn>
</v-col>
@@ -41,11 +41,37 @@
</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
: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">
<span>
Quota and rules<span v-if="group != ''"> for group <code>{{ group }}</code></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>How long will we store these archives: <strong>{{
displayPermissionValue(groupPermissions?.max_archive_lifespan_months, " months") }}</strong>
</li>
</ul>
</span>
</v-col>
</v-row>
</v-card-text>
</v-card>
@@ -93,6 +119,29 @@ export default {
validUrl() {
return this.url && this.urlValidator(this.url) === true;
},
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) {
@@ -178,6 +227,10 @@ export default {
};
poll();
},
displayPermissionValue(value, extraWord) {
if (value === undefined) { return "not set"; }
return value == -1 ? "no limit" : value + extraWord;
}
},
};
</script>

View File

@@ -10,7 +10,7 @@
<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 color="teal-lighten-1" size="small" icon class="mx-2" :disabled="loading" rounded
<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>
@@ -21,7 +21,7 @@
<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-delete-outline</v-icon>
@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>
@@ -30,7 +30,8 @@
<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">
<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>
@@ -63,7 +64,7 @@ export default {
{ title: "Group", value: "group_id", sortable: true },
{ title: "Frequency", value: "frequency", sortable: true },
{ title: "Created", value: "created_at", sortable: true },
{ title: "Last Archived", value: "last_archived_at", sortable: true },
{ title: "Last URL Archived", value: "last_url_archived_at", sortable: true },
{ title: 'Actions', value: "actions", align: 'center' },
],
@@ -83,6 +84,9 @@ export default {
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`, {
@@ -119,6 +123,7 @@ export default {
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));
}

View File

@@ -45,6 +45,7 @@ export default createStore({
sheets: [],
loading: false,
errorMessage: "",
// # TODO: reenable production API endpoint
// API_ENDPOINT: "https://auto-archiver-api.bellingcat.com"
API_ENDPOINT: "http://localhost:8004"
},
@@ -56,8 +57,13 @@ export default createStore({
setUserActiveState(state, active) {
state.user.active = active;
},
setUserGroups(state, groups) {
state.user.groups = groups;
setUserPermissions(state, permissions) {
state.user.permissions = permissions;
state.user.groups = Object.keys(permissions).filter(key => key !== "all");
saveToLocalStorage(state);
},
setUserUsage(state, usage) {
state.user.usage = usage;
saveToLocalStorage(state);
},
setSheets(state, sheets) {
@@ -85,7 +91,8 @@ export default createStore({
commit("setUser", response.user);
dispatch("checkActiveUser");
dispatch("checkUserGroups");
dispatch("checkUserPermissions");
dispatch("checkUserUsage");
}
commit("setUser", null);
@@ -147,11 +154,11 @@ export default createStore({
}
},
async checkUserGroups({ state, commit }) {
async checkUserPermissions({ state, commit }) {
try {
commit("setErrorMessage", "");
const r = await fetch(
`${state.API_ENDPOINT}/groups`,
`${state.API_ENDPOINT}/user/permissions`,
{
method: "GET",
headers: {
@@ -161,10 +168,30 @@ export default createStore({
}
);
const response = await r.json();
commit("setUserGroups", response);
commit("setUserPermissions", response);
} catch (error) {
console.error("checkUserGroups (firebase.js): ", error);
commit("setErrorMessage", "Unable to fetch user groups from the API");
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");
}
},
@@ -376,7 +403,8 @@ export default createStore({
} else {
//TODO: merge these into single endpoint in the future
store.dispatch("checkActiveUser");
store.dispatch("checkUserGroups");
store.dispatch("checkUserPermissions");
store.dispatch("checkUserUsage");
}
}).catch((error) => {
console.error("Error checking token expiration:", error);

View File

@@ -7,8 +7,6 @@
Search archives by URL
</v-card-title>
<v-card-text>
<!-- TODO: toggle between all/and my latest, maybe with tabs like sheets -->
<v-form>
<v-row>
<v-col cols="12" md="6">