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 }