From 24f1fb566dd399b77818fef4972ccc80080bc3fc Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 7 Aug 2021 10:20:14 +0100 Subject: [PATCH] Initial implementation of resource lists The ResourceList class creates or updates a list for the purpose of tracking resources on the Qortal network. This can be used for local blocking, or even for curating and sharing content lists. Lists are backed off to JSON files (in the lists folder) to ease sharing between nodes and users. This first implementation allows access to an address blacklist only, but has been written in such a way that other lists can be easily added. This might be needed in the future, e.g. to blacklist a group, a poll, or some hosted data. It could also be used by community members to curate lists of favourite or problematic content, which could then be shared or even subscribed to on the chain by other users. --- .gitignore | 1 + .../qortal/api/resource/ListsResource.java | 124 ++++++++++++++++++ .../java/org/qortal/list/ResourceList.java | 124 ++++++++++++++++++ .../org/qortal/list/ResourceListManager.java | 63 +++++++++ .../java/org/qortal/settings/Settings.java | 7 + 5 files changed, 319 insertions(+) create mode 100644 src/main/java/org/qortal/api/resource/ListsResource.java create mode 100644 src/main/java/org/qortal/list/ResourceList.java create mode 100644 src/main/java/org/qortal/list/ResourceListManager.java diff --git a/.gitignore b/.gitignore index 005ab005..69dd6906 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /db* +/lists/ /bin/ /target/ /qortal-backup/ diff --git a/src/main/java/org/qortal/api/resource/ListsResource.java b/src/main/java/org/qortal/api/resource/ListsResource.java new file mode 100644 index 00000000..0f243b5a --- /dev/null +++ b/src/main/java/org/qortal/api/resource/ListsResource.java @@ -0,0 +1,124 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import org.qortal.api.*; +import org.qortal.crypto.Crypto; +import org.qortal.data.account.AccountData; +import org.qortal.list.ResourceListManager; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.*; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; + + +@Path("/lists") +@Tag(name = "Lists") +public class ListsResource { + + @Context + HttpServletRequest request; + + @POST + @Path("/blacklist/address/{address}") + @Operation( + summary = "Add a QORT address to the local blacklist", + responses = { + @ApiResponse( + description = "Returns true on success, or an exception on failure", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) + public String addAddressToBlacklist(@PathParam("address") String address) { + if (!Crypto.isValidAddress(address)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + AccountData accountData = repository.getAccountRepository().getAccount(address); + // Not found? + if (accountData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + // Valid address, so go ahead and blacklist it + boolean success = ResourceListManager.getInstance().addAddressToBlacklist(address); + + return success ? "true" : "false"; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + + @DELETE + @Path("/blacklist/address/{address}") + @Operation( + summary = "Remove a QORT address from the local blacklist", + responses = { + @ApiResponse( + description = "Returns true on success, or an exception on failure", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) + public String removeAddressFromBlacklist(@PathParam("address") String address) { + if (!Crypto.isValidAddress(address)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + AccountData accountData = repository.getAccountRepository().getAccount(address); + // Not found? + if (accountData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + // Valid address, so go ahead and blacklist it + boolean success = ResourceListManager.getInstance().removeAddressFromBlacklist(address); + + return success ? "true" : "false"; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @GET + @Path("/blacklist/address/{address}") + @Operation( + summary = "Checks if an address is present in the local blacklist", + responses = { + @ApiResponse( + description = "Returns true or false if the list was queried, or an exception on failure", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) + public String checkAddressInBlacklist(@PathParam("address") String address) { + if (!Crypto.isValidAddress(address)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + AccountData accountData = repository.getAccountRepository().getAccount(address); + // Not found? + if (accountData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + // Valid address, so go ahead and blacklist it + boolean blacklisted = ResourceListManager.getInstance().isAddressInBlacklist(address); + + return blacklisted ? "true" : "false"; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + +} diff --git a/src/main/java/org/qortal/list/ResourceList.java b/src/main/java/org/qortal/list/ResourceList.java new file mode 100644 index 00000000..740b23d6 --- /dev/null +++ b/src/main/java/org/qortal/list/ResourceList.java @@ -0,0 +1,124 @@ +package org.qortal.list; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.json.JSONArray; +import org.qortal.settings.Settings; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +public class ResourceList { + + private String category; + private String resourceName; + private List list; + + /** + * ResourceList + * Creates or updates a list for the purpose of tracking resources on the Qortal network + * This can be used for local blocking, or even for curating and sharing content lists + * Lists are backed off to JSON files (in the lists folder) to ease sharing between nodes and users + * + * @param category - for instance "blacklist", "whitelist", or "userlist" + * @param resourceName - for instance "address", "poll", or "group" + * @throws IOException + */ + public ResourceList(String category, String resourceName) throws IOException { + this.category = category; + this.resourceName = resourceName; + this.load(); + } + + + /* Filesystem */ + + private Path getFilePath() { + String pathString = String.format("%s%s%s_%s.json", Settings.getInstance().getListsPath(), + File.separator, this.resourceName, this.category); + Path outputFilePath = Paths.get(pathString); + try { + Files.createDirectories(outputFilePath.getParent()); + } catch (IOException e) { + throw new IllegalStateException("Unable to create lists directory"); + } + return outputFilePath; + } + + public void save() throws IOException { + if (this.resourceName == null) { + throw new IllegalStateException("Can't save list with missing resource name"); + } + if (this.category == null) { + throw new IllegalStateException("Can't save list with missing category"); + } + String jsonString = ResourceList.listToJSONString(this.list); + + Path filePath = this.getFilePath(); + BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toString())); + writer.write(jsonString); + writer.close(); + } + + private boolean load() throws IOException { + Path path = this.getFilePath(); + File resourceListFile = new File(path.toString()); + if (!resourceListFile.exists()) { + return false; + } + + try { + String jsonString = new String(Files.readAllBytes(path)); + this.list = ResourceList.listFromJSONString(jsonString); + } catch (IOException e) { + throw new IOException(String.format("Couldn't read contents from file %s", path.toString())); + } + + return true; + } + + + /* List management */ + + public void add(String resource) { + this.list.add(resource); + } + + public void remove(String resource) { + this.list.remove(resource); + } + + public boolean contains(String resource) { + return this.list.contains(resource); + } + + + + /* Utils */ + + public static String listToJSONString(List list) { + JSONArray items = new JSONArray(); + for (String item : list) { + items.put(item); + } + return items.toString(4); + } + + private static List listFromJSONString(String jsonString) { + JSONArray jsonList = new JSONArray(jsonString); + List resourceList = new ArrayList<>(); + for (int i=0; i