gtsocial-umbx

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

import-export.js (6802B)


      1 /*
      2 	GoToSocial
      3 	Copyright (C) GoToSocial Authors admin@gotosocial.org
      4 	SPDX-License-Identifier: AGPL-3.0-or-later
      5 
      6 	This program is free software: you can redistribute it and/or modify
      7 	it under the terms of the GNU Affero General Public License as published by
      8 	the Free Software Foundation, either version 3 of the License, or
      9 	(at your option) any later version.
     10 
     11 	This program is distributed in the hope that it will be useful,
     12 	but WITHOUT ANY WARRANTY; without even the implied warranty of
     13 	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     14 	GNU Affero General Public License for more details.
     15 
     16 	You should have received a copy of the GNU Affero General Public License
     17 	along with this program.  If not, see <http://www.gnu.org/licenses/>.
     18 */
     19 
     20 "use strict";
     21 
     22 const Promise = require("bluebird");
     23 const fileDownload = require("js-file-download");
     24 const csv = require("papaparse");
     25 const { nanoid } = require("nanoid");
     26 
     27 const { isValidDomainBlock, hasBetterScope } = require("../../domain-block");
     28 
     29 const {
     30 	replaceCacheOnMutation,
     31 	domainListToObject,
     32 	unwrapRes
     33 } = require("../lib");
     34 
     35 function parseDomainList(list) {
     36 	if (list[0] == "[") {
     37 		return JSON.parse(list);
     38 	} else if (list.startsWith("#domain")) { // Mastodon CSV
     39 		const { data, errors } = csv.parse(list, {
     40 			header: true,
     41 			transformHeader: (header) => header.slice(1), // removes starting '#'
     42 			skipEmptyLines: true,
     43 			dynamicTyping: true
     44 		});
     45 
     46 		if (errors.length > 0) {
     47 			let error = "";
     48 			errors.forEach((err) => {
     49 				error += `${err.message} (line ${err.row})`;
     50 			});
     51 			throw error;
     52 		}
     53 
     54 		return data;
     55 	} else {
     56 		return list.split("\n").map((line) => {
     57 			let domain = line.trim();
     58 			let valid = true;
     59 			if (domain.startsWith("http")) {
     60 				try {
     61 					domain = new URL(domain).hostname;
     62 				} catch (e) {
     63 					valid = false;
     64 				}
     65 			}
     66 			return domain.length > 0
     67 				? { domain, valid }
     68 				: null;
     69 		}).filter((a) => a); // not `null`
     70 	}
     71 }
     72 
     73 function validateDomainList(list) {
     74 	list.forEach((entry) => {
     75 		if (entry.domain.startsWith("*.")) {
     76 			// domain block always includes all subdomains, wildcard is meaningless here
     77 			entry.domain = entry.domain.slice(2);
     78 		}
     79 
     80 		entry.valid = (entry.valid !== false) && isValidDomainBlock(entry.domain);
     81 		if (entry.valid) {
     82 			entry.suggest = hasBetterScope(entry.domain);
     83 		}
     84 		entry.checked = entry.valid;
     85 	});
     86 
     87 	return list;
     88 }
     89 
     90 function deduplicateDomainList(list) {
     91 	let domains = new Set();
     92 	return list.filter((entry) => {
     93 		if (domains.has(entry.domain)) {
     94 			return false;
     95 		} else {
     96 			domains.add(entry.domain);
     97 			return true;
     98 		}
     99 	});
    100 }
    101 
    102 module.exports = (build) => ({
    103 	processDomainList: build.mutation({
    104 		queryFn: (formData) => {
    105 			return Promise.try(() => {
    106 				if (formData.domains == undefined || formData.domains.length == 0) {
    107 					throw "No domains entered";
    108 				}
    109 				return parseDomainList(formData.domains);
    110 			}).then((parsed) => {
    111 				return deduplicateDomainList(parsed);
    112 			}).then((deduped) => {
    113 				return validateDomainList(deduped);
    114 			}).then((data) => {
    115 				data.forEach((entry) => {
    116 					entry.key = nanoid(); // unique id that stays stable even if domain gets modified by user
    117 				});
    118 				return { data };
    119 			}).catch((e) => {
    120 				return { error: e.toString() };
    121 			});
    122 		}
    123 	}),
    124 	exportDomainList: build.mutation({
    125 		queryFn: (formData, api, _extraOpts, baseQuery) => {
    126 			let process;
    127 
    128 			if (formData.exportType == "json") {
    129 				process = {
    130 					transformEntry: (entry) => ({
    131 						domain: entry.domain,
    132 						public_comment: entry.public_comment,
    133 						obfuscate: entry.obfuscate
    134 					}),
    135 					stringify: (list) => JSON.stringify(list),
    136 					extension: ".json",
    137 					mime: "application/json"
    138 				};
    139 			} else if (formData.exportType == "csv") {
    140 				process = {
    141 					transformEntry: (entry) => [
    142 						entry.domain,
    143 						"suspend", // severity
    144 						false, // reject_media
    145 						false, // reject_reports
    146 						entry.public_comment,
    147 						entry.obfuscate ?? false
    148 					],
    149 					stringify: (list) => csv.unparse({
    150 						fields: "#domain,#severity,#reject_media,#reject_reports,#public_comment,#obfuscate".split(","),
    151 						data: list
    152 					}),
    153 					extension: ".csv",
    154 					mime: "text/csv"
    155 				};
    156 			} else {
    157 				process = {
    158 					transformEntry: (entry) => entry.domain,
    159 					stringify: (list) => list.join("\n"),
    160 					extension: ".txt",
    161 					mime: "text/plain"
    162 				};
    163 			}
    164 
    165 			return Promise.try(() => {
    166 				return baseQuery({
    167 					url: `/api/v1/admin/domain_blocks`
    168 				});
    169 			}).then(unwrapRes).then((blockedInstances) => {
    170 				return blockedInstances.map(process.transformEntry);
    171 			}).then((exportList) => {
    172 				return process.stringify(exportList);
    173 			}).then((exportAsString) => {
    174 				if (formData.action == "export") {
    175 					return {
    176 						data: exportAsString
    177 					};
    178 				} else if (formData.action == "export-file") {
    179 					let domain = new URL(api.getState().oauth.instance).host;
    180 					let date = new Date();
    181 
    182 					let filename = [
    183 						domain,
    184 						"blocklist",
    185 						date.getFullYear(),
    186 						(date.getMonth() + 1).toString().padStart(2, "0"),
    187 						date.getDate().toString().padStart(2, "0"),
    188 					].join("-");
    189 
    190 					fileDownload(
    191 						exportAsString,
    192 						filename + process.extension,
    193 						process.mime
    194 					);
    195 				}
    196 				return { data: null };
    197 			}).catch((e) => {
    198 				return { error: e };
    199 			});
    200 		}
    201 	}),
    202 	importDomainList: build.mutation({
    203 		query: (formData) => {
    204 			const { domains } = formData;
    205 
    206 			// add/replace comments, obfuscation data
    207 			let process = entryProcessor(formData);
    208 			domains.forEach((entry) => {
    209 				process(entry);
    210 			});
    211 
    212 			return {
    213 				method: "POST",
    214 				url: `/api/v1/admin/domain_blocks?import=true`,
    215 				asForm: true,
    216 				discardEmpty: true,
    217 				body: {
    218 					domains: new Blob([JSON.stringify(domains)], { type: "application/json" })
    219 				}
    220 			};
    221 		},
    222 		transformResponse: domainListToObject,
    223 		...replaceCacheOnMutation("instanceBlocks")
    224 	})
    225 });
    226 
    227 const internalKeys = new Set("key,suggest,valid,checked".split(","));
    228 function entryProcessor(formData) {
    229 	let funcs = [];
    230 
    231 	["private_comment", "public_comment"].forEach((type) => {
    232 		let text = formData[type].trim();
    233 
    234 		if (text.length > 0) {
    235 			let behavior = formData[`${type}_behavior`];
    236 
    237 			if (behavior == "append") {
    238 				funcs.push(function appendComment(entry) {
    239 					if (entry[type] == undefined) {
    240 						entry[type] = text;
    241 					} else {
    242 						entry[type] = [entry[type], text].join("\n");
    243 					}
    244 				});
    245 			} else if (behavior == "replace") {
    246 				funcs.push(function replaceComment(entry) {
    247 					entry[type] = text;
    248 				});
    249 			}
    250 		}
    251 	});
    252 
    253 	return function process(entry) {
    254 		funcs.forEach((func) => {
    255 			func(entry);
    256 		});
    257 
    258 		entry.obfuscate = formData.obfuscate;
    259 
    260 		Object.entries(entry).forEach(([key, val]) => {
    261 			if (internalKeys.has(key) || val == undefined) {
    262 				delete entry[key];
    263 			}
    264 		});
    265 	};
    266 }