Files
2025-09-05 02:33:06 -04:00

334 lines
13 KiB
Java

package org.qortal.arbitrary.misc;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.io.FilenameUtils;
import org.json.JSONObject;
import org.qortal.arbitrary.ArbitraryDataRenderer;
import org.qortal.transaction.Transaction;
import org.qortal.utils.FilesystemUtils;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import static java.util.Arrays.stream;
import static java.util.stream.Collectors.toMap;
public enum Service {
AUTO_UPDATE(1, false, null, false, false, null),
ARBITRARY_DATA(100, false, null, false, false, null),
QCHAT_ATTACHMENT(120, true, 1024*1024L, true, false, null) {
@Override
public ValidationResult validate(Path path) throws IOException {
ValidationResult superclassResult = super.validate(path);
if (superclassResult != ValidationResult.OK) {
return superclassResult;
}
File[] files = path.toFile().listFiles();
// If already a single file, replace the list with one that contains that file only
if (files == null && path.toFile().isFile()) {
files = new File[] { path.toFile() };
}
// Now validate the file's extension
if (files != null && files[0] != null) {
final String extension = FilenameUtils.getExtension(files[0].getName()).toLowerCase();
// We must allow blank file extensions because these are used by data published from a plaintext or base64-encoded string
final List<String> allowedExtensions = Arrays.asList("qortal", "zip", "pdf", "txt", "odt", "ods", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "");
if (extension == null || !allowedExtensions.contains(extension)) {
return ValidationResult.INVALID_FILE_EXTENSION;
}
}
return ValidationResult.OK;
}
},
QCHAT_ATTACHMENT_PRIVATE(121, true, 1024*1024L, true, true, null),
ATTACHMENT(130, false, 50*1024*1024L, true, false, null),
ATTACHMENT_PRIVATE(131, true, 50*1024*1024L, true, true, null),
FILE(140, false, null, true, false, null),
FILE_PRIVATE(141, true, null, true, true, null),
FILES(150, false, null, false, false, null),
CHAIN_DATA(160, true, 239L, true, false, null),
WEBSITE(200, true, null, false, false, null) {
@Override
public ValidationResult validate(Path path) throws IOException {
ValidationResult superclassResult = super.validate(path);
if (superclassResult != ValidationResult.OK) {
return superclassResult;
}
// Custom validation function to require an index HTML file in the root directory
List<String> fileNames = ArbitraryDataRenderer.indexFiles();
List<String> files;
// single files are paackaged differently
if( path.toFile().isFile() ) {
files = new ArrayList<>(1);
files.add(path.getFileName().toString());
}
else {
files = new ArrayList<>(Arrays.asList(path.toFile().list()));
}
if (files != null) {
for (String file : files) {
Path fileName = Paths.get(file).getFileName();
if (fileName != null && fileNames.contains(fileName.toString())) {
return ValidationResult.OK;
}
}
}
return ValidationResult.MISSING_INDEX_FILE;
}
},
GIT_REPOSITORY(300, false, null, false, false, null),
IMAGE(400, true, 10*1024*1024L, true, false, null),
IMAGE_PRIVATE(401, true, 10*1024*1024L, true, true, null),
THUMBNAIL(410, true, 500*1024L, true, false, null),
QCHAT_IMAGE(420, true, 500*1024L, true, false, null),
VIDEO(500, false, null, true, false, null),
VIDEO_PRIVATE(501, true, null, true, true, null),
AUDIO(600, false, null, true, false, null),
AUDIO_PRIVATE(601, true, null, true, true, null),
QCHAT_AUDIO(610, true, 10*1024*1024L, true, false, null),
QCHAT_VOICE(620, true, 10*1024*1024L, true, false, null),
VOICE(630, true, 10*1024*1024L, true, false, null),
VOICE_PRIVATE(631, true, 10*1024*1024L, true, true, null),
PODCAST(640, false, null, true, false, null),
BLOG(700, false, null, false, false, null),
BLOG_POST(777, false, null, true, false, null),
BLOG_COMMENT(778, true, 500*1024L, true, false, null),
DOCUMENT(800, false, null, true, false, null),
DOCUMENT_PRIVATE(801, true, null, true, true, null),
LIST(900, true, null, true, false, null),
PLAYLIST(910, true, null, true, false, null),
APP(1000, true, 50*1024*1024L, false, false, null),
METADATA(1100, false, null, true, false, null),
JSON(1110, true, 25*1024L, true, false, null) {
@Override
public ValidationResult validate(Path path) throws IOException {
ValidationResult superclassResult = super.validate(path);
if (superclassResult != ValidationResult.OK) {
return superclassResult;
}
// Require valid JSON
byte[] data = FilesystemUtils.getSingleFileContents(path, 25*1024);
String json = new String(data, StandardCharsets.UTF_8);
try {
objectMapper.readTree(json);
return ValidationResult.OK;
} catch (IOException e) {
return ValidationResult.INVALID_CONTENT;
}
}
},
GIF_REPOSITORY(1200, true, 25*1024*1024L, false, false, null) {
@Override
public ValidationResult validate(Path path) throws IOException {
ValidationResult superclassResult = super.validate(path);
if (superclassResult != ValidationResult.OK) {
return superclassResult;
}
// Custom validation function to require .gif files only, and at least 1
int gifCount = 0;
File[] files = path.toFile().listFiles();
// If already a single file, replace the list with one that contains that file only
if (files == null && path.toFile().isFile()) {
files = new File[] { path.toFile() };
}
if (files != null) {
for (File file : files) {
if (file.getName().equals(".qortal")) {
continue;
}
if (file.isDirectory()) {
return ValidationResult.DIRECTORIES_NOT_ALLOWED;
}
String extension = FilenameUtils.getExtension(file.getName()).toLowerCase();
if (!Objects.equals(extension, "gif")) {
return ValidationResult.INVALID_FILE_EXTENSION;
}
gifCount++;
}
}
if (gifCount == 0) {
return ValidationResult.MISSING_DATA;
}
return ValidationResult.OK;
}
},
STORE(1300, false, null, true, false, null),
PRODUCT(1310, false, null, true, false, null),
OFFER(1330, false, null, true, false, null),
COUPON(1340, false, null, true, false, null),
CODE(1400, false, null, true, false, null),
PLUGIN(1410, false, null, true, false, null),
EXTENSION(1420, false, null, true, false, null),
GAME(1500, false, null, false, false, null),
ITEM(1510, false, null, true, false, null),
NFT(1600, false, null, true, false, null),
DATABASE(1700, false, null, false, false, null),
SNAPSHOT(1710, false, null, false, false, null),
COMMENT(1800, true, 500*1024L, true, false, null),
CHAIN_COMMENT(1810, true, 239L, true, false, null),
MAIL(1900, true, 1024*1024L, true, false, null),
MAIL_PRIVATE(1901, true, 5*1024*1024L, true, true, null),
MESSAGE(1910, true, 1024*1024L, true, false, null),
MESSAGE_PRIVATE(1911, true, 1024*1024L, true, true, null);
public final int value;
private final boolean requiresValidation;
private final Long maxSize;
private final boolean single;
private final boolean isPrivate;
private final List<String> requiredKeys;
private static final Map<Integer, Service> map = stream(Service.values())
.collect(toMap(service -> service.value, service -> service));
// For JSON validation
private static final ObjectMapper objectMapper = new ObjectMapper();
private static final String encryptedDataPrefix = "qortalEncryptedData";
private static final String encryptedGroupDataPrefix = "qortalGroupEncryptedData";
Service(int value, boolean requiresValidation, Long maxSize, boolean single, boolean isPrivate, List<String> requiredKeys) {
this.value = value;
this.requiresValidation = requiresValidation;
this.maxSize = maxSize;
this.single = single;
this.isPrivate = isPrivate;
this.requiredKeys = requiredKeys;
}
public ValidationResult validate(Path path) throws IOException {
if (!this.isValidationRequired()) {
return ValidationResult.OK;
}
// Load the first 25KB of data. This only needs to be long enough to check the prefix
// and also to allow for possible additional future validation of smaller files.
byte[] data = FilesystemUtils.getSingleFileContents(path, 25*1024);
long size = FilesystemUtils.getDirectorySize(path);
// Validate max size if needed
if (this.maxSize != null) {
if (size > this.maxSize) {
return ValidationResult.EXCEEDS_SIZE_LIMIT;
}
}
// Validate file count if needed
if (this.single && data == null) {
return ValidationResult.INVALID_FILE_COUNT;
}
// Validate private data for single file resources
if (this.single) {
String dataString = new String(data, StandardCharsets.UTF_8);
if (this.isPrivate && !dataString.startsWith(encryptedDataPrefix) && !dataString.startsWith(encryptedGroupDataPrefix)) {
return ValidationResult.DATA_NOT_ENCRYPTED;
}
if (!this.isPrivate && (dataString.startsWith(encryptedDataPrefix) || dataString.startsWith(encryptedGroupDataPrefix))) {
return ValidationResult.DATA_ENCRYPTED;
}
}
// Validate required keys if needed
if (this.requiredKeys != null) {
if (data == null) {
return ValidationResult.MISSING_KEYS;
}
JSONObject json = Service.toJsonObject(data);
for (String key : this.requiredKeys) {
if (!json.has(key)) {
return ValidationResult.MISSING_KEYS;
}
}
}
// Validation passed
return ValidationResult.OK;
}
public boolean isValidationRequired() {
// We must always validate single file resources, to ensure they are actually a single file
return this.requiresValidation || this.single;
}
public boolean isPrivate() {
return this.isPrivate;
}
public static Service valueOf(int value) {
return map.get(value);
}
public static JSONObject toJsonObject(byte[] data) {
String dataString = new String(data, StandardCharsets.UTF_8);
return new JSONObject(dataString);
}
public static List<Service> publicServices() {
List<Service> privateServices = new ArrayList<>();
for (Service service : Service.values()) {
if (!service.isPrivate) {
privateServices.add(service);
}
}
return privateServices;
}
/**
* Fetch a list of Service objects that require encrypted data.
*
* These can ultimately be used to help inform the cleanup manager
* on the best order to delete files when the node runs out of space.
* Public data should be given priority over private data (unless
* this node is part of a data market contract for that data - this
* isn't developed yet).
*
* @return a list of Service objects that require encrypted data.
*/
public static List<Service> privateServices() {
List<Service> privateServices = new ArrayList<>();
for (Service service : Service.values()) {
if (service.isPrivate) {
privateServices.add(service);
}
}
return privateServices;
}
public enum ValidationResult {
OK(1),
MISSING_KEYS(2),
EXCEEDS_SIZE_LIMIT(3),
MISSING_INDEX_FILE(4),
DIRECTORIES_NOT_ALLOWED(5),
INVALID_FILE_EXTENSION(6),
MISSING_DATA(7),
INVALID_FILE_COUNT(8),
INVALID_CONTENT(9),
DATA_NOT_ENCRYPTED(10),
DATA_ENCRYPTED(10);
public final int value;
private static final Map<Integer, Transaction.ValidationResult> map = stream(Transaction.ValidationResult.values()).collect(toMap(result -> result.value, result -> result));
ValidationResult(int value) {
this.value = value;
}
public static Transaction.ValidationResult valueOf(int value) {
return map.get(value);
}
}
}